meta: move public crates to packages/
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I928162008cb1ba02e1aa0e7aa971e8326a6a6964
This commit is contained in:
parent
70b0113d8a
commit
00bab69598
308 changed files with 53890 additions and 53889 deletions
40
packages/pinakes-ui/Cargo.toml
Normal file
40
packages/pinakes-ui/Cargo.toml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
[package]
|
||||
name = "pinakes-ui"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["web"]
|
||||
web = ["dioxus/web"]
|
||||
desktop = ["dioxus/desktop"]
|
||||
mobile = ["dioxus/mobile"]
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
dioxus = { workspace = true }
|
||||
dioxus-core = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
pulldown-cmark = { workspace = true }
|
||||
gray_matter = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
ammonia = { workspace = true }
|
||||
dioxus-free-icons = { workspace = true }
|
||||
gloo-timers = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
urlencoding = { workspace = true }
|
||||
pinakes-plugin-api = { workspace = true }
|
||||
rustc-hash = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
21
packages/pinakes-ui/Dioxus.toml
Normal file
21
packages/pinakes-ui/Dioxus.toml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
[application]
|
||||
|
||||
[web.app]
|
||||
|
||||
# HTML title tag content
|
||||
title = "pinakes"
|
||||
|
||||
# include `assets` in web platform
|
||||
[web.resource]
|
||||
|
||||
# Additional CSS style files
|
||||
style = []
|
||||
|
||||
# Additional JavaScript files
|
||||
script = []
|
||||
|
||||
[web.resource.dev]
|
||||
|
||||
# Javascript code file
|
||||
# serve: [dev-server] only
|
||||
script = []
|
||||
4627
packages/pinakes-ui/assets/css/main.css
Normal file
4627
packages/pinakes-ui/assets/css/main.css
Normal file
File diff suppressed because it is too large
Load diff
493
packages/pinakes-ui/assets/styles/_audit.scss
Normal file
493
packages/pinakes-ui/assets/styles/_audit.scss
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
@use 'variables' as *;
|
||||
@use 'mixins' as *;
|
||||
|
||||
// Audit log
|
||||
.audit-controls {
|
||||
@include flex(row, flex-start, center, $space-4);
|
||||
margin-bottom: $space-6;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: $space-2 24px $space-2 $space-4;
|
||||
font-size: $font-size-base;
|
||||
background: $bg-2;
|
||||
}
|
||||
|
||||
.action-danger {
|
||||
background: $error-medium;
|
||||
color: $error-text;
|
||||
}
|
||||
|
||||
.action-updated {
|
||||
background: $action-updated-bg;
|
||||
color: $action-updated-text;
|
||||
}
|
||||
|
||||
.action-collection {
|
||||
background: $action-collection-bg;
|
||||
color: $action-collection-text;
|
||||
}
|
||||
|
||||
.action-collection-remove {
|
||||
background: $action-collection-remove-bg;
|
||||
color: $action-collection-remove-text;
|
||||
}
|
||||
|
||||
.action-opened {
|
||||
background: $action-opened-bg;
|
||||
color: $action-opened-text;
|
||||
}
|
||||
|
||||
.action-scanned {
|
||||
background: $action-scanned-bg;
|
||||
color: $text-2;
|
||||
}
|
||||
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
color: $accent-text;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.clickable-row {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: $overlay-light;
|
||||
}
|
||||
}
|
||||
|
||||
// Duplicates
|
||||
.duplicates-view {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.duplicates-header {
|
||||
@include flex-between;
|
||||
margin-bottom: $space-8;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.duplicates-summary {
|
||||
@include flex(row, flex-start, center, $space-6);
|
||||
}
|
||||
|
||||
.duplicate-group {
|
||||
border: 1px solid $border;
|
||||
border-radius: $radius;
|
||||
margin-bottom: $space-4;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.duplicate-group-header {
|
||||
@include flex(row, flex-start, center, $space-6);
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: $bg-2;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: $text-0;
|
||||
font-size: $font-size-lg;
|
||||
|
||||
&:hover {
|
||||
background: $bg-3;
|
||||
}
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
font-size: $font-size-sm;
|
||||
width: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
font-weight: $font-weight-semibold;
|
||||
flex: 1;
|
||||
@include text-truncate;
|
||||
}
|
||||
|
||||
.group-badge {
|
||||
background: $accent;
|
||||
color: white;
|
||||
padding: $space-1 $space-4;
|
||||
border-radius: 10px;
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-semibold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.group-size {
|
||||
flex-shrink: 0;
|
||||
font-size: $font-size-md;
|
||||
}
|
||||
|
||||
.group-hash {
|
||||
font-size: $font-size-base;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.duplicate-items {
|
||||
border-top: 1px solid $border;
|
||||
}
|
||||
|
||||
.duplicate-item {
|
||||
@include flex(row, flex-start, center, $space-6);
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid $border-subtle;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&-keep {
|
||||
background: $green-light;
|
||||
}
|
||||
}
|
||||
|
||||
.dup-thumb {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex-shrink: 0;
|
||||
border-radius: $radius-sm;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dup-thumb-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.dup-thumb-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@include flex-center;
|
||||
background: $bg-3;
|
||||
font-size: 20px;
|
||||
color: $text-2;
|
||||
}
|
||||
|
||||
.dup-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dup-filename {
|
||||
font-weight: $font-weight-semibold;
|
||||
font-size: $font-size-lg;
|
||||
@include text-truncate;
|
||||
}
|
||||
|
||||
.dup-path {
|
||||
font-size: $font-size-base;
|
||||
@include text-truncate;
|
||||
}
|
||||
|
||||
.dup-meta {
|
||||
font-size: $font-size-md;
|
||||
margin-top: $space-1;
|
||||
}
|
||||
|
||||
.dup-actions {
|
||||
@include flex(row, flex-start, center, 6px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.keep-badge {
|
||||
background: $green-medium;
|
||||
color: $green-text;
|
||||
padding: $space-1 $space-5;
|
||||
border-radius: 10px;
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-semibold;
|
||||
}
|
||||
|
||||
// Saved searches
|
||||
.saved-searches-list {
|
||||
@include flex(column, flex-start, stretch, $space-2);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.saved-search-item {
|
||||
@include flex-between;
|
||||
padding: $space-4 $space-6;
|
||||
background: $bg-1;
|
||||
border-radius: $radius-sm;
|
||||
cursor: pointer;
|
||||
transition: background $transition-slow ease;
|
||||
|
||||
&:hover {
|
||||
background: $bg-2;
|
||||
}
|
||||
}
|
||||
|
||||
.saved-search-info {
|
||||
@include flex(column, flex-start, stretch, $space-1);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.saved-search-name {
|
||||
font-weight: $font-weight-medium;
|
||||
color: $text-0;
|
||||
}
|
||||
|
||||
.saved-search-query {
|
||||
font-size: $font-size-base;
|
||||
@include text-truncate;
|
||||
}
|
||||
|
||||
// Backlinks
|
||||
.backlinks-panel,
|
||||
.outgoing-links-panel {
|
||||
background: $bg-2;
|
||||
border: 1px solid $border;
|
||||
border-radius: $radius;
|
||||
margin-top: $space-8;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.backlinks-header,
|
||||
.outgoing-links-header {
|
||||
@include flex(row, flex-start, center, $space-4);
|
||||
padding: 10px 14px;
|
||||
background: $bg-3;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background $transition-base;
|
||||
|
||||
&:hover {
|
||||
background: $overlay-medium;
|
||||
}
|
||||
}
|
||||
|
||||
.backlinks-toggle,
|
||||
.outgoing-links-toggle {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-2;
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.backlinks-title,
|
||||
.outgoing-links-title {
|
||||
font-size: $font-size-md;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.backlinks-count,
|
||||
.outgoing-links-count {
|
||||
font-size: $font-size-base;
|
||||
color: $text-2;
|
||||
}
|
||||
|
||||
.backlinks-reindex-btn {
|
||||
@include flex-center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
margin-left: auto;
|
||||
background: transparent;
|
||||
border: 1px solid $border;
|
||||
border-radius: $radius-sm;
|
||||
color: $text-2;
|
||||
font-size: $font-size-md;
|
||||
cursor: pointer;
|
||||
transition: background $transition-base, color $transition-base,
|
||||
border-color $transition-base;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: $bg-2;
|
||||
color: $text-0;
|
||||
border-color: $border-strong;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.backlinks-content,
|
||||
.outgoing-links-content {
|
||||
padding: $space-6;
|
||||
border-top: 1px solid $border-subtle;
|
||||
}
|
||||
|
||||
.backlinks-loading,
|
||||
.outgoing-links-loading {
|
||||
@include flex(row, flex-start, center, $space-4);
|
||||
padding: $space-6;
|
||||
color: $text-2;
|
||||
font-size: $font-size-md;
|
||||
}
|
||||
|
||||
.backlinks-error,
|
||||
.outgoing-links-error {
|
||||
padding: $space-4 $space-6;
|
||||
background: $error-bg;
|
||||
border: 1px solid $error-border;
|
||||
border-radius: $radius-sm;
|
||||
font-size: $font-size-md;
|
||||
color: $error;
|
||||
}
|
||||
|
||||
.backlinks-empty,
|
||||
.outgoing-links-empty {
|
||||
padding: $space-8;
|
||||
text-align: center;
|
||||
color: $text-2;
|
||||
font-size: $font-size-md;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.backlinks-list,
|
||||
.outgoing-links-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
@include flex(column, flex-start, stretch, 6px);
|
||||
}
|
||||
|
||||
.backlink-item,
|
||||
.outgoing-link-item {
|
||||
padding: $space-5 $space-6;
|
||||
background: $bg-0;
|
||||
border: 1px solid $border-subtle;
|
||||
border-radius: $radius-sm;
|
||||
cursor: pointer;
|
||||
transition: background $transition-base, border-color $transition-base;
|
||||
|
||||
&:hover {
|
||||
background: $bg-1;
|
||||
border-color: $border;
|
||||
}
|
||||
|
||||
&.unresolved {
|
||||
opacity: 0.7;
|
||||
border-style: dashed;
|
||||
}
|
||||
}
|
||||
|
||||
.backlink-source,
|
||||
.outgoing-link-target {
|
||||
@include flex(row, flex-start, center, $space-4);
|
||||
margin-bottom: $space-1;
|
||||
}
|
||||
|
||||
.backlink-title,
|
||||
.outgoing-link-text {
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-medium;
|
||||
color: $text-0;
|
||||
flex: 1;
|
||||
@include text-truncate;
|
||||
}
|
||||
|
||||
.backlink-type-badge,
|
||||
.outgoing-link-type-badge {
|
||||
display: inline-block;
|
||||
padding: 1px $space-3;
|
||||
border-radius: $radius-xl;
|
||||
font-size: 9px;
|
||||
font-weight: $font-weight-semibold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: $letter-spacing-wide;
|
||||
|
||||
&.backlink-type-wikilink,
|
||||
&.link-type-wikilink {
|
||||
background: $accent-dim;
|
||||
color: $accent-text;
|
||||
}
|
||||
|
||||
&.backlink-type-embed,
|
||||
&.link-type-embed {
|
||||
background: $type-audio-bg;
|
||||
color: $type-audio-text;
|
||||
}
|
||||
|
||||
&.backlink-type-markdown_link,
|
||||
&.link-type-markdown_link {
|
||||
background: $type-document-bg;
|
||||
color: $type-document-text;
|
||||
}
|
||||
}
|
||||
|
||||
.backlink-context {
|
||||
font-size: $font-size-base;
|
||||
color: $text-2;
|
||||
line-height: $line-height-normal;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.backlink-line {
|
||||
color: $text-1;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
.unresolved-badge {
|
||||
padding: 1px $space-3;
|
||||
border-radius: $radius-xl;
|
||||
font-size: 9px;
|
||||
font-weight: $font-weight-semibold;
|
||||
background: $warning-light;
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
.outgoing-links-unresolved-badge {
|
||||
margin-left: $space-4;
|
||||
padding: $space-1 $space-4;
|
||||
border-radius: 10px;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
background: $warning-medium;
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
.outgoing-links-global-unresolved {
|
||||
@include flex(row, flex-start, center, 6px);
|
||||
margin-top: $space-6;
|
||||
padding: $space-5 $space-6;
|
||||
background: $warning-bg;
|
||||
border: 1px solid $warning-border;
|
||||
border-radius: $radius-sm;
|
||||
font-size: $font-size-base;
|
||||
color: $text-2;
|
||||
|
||||
.unresolved-icon {
|
||||
color: $warning;
|
||||
}
|
||||
}
|
||||
|
||||
.backlinks-message {
|
||||
padding: $space-4 $space-5;
|
||||
margin-bottom: 10px;
|
||||
border-radius: $radius-sm;
|
||||
font-size: $font-size-base;
|
||||
|
||||
&.success {
|
||||
background: $success-bg;
|
||||
border: 1px solid $success-border;
|
||||
color: $success;
|
||||
}
|
||||
|
||||
&.error {
|
||||
background: $error-bg;
|
||||
border: 1px solid $error-border;
|
||||
color: $error;
|
||||
}
|
||||
}
|
||||
218
packages/pinakes-ui/assets/styles/_base.scss
Normal file
218
packages/pinakes-ui/assets/styles/_base.scss
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
@use 'variables' as *;
|
||||
@use 'mixins' as *;
|
||||
|
||||
// Reset & base
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
@include scrollbar;
|
||||
}
|
||||
|
||||
// CSS custom properties
|
||||
:root {
|
||||
// Background
|
||||
--bg-0: #{$bg-0};
|
||||
--bg-1: #{$bg-1};
|
||||
--bg-2: #{$bg-2};
|
||||
--bg-3: #{$bg-3};
|
||||
|
||||
// Border
|
||||
--border-subtle: #{$border-subtle};
|
||||
--border: #{$border};
|
||||
--border-strong: #{$border-strong};
|
||||
|
||||
// Text
|
||||
--text-0: #{$text-0};
|
||||
--text-1: #{$text-1};
|
||||
--text-2: #{$text-2};
|
||||
|
||||
// Accent
|
||||
--accent: #{$accent};
|
||||
--accent-dim: #{$accent-dim};
|
||||
--accent-text: #{$accent-text};
|
||||
|
||||
// Semantic
|
||||
--success: #{$success};
|
||||
--error: #{$error};
|
||||
--warning: #{$warning};
|
||||
|
||||
// Border radius
|
||||
--radius-sm: #{$radius-sm};
|
||||
--radius: #{$radius};
|
||||
--radius-md: #{$radius-md};
|
||||
|
||||
// Shadows
|
||||
--shadow-sm: #{$shadow-sm};
|
||||
--shadow: #{$shadow};
|
||||
--shadow-lg: #{$shadow-lg};
|
||||
}
|
||||
|
||||
// Body
|
||||
body {
|
||||
font-family: $font-family-base;
|
||||
background: var(--bg-0);
|
||||
color: var(--text-0);
|
||||
font-size: $font-size-lg;
|
||||
line-height: $line-height-relaxed;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Focus styles
|
||||
:focus-visible {
|
||||
@include focus-outline;
|
||||
}
|
||||
|
||||
// Selection
|
||||
::selection {
|
||||
background: $accent-dim;
|
||||
color: $accent-text;
|
||||
}
|
||||
|
||||
// Links
|
||||
a {
|
||||
color: $accent-text;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
// Code
|
||||
code {
|
||||
padding: 1px 5px;
|
||||
border-radius: $radius-sm;
|
||||
background: $bg-0;
|
||||
color: $accent-text;
|
||||
font-family: $font-family-mono;
|
||||
font-size: $font-size-base;
|
||||
}
|
||||
|
||||
// Lists
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
|
||||
li {
|
||||
padding: 3px 0;
|
||||
font-size: $font-size-md;
|
||||
color: $text-1;
|
||||
}
|
||||
}
|
||||
|
||||
// Utility text
|
||||
.text-muted {
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: $font-size-base;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: $font-family-mono;
|
||||
font-size: $font-size-md;
|
||||
}
|
||||
|
||||
// Utility layout
|
||||
.flex-row {
|
||||
@include flex(row, flex-start, center, $space-4);
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
@include flex-between;
|
||||
}
|
||||
|
||||
.mb-16 {
|
||||
margin-bottom: $space-8;
|
||||
}
|
||||
|
||||
.mb-8 {
|
||||
margin-bottom: $space-6;
|
||||
}
|
||||
|
||||
.mt-16 {
|
||||
margin-top: $space-8;
|
||||
}
|
||||
|
||||
.mt-8 {
|
||||
margin-top: $space-6;
|
||||
}
|
||||
|
||||
// Animations
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes indeterminate {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(400%);
|
||||
}
|
||||
}
|
||||
1256
packages/pinakes-ui/assets/styles/_components.scss
Normal file
1256
packages/pinakes-ui/assets/styles/_components.scss
Normal file
File diff suppressed because it is too large
Load diff
310
packages/pinakes-ui/assets/styles/_graph.scss
Normal file
310
packages/pinakes-ui/assets/styles/_graph.scss
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
@use 'variables' as *;
|
||||
@use 'mixins' as *;
|
||||
|
||||
// Graph view
|
||||
|
||||
.graph-view {
|
||||
@include flex(column);
|
||||
height: 100%;
|
||||
background: $bg-1;
|
||||
border-radius: $radius;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.graph-toolbar {
|
||||
@include flex(row, flex-start, center, $space-8);
|
||||
padding: $space-6 $space-8;
|
||||
background: $bg-2;
|
||||
border-bottom: 1px solid $border;
|
||||
}
|
||||
|
||||
.graph-title {
|
||||
font-size: $font-size-xl;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-0;
|
||||
}
|
||||
|
||||
.graph-controls {
|
||||
@include flex(row, flex-start, center, $space-4);
|
||||
font-size: $font-size-md;
|
||||
color: $text-1;
|
||||
|
||||
select {
|
||||
padding: $space-2 20px $space-2 $space-4;
|
||||
font-size: $font-size-base;
|
||||
background: $bg-3;
|
||||
}
|
||||
}
|
||||
|
||||
.graph-stats {
|
||||
margin-left: auto;
|
||||
font-size: $font-size-base;
|
||||
color: $text-2;
|
||||
}
|
||||
|
||||
.graph-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
@include flex-center;
|
||||
overflow: hidden;
|
||||
background: $bg-0;
|
||||
}
|
||||
|
||||
.graph-loading,
|
||||
.graph-error,
|
||||
.graph-empty {
|
||||
@include flex(column, center, center, $space-5);
|
||||
padding: 48px;
|
||||
color: $text-2;
|
||||
font-size: $font-size-lg;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.graph-svg {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.graph-svg-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.graph-zoom-controls {
|
||||
position: absolute;
|
||||
top: $space-8;
|
||||
left: $space-8;
|
||||
@include flex(column, flex-start, stretch, $space-4);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.zoom-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
background: $bg-2;
|
||||
border: 1px solid $border;
|
||||
color: $text-0;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
@include flex-center;
|
||||
cursor: pointer;
|
||||
transition: all $transition-slow;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&:hover {
|
||||
background: $bg-3;
|
||||
border-color: $border-strong;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
// Graph elements
|
||||
|
||||
.graph-edges line {
|
||||
stroke: $border-strong;
|
||||
stroke-width: 1;
|
||||
opacity: 0.6;
|
||||
|
||||
&.edge-type-wikilink {
|
||||
stroke: $accent;
|
||||
}
|
||||
|
||||
&.edge-type-embed {
|
||||
stroke: $graph-edge-embed;
|
||||
stroke-dasharray: 4 2;
|
||||
}
|
||||
}
|
||||
|
||||
.graph-nodes {
|
||||
.graph-node {
|
||||
cursor: pointer;
|
||||
|
||||
circle {
|
||||
fill: $graph-node-fill;
|
||||
stroke: $graph-node-stroke;
|
||||
stroke-width: 2;
|
||||
transition: fill $transition-slow, stroke $transition-slow;
|
||||
}
|
||||
|
||||
&:hover circle {
|
||||
fill: $graph-node-hover;
|
||||
}
|
||||
|
||||
&.selected circle {
|
||||
fill: $accent;
|
||||
stroke: $graph-node-selected;
|
||||
}
|
||||
|
||||
text {
|
||||
fill: $text-1;
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: central;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Node details panel
|
||||
|
||||
.node-details-panel {
|
||||
position: absolute;
|
||||
top: $space-8;
|
||||
right: $space-8;
|
||||
width: 280px;
|
||||
background: $bg-2;
|
||||
border: 1px solid $border;
|
||||
border-radius: $radius;
|
||||
box-shadow: $shadow;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.node-details-header {
|
||||
@include flex-between;
|
||||
padding: $space-5 14px;
|
||||
border-bottom: 1px solid $border-subtle;
|
||||
|
||||
h3 {
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-0;
|
||||
margin: 0;
|
||||
@include text-truncate;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: $text-2;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: $space-1 $space-3;
|
||||
line-height: 1;
|
||||
|
||||
&:hover {
|
||||
color: $text-0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.node-details-content {
|
||||
padding: 14px;
|
||||
|
||||
.node-title {
|
||||
font-size: $font-size-md;
|
||||
color: $text-1;
|
||||
margin-bottom: $space-6;
|
||||
}
|
||||
}
|
||||
|
||||
.node-stats {
|
||||
display: flex;
|
||||
gap: $space-8;
|
||||
margin-bottom: $space-6;
|
||||
|
||||
.stat {
|
||||
font-size: $font-size-md;
|
||||
color: $text-2;
|
||||
|
||||
strong {
|
||||
color: $text-0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Physics controls
|
||||
|
||||
.physics-controls-panel {
|
||||
position: absolute;
|
||||
top: $space-8;
|
||||
right: $space-8;
|
||||
width: 300px;
|
||||
background: $bg-2;
|
||||
border: 1px solid $border;
|
||||
border-radius: $radius;
|
||||
box-shadow: $shadow;
|
||||
padding: $space-8;
|
||||
z-index: 10;
|
||||
|
||||
h4 {
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-0;
|
||||
margin: 0 0 $space-8 0;
|
||||
padding-bottom: $space-4;
|
||||
border-bottom: 1px solid $border-subtle;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
margin-top: $space-4;
|
||||
}
|
||||
}
|
||||
|
||||
.control-group {
|
||||
margin-bottom: 14px;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-medium;
|
||||
color: $text-1;
|
||||
margin-bottom: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: $letter-spacing-uppercase;
|
||||
}
|
||||
|
||||
input[type='range'] {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
border-radius: $space-2;
|
||||
background: $bg-3;
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: $accent;
|
||||
cursor: pointer;
|
||||
transition: transform $transition-base;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: $accent;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: transform $transition-base;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.control-value {
|
||||
display: inline-block;
|
||||
margin-top: $space-1;
|
||||
font-size: $font-size-base;
|
||||
color: $text-2;
|
||||
font-family: $font-family-mono;
|
||||
}
|
||||
344
packages/pinakes-ui/assets/styles/_layout.scss
Normal file
344
packages/pinakes-ui/assets/styles/_layout.scss
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
@use 'variables' as *;
|
||||
@use 'mixins' as *;
|
||||
|
||||
// App layout
|
||||
|
||||
.app {
|
||||
@include flex(row, flex-start, stretch);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Sidebar
|
||||
|
||||
.sidebar {
|
||||
width: $sidebar-width;
|
||||
min-width: $sidebar-width;
|
||||
max-width: $sidebar-width;
|
||||
background: $bg-1;
|
||||
border-right: 1px solid $border;
|
||||
@include flex(column);
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
z-index: $z-dropdown;
|
||||
transition: width $transition-slow, min-width $transition-slow,
|
||||
max-width $transition-slow;
|
||||
|
||||
&.collapsed {
|
||||
width: $sidebar-collapsed-width;
|
||||
min-width: $sidebar-collapsed-width;
|
||||
max-width: $sidebar-collapsed-width;
|
||||
|
||||
.nav-label,
|
||||
.sidebar-header .logo,
|
||||
.sidebar-header .version,
|
||||
.nav-badge,
|
||||
.nav-item-text,
|
||||
.sidebar-footer .status-text,
|
||||
.user-name,
|
||||
.role-badge,
|
||||
.user-info .btn,
|
||||
.sidebar-import-header span,
|
||||
.sidebar-import-file {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
justify-content: center;
|
||||
padding: $space-4;
|
||||
border-left: none;
|
||||
border-radius: $radius-sm;
|
||||
|
||||
&.active {
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 12px $space-4;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
padding: 0 $space-2;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: $space-4;
|
||||
|
||||
.user-info {
|
||||
justify-content: center;
|
||||
padding: $space-2;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-import-progress {
|
||||
padding: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: $space-8 $space-8 20px;
|
||||
@include flex(row, flex-start, baseline, $space-4);
|
||||
|
||||
.logo {
|
||||
font-size: $font-size-2xl;
|
||||
font-weight: $font-weight-bold;
|
||||
letter-spacing: $letter-spacing-tight;
|
||||
color: $text-0;
|
||||
}
|
||||
|
||||
.version {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-2;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
@include button-ghost;
|
||||
color: $text-2;
|
||||
padding: $space-4;
|
||||
font-size: $font-size-4xl;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
color: $text-0;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: $space-6;
|
||||
border-top: 1px solid $border-subtle;
|
||||
overflow: visible;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Navigation
|
||||
|
||||
.nav-section {
|
||||
padding: 0 $space-4;
|
||||
margin-bottom: $space-1;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
padding: $space-4 $space-4 $space-2;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-semibold;
|
||||
@include text-uppercase;
|
||||
color: $text-2;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
@include flex(row, flex-start, center, $space-4);
|
||||
padding: 6px $space-4;
|
||||
border-radius: $radius-sm;
|
||||
cursor: pointer;
|
||||
color: $text-1;
|
||||
font-size: $font-size-lg;
|
||||
font-weight: 450;
|
||||
transition: color $transition-base, background $transition-base;
|
||||
border: none;
|
||||
background: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border-left: 2px solid transparent;
|
||||
margin-left: 0;
|
||||
|
||||
&:hover {
|
||||
color: $text-0;
|
||||
background: $overlay-light;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: $accent-text;
|
||||
border-left-color: $accent;
|
||||
background: $accent-dim;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-item-text {
|
||||
flex: 1;
|
||||
@include text-truncate;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar:not(.collapsed) .nav-item-text {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.nav-badge {
|
||||
margin-left: auto;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-2;
|
||||
background: $bg-3;
|
||||
padding: 1px $space-3;
|
||||
border-radius: $radius-xl;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
// Status indicator
|
||||
|
||||
.status-indicator {
|
||||
@include flex(row, center, center, 6px);
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-medium;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.sidebar:not(.collapsed) .status-indicator {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
@include status-dot;
|
||||
|
||||
&.connected {
|
||||
background: $success;
|
||||
}
|
||||
|
||||
&.disconnected {
|
||||
background: $error;
|
||||
}
|
||||
|
||||
&.checking {
|
||||
background: $warning;
|
||||
@include pulse;
|
||||
}
|
||||
}
|
||||
|
||||
.status-text {
|
||||
color: $text-2;
|
||||
@include text-truncate;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar:not(.collapsed) .status-text {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
// Main content
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
@include flex(column);
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
height: $header-height;
|
||||
min-height: $header-height;
|
||||
border-bottom: 1px solid $border-subtle;
|
||||
@include flex(row, flex-start, center, 12px);
|
||||
padding: 0 20px;
|
||||
background: $bg-1;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: $font-size-xl;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-0;
|
||||
}
|
||||
|
||||
.header-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: $overlay-strong transparent;
|
||||
}
|
||||
|
||||
// Import progress
|
||||
|
||||
.sidebar-import-progress {
|
||||
padding: $space-5 $space-6;
|
||||
background: $bg-2;
|
||||
border-top: 1px solid $border-subtle;
|
||||
font-size: $font-size-base;
|
||||
}
|
||||
|
||||
.sidebar-import-header {
|
||||
@include flex(row, flex-start, center, 6px);
|
||||
margin-bottom: $space-2;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.sidebar-import-file {
|
||||
color: $text-2;
|
||||
font-size: $font-size-sm;
|
||||
@include text-truncate;
|
||||
margin-bottom: $space-2;
|
||||
}
|
||||
|
||||
.sidebar-import-progress .progress-bar {
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
// User info
|
||||
|
||||
.user-info {
|
||||
@include flex(row, flex-start, center, 6px);
|
||||
font-size: $font-size-md;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: $font-weight-medium;
|
||||
color: $text-0;
|
||||
@include text-truncate;
|
||||
max-width: 90px;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
display: inline-block;
|
||||
padding: 1px $space-3;
|
||||
border-radius: $radius-sm;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-semibold;
|
||||
text-transform: uppercase;
|
||||
|
||||
&.role-admin {
|
||||
background: $role-admin-bg;
|
||||
color: $role-admin-text;
|
||||
}
|
||||
|
||||
&.role-editor {
|
||||
background: $role-editor-bg;
|
||||
color: $role-editor-text;
|
||||
}
|
||||
|
||||
&.role-viewer {
|
||||
background: $role-viewer-bg;
|
||||
color: $role-viewer-text;
|
||||
}
|
||||
}
|
||||
715
packages/pinakes-ui/assets/styles/_media.scss
Normal file
715
packages/pinakes-ui/assets/styles/_media.scss
Normal file
|
|
@ -0,0 +1,715 @@
|
|||
@use 'variables' as *;
|
||||
@use 'mixins' as *;
|
||||
|
||||
// Media cards & grid
|
||||
|
||||
.media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: $space-6;
|
||||
}
|
||||
|
||||
.media-card {
|
||||
background: $bg-2;
|
||||
border: 1px solid $border;
|
||||
border-radius: $radius;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s, box-shadow 0.12s;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
border-color: $border-strong;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: $accent;
|
||||
box-shadow: 0 0 0 1px $accent;
|
||||
}
|
||||
}
|
||||
|
||||
.card-checkbox {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
transition: opacity $transition-base;
|
||||
|
||||
input[type='checkbox'] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
filter: drop-shadow(0 1px 2px $drop-shadow);
|
||||
}
|
||||
}
|
||||
|
||||
.media-card:hover .card-checkbox,
|
||||
.media-card.selected .card-checkbox {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card-thumbnail {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
background: $bg-0;
|
||||
@include flex-center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
img,
|
||||
.card-thumb-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.card-type-icon {
|
||||
font-size: 32px;
|
||||
opacity: 0.4;
|
||||
@include flex-center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.card-info {
|
||||
padding: $space-4 $space-5;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-size: $font-size-md;
|
||||
font-weight: $font-weight-medium;
|
||||
color: $text-0;
|
||||
@include text-truncate;
|
||||
margin-bottom: $space-2;
|
||||
}
|
||||
|
||||
.card-title,
|
||||
.card-artist {
|
||||
font-size: $font-size-sm;
|
||||
@include text-truncate;
|
||||
line-height: $line-height-base;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
@include flex(row, flex-start, center, 6px);
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
.card-size {
|
||||
color: $text-2;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
// Table thumbnails
|
||||
|
||||
.table-thumb-cell {
|
||||
width: 36px;
|
||||
padding: $space-2 6px !important;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.table-thumb {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: cover;
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.table-thumb-overlay {
|
||||
position: absolute;
|
||||
top: $space-2;
|
||||
left: 6px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.table-type-icon {
|
||||
@include flex-center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 14px;
|
||||
opacity: 0.5;
|
||||
border-radius: 3px;
|
||||
background: $bg-0;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
// Type badges
|
||||
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 1px $space-3;
|
||||
border-radius: $radius-sm;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-semibold;
|
||||
@include text-uppercase;
|
||||
|
||||
&.type-audio {
|
||||
background: $type-audio-bg;
|
||||
color: $type-audio-text;
|
||||
}
|
||||
|
||||
&.type-video {
|
||||
background: $type-video-bg;
|
||||
color: $type-video-text;
|
||||
}
|
||||
|
||||
&.type-image {
|
||||
background: $type-image-bg;
|
||||
color: $type-image-text;
|
||||
}
|
||||
|
||||
&.type-document {
|
||||
background: $type-document-bg;
|
||||
color: $type-document-text;
|
||||
}
|
||||
|
||||
&.type-text {
|
||||
background: $type-text-bg;
|
||||
color: $type-text-text;
|
||||
}
|
||||
|
||||
&.type-other {
|
||||
background: $type-other-bg;
|
||||
color: $text-2;
|
||||
}
|
||||
}
|
||||
|
||||
// Tag badges
|
||||
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $space-2;
|
||||
}
|
||||
|
||||
.tag-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $space-2;
|
||||
padding: $space-1 $space-5;
|
||||
background: $accent-dim;
|
||||
color: $accent-text;
|
||||
border-radius: 12px;
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-medium;
|
||||
|
||||
&.selected {
|
||||
background: $accent;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:not(.selected) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tag-remove {
|
||||
cursor: pointer;
|
||||
opacity: 0.4;
|
||||
font-size: $font-size-lg;
|
||||
line-height: 1;
|
||||
transition: opacity $transition-base;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tag hierarchy
|
||||
|
||||
.tag-group {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tag-children {
|
||||
margin-left: $space-8;
|
||||
margin-top: $space-2;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $space-2;
|
||||
}
|
||||
|
||||
.tag-confirm-delete {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $space-2;
|
||||
font-size: $font-size-sm;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.tag-confirm-yes {
|
||||
cursor: pointer;
|
||||
color: $error;
|
||||
font-weight: $font-weight-semibold;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-confirm-no {
|
||||
cursor: pointer;
|
||||
color: $text-2;
|
||||
font-weight: $font-weight-medium;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
// Detail view
|
||||
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: $space-8;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $space-4;
|
||||
}
|
||||
|
||||
.detail-field {
|
||||
padding: $space-5 $space-6;
|
||||
background: $bg-0;
|
||||
border-radius: $radius-sm;
|
||||
border: 1px solid $border-subtle;
|
||||
|
||||
&.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
margin-top: $space-2;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 64px;
|
||||
resize: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-2;
|
||||
@include text-uppercase($letter-spacing-wider);
|
||||
margin-bottom: $space-1;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: $font-size-lg;
|
||||
color: $text-0;
|
||||
word-break: break-all;
|
||||
|
||||
&.mono {
|
||||
font-family: $font-family-mono;
|
||||
font-size: $font-size-base;
|
||||
color: $text-1;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-preview {
|
||||
margin-bottom: $space-8;
|
||||
background: $bg-0;
|
||||
border: 1px solid $border-subtle;
|
||||
border-radius: $radius;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
|
||||
&:has(.markdown-viewer) {
|
||||
max-height: none;
|
||||
overflow-y: auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&:not(:has(.markdown-viewer)) {
|
||||
max-height: 450px;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 400px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
audio {
|
||||
width: 100%;
|
||||
padding: $space-8;
|
||||
}
|
||||
|
||||
video {
|
||||
max-width: 100%;
|
||||
max-height: 400px;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-no-preview {
|
||||
padding: $space-8 $space-8;
|
||||
text-align: center;
|
||||
@include flex(column, center, center, $space-5);
|
||||
}
|
||||
|
||||
// Frontmatter
|
||||
|
||||
.frontmatter-card {
|
||||
max-width: 800px;
|
||||
background: $bg-2;
|
||||
border: 1px solid $border;
|
||||
border-radius: $radius;
|
||||
padding: $space-6 $space-8;
|
||||
margin-bottom: $space-8;
|
||||
}
|
||||
|
||||
.frontmatter-fields {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: $space-2 $space-6;
|
||||
margin: 0;
|
||||
|
||||
dt {
|
||||
font-weight: $font-weight-semibold;
|
||||
font-size: $font-size-md;
|
||||
color: $text-1;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
dd {
|
||||
font-size: $font-size-lg;
|
||||
color: $text-0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Empty state
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px $space-6;
|
||||
color: $text-2;
|
||||
|
||||
.empty-icon {
|
||||
font-size: $font-size-7xl;
|
||||
margin-bottom: $space-6;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: $font-size-2xl;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-1;
|
||||
margin-bottom: $space-2;
|
||||
}
|
||||
|
||||
.empty-subtitle {
|
||||
font-size: $font-size-md;
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
line-height: $line-height-relaxed;
|
||||
}
|
||||
|
||||
// Toast
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: $space-8;
|
||||
right: $space-8;
|
||||
z-index: $z-toast;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 6px;
|
||||
align-items: flex-end;
|
||||
|
||||
.toast {
|
||||
position: static;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: $space-8;
|
||||
right: $space-8;
|
||||
padding: $space-5 $space-8;
|
||||
border-radius: $radius;
|
||||
background: $bg-3;
|
||||
border: 1px solid $border;
|
||||
color: $text-0;
|
||||
font-size: $font-size-md;
|
||||
box-shadow: $shadow;
|
||||
z-index: $z-toast;
|
||||
animation: slide-up 0.15s ease-out;
|
||||
max-width: 420px;
|
||||
|
||||
&.success {
|
||||
border-left: 3px solid $success;
|
||||
}
|
||||
|
||||
&.error {
|
||||
border-left: 3px solid $error;
|
||||
}
|
||||
}
|
||||
|
||||
// Banners
|
||||
|
||||
.offline-banner,
|
||||
.error-banner {
|
||||
background: $error-bg;
|
||||
border: 1px solid $error-border;
|
||||
border-radius: $radius-sm;
|
||||
padding: $space-5 $space-6;
|
||||
margin-bottom: $space-6;
|
||||
font-size: $font-size-md;
|
||||
color: $error-text;
|
||||
@include flex(row, flex-start, center, $space-4);
|
||||
|
||||
.offline-icon,
|
||||
.error-icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.readonly-banner {
|
||||
@include flex(row, flex-start, center, $space-4);
|
||||
padding: $space-5 $space-6;
|
||||
background: $warning-bg;
|
||||
border: 1px solid $warning-border;
|
||||
border-radius: $radius-sm;
|
||||
margin-bottom: $space-8;
|
||||
font-size: $font-size-md;
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
.batch-actions {
|
||||
@include flex(row, flex-start, center, $space-4);
|
||||
padding: $space-4 $space-5;
|
||||
background: $accent-dim;
|
||||
border: 1px solid $accent-border;
|
||||
border-radius: $radius-sm;
|
||||
margin-bottom: $space-6;
|
||||
font-size: $font-size-md;
|
||||
font-weight: $font-weight-medium;
|
||||
color: $accent-text;
|
||||
}
|
||||
|
||||
.select-all-banner {
|
||||
@include flex(row, center, center, $space-4);
|
||||
padding: $space-5 $space-8;
|
||||
background: $info-bg;
|
||||
border-radius: 6px;
|
||||
margin-bottom: $space-4;
|
||||
font-size: 0.85rem;
|
||||
color: $text-1;
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: $accent;
|
||||
cursor: pointer;
|
||||
font-weight: $font-weight-semibold;
|
||||
text-decoration: underline;
|
||||
font-size: 0.85rem;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
color: $text-0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import status
|
||||
|
||||
.import-status-panel {
|
||||
background: $bg-2;
|
||||
border: 1px solid $accent;
|
||||
border-radius: $radius;
|
||||
padding: $space-6 $space-8;
|
||||
margin-bottom: $space-8;
|
||||
}
|
||||
|
||||
.import-status-header {
|
||||
@include flex(row, flex-start, center, $space-4);
|
||||
margin-bottom: $space-4;
|
||||
font-size: $font-size-lg;
|
||||
color: $text-0;
|
||||
}
|
||||
|
||||
.import-current-file {
|
||||
@include flex(row, flex-start, center, $space-1);
|
||||
margin-bottom: 6px;
|
||||
font-size: $font-size-md;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.import-file-label {
|
||||
color: $text-2;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.import-file-name {
|
||||
color: $text-0;
|
||||
@include text-truncate;
|
||||
font-family: monospace;
|
||||
font-size: $font-size-base;
|
||||
}
|
||||
|
||||
.import-queue-indicator {
|
||||
@include flex(row, flex-start, center, $space-1);
|
||||
margin-bottom: $space-4;
|
||||
font-size: $font-size-base;
|
||||
}
|
||||
|
||||
.import-queue-badge {
|
||||
@include flex-center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 6px;
|
||||
background: $accent-dim;
|
||||
color: $accent-text;
|
||||
border-radius: 9px;
|
||||
font-weight: $font-weight-semibold;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
.import-queue-text {
|
||||
color: $text-2;
|
||||
}
|
||||
|
||||
.import-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: $space-8;
|
||||
border-bottom: 1px solid $border;
|
||||
}
|
||||
|
||||
.import-tab {
|
||||
padding: $space-5 $space-8;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: $text-2;
|
||||
font-size: $font-size-md;
|
||||
font-weight: $font-weight-medium;
|
||||
cursor: pointer;
|
||||
transition: color $transition-base, border-color $transition-base;
|
||||
|
||||
&:hover {
|
||||
color: $text-0;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: $accent-text;
|
||||
border-bottom-color: $accent;
|
||||
}
|
||||
}
|
||||
|
||||
// Queue panel
|
||||
|
||||
.queue-panel {
|
||||
@include flex(column);
|
||||
border-left: 1px solid $border;
|
||||
background: $bg-1;
|
||||
min-width: 280px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.queue-header {
|
||||
@include flex-between;
|
||||
padding: $space-6 $space-8;
|
||||
border-bottom: 1px solid $border-subtle;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: $text-0;
|
||||
}
|
||||
}
|
||||
|
||||
.queue-controls {
|
||||
display: flex;
|
||||
gap: $space-1;
|
||||
}
|
||||
|
||||
.queue-list {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.queue-item {
|
||||
@include flex(row, flex-start, center);
|
||||
padding: $space-4 $space-8;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid $border-subtle;
|
||||
transition: background $transition-slow;
|
||||
|
||||
&:hover {
|
||||
background: $bg-2;
|
||||
|
||||
.queue-item-remove {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&-active {
|
||||
background: $accent-dim;
|
||||
border-left: 3px solid $accent;
|
||||
}
|
||||
}
|
||||
|
||||
.queue-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.queue-item-title {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: $text-0;
|
||||
@include text-truncate;
|
||||
}
|
||||
|
||||
.queue-item-artist {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: $text-2;
|
||||
}
|
||||
|
||||
.queue-item-remove {
|
||||
opacity: 0;
|
||||
transition: opacity $transition-slow;
|
||||
}
|
||||
|
||||
.queue-empty {
|
||||
padding: $space-8 $space-8;
|
||||
text-align: center;
|
||||
color: $text-2;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
300
packages/pinakes-ui/assets/styles/_mixins.scss
Normal file
300
packages/pinakes-ui/assets/styles/_mixins.scss
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
@use 'variables' as *;
|
||||
|
||||
// Utility mixins
|
||||
|
||||
@mixin flex($direction: row, $justify: flex-start, $align: stretch, $gap: 0) {
|
||||
display: flex;
|
||||
flex-direction: $direction;
|
||||
justify-content: $justify;
|
||||
align-items: $align;
|
||||
@if $gap != 0 {
|
||||
gap: $gap;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@mixin flex-between {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// Text mixins
|
||||
|
||||
@mixin text-truncate {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@mixin text-uppercase($letter-spacing: $letter-spacing-wider) {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: $letter-spacing;
|
||||
}
|
||||
|
||||
@mixin font-mono($size: $font-size-md) {
|
||||
font-family: $font-family-mono;
|
||||
font-size: $size;
|
||||
}
|
||||
|
||||
// Button mixins
|
||||
|
||||
@mixin button-base {
|
||||
padding: 5px 12px;
|
||||
border-radius: $radius-sm;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: $font-size-md;
|
||||
font-weight: $font-weight-medium;
|
||||
transition: all $transition-base;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
white-space: nowrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@mixin button-variant($bg, $color, $border: null, $hover-bg: null) {
|
||||
background: $bg;
|
||||
color: $color;
|
||||
@if $border {
|
||||
border: 1px solid $border;
|
||||
}
|
||||
@if $hover-bg {
|
||||
&:hover {
|
||||
background: $hover-bg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin button-ghost {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
// Card mixins
|
||||
|
||||
@mixin card($padding: $space-8) {
|
||||
background: $bg-2;
|
||||
border: 1px solid $border;
|
||||
border-radius: $radius;
|
||||
padding: $padding;
|
||||
}
|
||||
|
||||
@mixin card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: $space-6;
|
||||
}
|
||||
|
||||
// Form mixins
|
||||
|
||||
@mixin input-base {
|
||||
padding: 6px 10px;
|
||||
border-radius: $radius-sm;
|
||||
border: 1px solid $border;
|
||||
background: $bg-0;
|
||||
color: $text-0;
|
||||
font-size: $font-size-lg;
|
||||
outline: none;
|
||||
transition: border-color $transition-slow;
|
||||
font-family: inherit;
|
||||
|
||||
&::placeholder {
|
||||
color: $text-2;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: $accent;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin form-label {
|
||||
display: block;
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-1;
|
||||
margin-bottom: $space-2;
|
||||
@include text-uppercase($letter-spacing-wide);
|
||||
}
|
||||
|
||||
// Scrollbar mixins
|
||||
|
||||
@mixin scrollbar($width: 5px, $thumb-color: $overlay-strong, $track-color: transparent) {
|
||||
&::-webkit-scrollbar {
|
||||
width: $width;
|
||||
height: $width;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: $track-color;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: $thumb-color;
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: $border-strong;
|
||||
}
|
||||
}
|
||||
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: $thumb-color $track-color;
|
||||
}
|
||||
|
||||
// Status/state mixins
|
||||
|
||||
@mixin status-dot($size: 6px) {
|
||||
width: $size;
|
||||
height: $size;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@mixin disabled-state($opacity: 0.4) {
|
||||
opacity: $opacity;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@mixin focus-outline($color: $accent, $width: 2px, $offset: 2px) {
|
||||
&:focus-visible {
|
||||
outline: $width solid $color;
|
||||
outline-offset: $offset;
|
||||
}
|
||||
}
|
||||
|
||||
// Animation mixins
|
||||
|
||||
@mixin animation($name, $duration: 0.7s, $timing: linear, $iteration: infinite) {
|
||||
animation: $name $duration $timing $iteration;
|
||||
}
|
||||
|
||||
@mixin fade-in($duration: 0.1s) {
|
||||
animation: fade-in $duration ease-out;
|
||||
}
|
||||
|
||||
@mixin slide-up($duration: 0.15s, $distance: 8px) {
|
||||
animation: slide-up $duration ease-out;
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY($distance);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin pulse($duration: 1.5s) {
|
||||
animation: pulse $duration infinite;
|
||||
}
|
||||
|
||||
@mixin skeleton-pulse {
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
background: $bg-3;
|
||||
}
|
||||
|
||||
// Badge mixins
|
||||
|
||||
@mixin badge-base {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $space-2;
|
||||
padding: $space-1 $space-3;
|
||||
border-radius: $radius-xl;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
@mixin badge-variant($bg, $color) {
|
||||
background: $bg;
|
||||
color: $color;
|
||||
}
|
||||
|
||||
// Tooltip mixins
|
||||
|
||||
@mixin tooltip-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: $bg-3;
|
||||
color: $text-2;
|
||||
font-size: $font-size-xs;
|
||||
font-weight: $font-weight-bold;
|
||||
cursor: help;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
margin-left: $space-2;
|
||||
|
||||
&:hover {
|
||||
background: $accent-dim;
|
||||
color: $accent-text;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin tooltip-content {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 6px 10px;
|
||||
background: $bg-3;
|
||||
border: 1px solid $border;
|
||||
border-radius: $radius-sm;
|
||||
color: $text-0;
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-normal;
|
||||
line-height: $line-height-normal;
|
||||
white-space: normal;
|
||||
width: 220px;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
box-shadow: $shadow;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Media queries
|
||||
|
||||
@mixin mobile {
|
||||
@media (max-width: 768px) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin tablet {
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin desktop {
|
||||
@media (min-width: 1025px) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
// Reduced motion
|
||||
|
||||
@mixin respect-reduced-motion {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
788
packages/pinakes-ui/assets/styles/_plugins.scss
Normal file
788
packages/pinakes-ui/assets/styles/_plugins.scss
Normal file
|
|
@ -0,0 +1,788 @@
|
|||
@use 'variables' as *;
|
||||
@use 'mixins' as *;
|
||||
|
||||
// Plugin UI renderer layout classes.
|
||||
//
|
||||
// Dynamic values are passed via CSS custom properties set on the element.
|
||||
// The layout rules here consume those properties via var() so the renderer
|
||||
// never injects full CSS rule strings.
|
||||
|
||||
// Page wrapper
|
||||
.plugin-page {
|
||||
padding: $space-8 $space-12;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.plugin-page-title {
|
||||
font-size: $font-size-xl;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-0;
|
||||
margin: 0 0 $space-8;
|
||||
}
|
||||
|
||||
// Container: vertical flex column with configurable gap and padding.
|
||||
.plugin-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--plugin-gap, 0px);
|
||||
padding: var(--plugin-padding, 0);
|
||||
}
|
||||
|
||||
// Grid: CSS grid with a configurable column count and gap.
|
||||
.plugin-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--plugin-columns, 1), 1fr);
|
||||
gap: var(--plugin-gap, 0px);
|
||||
}
|
||||
|
||||
// Flex: display:flex driven by data-* attribute selectors.
|
||||
// The gap is a CSS custom property; direction/justify/align/wrap are
|
||||
// plain enum strings placed in data attributes by the renderer.
|
||||
.plugin-flex {
|
||||
display: flex;
|
||||
gap: var(--plugin-gap, 0px);
|
||||
|
||||
&[data-direction='row'] { flex-direction: row; }
|
||||
&[data-direction='column'] { flex-direction: column; }
|
||||
|
||||
&[data-justify='flex-start'] { justify-content: flex-start; }
|
||||
&[data-justify='flex-end'] { justify-content: flex-end; }
|
||||
&[data-justify='center'] { justify-content: center; }
|
||||
&[data-justify='space-between'] { justify-content: space-between; }
|
||||
&[data-justify='space-around'] { justify-content: space-around; }
|
||||
&[data-justify='space-evenly'] { justify-content: space-evenly; }
|
||||
|
||||
&[data-align='flex-start'] { align-items: flex-start; }
|
||||
&[data-align='flex-end'] { align-items: flex-end; }
|
||||
&[data-align='center'] { align-items: center; }
|
||||
&[data-align='stretch'] { align-items: stretch; }
|
||||
&[data-align='baseline'] { align-items: baseline; }
|
||||
|
||||
&[data-wrap='wrap'] { flex-wrap: wrap; }
|
||||
&[data-wrap='nowrap'] { flex-wrap: nowrap; }
|
||||
}
|
||||
|
||||
// Split: side-by-side sidebar + main area.
|
||||
.plugin-split {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
// Sidebar width is driven by --plugin-sidebar-width.
|
||||
.plugin-split-sidebar {
|
||||
width: var(--plugin-sidebar-width, 200px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.plugin-split-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Card
|
||||
.plugin-card {
|
||||
background: $bg-2;
|
||||
border: 1px solid $border;
|
||||
border-radius: $radius-md;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plugin-card-header {
|
||||
padding: $space-6 $space-8;
|
||||
font-size: $font-size-md;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-0;
|
||||
border-bottom: 1px solid $border;
|
||||
background: $bg-3;
|
||||
}
|
||||
|
||||
.plugin-card-content {
|
||||
padding: $space-8;
|
||||
}
|
||||
|
||||
.plugin-card-footer {
|
||||
padding: $space-6 $space-8;
|
||||
border-top: 1px solid $border;
|
||||
background: $bg-1;
|
||||
}
|
||||
|
||||
// Typography
|
||||
.plugin-heading {
|
||||
color: $text-0;
|
||||
margin: 0;
|
||||
line-height: $line-height-tight;
|
||||
|
||||
&.level-1 { font-size: $font-size-6xl; font-weight: $font-weight-bold; }
|
||||
&.level-2 { font-size: $font-size-4xl; font-weight: $font-weight-semibold; }
|
||||
&.level-3 { font-size: $font-size-3xl; font-weight: $font-weight-semibold; }
|
||||
&.level-4 { font-size: $font-size-xl; font-weight: $font-weight-medium; }
|
||||
&.level-5 { font-size: $font-size-lg; font-weight: $font-weight-medium; }
|
||||
&.level-6 { font-size: $font-size-md; font-weight: $font-weight-medium; }
|
||||
}
|
||||
|
||||
.plugin-text {
|
||||
margin: 0;
|
||||
font-size: $font-size-md;
|
||||
color: $text-0;
|
||||
line-height: $line-height-normal;
|
||||
|
||||
&.text-secondary { color: $text-1; }
|
||||
&.text-error { color: $error-text; }
|
||||
&.text-success { color: $success; }
|
||||
&.text-warning { color: $warning; }
|
||||
&.text-bold { font-weight: $font-weight-semibold; }
|
||||
&.text-italic { font-style: italic; }
|
||||
&.text-small { font-size: $font-size-sm; }
|
||||
&.text-large { font-size: $font-size-2xl; }
|
||||
}
|
||||
|
||||
.plugin-code {
|
||||
background: $bg-1;
|
||||
border: 1px solid $border;
|
||||
border-radius: $radius;
|
||||
padding: $space-8 $space-12;
|
||||
font-family: $font-family-mono;
|
||||
font-size: $font-size-md;
|
||||
color: $text-0;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
|
||||
code {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
// Tabs
|
||||
.plugin-tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.plugin-tab-list {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
border-bottom: 1px solid $border;
|
||||
margin-bottom: $space-8;
|
||||
}
|
||||
|
||||
.plugin-tab {
|
||||
padding: $space-4 $space-10;
|
||||
font-size: $font-size-md;
|
||||
font-weight: $font-weight-medium;
|
||||
color: $text-1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: color $transition-base, border-color $transition-base;
|
||||
|
||||
&:hover {
|
||||
color: $text-0;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: $accent-text;
|
||||
border-bottom-color: $accent;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
margin-right: $space-2;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-tab-panels {}
|
||||
|
||||
.plugin-tab-panel {
|
||||
&:not(.active) { display: none; }
|
||||
}
|
||||
|
||||
// Description list
|
||||
.plugin-description-list-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.plugin-description-list {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: $space-2 $space-8;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
dt {
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
color: $text-1;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: $letter-spacing-uppercase;
|
||||
padding: $space-3 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
dd {
|
||||
font-size: $font-size-md;
|
||||
color: $text-0;
|
||||
padding: $space-3 0;
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
&.horizontal {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $space-8 $space-12;
|
||||
|
||||
dt {
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
dd {
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
// Pair dt+dd side by side
|
||||
dt, dd {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
// Each dt/dd pair sits in its own flex group via a wrapper approach.
|
||||
// Since we can't group them, use a two-column repeat trick instead.
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
|
||||
dt {
|
||||
font-size: $font-size-xs;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: $letter-spacing-uppercase;
|
||||
color: $text-2;
|
||||
margin-bottom: $space-1;
|
||||
}
|
||||
|
||||
dd {
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Data table
|
||||
.plugin-data-table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.plugin-data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: $font-size-md;
|
||||
|
||||
thead {
|
||||
tr {
|
||||
border-bottom: 1px solid $border-strong;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: $space-4 $space-6;
|
||||
text-align: left;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-1;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: $letter-spacing-uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
tbody {
|
||||
tr {
|
||||
border-bottom: 1px solid $border-subtle;
|
||||
transition: background $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: $overlay-light;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
padding: $space-4 $space-6;
|
||||
color: $text-0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Table column with a plugin-specified fixed width.
|
||||
.plugin-col-constrained {
|
||||
width: var(--plugin-col-width);
|
||||
}
|
||||
|
||||
.table-filter {
|
||||
margin-bottom: $space-6;
|
||||
|
||||
input {
|
||||
width: 240px;
|
||||
padding: $space-3 $space-6;
|
||||
background: $bg-1;
|
||||
border: 1px solid $border;
|
||||
border-radius: $radius;
|
||||
color: $text-0;
|
||||
font-size: $font-size-md;
|
||||
|
||||
&::placeholder { color: $text-2; }
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $accent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-6;
|
||||
padding: $space-4 0;
|
||||
font-size: $font-size-md;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
white-space: nowrap;
|
||||
width: 1%;
|
||||
|
||||
.plugin-button {
|
||||
padding: $space-2 $space-4;
|
||||
font-size: $font-size-sm;
|
||||
margin-right: $space-2;
|
||||
}
|
||||
}
|
||||
|
||||
// Media grid: reuses column/gap variables from plugin-grid.
|
||||
.plugin-media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--plugin-columns, 2), 1fr);
|
||||
gap: var(--plugin-gap, 8px);
|
||||
}
|
||||
|
||||
.media-grid-item {
|
||||
background: $bg-2;
|
||||
border: 1px solid $border;
|
||||
border-radius: $radius-md;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.media-grid-img {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.media-grid-no-img {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: $bg-3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: $font-size-sm;
|
||||
color: $text-2;
|
||||
}
|
||||
|
||||
.media-grid-caption {
|
||||
padding: $space-4 $space-6;
|
||||
font-size: $font-size-sm;
|
||||
color: $text-0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// List
|
||||
.plugin-list-wrapper {}
|
||||
|
||||
.plugin-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.plugin-list-item {
|
||||
padding: $space-4 0;
|
||||
}
|
||||
|
||||
.plugin-list-divider {
|
||||
border: none;
|
||||
border-top: 1px solid $border-subtle;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.plugin-list-empty {
|
||||
padding: $space-8;
|
||||
text-align: center;
|
||||
color: $text-2;
|
||||
font-size: $font-size-md;
|
||||
}
|
||||
|
||||
// Interactive: buttons
|
||||
.plugin-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $space-3;
|
||||
padding: $space-4 $space-8;
|
||||
border: 1px solid $border;
|
||||
border-radius: $radius;
|
||||
font-size: $font-size-md;
|
||||
font-weight: $font-weight-medium;
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast, border-color $transition-fast,
|
||||
color $transition-fast;
|
||||
background: $bg-2;
|
||||
color: $text-0;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.btn-primary {
|
||||
background: $accent;
|
||||
border-color: $accent;
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) { background: $accent-hover; }
|
||||
}
|
||||
|
||||
&.btn-secondary {
|
||||
background: $bg-3;
|
||||
border-color: $border-strong;
|
||||
color: $text-0;
|
||||
|
||||
&:hover:not(:disabled) { background: $overlay-medium; }
|
||||
}
|
||||
|
||||
&.btn-tertiary {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
color: $accent-text;
|
||||
|
||||
&:hover:not(:disabled) { background: $accent-dim; }
|
||||
}
|
||||
|
||||
&.btn-danger {
|
||||
background: transparent;
|
||||
border-color: $error-border;
|
||||
color: $error-text;
|
||||
|
||||
&:hover:not(:disabled) { background: $error-bg; }
|
||||
}
|
||||
|
||||
&.btn-success {
|
||||
background: transparent;
|
||||
border-color: $success-border;
|
||||
color: $success;
|
||||
|
||||
&:hover:not(:disabled) { background: $success-bg; }
|
||||
}
|
||||
|
||||
&.btn-ghost {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
color: $text-1;
|
||||
|
||||
&:hover:not(:disabled) { background: $btn-ghost-hover; }
|
||||
}
|
||||
}
|
||||
|
||||
// Badges
|
||||
.plugin-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: $space-1 $space-4;
|
||||
border-radius: $radius-full;
|
||||
font-size: $font-size-xs;
|
||||
font-weight: $font-weight-semibold;
|
||||
letter-spacing: $letter-spacing-uppercase;
|
||||
text-transform: uppercase;
|
||||
|
||||
&.badge-default, &.badge-neutral {
|
||||
background: $overlay-medium;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
&.badge-primary {
|
||||
background: $accent-dim;
|
||||
color: $accent-text;
|
||||
}
|
||||
|
||||
&.badge-secondary {
|
||||
background: $overlay-light;
|
||||
color: $text-0;
|
||||
}
|
||||
|
||||
&.badge-success {
|
||||
background: $success-bg;
|
||||
color: $success;
|
||||
}
|
||||
|
||||
&.badge-warning {
|
||||
background: $warning-bg;
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
&.badge-error {
|
||||
background: $error-bg;
|
||||
color: $error-text;
|
||||
}
|
||||
|
||||
&.badge-info {
|
||||
background: $info-bg;
|
||||
color: $accent-text;
|
||||
}
|
||||
}
|
||||
|
||||
// Form
|
||||
.plugin-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-8;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-3;
|
||||
|
||||
label {
|
||||
font-size: $font-size-md;
|
||||
font-weight: $font-weight-medium;
|
||||
color: $text-0;
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
padding: $space-4 $space-6;
|
||||
background: $bg-1;
|
||||
border: 1px solid $border;
|
||||
border-radius: $radius;
|
||||
color: $text-0;
|
||||
font-size: $font-size-md;
|
||||
font-family: inherit;
|
||||
|
||||
&::placeholder { color: $text-2; }
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $accent;
|
||||
box-shadow: 0 0 0 2px $accent-dim;
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 80px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23a0a0b8' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right $space-6 center;
|
||||
padding-right: $space-16;
|
||||
}
|
||||
}
|
||||
|
||||
.form-help {
|
||||
margin: 0;
|
||||
font-size: $font-size-sm;
|
||||
color: $text-2;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: $space-6;
|
||||
padding-top: $space-4;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: $error;
|
||||
}
|
||||
|
||||
// Link
|
||||
.plugin-link {
|
||||
color: $accent-text;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover { text-decoration: underline; }
|
||||
}
|
||||
|
||||
.plugin-link-blocked {
|
||||
color: $text-2;
|
||||
text-decoration: line-through;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
// Progress
|
||||
.plugin-progress {
|
||||
background: $bg-1;
|
||||
border: 1px solid $border;
|
||||
border-radius: $radius;
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-4;
|
||||
}
|
||||
|
||||
.plugin-progress-bar {
|
||||
height: 100%;
|
||||
background: $accent;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
width: var(--plugin-progress, 0%);
|
||||
}
|
||||
|
||||
.plugin-progress-label {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-1;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Chart wrapper: height is driven by --plugin-chart-height.
|
||||
.plugin-chart {
|
||||
overflow: auto;
|
||||
height: var(--plugin-chart-height, 200px);
|
||||
|
||||
.chart-title {
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-0;
|
||||
margin-bottom: $space-4;
|
||||
}
|
||||
|
||||
.chart-x-label, .chart-y-label {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-2;
|
||||
margin-bottom: $space-2;
|
||||
}
|
||||
|
||||
.chart-data-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.chart-no-data {
|
||||
padding: $space-12;
|
||||
text-align: center;
|
||||
color: $text-2;
|
||||
font-size: $font-size-md;
|
||||
}
|
||||
}
|
||||
|
||||
// Loading / error states
|
||||
.plugin-loading {
|
||||
padding: $space-8;
|
||||
color: $text-1;
|
||||
font-size: $font-size-md;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.plugin-error {
|
||||
padding: $space-6 $space-8;
|
||||
background: $error-bg;
|
||||
border: 1px solid $error-border;
|
||||
border-radius: $radius;
|
||||
color: $error-text;
|
||||
font-size: $font-size-md;
|
||||
}
|
||||
|
||||
// Feedback toast
|
||||
.plugin-feedback {
|
||||
position: sticky;
|
||||
bottom: $space-8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $space-8;
|
||||
padding: $space-6 $space-8;
|
||||
border-radius: $radius-md;
|
||||
font-size: $font-size-md;
|
||||
z-index: $z-toast;
|
||||
box-shadow: $shadow-lg;
|
||||
|
||||
&.success {
|
||||
background: $success-bg;
|
||||
border: 1px solid $success-border;
|
||||
color: $success;
|
||||
}
|
||||
|
||||
&.error {
|
||||
background: $error-bg;
|
||||
border: 1px solid $error-border;
|
||||
color: $error-text;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-feedback-dismiss {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: $font-size-xl;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover { opacity: 1; }
|
||||
}
|
||||
|
||||
// Modal
|
||||
.plugin-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: $z-modal-backdrop;
|
||||
}
|
||||
|
||||
.plugin-modal {
|
||||
position: relative;
|
||||
background: $bg-2;
|
||||
border: 1px solid $border-strong;
|
||||
border-radius: $radius-xl;
|
||||
padding: $space-16;
|
||||
min-width: 380px;
|
||||
max-width: 640px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: $shadow-lg;
|
||||
z-index: $z-modal;
|
||||
}
|
||||
|
||||
.plugin-modal-close {
|
||||
position: absolute;
|
||||
top: $space-8;
|
||||
right: $space-8;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: $text-1;
|
||||
font-size: $font-size-xl;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: $space-2;
|
||||
border-radius: $radius;
|
||||
|
||||
&:hover {
|
||||
background: $overlay-medium;
|
||||
color: $text-0;
|
||||
}
|
||||
}
|
||||
508
packages/pinakes-ui/assets/styles/_sections.scss
Normal file
508
packages/pinakes-ui/assets/styles/_sections.scss
Normal file
|
|
@ -0,0 +1,508 @@
|
|||
@use 'variables' as *;
|
||||
@use 'mixins' as *;
|
||||
|
||||
// Statistics
|
||||
|
||||
.statistics-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stats-overview,
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: $space-8;
|
||||
margin-bottom: $space-12;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: $bg-2;
|
||||
border: 1px solid $border;
|
||||
border-radius: $radius;
|
||||
padding: 20px;
|
||||
@include flex(row, flex-start, center, $space-8);
|
||||
|
||||
&.stat-primary {
|
||||
border-left: 3px solid $accent;
|
||||
}
|
||||
|
||||
&.stat-success {
|
||||
border-left: 3px solid $success;
|
||||
}
|
||||
|
||||
&.stat-info {
|
||||
border-left: 3px solid $type-document-text;
|
||||
}
|
||||
|
||||
&.stat-warning {
|
||||
border-left: 3px solid $warning;
|
||||
}
|
||||
|
||||
&.stat-purple {
|
||||
border-left: 3px solid $type-audio-text;
|
||||
}
|
||||
|
||||
&.stat-danger {
|
||||
border-left: 3px solid $error;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
flex-shrink: 0;
|
||||
color: $text-2;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: $font-size-6xl;
|
||||
font-weight: $font-weight-bold;
|
||||
color: $text-0;
|
||||
line-height: $line-height-tight;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: $font-size-md;
|
||||
color: $text-2;
|
||||
margin-top: $space-2;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
background: $bg-2;
|
||||
border: 1px solid $border;
|
||||
border-radius: $radius;
|
||||
padding: $space-8;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: $font-size-3xl;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-0;
|
||||
margin-bottom: 20px;
|
||||
|
||||
// Alternative smaller size used in some places
|
||||
&.small {
|
||||
font-size: $font-size-xl;
|
||||
margin-bottom: $space-6;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid $border-subtle;
|
||||
}
|
||||
}
|
||||
|
||||
// Chart bars
|
||||
|
||||
.chart-bars {
|
||||
@include flex(column, flex-start, stretch, $space-8);
|
||||
}
|
||||
|
||||
.bar-item {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr 80px;
|
||||
align-items: center;
|
||||
gap: $space-8;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-medium;
|
||||
color: $text-1;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.bar-track {
|
||||
height: 28px;
|
||||
background: $bg-3;
|
||||
border-radius: $radius-sm;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border-radius: $radius-sm;
|
||||
|
||||
&.bar-primary {
|
||||
background: linear-gradient(90deg, $accent 0%, $gradient-accent-end 100%);
|
||||
}
|
||||
|
||||
&.bar-success {
|
||||
background: linear-gradient(90deg, $success 0%, $gradient-success-end 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.bar-value {
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-1;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
// Settings
|
||||
|
||||
.settings-section {
|
||||
margin-bottom: $space-8;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
background: $bg-2;
|
||||
border: 1px solid $border;
|
||||
border-radius: $radius-md;
|
||||
padding: 20px;
|
||||
margin-bottom: $space-8;
|
||||
|
||||
&.danger-card {
|
||||
border: 1px solid $error-border-light;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-card-header {
|
||||
@include flex-between;
|
||||
margin-bottom: $space-8;
|
||||
padding-bottom: $space-6;
|
||||
border-bottom: 1px solid $border-subtle;
|
||||
}
|
||||
|
||||
.settings-card-title {
|
||||
font-size: $font-size-xl;
|
||||
font-weight: $font-weight-semibold;
|
||||
}
|
||||
|
||||
.settings-card-body {
|
||||
padding-top: $space-1;
|
||||
}
|
||||
|
||||
.settings-field {
|
||||
@include flex-between;
|
||||
padding: $space-4 0;
|
||||
border-bottom: 1px solid $border-subtle;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
select {
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
// Config status
|
||||
|
||||
.config-path {
|
||||
font-size: $font-size-base;
|
||||
color: $text-2;
|
||||
margin-bottom: $space-6;
|
||||
font-family: $font-family-mono;
|
||||
padding: 6px $space-5;
|
||||
background: $bg-0;
|
||||
border-radius: $radius-sm;
|
||||
border: 1px solid $border-subtle;
|
||||
}
|
||||
|
||||
.config-status {
|
||||
@include flex(row, flex-start, center, 6px);
|
||||
padding: 3px $space-5;
|
||||
border-radius: 12px;
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-semibold;
|
||||
|
||||
&.writable {
|
||||
background: $success-light;
|
||||
color: $success;
|
||||
}
|
||||
|
||||
&.readonly {
|
||||
background: $error-medium;
|
||||
color: $error;
|
||||
}
|
||||
}
|
||||
|
||||
// Root list
|
||||
|
||||
.root-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.root-item {
|
||||
@include flex-between;
|
||||
padding: $space-4 $space-6;
|
||||
background: $bg-0;
|
||||
border: 1px solid $border-subtle;
|
||||
border-radius: $radius-sm;
|
||||
margin-bottom: $space-2;
|
||||
font-family: $font-family-mono;
|
||||
font-size: $font-size-md;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
// Info row
|
||||
|
||||
.info-row {
|
||||
@include flex-between;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid $border-subtle;
|
||||
font-size: $font-size-lg;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: $text-1;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: $text-0;
|
||||
}
|
||||
|
||||
// Tasks
|
||||
|
||||
.tasks-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
||||
gap: $space-8;
|
||||
padding: $space-6;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
background: $bg-2;
|
||||
border: 1px solid $border;
|
||||
border-radius: $radius;
|
||||
overflow: hidden;
|
||||
transition: all $transition-slower;
|
||||
|
||||
&:hover {
|
||||
border-color: $border-strong;
|
||||
box-shadow: $shadow-card-hover;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&-enabled {
|
||||
border-left: 3px solid $success;
|
||||
}
|
||||
|
||||
&-disabled {
|
||||
border-left: 3px solid $text-3;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.task-card-header {
|
||||
@include flex-between;
|
||||
align-items: flex-start;
|
||||
padding: $space-8;
|
||||
border-bottom: 1px solid $border-subtle;
|
||||
}
|
||||
|
||||
.task-header-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-name {
|
||||
font-size: $font-size-3xl;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-0;
|
||||
margin-bottom: $space-1;
|
||||
}
|
||||
|
||||
.task-schedule {
|
||||
@include flex(row, flex-start, center, 6px);
|
||||
font-size: $font-size-md;
|
||||
color: $text-2;
|
||||
font-family: $font-family-mono-alt;
|
||||
}
|
||||
|
||||
.schedule-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.task-status-badge {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
@include flex(row, flex-start, center, 6px);
|
||||
padding: $space-1 10px;
|
||||
border-radius: $radius-sm;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-semibold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: $letter-spacing-wide;
|
||||
|
||||
&.status-enabled {
|
||||
background: $green-medium;
|
||||
color: $success;
|
||||
|
||||
.status-dot {
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
&.status-disabled {
|
||||
background: $bg-3;
|
||||
color: $text-2;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
@include status-dot;
|
||||
background: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.task-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: $space-6;
|
||||
padding: $space-8;
|
||||
}
|
||||
|
||||
.task-info-item {
|
||||
@include flex(row, flex-start, flex-start, 10px);
|
||||
}
|
||||
|
||||
.task-info-icon {
|
||||
font-size: 18px;
|
||||
color: $text-2;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-info-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-info-label {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-2;
|
||||
font-weight: $font-weight-semibold;
|
||||
@include text-uppercase($letter-spacing-wide);
|
||||
margin-bottom: $space-1;
|
||||
}
|
||||
|
||||
.task-info-value {
|
||||
font-size: $font-size-md;
|
||||
color: $text-1;
|
||||
font-weight: $font-weight-medium;
|
||||
@include text-truncate;
|
||||
}
|
||||
|
||||
.task-card-actions {
|
||||
display: flex;
|
||||
gap: $space-4;
|
||||
padding: $space-5 $space-8;
|
||||
background: $bg-1;
|
||||
border-top: 1px solid $border-subtle;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Database actions
|
||||
|
||||
.db-actions {
|
||||
@include flex(column, flex-start, stretch, $space-8);
|
||||
padding: $space-5;
|
||||
}
|
||||
|
||||
.db-action-row {
|
||||
@include flex(row, space-between, center, $space-8);
|
||||
padding: $space-5;
|
||||
border-radius: 6px;
|
||||
background: $overlay-inverse-light;
|
||||
}
|
||||
|
||||
.db-action-info {
|
||||
flex: 1;
|
||||
|
||||
h4 {
|
||||
font-size: 0.95rem;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-0;
|
||||
margin-bottom: $space-1;
|
||||
}
|
||||
}
|
||||
|
||||
.db-action-confirm {
|
||||
@include flex(row, flex-start, center, $space-4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Library page
|
||||
|
||||
.library-toolbar {
|
||||
@include flex-between;
|
||||
padding: $space-4 0;
|
||||
margin-bottom: $space-6;
|
||||
gap: $space-6;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
@include flex(row, flex-start, center, $space-5);
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
@include flex(row, flex-start, center, $space-5);
|
||||
}
|
||||
|
||||
.sort-control,
|
||||
.page-size-control {
|
||||
select {
|
||||
padding: $space-2 24px $space-2 $space-4;
|
||||
font-size: $font-size-base;
|
||||
background: $bg-2;
|
||||
}
|
||||
}
|
||||
|
||||
.page-size-control {
|
||||
@include flex(row, flex-start, center, $space-2);
|
||||
}
|
||||
|
||||
.library-stats {
|
||||
@include flex-between;
|
||||
padding: $space-1 0 6px 0;
|
||||
font-size: $font-size-base;
|
||||
}
|
||||
|
||||
.type-filter-row {
|
||||
@include flex(row, flex-start, center, 6px);
|
||||
padding: $space-2 0;
|
||||
margin-bottom: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
// Pagination
|
||||
|
||||
.pagination {
|
||||
@include flex-center;
|
||||
gap: $space-2;
|
||||
margin-top: $space-8;
|
||||
padding: $space-4 0;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
min-width: 28px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.page-ellipsis {
|
||||
color: $text-2;
|
||||
padding: 0 $space-2;
|
||||
font-size: $font-size-md;
|
||||
user-select: none;
|
||||
}
|
||||
281
packages/pinakes-ui/assets/styles/_themes.scss
Normal file
281
packages/pinakes-ui/assets/styles/_themes.scss
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
@use 'variables' as *;
|
||||
@use 'mixins' as *;
|
||||
|
||||
// Light theme
|
||||
|
||||
.theme-light {
|
||||
// Background
|
||||
--bg-0: #{$light-bg-0};
|
||||
--bg-1: #{$light-bg-1};
|
||||
--bg-2: #{$light-bg-2};
|
||||
--bg-3: #{$light-bg-3};
|
||||
|
||||
// Border
|
||||
--border-subtle: #{$light-border-subtle};
|
||||
--border: #{$light-border};
|
||||
--border-strong: #{$light-border-strong};
|
||||
|
||||
// Text
|
||||
--text-0: #{$light-text-0};
|
||||
--text-1: #{$light-text-1};
|
||||
--text-2: #{$light-text-2};
|
||||
|
||||
// Accent
|
||||
--accent: #{$light-accent};
|
||||
--accent-dim: #{$light-accent-dim};
|
||||
--accent-text: #{$light-accent-text};
|
||||
|
||||
// Shadows
|
||||
--shadow-sm: #{$light-shadow-sm};
|
||||
--shadow: #{$light-shadow};
|
||||
--shadow-lg: #{$light-shadow-lg};
|
||||
|
||||
// Scrollbar
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: $overlay-inverse-strong;
|
||||
|
||||
&:hover {
|
||||
background: $overlay-inverse-medium;
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: $overlay-inverse-light;
|
||||
}
|
||||
|
||||
// Graph
|
||||
.graph-nodes .graph-node text {
|
||||
fill: $light-text-0;
|
||||
}
|
||||
|
||||
.graph-edges line {
|
||||
stroke: $overlay-inverse-strong;
|
||||
}
|
||||
|
||||
// PDF
|
||||
.pdf-container {
|
||||
background: $light-bg-3;
|
||||
}
|
||||
}
|
||||
|
||||
// Skeleton
|
||||
|
||||
.skeleton-pulse {
|
||||
@include skeleton-pulse;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
@include flex(column, flex-start, stretch, $space-4);
|
||||
padding: $space-4;
|
||||
}
|
||||
|
||||
.skeleton-thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.skeleton-text {
|
||||
height: 14px;
|
||||
width: 80%;
|
||||
|
||||
&-short {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-row {
|
||||
display: flex;
|
||||
gap: $space-6;
|
||||
padding: 10px $space-8;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.skeleton-cell {
|
||||
height: 14px;
|
||||
flex: 1;
|
||||
border-radius: 4px;
|
||||
|
||||
&-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&-wide {
|
||||
flex: 3;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@include flex(column, center, center, $space-5);
|
||||
background: $media-overlay-light;
|
||||
z-index: 100;
|
||||
border-radius: $radius-lg;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid $border;
|
||||
border-top-color: $accent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
color: $text-1;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
// Login
|
||||
|
||||
.login-container {
|
||||
@include flex-center;
|
||||
height: 100vh;
|
||||
background: $bg-0;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: $bg-2;
|
||||
border: 1px solid $border;
|
||||
border-radius: $radius-md;
|
||||
padding: $space-12;
|
||||
width: 360px;
|
||||
box-shadow: $shadow-lg;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 20px;
|
||||
font-weight: $font-weight-bold;
|
||||
color: $text-0;
|
||||
text-align: center;
|
||||
margin-bottom: $space-1;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
font-size: $font-size-lg;
|
||||
color: $text-2;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
background: $error-light;
|
||||
border: 1px solid $error-border;
|
||||
border-radius: $radius-sm;
|
||||
padding: $space-4 $space-6;
|
||||
margin-bottom: $space-6;
|
||||
font-size: $font-size-md;
|
||||
color: $error;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
input[type='text'],
|
||||
input[type='password'] {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
padding: $space-4 $space-8;
|
||||
font-size: $font-size-lg;
|
||||
margin-top: $space-1;
|
||||
}
|
||||
|
||||
// Pagination
|
||||
|
||||
.pagination {
|
||||
@include flex(row, center, center, $space-1);
|
||||
margin-top: $space-8;
|
||||
padding: $space-4 0;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
min-width: 28px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.page-ellipsis {
|
||||
color: $text-2;
|
||||
padding: 0 $space-2;
|
||||
font-size: $font-size-md;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
// Help overlay
|
||||
|
||||
.help-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: $media-overlay-medium;
|
||||
@include flex-center;
|
||||
z-index: 200;
|
||||
animation: fade-in 0.1s ease-out;
|
||||
}
|
||||
|
||||
.help-dialog {
|
||||
background: $bg-2;
|
||||
border: 1px solid $border;
|
||||
border-radius: $radius-md;
|
||||
padding: $space-8;
|
||||
min-width: 300px;
|
||||
max-width: 400px;
|
||||
box-shadow: $shadow-lg;
|
||||
|
||||
h3 {
|
||||
font-size: $font-size-3xl;
|
||||
font-weight: $font-weight-semibold;
|
||||
margin-bottom: $space-8;
|
||||
}
|
||||
}
|
||||
|
||||
.help-shortcuts {
|
||||
@include flex(column, flex-start, stretch, $space-4);
|
||||
margin-bottom: $space-8;
|
||||
}
|
||||
|
||||
.shortcut-row {
|
||||
@include flex(row, flex-start, center, $space-6);
|
||||
|
||||
kbd {
|
||||
display: inline-block;
|
||||
padding: $space-1 $space-4;
|
||||
background: $bg-0;
|
||||
border: 1px solid $border;
|
||||
border-radius: $radius-sm;
|
||||
font-family: $font-family-mono;
|
||||
font-size: $font-size-base;
|
||||
color: $text-0;
|
||||
min-width: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: $font-size-lg;
|
||||
color: $text-1;
|
||||
}
|
||||
}
|
||||
|
||||
.help-close {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 6px $space-6;
|
||||
background: $bg-3;
|
||||
border: 1px solid $border;
|
||||
border-radius: $radius-sm;
|
||||
color: $text-0;
|
||||
font-size: $font-size-md;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
background: $overlay-strong;
|
||||
}
|
||||
}
|
||||
256
packages/pinakes-ui/assets/styles/_variables.scss
Normal file
256
packages/pinakes-ui/assets/styles/_variables.scss
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
// Background colors
|
||||
$bg-0: #111118;
|
||||
$bg-1: #18181f;
|
||||
$bg-2: #1f1f28;
|
||||
$bg-3: #26263a;
|
||||
|
||||
// Border colors
|
||||
$border-subtle: rgba(255, 255, 255, 0.06);
|
||||
$border: rgba(255, 255, 255, 0.09);
|
||||
$border-strong: rgba(255, 255, 255, 0.14);
|
||||
|
||||
// Text colors
|
||||
$text-0: #dcdce4;
|
||||
$text-1: #a0a0b8;
|
||||
$text-2: #6c6c84;
|
||||
$text-3: #4a4a5e;
|
||||
|
||||
// Accent colors
|
||||
$accent: #7c7ef5;
|
||||
$accent-dim: rgba(124, 126, 245, 0.15);
|
||||
$accent-text: #9698f7;
|
||||
$accent-hover: #8b8df7;
|
||||
|
||||
// Semantic colors
|
||||
$success: #3ec97a;
|
||||
$error: #e45858;
|
||||
$warning: #d4a037;
|
||||
|
||||
// Derived semantic colors with transparency
|
||||
$error-bg: rgba(228, 88, 88, 0.06);
|
||||
$error-border: rgba(228, 88, 88, 0.2);
|
||||
$error-text: #d47070;
|
||||
|
||||
$success-bg: rgba(62, 201, 122, 0.08);
|
||||
$success-border: rgba(62, 201, 122, 0.2);
|
||||
|
||||
$warning-bg: rgba(212, 160, 55, 0.06);
|
||||
$warning-border: rgba(212, 160, 55, 0.15);
|
||||
|
||||
// Type badge colors
|
||||
$type-audio-bg: rgba(139, 92, 246, 0.1);
|
||||
$type-audio-text: #9d8be0;
|
||||
|
||||
$type-video-bg: rgba(200, 72, 130, 0.1);
|
||||
$type-video-text: #d07eaa;
|
||||
|
||||
$type-image-bg: rgba(34, 160, 80, 0.1);
|
||||
$type-image-text: #5cb97a;
|
||||
|
||||
$type-document-bg: rgba(59, 120, 200, 0.1);
|
||||
$type-document-text: #6ca0d4;
|
||||
|
||||
$type-text-bg: rgba(200, 160, 36, 0.1);
|
||||
$type-text-text: #c4a840;
|
||||
|
||||
$type-other-bg: rgba(128, 128, 160, 0.08);
|
||||
|
||||
// Action badge colors (audit)
|
||||
$action-updated-bg: rgba(59, 120, 200, 0.1);
|
||||
$action-updated-text: #6ca0d4;
|
||||
|
||||
$action-collection-bg: rgba(34, 160, 80, 0.1);
|
||||
$action-collection-text: #5cb97a;
|
||||
|
||||
$action-collection-remove-bg: rgba(212, 160, 55, 0.1);
|
||||
$action-collection-remove-text: #c4a840;
|
||||
|
||||
$action-opened-bg: rgba(139, 92, 246, 0.1);
|
||||
$action-opened-text: #9d8be0;
|
||||
|
||||
$action-scanned-bg: rgba(128, 128, 160, 0.08);
|
||||
|
||||
// Role badge colors
|
||||
$role-admin-bg: rgba(139, 92, 246, 0.1);
|
||||
$role-admin-text: #9d8be0;
|
||||
|
||||
$role-editor-bg: rgba(34, 160, 80, 0.1);
|
||||
$role-editor-text: #5cb97a;
|
||||
|
||||
$role-viewer-bg: rgba(59, 120, 200, 0.1);
|
||||
$role-viewer-text: #6ca0d4;
|
||||
|
||||
// Graph colors
|
||||
$graph-node-fill: #4caf50;
|
||||
$graph-node-stroke: #388e3c;
|
||||
$graph-node-hover: #66bb6a;
|
||||
$graph-node-selected: #5456d6;
|
||||
$graph-edge-embed: #9d8be0;
|
||||
|
||||
// Overlay backgrounds
|
||||
$overlay-light: rgba(255, 255, 255, 0.03);
|
||||
$overlay-medium: rgba(255, 255, 255, 0.04);
|
||||
$overlay-strong: rgba(255, 255, 255, 0.06);
|
||||
$overlay-subtle: rgba(255, 255, 255, 0.02);
|
||||
|
||||
// Inverse overlays (for light backgrounds)
|
||||
$overlay-inverse-light: rgba(0, 0, 0, 0.06);
|
||||
$overlay-inverse-medium: rgba(0, 0, 0, 0.08);
|
||||
$overlay-inverse-strong: rgba(0, 0, 0, 0.12);
|
||||
|
||||
// Semantic variants
|
||||
$error-light: rgba(228, 88, 88, 0.08);
|
||||
$error-medium: rgba(228, 88, 88, 0.1);
|
||||
$error-border-light: rgba(228, 88, 88, 0.25);
|
||||
|
||||
$success-light: rgba(62, 201, 122, 0.1);
|
||||
|
||||
$info-bg: rgba(99, 102, 241, 0.08);
|
||||
$info-bg-light: rgba(99, 102, 241, 0.12);
|
||||
$accent-border: rgba(124, 126, 245, 0.2);
|
||||
|
||||
$purple-bg: rgba(124, 126, 245, 0.04);
|
||||
$purple-light: rgba(139, 92, 246, 0.08);
|
||||
$purple-border: rgba(139, 92, 246, 0.3);
|
||||
|
||||
$warning-light: rgba(212, 160, 55, 0.1);
|
||||
$warning-medium: rgba(212, 160, 55, 0.12);
|
||||
|
||||
$green-light: rgba(76, 175, 80, 0.06);
|
||||
$green-medium: rgba(76, 175, 80, 0.12);
|
||||
$green-text: #4caf50;
|
||||
|
||||
// UI element backgrounds
|
||||
$btn-danger-hover: rgba(228, 88, 88, 0.08);
|
||||
$btn-ghost-hover: rgba(255, 255, 255, 0.04);
|
||||
$btn-secondary-hover: rgba(255, 255, 255, 0.06);
|
||||
|
||||
// Media viewer overlays
|
||||
$media-overlay-bg: rgba(0, 0, 0, 0.92);
|
||||
$media-overlay-medium: rgba(0, 0, 0, 0.5);
|
||||
$media-overlay-light: rgba(0, 0, 0, 0.3);
|
||||
$media-controls-bg: rgba(0, 0, 0, 0.7);
|
||||
|
||||
// Image viewer
|
||||
$image-viewer-toolbar-bg: rgba(0, 0, 0, 0.5);
|
||||
$image-viewer-border: rgba(255, 255, 255, 0.08);
|
||||
$image-viewer-btn-bg: rgba(255, 255, 255, 0.06);
|
||||
$image-viewer-btn-border: rgba(255, 255, 255, 0.1);
|
||||
$image-viewer-btn-hover: rgba(255, 255, 255, 0.12);
|
||||
|
||||
// Shadows
|
||||
$drop-shadow: rgba(0, 0, 0, 0.5);
|
||||
|
||||
// Gradients
|
||||
$gradient-accent-end: #7c7ef3;
|
||||
$gradient-success-end: #66bb6a;
|
||||
|
||||
// Light theme
|
||||
$light-bg-0: #f5f5f7;
|
||||
$light-bg-1: #eeeef0;
|
||||
$light-bg-2: #ffffff;
|
||||
$light-bg-3: #e8e8ec;
|
||||
|
||||
$light-border-subtle: rgba(0, 0, 0, 0.06);
|
||||
$light-border: rgba(0, 0, 0, 0.1);
|
||||
$light-border-strong: rgba(0, 0, 0, 0.16);
|
||||
|
||||
$light-text-0: #1a1a2e;
|
||||
$light-text-1: #555570;
|
||||
$light-text-2: #8888a0;
|
||||
|
||||
$light-accent: #6366f1;
|
||||
$light-accent-dim: rgba(99, 102, 241, 0.1);
|
||||
$light-accent-text: #4f52e8;
|
||||
|
||||
// Spacing
|
||||
$space-1: 2px;
|
||||
$space-2: 4px;
|
||||
$space-3: 6px;
|
||||
$space-4: 8px;
|
||||
$space-5: 10px;
|
||||
$space-6: 12px;
|
||||
$space-7: 14px;
|
||||
$space-8: 16px;
|
||||
$space-10: 20px;
|
||||
$space-12: 24px;
|
||||
$space-16: 32px;
|
||||
$space-20: 40px;
|
||||
$space-24: 48px;
|
||||
|
||||
// Border radius
|
||||
$radius-sm: 3px;
|
||||
$radius: 5px;
|
||||
$radius-md: 7px;
|
||||
$radius-lg: 8px;
|
||||
$radius-xl: 12px;
|
||||
$radius-full: 50%;
|
||||
|
||||
// Shadows
|
||||
$shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
$shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
|
||||
$shadow-lg: 0 4px 20px rgba(0, 0, 0, 0.45);
|
||||
$shadow-card-hover: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
|
||||
$light-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
$light-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
$light-shadow-lg: 0 4px 20px rgba(0, 0, 0, 0.12);
|
||||
|
||||
// Typography
|
||||
$font-family-base: 'Inter', -apple-system, 'Segoe UI', system-ui, sans-serif;
|
||||
$font-family-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
|
||||
$font-family-mono-alt: 'Menlo', 'Monaco', 'Courier New', monospace;
|
||||
|
||||
$font-size-xs: 9px;
|
||||
$font-size-sm: 10px;
|
||||
$font-size-base: 11px;
|
||||
$font-size-md: 12px;
|
||||
$font-size-lg: 13px;
|
||||
$font-size-xl: 14px;
|
||||
$font-size-2xl: 15px;
|
||||
$font-size-3xl: 16px;
|
||||
$font-size-4xl: 18px;
|
||||
$font-size-5xl: 20px;
|
||||
$font-size-6xl: 28px;
|
||||
$font-size-7xl: 32px;
|
||||
$font-size-8xl: 48px;
|
||||
|
||||
$font-weight-normal: 400;
|
||||
$font-weight-medium: 500;
|
||||
$font-weight-semibold: 600;
|
||||
$font-weight-bold: 700;
|
||||
|
||||
$line-height-tight: 1.2;
|
||||
$line-height-base: 1.3;
|
||||
$line-height-normal: 1.4;
|
||||
$line-height-relaxed: 1.5;
|
||||
$line-height-loose: 1.7;
|
||||
|
||||
$letter-spacing-tight: -0.4px;
|
||||
$letter-spacing-normal: 0;
|
||||
$letter-spacing-wide: 0.03em;
|
||||
$letter-spacing-wider: 0.04em;
|
||||
$letter-spacing-widest: 0.06em;
|
||||
$letter-spacing-uppercase: 0.5px;
|
||||
|
||||
// Layout dimensions
|
||||
$sidebar-width: 220px;
|
||||
$sidebar-collapsed-width: 48px;
|
||||
$header-height: 48px;
|
||||
|
||||
// Z-index scale
|
||||
$z-base: 0;
|
||||
$z-dropdown: 10;
|
||||
$z-sticky: 20;
|
||||
$z-fixed: 30;
|
||||
$z-modal-backdrop: 100;
|
||||
$z-modal: 200;
|
||||
$z-toast: 300;
|
||||
|
||||
// Transitions
|
||||
$transition-fast: 0.08s;
|
||||
$transition-base: 0.1s;
|
||||
$transition-slow: 0.15s;
|
||||
$transition-slower: 0.2s;
|
||||
$transition-timing-default: ease;
|
||||
$transition-timing-smooth: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
14
packages/pinakes-ui/assets/styles/main.scss
Normal file
14
packages/pinakes-ui/assets/styles/main.scss
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// Main stylesheet entry point
|
||||
// Imports all partials in order
|
||||
|
||||
@use 'variables' as *;
|
||||
@use 'mixins' as *;
|
||||
@use 'base';
|
||||
@use 'layout';
|
||||
@use 'components';
|
||||
@use 'media';
|
||||
@use 'sections';
|
||||
@use 'audit';
|
||||
@use 'graph';
|
||||
@use 'themes';
|
||||
@use 'plugins';
|
||||
2930
packages/pinakes-ui/src/app.rs
Normal file
2930
packages/pinakes-ui/src/app.rs
Normal file
File diff suppressed because it is too large
Load diff
2378
packages/pinakes-ui/src/client.rs
Normal file
2378
packages/pinakes-ui/src/client.rs
Normal file
File diff suppressed because it is too large
Load diff
126
packages/pinakes-ui/src/components/audit.rs
Normal file
126
packages/pinakes-ui/src/components/audit.rs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
use dioxus::prelude::*;
|
||||
|
||||
use super::{
|
||||
pagination::Pagination as PaginationControls,
|
||||
utils::format_timestamp,
|
||||
};
|
||||
use crate::client::AuditEntryResponse;
|
||||
|
||||
const ACTION_OPTIONS: &[&str] = &[
|
||||
"All",
|
||||
"imported",
|
||||
"deleted",
|
||||
"tagged",
|
||||
"untagged",
|
||||
"updated",
|
||||
"added_to_collection",
|
||||
"removed_from_collection",
|
||||
"opened",
|
||||
"scanned",
|
||||
];
|
||||
|
||||
#[component]
|
||||
pub fn AuditLog(
|
||||
entries: Vec<AuditEntryResponse>,
|
||||
on_select: EventHandler<String>,
|
||||
audit_page: u64,
|
||||
total_pages: u64,
|
||||
on_page_change: EventHandler<u64>,
|
||||
audit_filter: String,
|
||||
on_filter_change: EventHandler<String>,
|
||||
) -> Element {
|
||||
if entries.is_empty() {
|
||||
return rsx! {
|
||||
div { class: "empty-state",
|
||||
h3 { class: "empty-title", "No audit entries" }
|
||||
p { class: "empty-subtitle", "Activity will appear here as you use the application." }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
rsx! {
|
||||
div { class: "audit-controls",
|
||||
select {
|
||||
class: "filter-select",
|
||||
value: "{audit_filter}",
|
||||
onchange: move |evt: Event<FormData>| {
|
||||
on_filter_change.call(evt.value().to_string());
|
||||
},
|
||||
for option in ACTION_OPTIONS.iter() {
|
||||
option {
|
||||
key: "{option}",
|
||||
value: "{option}",
|
||||
selected: audit_filter == *option,
|
||||
"{option}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
table { class: "data-table",
|
||||
thead {
|
||||
tr {
|
||||
th { "Action" }
|
||||
th { "Media ID" }
|
||||
th { "Details" }
|
||||
th { "Timestamp" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for entry in entries.iter() {
|
||||
{
|
||||
let media_id = entry.media_id.clone().unwrap_or_default();
|
||||
let truncated_id = if media_id.len() > 8 {
|
||||
format!("{}...", &media_id[..8])
|
||||
} else {
|
||||
media_id.clone()
|
||||
};
|
||||
let details = entry.details.clone().unwrap_or_default();
|
||||
let action_class = action_badge_class(&entry.action);
|
||||
let timestamp = format_timestamp(&entry.timestamp);
|
||||
let click_id = media_id.clone();
|
||||
let has_media_id = !media_id.is_empty();
|
||||
rsx! {
|
||||
tr { key: "{entry.id}",
|
||||
td {
|
||||
span { class: "type-badge {action_class}", "{entry.action}" }
|
||||
}
|
||||
td {
|
||||
if has_media_id {
|
||||
span {
|
||||
class: "mono clickable",
|
||||
onclick: move |_| {
|
||||
on_select.call(click_id.clone());
|
||||
},
|
||||
"{truncated_id}"
|
||||
}
|
||||
} else {
|
||||
span { class: "mono", "{truncated_id}" }
|
||||
}
|
||||
}
|
||||
td { "{details}" }
|
||||
td { "{timestamp}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PaginationControls { current_page: audit_page, total_pages, on_page_change }
|
||||
}
|
||||
}
|
||||
|
||||
fn action_badge_class(action: &str) -> &'static str {
|
||||
match action {
|
||||
"imported" => "type-image",
|
||||
"deleted" => "action-danger",
|
||||
"tagged" | "untagged" => "tag-badge",
|
||||
"updated" => "action-updated",
|
||||
"added_to_collection" => "action-collection",
|
||||
"removed_from_collection" => "action-collection-remove",
|
||||
"opened" => "action-opened",
|
||||
"scanned" => "action-scanned",
|
||||
_ => "type-other",
|
||||
}
|
||||
}
|
||||
390
packages/pinakes-ui/src/components/backlinks_panel.rs
Normal file
390
packages/pinakes-ui/src/components/backlinks_panel.rs
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
//! Backlinks panel component for showing incoming links to a note.
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::client::{ApiClient, BacklinkItem, BacklinksResponse};
|
||||
|
||||
/// Panel displaying backlinks (incoming links) to a media item.
|
||||
#[component]
|
||||
pub fn BacklinksPanel(
|
||||
media_id: String,
|
||||
client: ApiClient,
|
||||
on_navigate: EventHandler<String>,
|
||||
) -> Element {
|
||||
let mut backlinks = use_signal(|| Option::<BacklinksResponse>::None);
|
||||
let mut loading = use_signal(|| true);
|
||||
let mut error = use_signal(|| Option::<String>::None);
|
||||
let mut collapsed = use_signal(|| false);
|
||||
let mut reindexing = use_signal(|| false);
|
||||
let mut reindex_message = use_signal(|| Option::<(String, bool)>::None); // (message, is_error)
|
||||
|
||||
// Clone values for manual fetch function (used after reindex)
|
||||
let fetch_client = client.clone();
|
||||
let fetch_media_id = media_id.clone();
|
||||
|
||||
// Clone for reindex handler
|
||||
let reindex_client = client.clone();
|
||||
let reindex_media_id = media_id.clone();
|
||||
|
||||
// Fetch backlinks using use_resource to automatically track media_id changes
|
||||
// This ensures the backlinks are reloaded whenever we navigate to a different
|
||||
// note
|
||||
let backlinks_resource = use_resource(move || {
|
||||
let client = client.clone();
|
||||
let id = media_id.clone();
|
||||
async move { client.get_backlinks(&id).await }
|
||||
});
|
||||
|
||||
// Update local state based on resource state
|
||||
use_effect(move || {
|
||||
match &*backlinks_resource.read_unchecked() {
|
||||
Some(Ok(resp)) => {
|
||||
backlinks.set(Some(resp.clone()));
|
||||
loading.set(false);
|
||||
error.set(None);
|
||||
},
|
||||
Some(Err(e)) => {
|
||||
error.set(Some(format!("Failed to load backlinks: {e}")));
|
||||
loading.set(false);
|
||||
},
|
||||
None => {
|
||||
loading.set(true);
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch backlinks function for manual refresh (like after reindex)
|
||||
let fetch_backlinks = {
|
||||
let client = fetch_client;
|
||||
let id = fetch_media_id;
|
||||
move || {
|
||||
let client = client.clone();
|
||||
let id = id.clone();
|
||||
spawn(async move {
|
||||
loading.set(true);
|
||||
error.set(None);
|
||||
match client.get_backlinks(&id).await {
|
||||
Ok(resp) => {
|
||||
backlinks.set(Some(resp));
|
||||
},
|
||||
Err(e) => {
|
||||
error.set(Some(format!("Failed to load backlinks: {e}")));
|
||||
},
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Reindex links handler
|
||||
let on_reindex = {
|
||||
let client = reindex_client;
|
||||
let id = reindex_media_id;
|
||||
let fetch_backlinks = fetch_backlinks.clone();
|
||||
move |evt: MouseEvent| {
|
||||
evt.stop_propagation(); // Don't toggle collapse
|
||||
let client = client.clone();
|
||||
let id = id.clone();
|
||||
let fetch_backlinks = fetch_backlinks.clone();
|
||||
spawn(async move {
|
||||
reindexing.set(true);
|
||||
reindex_message.set(None);
|
||||
match client.reindex_links(&id).await {
|
||||
Ok(resp) => {
|
||||
reindex_message.set(Some((
|
||||
format!("Reindexed: {} links extracted", resp.links_extracted),
|
||||
false,
|
||||
)));
|
||||
// Refresh backlinks after reindex
|
||||
fetch_backlinks();
|
||||
},
|
||||
Err(e) => {
|
||||
reindex_message.set(Some((format!("Reindex failed: {e}"), true)));
|
||||
},
|
||||
}
|
||||
reindexing.set(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let is_loading = *loading.read();
|
||||
let is_collapsed = *collapsed.read();
|
||||
let is_reindexing = *reindexing.read();
|
||||
let backlink_data = backlinks.read();
|
||||
let count = backlink_data.as_ref().map(|b| b.count).unwrap_or(0);
|
||||
|
||||
rsx! {
|
||||
div { class: "backlinks-panel",
|
||||
// Header with toggle
|
||||
div {
|
||||
class: "backlinks-header",
|
||||
onclick: move |_| {
|
||||
let current = *collapsed.read();
|
||||
collapsed.set(!current);
|
||||
},
|
||||
span { class: "backlinks-toggle",
|
||||
if is_collapsed {
|
||||
"\u{25b6}"
|
||||
} else {
|
||||
"\u{25bc}"
|
||||
}
|
||||
}
|
||||
span { class: "backlinks-title", "Backlinks" }
|
||||
span { class: "backlinks-count", "({count})" }
|
||||
// Reindex button
|
||||
button {
|
||||
class: "backlinks-reindex-btn",
|
||||
title: "Re-extract links from this note",
|
||||
disabled: is_reindexing,
|
||||
onclick: on_reindex,
|
||||
if is_reindexing {
|
||||
span { class: "spinner-tiny" }
|
||||
} else {
|
||||
"\u{21bb}" // Refresh symbol
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !is_collapsed {
|
||||
div { class: "backlinks-content",
|
||||
// Show reindex message if present
|
||||
if let Some((ref msg, is_err)) = *reindex_message.read() {
|
||||
div { class: if is_err { "backlinks-message error" } else { "backlinks-message success" },
|
||||
"{msg}"
|
||||
}
|
||||
}
|
||||
|
||||
if is_loading {
|
||||
div { class: "backlinks-loading",
|
||||
div { class: "spinner-small" }
|
||||
"Loading backlinks..."
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref err) = *error.read() {
|
||||
div { class: "backlinks-error", "{err}" }
|
||||
}
|
||||
|
||||
if !is_loading && error.read().is_none() {
|
||||
if let Some(ref data) = *backlink_data {
|
||||
if data.backlinks.is_empty() {
|
||||
div { class: "backlinks-empty", "No other notes link to this one." }
|
||||
} else {
|
||||
ul { class: "backlinks-list",
|
||||
for backlink in &data.backlinks {
|
||||
BacklinkItemView {
|
||||
backlink: backlink.clone(),
|
||||
on_navigate: on_navigate,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Individual backlink item view.
|
||||
#[component]
|
||||
fn BacklinkItemView(
|
||||
backlink: BacklinkItem,
|
||||
on_navigate: EventHandler<String>,
|
||||
) -> Element {
|
||||
let source_id = backlink.source_id.clone();
|
||||
let title = backlink
|
||||
.source_title
|
||||
.clone()
|
||||
.unwrap_or_else(|| backlink.source_path.clone());
|
||||
let context = backlink.context.clone();
|
||||
let line_number = backlink.line_number;
|
||||
let link_type = backlink.link_type.clone();
|
||||
|
||||
rsx! {
|
||||
li {
|
||||
class: "backlink-item",
|
||||
onclick: move |_| on_navigate.call(source_id.clone()),
|
||||
div { class: "backlink-source",
|
||||
span { class: "backlink-title", "{title}" }
|
||||
span { class: "backlink-type-badge backlink-type-{link_type}", "{link_type}" }
|
||||
}
|
||||
if let Some(ref ctx) = context {
|
||||
div { class: "backlink-context",
|
||||
if let Some(ln) = line_number {
|
||||
span { class: "backlink-line", "L{ln}: " }
|
||||
}
|
||||
"\"{ctx}\""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Outgoing links panel showing what this note links to.
|
||||
#[component]
|
||||
pub fn OutgoingLinksPanel(
|
||||
media_id: String,
|
||||
client: ApiClient,
|
||||
on_navigate: EventHandler<String>,
|
||||
) -> Element {
|
||||
let mut links =
|
||||
use_signal(|| Option::<crate::client::OutgoingLinksResponse>::None);
|
||||
let mut loading = use_signal(|| true);
|
||||
let mut error = use_signal(|| Option::<String>::None);
|
||||
let mut collapsed = use_signal(|| true); // Collapsed by default
|
||||
let mut global_unresolved = use_signal(|| Option::<u64>::None);
|
||||
|
||||
// Fetch outgoing links using use_resource to automatically track media_id
|
||||
// changes This ensures the links are reloaded whenever we navigate to a
|
||||
// different note
|
||||
let links_resource = use_resource(move || {
|
||||
let client = client.clone();
|
||||
let id = media_id.clone();
|
||||
async move {
|
||||
let links_result = client.get_outgoing_links(&id).await;
|
||||
let unresolved_count = client.get_unresolved_links_count().await.ok();
|
||||
(links_result, unresolved_count)
|
||||
}
|
||||
});
|
||||
|
||||
// Update local state based on resource state
|
||||
use_effect(move || {
|
||||
match &*links_resource.read_unchecked() {
|
||||
Some((Ok(resp), unresolved_count)) => {
|
||||
links.set(Some(resp.clone()));
|
||||
loading.set(false);
|
||||
error.set(None);
|
||||
if let Some(count) = unresolved_count {
|
||||
global_unresolved.set(Some(*count));
|
||||
}
|
||||
},
|
||||
Some((Err(e), _)) => {
|
||||
error.set(Some(format!("Failed to load links: {e}")));
|
||||
loading.set(false);
|
||||
},
|
||||
None => {
|
||||
loading.set(true);
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
let is_loading = *loading.read();
|
||||
let is_collapsed = *collapsed.read();
|
||||
let link_data = links.read();
|
||||
let count = link_data.as_ref().map(|l| l.count).unwrap_or(0);
|
||||
let unresolved_in_note = link_data
|
||||
.as_ref()
|
||||
.map(|l| l.links.iter().filter(|link| !link.is_resolved).count())
|
||||
.unwrap_or(0);
|
||||
|
||||
rsx! {
|
||||
div { class: "outgoing-links-panel",
|
||||
// Header with toggle
|
||||
div {
|
||||
class: "outgoing-links-header",
|
||||
onclick: move |_| {
|
||||
let current = *collapsed.read();
|
||||
collapsed.set(!current);
|
||||
},
|
||||
span { class: "outgoing-links-toggle",
|
||||
if is_collapsed {
|
||||
"\u{25b6}"
|
||||
} else {
|
||||
"\u{25bc}"
|
||||
}
|
||||
}
|
||||
span { class: "outgoing-links-title", "Outgoing Links" }
|
||||
span { class: "outgoing-links-count", "({count})" }
|
||||
if unresolved_in_note > 0 {
|
||||
span {
|
||||
class: "outgoing-links-unresolved-badge",
|
||||
title: "Unresolved links in this note",
|
||||
"{unresolved_in_note} unresolved"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !is_collapsed {
|
||||
div { class: "outgoing-links-content",
|
||||
if is_loading {
|
||||
div { class: "outgoing-links-loading",
|
||||
div { class: "spinner-small" }
|
||||
"Loading links..."
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref err) = *error.read() {
|
||||
div { class: "outgoing-links-error", "{err}" }
|
||||
}
|
||||
|
||||
if !is_loading && error.read().is_none() {
|
||||
if let Some(ref data) = *link_data {
|
||||
if data.links.is_empty() {
|
||||
div { class: "outgoing-links-empty", "This note has no outgoing links." }
|
||||
} else {
|
||||
ul { class: "outgoing-links-list",
|
||||
for link in &data.links {
|
||||
OutgoingLinkItemView {
|
||||
link: link.clone(),
|
||||
on_navigate: on_navigate,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show global unresolved count if any
|
||||
if let Some(global_count) = *global_unresolved.read() {
|
||||
if global_count > 0 {
|
||||
div { class: "outgoing-links-global-unresolved",
|
||||
span { class: "unresolved-icon", "\u{26a0}" }
|
||||
" {global_count} unresolved links across all notes"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Individual outgoing link item view.
|
||||
#[component]
|
||||
fn OutgoingLinkItemView(
|
||||
link: crate::client::OutgoingLinkItem,
|
||||
on_navigate: EventHandler<String>,
|
||||
) -> Element {
|
||||
let target_id = link.target_id.clone();
|
||||
let target_path = link.target_path.clone();
|
||||
let link_text = link.link_text.clone();
|
||||
let is_resolved = link.is_resolved;
|
||||
let link_type = link.link_type.clone();
|
||||
|
||||
let display_text = link_text.unwrap_or_else(|| target_path.clone());
|
||||
let resolved_class = if is_resolved {
|
||||
"resolved"
|
||||
} else {
|
||||
"unresolved"
|
||||
};
|
||||
|
||||
rsx! {
|
||||
li {
|
||||
class: "outgoing-link-item {resolved_class}",
|
||||
onclick: move |_| {
|
||||
if let Some(ref id) = target_id {
|
||||
on_navigate.call(id.clone());
|
||||
}
|
||||
},
|
||||
div { class: "outgoing-link-target",
|
||||
span { class: "outgoing-link-text", "{display_text}" }
|
||||
span { class: "outgoing-link-type-badge link-type-{link_type}", "{link_type}" }
|
||||
if !is_resolved {
|
||||
span { class: "unresolved-badge", "unresolved" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
475
packages/pinakes-ui/src/components/books.rs
Normal file
475
packages/pinakes-ui/src/components/books.rs
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
use dioxus::prelude::*;
|
||||
|
||||
use super::utils::type_badge_class;
|
||||
use crate::client::{AuthorSummary, MediaResponse, SeriesSummary};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum BooksTab {
|
||||
AllBooks,
|
||||
Series,
|
||||
Authors,
|
||||
ReadingList,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Books(
|
||||
books: Vec<MediaResponse>,
|
||||
series_list: Vec<SeriesSummary>,
|
||||
authors_list: Vec<AuthorSummary>,
|
||||
series_books: Vec<MediaResponse>,
|
||||
author_books: Vec<MediaResponse>,
|
||||
reading_list: Vec<MediaResponse>,
|
||||
viewing_series: Option<String>,
|
||||
viewing_author: Option<String>,
|
||||
on_select: EventHandler<String>,
|
||||
on_load_books: EventHandler<(u64, u64, Option<String>, Option<String>)>,
|
||||
on_load_series: EventHandler<()>,
|
||||
on_load_authors: EventHandler<()>,
|
||||
on_load_reading_list: EventHandler<Option<String>>,
|
||||
on_view_series: EventHandler<String>,
|
||||
on_view_author: EventHandler<String>,
|
||||
on_back_to_list: EventHandler<()>,
|
||||
) -> Element {
|
||||
let mut active_tab = use_signal(|| BooksTab::AllBooks);
|
||||
let mut filter_author = use_signal(String::new);
|
||||
let mut filter_series = use_signal(String::new);
|
||||
let mut reading_status_filter = use_signal(String::new);
|
||||
let mut books_page = use_signal(|| 0u64);
|
||||
let books_page_size = 50u64;
|
||||
|
||||
// Series detail view
|
||||
if let Some(ref series_name) = viewing_series {
|
||||
let name = series_name.clone();
|
||||
return rsx! {
|
||||
button {
|
||||
class: "btn btn-ghost mb-16",
|
||||
onclick: move |_| on_back_to_list.call(()),
|
||||
"\u{2190} Back to Series"
|
||||
}
|
||||
|
||||
h3 { class: "mb-16", "Series: {name}" }
|
||||
|
||||
if series_books.is_empty() {
|
||||
div { class: "empty-state",
|
||||
p { class: "empty-subtitle", "No books found in this series." }
|
||||
}
|
||||
} else {
|
||||
table { class: "data-table",
|
||||
thead {
|
||||
tr {
|
||||
th { "Title" }
|
||||
th { "Type" }
|
||||
th { "Artist" }
|
||||
th { "File" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for item in series_books.iter() {
|
||||
{
|
||||
let title = item.title.clone().unwrap_or_else(|| item.file_name.clone());
|
||||
let artist = item.artist.clone().unwrap_or_default();
|
||||
let badge_class = type_badge_class(&item.media_type);
|
||||
let row_click = {
|
||||
let mid = item.id.clone();
|
||||
move |_| on_select.call(mid.clone())
|
||||
};
|
||||
rsx! {
|
||||
tr { key: "{item.id}", class: "clickable-row", onclick: row_click,
|
||||
td { "{title}" }
|
||||
td {
|
||||
span { class: "type-badge {badge_class}", "{item.media_type}" }
|
||||
}
|
||||
td { "{artist}" }
|
||||
td { "{item.file_name}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Author detail view
|
||||
if let Some(ref author_name) = viewing_author {
|
||||
let name = author_name.clone();
|
||||
return rsx! {
|
||||
button {
|
||||
class: "btn btn-ghost mb-16",
|
||||
onclick: move |_| on_back_to_list.call(()),
|
||||
"\u{2190} Back to Authors"
|
||||
}
|
||||
|
||||
h3 { class: "mb-16", "Author: {name}" }
|
||||
|
||||
if author_books.is_empty() {
|
||||
div { class: "empty-state",
|
||||
p { class: "empty-subtitle", "No books found by this author." }
|
||||
}
|
||||
} else {
|
||||
table { class: "data-table",
|
||||
thead {
|
||||
tr {
|
||||
th { "Title" }
|
||||
th { "Type" }
|
||||
th { "File" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for item in author_books.iter() {
|
||||
{
|
||||
let title = item.title.clone().unwrap_or_else(|| item.file_name.clone());
|
||||
let badge_class = type_badge_class(&item.media_type);
|
||||
let row_click = {
|
||||
let mid = item.id.clone();
|
||||
move |_| on_select.call(mid.clone())
|
||||
};
|
||||
rsx! {
|
||||
tr { key: "{item.id}", class: "clickable-row", onclick: row_click,
|
||||
td { "{title}" }
|
||||
td {
|
||||
span { class: "type-badge {badge_class}", "{item.media_type}" }
|
||||
}
|
||||
td { "{item.file_name}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Main tabbed view
|
||||
rsx! {
|
||||
div { class: "card",
|
||||
div { class: "card-header",
|
||||
h3 { class: "card-title", "Books" }
|
||||
}
|
||||
|
||||
// Tab bar
|
||||
div { class: "form-row mb-16",
|
||||
button {
|
||||
class: if *active_tab.read() == BooksTab::AllBooks { "btn btn-primary" } else { "btn btn-secondary" },
|
||||
onclick: {
|
||||
move |_| {
|
||||
active_tab.set(BooksTab::AllBooks);
|
||||
let author = {
|
||||
let a = filter_author.read().clone();
|
||||
if a.is_empty() { None } else { Some(a) }
|
||||
};
|
||||
let series = {
|
||||
let s = filter_series.read().clone();
|
||||
if s.is_empty() { None } else { Some(s) }
|
||||
};
|
||||
books_page.set(0);
|
||||
on_load_books.call((0, books_page_size, author, series));
|
||||
}
|
||||
},
|
||||
"All Books"
|
||||
}
|
||||
button {
|
||||
class: if *active_tab.read() == BooksTab::Series { "btn btn-primary" } else { "btn btn-secondary" },
|
||||
onclick: move |_| {
|
||||
active_tab.set(BooksTab::Series);
|
||||
on_load_series.call(());
|
||||
},
|
||||
"Series"
|
||||
}
|
||||
button {
|
||||
class: if *active_tab.read() == BooksTab::Authors { "btn btn-primary" } else { "btn btn-secondary" },
|
||||
onclick: move |_| {
|
||||
active_tab.set(BooksTab::Authors);
|
||||
on_load_authors.call(());
|
||||
},
|
||||
"Authors"
|
||||
}
|
||||
button {
|
||||
class: if *active_tab.read() == BooksTab::ReadingList { "btn btn-primary" } else { "btn btn-secondary" },
|
||||
onclick: move |_| {
|
||||
active_tab.set(BooksTab::ReadingList);
|
||||
on_load_reading_list.call(None);
|
||||
},
|
||||
"Reading List"
|
||||
}
|
||||
}
|
||||
|
||||
// Tab content
|
||||
match *active_tab.read() {
|
||||
BooksTab::AllBooks => {
|
||||
let search_click = {
|
||||
move |_| {
|
||||
let author = {
|
||||
let a = filter_author.read().clone();
|
||||
if a.is_empty() { None } else { Some(a) }
|
||||
};
|
||||
let series = {
|
||||
let s = filter_series.read().clone();
|
||||
if s.is_empty() { None } else { Some(s) }
|
||||
};
|
||||
books_page.set(0);
|
||||
on_load_books.call((0, books_page_size, author, series));
|
||||
}
|
||||
};
|
||||
rsx! {
|
||||
// Filters
|
||||
div { class: "form-row mb-16",
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Filter by author...",
|
||||
value: "{filter_author}",
|
||||
oninput: move |e| filter_author.set(e.value()),
|
||||
}
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Filter by series...",
|
||||
value: "{filter_series}",
|
||||
oninput: move |e| filter_series.set(e.value()),
|
||||
}
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
onclick: search_click,
|
||||
"Search"
|
||||
}
|
||||
}
|
||||
|
||||
if books.is_empty() {
|
||||
div { class: "empty-state",
|
||||
p { class: "empty-subtitle", "No books found. Try adjusting your filters or import some books." }
|
||||
}
|
||||
} else {
|
||||
table { class: "data-table",
|
||||
thead {
|
||||
tr {
|
||||
th { "Title" }
|
||||
th { "Type" }
|
||||
th { "Artist" }
|
||||
th { "File" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for item in books.iter() {
|
||||
{
|
||||
let title = item.title.clone().unwrap_or_else(|| item.file_name.clone());
|
||||
let artist = item.artist.clone().unwrap_or_default();
|
||||
let badge_class = type_badge_class(&item.media_type);
|
||||
let row_click = {
|
||||
let mid = item.id.clone();
|
||||
move |_| on_select.call(mid.clone())
|
||||
};
|
||||
rsx! {
|
||||
tr { key: "{item.id}", class: "clickable-row", onclick: row_click,
|
||||
td { "{title}" }
|
||||
td {
|
||||
span { class: "type-badge {badge_class}", "{item.media_type}" }
|
||||
}
|
||||
td { "{artist}" }
|
||||
td { "{item.file_name}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination
|
||||
if books.len() as u64 >= books_page_size {
|
||||
div { class: "form-row mt-16",
|
||||
if *books_page.read() > 0 {
|
||||
button {
|
||||
class: "btn btn-secondary",
|
||||
onclick: {
|
||||
move |_| {
|
||||
let page = *books_page.read() - 1;
|
||||
books_page.set(page);
|
||||
let author = {
|
||||
let a = filter_author.read().clone();
|
||||
if a.is_empty() { None } else { Some(a) }
|
||||
};
|
||||
let series = {
|
||||
let s = filter_series.read().clone();
|
||||
if s.is_empty() { None } else { Some(s) }
|
||||
};
|
||||
on_load_books.call((page * books_page_size, books_page_size, author, series));
|
||||
}
|
||||
},
|
||||
"\u{2190} Previous"
|
||||
}
|
||||
}
|
||||
span { class: "text-muted", "Page {books_page.read().checked_add(1).unwrap_or(1)}" }
|
||||
button {
|
||||
class: "btn btn-secondary",
|
||||
onclick: {
|
||||
move |_| {
|
||||
let page = *books_page.read() + 1;
|
||||
books_page.set(page);
|
||||
let author = {
|
||||
let a = filter_author.read().clone();
|
||||
if a.is_empty() { None } else { Some(a) }
|
||||
};
|
||||
let series = {
|
||||
let s = filter_series.read().clone();
|
||||
if s.is_empty() { None } else { Some(s) }
|
||||
};
|
||||
on_load_books.call((page * books_page_size, books_page_size, author, series));
|
||||
}
|
||||
},
|
||||
"Next \u{2192}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
BooksTab::Series => {
|
||||
rsx! {
|
||||
if series_list.is_empty() {
|
||||
div { class: "empty-state",
|
||||
p { class: "empty-subtitle", "No series found." }
|
||||
}
|
||||
} else {
|
||||
table { class: "data-table",
|
||||
thead {
|
||||
tr {
|
||||
th { "Series Name" }
|
||||
th { "Books" }
|
||||
th { "" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for series in series_list.iter() {
|
||||
{
|
||||
let view_click = {
|
||||
let name = series.name.clone();
|
||||
move |_| on_view_series.call(name.clone())
|
||||
};
|
||||
rsx! {
|
||||
tr { key: "{series.name}",
|
||||
td { "{series.name}" }
|
||||
td { "{series.book_count}" }
|
||||
td {
|
||||
button {
|
||||
class: "btn btn-sm btn-secondary",
|
||||
onclick: view_click,
|
||||
"View"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
BooksTab::Authors => {
|
||||
rsx! {
|
||||
if authors_list.is_empty() {
|
||||
div { class: "empty-state",
|
||||
p { class: "empty-subtitle", "No authors found." }
|
||||
}
|
||||
} else {
|
||||
table { class: "data-table",
|
||||
thead {
|
||||
tr {
|
||||
th { "Author" }
|
||||
th { "Books" }
|
||||
th { "" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for author in authors_list.iter() {
|
||||
{
|
||||
let view_click = {
|
||||
let name = author.name.clone();
|
||||
move |_| on_view_author.call(name.clone())
|
||||
};
|
||||
rsx! {
|
||||
tr { key: "{author.name}",
|
||||
td { "{author.name}" }
|
||||
td { "{author.book_count}" }
|
||||
td {
|
||||
button {
|
||||
class: "btn btn-sm btn-secondary",
|
||||
onclick: view_click,
|
||||
"View"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
BooksTab::ReadingList => {
|
||||
rsx! {
|
||||
div { class: "form-row mb-16",
|
||||
select {
|
||||
value: "{reading_status_filter}",
|
||||
onchange: {
|
||||
move |e: Event<FormData>| {
|
||||
let val = e.value();
|
||||
reading_status_filter.set(val.clone());
|
||||
let status = if val.is_empty() { None } else { Some(val) };
|
||||
on_load_reading_list.call(status);
|
||||
}
|
||||
},
|
||||
option { value: "", "All statuses" }
|
||||
option { value: "ToRead", "To Read" }
|
||||
option { value: "Reading", "Reading" }
|
||||
option { value: "Completed", "Completed" }
|
||||
option { value: "Abandoned", "Abandoned" }
|
||||
}
|
||||
}
|
||||
|
||||
if reading_list.is_empty() {
|
||||
div { class: "empty-state",
|
||||
p { class: "empty-subtitle", "No books in your reading list yet." }
|
||||
}
|
||||
} else {
|
||||
table { class: "data-table",
|
||||
thead {
|
||||
tr {
|
||||
th { "Title" }
|
||||
th { "Type" }
|
||||
th { "Artist" }
|
||||
th { "File" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for item in reading_list.iter() {
|
||||
{
|
||||
let title = item.title.clone().unwrap_or_else(|| item.file_name.clone());
|
||||
let artist = item.artist.clone().unwrap_or_default();
|
||||
let badge_class = type_badge_class(&item.media_type);
|
||||
let row_click = {
|
||||
let mid = item.id.clone();
|
||||
move |_| on_select.call(mid.clone())
|
||||
};
|
||||
rsx! {
|
||||
tr { key: "{item.id}", class: "clickable-row", onclick: row_click,
|
||||
td { "{title}" }
|
||||
td {
|
||||
span { class: "type-badge {badge_class}", "{item.media_type}" }
|
||||
}
|
||||
td { "{artist}" }
|
||||
td { "{item.file_name}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
42
packages/pinakes-ui/src/components/breadcrumb.rs
Normal file
42
packages/pinakes-ui/src/components/breadcrumb.rs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
use dioxus::prelude::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct BreadcrumbItem {
|
||||
pub label: String,
|
||||
pub view: Option<String>,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Breadcrumb(
|
||||
items: Vec<BreadcrumbItem>,
|
||||
on_navigate: EventHandler<Option<String>>,
|
||||
) -> Element {
|
||||
rsx! {
|
||||
nav { class: "breadcrumb",
|
||||
for (i , item) in items.iter().enumerate() {
|
||||
if i > 0 {
|
||||
span { class: "breadcrumb-sep", " > " }
|
||||
}
|
||||
if i < items.len() - 1 {
|
||||
{
|
||||
let view = item.view.clone();
|
||||
let label = item.label.clone();
|
||||
rsx! {
|
||||
a {
|
||||
class: "breadcrumb-link",
|
||||
href: "#",
|
||||
onclick: move |e: Event<MouseData>| {
|
||||
e.prevent_default();
|
||||
on_navigate.call(view.clone());
|
||||
},
|
||||
"{label}"
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
span { class: "breadcrumb-current", "{item.label}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
323
packages/pinakes-ui/src/components/collections.rs
Normal file
323
packages/pinakes-ui/src/components/collections.rs
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
use dioxus::prelude::*;
|
||||
|
||||
use super::utils::{format_size, type_badge_class};
|
||||
use crate::client::{CollectionResponse, MediaResponse};
|
||||
|
||||
#[component]
|
||||
pub fn Collections(
|
||||
collections: Vec<CollectionResponse>,
|
||||
collection_members: Vec<MediaResponse>,
|
||||
viewing_collection: Option<String>,
|
||||
on_create: EventHandler<(String, String, Option<String>, Option<String>)>,
|
||||
on_delete: EventHandler<String>,
|
||||
on_view_members: EventHandler<String>,
|
||||
on_back_to_list: EventHandler<()>,
|
||||
on_remove_member: EventHandler<(String, String)>,
|
||||
on_select: EventHandler<String>,
|
||||
on_add_member: EventHandler<(String, String)>,
|
||||
all_media: Vec<MediaResponse>,
|
||||
) -> Element {
|
||||
let mut new_name = use_signal(String::new);
|
||||
let mut new_kind = use_signal(|| String::from("manual"));
|
||||
let mut new_description = use_signal(String::new);
|
||||
let mut new_filter_query = use_signal(String::new);
|
||||
let mut confirm_delete: Signal<Option<String>> = use_signal(|| None);
|
||||
let mut show_add_modal = use_signal(|| false);
|
||||
|
||||
// Detail view: viewing a specific collection's members
|
||||
if let Some(ref col_id) = viewing_collection {
|
||||
let col_name = collections
|
||||
.iter()
|
||||
.find(|c| &c.id == col_id)
|
||||
.map(|c| c.name.clone())
|
||||
.unwrap_or_else(|| col_id.clone());
|
||||
|
||||
let back_click = move |_| on_back_to_list.call(());
|
||||
|
||||
// Collect IDs of current members to filter available media
|
||||
let member_ids: Vec<String> =
|
||||
collection_members.iter().map(|m| m.id.clone()).collect();
|
||||
let available_media: Vec<&MediaResponse> = all_media
|
||||
.iter()
|
||||
.filter(|m| !member_ids.contains(&m.id))
|
||||
.collect();
|
||||
|
||||
let modal_col_id = col_id.clone();
|
||||
|
||||
return rsx! {
|
||||
button { class: "btn btn-ghost mb-16", onclick: back_click, "\u{2190} Back to Collections" }
|
||||
|
||||
h3 { class: "mb-16", "{col_name}" }
|
||||
|
||||
div { class: "form-row mb-16",
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
onclick: move |_| show_add_modal.set(true),
|
||||
"Add Media"
|
||||
}
|
||||
}
|
||||
|
||||
if collection_members.is_empty() {
|
||||
div { class: "empty-state",
|
||||
p { class: "empty-subtitle", "This collection has no members." }
|
||||
}
|
||||
} else {
|
||||
table { class: "data-table",
|
||||
thead {
|
||||
tr {
|
||||
th { "Name" }
|
||||
th { "Type" }
|
||||
th { "Artist" }
|
||||
th { "Size" }
|
||||
th { "" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for item in collection_members.iter() {
|
||||
{
|
||||
let artist = item.artist.clone().unwrap_or_default();
|
||||
let size = format_size(item.file_size);
|
||||
let badge_class = type_badge_class(&item.media_type);
|
||||
let remove_cid = col_id.clone();
|
||||
let remove_mid = item.id.clone();
|
||||
let row_click = {
|
||||
let mid = item.id.clone();
|
||||
move |_| on_select.call(mid.clone())
|
||||
};
|
||||
rsx! {
|
||||
tr { key: "{item.id}", class: "clickable-row", onclick: row_click,
|
||||
td { "{item.file_name}" }
|
||||
td {
|
||||
span { class: "type-badge {badge_class}", "{item.media_type}" }
|
||||
}
|
||||
td { "{artist}" }
|
||||
td { "{size}" }
|
||||
td {
|
||||
button {
|
||||
class: "btn btn-danger btn-sm",
|
||||
onclick: move |e: Event<MouseData>| {
|
||||
e.stop_propagation();
|
||||
on_remove_member.call((remove_cid.clone(), remove_mid.clone()));
|
||||
},
|
||||
"Remove"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add Media modal
|
||||
if *show_add_modal.read() {
|
||||
div {
|
||||
class: "modal-overlay",
|
||||
onclick: move |_| show_add_modal.set(false),
|
||||
div {
|
||||
class: "modal",
|
||||
onclick: move |e: Event<MouseData>| e.stop_propagation(),
|
||||
div { class: "modal-header",
|
||||
h3 { "Add Media to Collection" }
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
onclick: move |_| show_add_modal.set(false),
|
||||
"\u{2715}"
|
||||
}
|
||||
}
|
||||
div { class: "modal-body",
|
||||
if available_media.is_empty() {
|
||||
p { "No media available to add." }
|
||||
} else {
|
||||
table { class: "data-table",
|
||||
thead {
|
||||
tr {
|
||||
th { "Name" }
|
||||
th { "Type" }
|
||||
th { "Artist" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for media in available_media.iter() {
|
||||
{
|
||||
let artist = media.artist.clone().unwrap_or_default();
|
||||
let badge_class = type_badge_class(&media.media_type);
|
||||
let add_click = {
|
||||
let cid = modal_col_id.clone();
|
||||
let mid = media.id.clone();
|
||||
move |_| {
|
||||
on_add_member.call((cid.clone(), mid.clone()));
|
||||
show_add_modal.set(false);
|
||||
}
|
||||
};
|
||||
rsx! {
|
||||
tr { key: "{media.id}", class: "clickable-row", onclick: add_click,
|
||||
td { "{media.file_name}" }
|
||||
td {
|
||||
span { class: "type-badge {badge_class}", "{media.media_type}" }
|
||||
}
|
||||
td { "{artist}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// List view: show all collections with create form
|
||||
let is_virtual = *new_kind.read() == "virtual";
|
||||
|
||||
let create_click = move |_| {
|
||||
let name = new_name.read().clone();
|
||||
if name.is_empty() {
|
||||
return;
|
||||
}
|
||||
let kind = new_kind.read().clone();
|
||||
let desc = {
|
||||
let d = new_description.read().clone();
|
||||
if d.is_empty() { None } else { Some(d) }
|
||||
};
|
||||
let filter = {
|
||||
let f = new_filter_query.read().clone();
|
||||
if f.is_empty() { None } else { Some(f) }
|
||||
};
|
||||
on_create.call((name, kind, desc, filter));
|
||||
new_name.set(String::new());
|
||||
new_kind.set(String::from("manual"));
|
||||
new_description.set(String::new());
|
||||
new_filter_query.set(String::new());
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "card",
|
||||
div { class: "card-header",
|
||||
h3 { class: "card-title", "Collections" }
|
||||
}
|
||||
|
||||
div { class: "form-row mb-16",
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Collection name...",
|
||||
value: "{new_name}",
|
||||
oninput: move |e| new_name.set(e.value()),
|
||||
}
|
||||
select {
|
||||
value: "{new_kind}",
|
||||
onchange: move |e| new_kind.set(e.value()),
|
||||
option { value: "manual", "Manual" }
|
||||
option { value: "virtual", "Virtual" }
|
||||
}
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Description (optional)...",
|
||||
value: "{new_description}",
|
||||
oninput: move |e| new_description.set(e.value()),
|
||||
}
|
||||
}
|
||||
|
||||
if is_virtual {
|
||||
div { class: "form-row mb-16",
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Filter query for virtual collection...",
|
||||
value: "{new_filter_query}",
|
||||
oninput: move |e| new_filter_query.set(e.value()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "form-row mb-16",
|
||||
button { class: "btn btn-primary", onclick: create_click, "Create" }
|
||||
}
|
||||
|
||||
if collections.is_empty() {
|
||||
div { class: "empty-state",
|
||||
p { class: "empty-subtitle", "No collections yet. Create one above." }
|
||||
}
|
||||
} else {
|
||||
table { class: "data-table",
|
||||
thead {
|
||||
tr {
|
||||
th { "Name" }
|
||||
th { "Kind" }
|
||||
th { "Description" }
|
||||
th { "" }
|
||||
th { "" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for col in collections.iter() {
|
||||
{
|
||||
let desc = col.description.clone().unwrap_or_default();
|
||||
let kind_class = if col.kind == "virtual" {
|
||||
"type-document"
|
||||
} else {
|
||||
"type-other"
|
||||
};
|
||||
let view_click = {
|
||||
let id = col.id.clone();
|
||||
move |_| on_view_members.call(id.clone())
|
||||
};
|
||||
let col_id_for_delete = col.id.clone();
|
||||
let is_confirming = confirm_delete
|
||||
.read()
|
||||
.as_ref()
|
||||
.map(|id| id == &col.id)
|
||||
.unwrap_or(false);
|
||||
rsx! {
|
||||
tr { key: "{col.id}",
|
||||
td { "{col.name}" }
|
||||
td {
|
||||
span { class: "type-badge {kind_class}", "{col.kind}" }
|
||||
}
|
||||
td { "{desc}" }
|
||||
td {
|
||||
button { class: "btn btn-sm btn-secondary", onclick: view_click, "View" }
|
||||
}
|
||||
td {
|
||||
if is_confirming {
|
||||
button {
|
||||
class: "btn btn-danger btn-sm",
|
||||
onclick: {
|
||||
let id = col_id_for_delete.clone();
|
||||
move |_| {
|
||||
on_delete.call(id.clone());
|
||||
confirm_delete.set(None);
|
||||
}
|
||||
},
|
||||
"Confirm"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-ghost btn-sm",
|
||||
onclick: move |_| confirm_delete.set(None),
|
||||
"Cancel"
|
||||
}
|
||||
} else {
|
||||
button {
|
||||
class: "btn btn-danger btn-sm",
|
||||
onclick: {
|
||||
let id = col_id_for_delete.clone();
|
||||
move |_| confirm_delete.set(Some(id.clone()))
|
||||
},
|
||||
"Delete"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
195
packages/pinakes-ui/src/components/database.rs
Normal file
195
packages/pinakes-ui/src/components/database.rs
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
use dioxus::prelude::*;
|
||||
|
||||
use super::utils::format_size;
|
||||
use crate::client::DatabaseStatsResponse;
|
||||
|
||||
#[component]
|
||||
pub fn Database(
|
||||
stats: Option<DatabaseStatsResponse>,
|
||||
on_refresh: EventHandler<()>,
|
||||
on_vacuum: EventHandler<()>,
|
||||
on_clear: EventHandler<()>,
|
||||
on_backup: EventHandler<String>,
|
||||
) -> Element {
|
||||
let mut confirm_clear = use_signal(|| false);
|
||||
let mut confirm_vacuum = use_signal(|| false);
|
||||
let mut backup_path = use_signal(String::new);
|
||||
|
||||
rsx! {
|
||||
div { class: "card mb-16",
|
||||
div { class: "card-header",
|
||||
h3 { class: "card-title", "Database Overview" }
|
||||
button {
|
||||
class: "btn btn-sm btn-secondary",
|
||||
onclick: move |_| on_refresh.call(()),
|
||||
"\u{21bb} Refresh"
|
||||
}
|
||||
}
|
||||
|
||||
match stats.as_ref() {
|
||||
Some(s) => {
|
||||
let size_str = format_size(s.database_size_bytes);
|
||||
rsx! {
|
||||
div { class: "stats-grid",
|
||||
div { class: "stat-card",
|
||||
div { class: "stat-value", "{s.media_count}" }
|
||||
div { class: "stat-label", "Media Items" }
|
||||
}
|
||||
div { class: "stat-card",
|
||||
div { class: "stat-value", "{s.tag_count}" }
|
||||
div { class: "stat-label", "Tags" }
|
||||
}
|
||||
div { class: "stat-card",
|
||||
div { class: "stat-value", "{s.collection_count}" }
|
||||
div { class: "stat-label", "Collections" }
|
||||
}
|
||||
div { class: "stat-card",
|
||||
div { class: "stat-value", "{s.audit_count}" }
|
||||
div { class: "stat-label", "Audit Entries" }
|
||||
}
|
||||
div { class: "stat-card",
|
||||
div { class: "stat-value", "{size_str}" }
|
||||
div { class: "stat-label", "Database Size" }
|
||||
}
|
||||
div { class: "stat-card",
|
||||
div { class: "stat-value", "{s.backend_name}" }
|
||||
div { class: "stat-label", "Backend" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => rsx! {
|
||||
div { class: "empty-state",
|
||||
p { class: "text-muted", "Loading database stats..." }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Maintenance actions
|
||||
div { class: "card mb-16",
|
||||
div { class: "card-header",
|
||||
h3 { class: "card-title", "Maintenance" }
|
||||
}
|
||||
|
||||
div { class: "db-actions",
|
||||
// Vacuum
|
||||
div { class: "db-action-row",
|
||||
div { class: "db-action-info",
|
||||
h4 { "Vacuum Database" }
|
||||
p { class: "text-muted text-sm",
|
||||
"Reclaim unused disk space and optimize the database. "
|
||||
"This is safe to run at any time but may briefly lock the database."
|
||||
}
|
||||
}
|
||||
if *confirm_vacuum.read() {
|
||||
div { class: "db-action-confirm",
|
||||
span { class: "text-sm", "Run vacuum?" }
|
||||
button {
|
||||
class: "btn btn-sm btn-primary",
|
||||
onclick: move |_| {
|
||||
confirm_vacuum.set(false);
|
||||
on_vacuum.call(());
|
||||
},
|
||||
"Confirm"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-sm btn-ghost",
|
||||
onclick: move |_| confirm_vacuum.set(false),
|
||||
"Cancel"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
button {
|
||||
class: "btn btn-secondary",
|
||||
onclick: move |_| confirm_vacuum.set(true),
|
||||
"Vacuum"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backup
|
||||
div { class: "db-action-row",
|
||||
div { class: "db-action-info",
|
||||
h4 { "Backup Database" }
|
||||
p { class: "text-muted text-sm",
|
||||
"Create a copy of the database at the specified path. "
|
||||
"The backup is a full snapshot of the current state."
|
||||
}
|
||||
}
|
||||
div { class: "form-row",
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "/path/to/backup.db",
|
||||
value: "{backup_path}",
|
||||
oninput: move |e| backup_path.set(e.value()),
|
||||
style: "max-width: 300px;",
|
||||
}
|
||||
button {
|
||||
class: "btn btn-secondary",
|
||||
disabled: backup_path.read().is_empty(),
|
||||
onclick: {
|
||||
let mut backup_path = backup_path;
|
||||
move |_| {
|
||||
let path = backup_path.read().clone();
|
||||
if !path.is_empty() {
|
||||
on_backup.call(path);
|
||||
backup_path.set(String::new());
|
||||
}
|
||||
}
|
||||
},
|
||||
"Backup"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Danger zone
|
||||
div { class: "card mb-16 danger-card",
|
||||
div { class: "card-header",
|
||||
h3 { class: "card-title", style: "color: var(--danger);", "Danger Zone" }
|
||||
}
|
||||
|
||||
div { class: "db-actions",
|
||||
div { class: "db-action-row",
|
||||
div { class: "db-action-info",
|
||||
h4 { "Clear All Data" }
|
||||
p { class: "text-muted text-sm",
|
||||
"Permanently delete all media records, tags, collections, and audit entries. "
|
||||
"This cannot be undone. Files on disk are not affected."
|
||||
}
|
||||
}
|
||||
if *confirm_clear.read() {
|
||||
div { class: "db-action-confirm",
|
||||
span {
|
||||
class: "text-sm",
|
||||
style: "color: var(--danger);",
|
||||
"This will delete everything. Are you sure?"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-sm btn-danger",
|
||||
onclick: move |_| {
|
||||
confirm_clear.set(false);
|
||||
on_clear.call(());
|
||||
},
|
||||
"Yes, Delete Everything"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-sm btn-ghost",
|
||||
onclick: move |_| confirm_clear.set(false),
|
||||
"Cancel"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
button {
|
||||
class: "btn btn-danger",
|
||||
onclick: move |_| confirm_clear.set(true),
|
||||
"Clear All Data"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1533
packages/pinakes-ui/src/components/detail.rs
Normal file
1533
packages/pinakes-ui/src/components/detail.rs
Normal file
File diff suppressed because it is too large
Load diff
182
packages/pinakes-ui/src/components/duplicates.rs
Normal file
182
packages/pinakes-ui/src/components/duplicates.rs
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
use dioxus::prelude::*;
|
||||
|
||||
use super::utils::{format_size, format_timestamp};
|
||||
use crate::client::DuplicateGroupResponse;
|
||||
|
||||
#[component]
|
||||
pub fn Duplicates(
|
||||
groups: Vec<DuplicateGroupResponse>,
|
||||
server_url: String,
|
||||
on_delete: EventHandler<String>,
|
||||
on_refresh: EventHandler<()>,
|
||||
) -> Element {
|
||||
let mut expanded_group = use_signal(|| Option::<String>::None);
|
||||
let mut confirm_delete = use_signal(|| Option::<String>::None);
|
||||
|
||||
let total_groups = groups.len();
|
||||
let total_duplicates: usize =
|
||||
groups.iter().map(|g| g.items.len().saturating_sub(1)).sum();
|
||||
|
||||
rsx! {
|
||||
div { class: "duplicates-view",
|
||||
div { class: "duplicates-header",
|
||||
h3 { "Duplicates" }
|
||||
div { class: "duplicates-summary",
|
||||
span { class: "text-muted",
|
||||
"{total_groups} group(s), {total_duplicates} duplicate(s)"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-sm btn-secondary",
|
||||
onclick: move |_| on_refresh.call(()),
|
||||
"Refresh"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if groups.is_empty() {
|
||||
div { class: "empty-state",
|
||||
p { class: "text-muted", "No duplicate files found." }
|
||||
}
|
||||
}
|
||||
|
||||
for group in groups.iter() {
|
||||
{
|
||||
let hash = group.content_hash.clone();
|
||||
let is_expanded = expanded_group.read().as_ref() == Some(&hash);
|
||||
let hash_for_toggle = hash.clone();
|
||||
let item_count = group.items.len();
|
||||
let first_name = group
|
||||
.items
|
||||
|
||||
// Group header
|
||||
|
||||
// Expanded: show items
|
||||
|
||||
// Thumbnail
|
||||
|
||||
// Info
|
||||
|
||||
// Actions
|
||||
|
||||
.first()
|
||||
.map(|i| i.file_name.clone())
|
||||
.unwrap_or_default();
|
||||
let total_size: u64 = group.items.iter().map(|i| i.file_size).sum();
|
||||
let short_hash = if hash.len() > 12 {
|
||||
format!("{}...", &hash[..12])
|
||||
} else {
|
||||
hash.clone()
|
||||
};
|
||||
rsx! {
|
||||
div { class: "duplicate-group", key: "{hash}",
|
||||
|
||||
|
||||
|
||||
button {
|
||||
class: "duplicate-group-header",
|
||||
onclick: move |_| {
|
||||
let current = expanded_group.read().clone();
|
||||
if current.as_ref() == Some(&hash_for_toggle) {
|
||||
expanded_group.set(None);
|
||||
} else {
|
||||
expanded_group.set(Some(hash_for_toggle.clone()));
|
||||
}
|
||||
},
|
||||
span { class: "expand-icon",
|
||||
if is_expanded {
|
||||
"\u{25bc}"
|
||||
} else {
|
||||
"\u{25b6}"
|
||||
}
|
||||
}
|
||||
span { class: "group-name", "{first_name}" }
|
||||
span { class: "group-badge", "{item_count} files" }
|
||||
span { class: "group-size text-muted", "{format_size(total_size)}" }
|
||||
span { class: "group-hash mono text-muted", "{short_hash}" }
|
||||
}
|
||||
|
||||
if is_expanded {
|
||||
div { class: "duplicate-items",
|
||||
for (idx , item) in group.items.iter().enumerate() {
|
||||
{
|
||||
let item_id = item.id.clone();
|
||||
let is_first = idx == 0;
|
||||
let is_confirming = confirm_delete.read().as_ref() == Some(&item_id);
|
||||
let thumb_url = format!("{}/api/v1/media/{}/thumbnail", server_url, item.id);
|
||||
let has_thumb = item.has_thumbnail;
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: if is_first { "duplicate-item duplicate-item-keep" } else { "duplicate-item" },
|
||||
key: "{item_id}",
|
||||
|
||||
|
||||
|
||||
div { class: "dup-thumb",
|
||||
if has_thumb {
|
||||
img {
|
||||
src: "{thumb_url}",
|
||||
alt: "{item.file_name}",
|
||||
class: "dup-thumb-img",
|
||||
}
|
||||
} else {
|
||||
div { class: "dup-thumb-placeholder", "\u{1f5bc}" }
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "dup-info",
|
||||
div { class: "dup-filename", "{item.file_name}" }
|
||||
div { class: "dup-path mono text-muted", "{item.path}" }
|
||||
div { class: "dup-meta",
|
||||
span { "{format_size(item.file_size)}" }
|
||||
span { class: "text-muted", " | " }
|
||||
span { "{format_timestamp(&item.created_at)}" }
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "dup-actions",
|
||||
if is_first {
|
||||
span { class: "keep-badge", "Keep" }
|
||||
}
|
||||
|
||||
if is_confirming {
|
||||
button {
|
||||
class: "btn btn-sm btn-danger",
|
||||
onclick: {
|
||||
let id = item_id.clone();
|
||||
move |_| {
|
||||
confirm_delete.set(None);
|
||||
on_delete.call(id.clone());
|
||||
}
|
||||
},
|
||||
"Confirm"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-sm btn-ghost",
|
||||
onclick: move |_| confirm_delete.set(None),
|
||||
"Cancel"
|
||||
}
|
||||
} else if !is_first {
|
||||
button {
|
||||
class: "btn btn-sm btn-danger",
|
||||
onclick: {
|
||||
let id = item_id.clone();
|
||||
move |_| confirm_delete.set(Some(id.clone()))
|
||||
},
|
||||
"Delete"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
674
packages/pinakes-ui/src/components/graph_view.rs
Normal file
674
packages/pinakes-ui/src/components/graph_view.rs
Normal file
|
|
@ -0,0 +1,674 @@
|
|||
//! Graph visualization component for markdown note connections.
|
||||
//!
|
||||
//! Renders a force-directed graph showing connections between notes.
|
||||
use dioxus::prelude::*;
|
||||
use rustc_hash::FxHashMap;
|
||||
|
||||
use crate::client::{
|
||||
ApiClient,
|
||||
GraphEdgeResponse,
|
||||
GraphNodeResponse,
|
||||
GraphResponse,
|
||||
};
|
||||
|
||||
/// Graph view component showing note connections.
|
||||
#[component]
|
||||
pub fn GraphView(
|
||||
client: ApiClient,
|
||||
center_id: Option<String>,
|
||||
on_navigate: EventHandler<String>,
|
||||
) -> Element {
|
||||
let mut graph_data = use_signal(|| Option::<GraphResponse>::None);
|
||||
let mut loading = use_signal(|| true);
|
||||
let mut error = use_signal(|| Option::<String>::None);
|
||||
let mut depth = use_signal(|| 2u32);
|
||||
let mut selected_node = use_signal(|| Option::<String>::None);
|
||||
|
||||
// Fetch graph data
|
||||
let center = center_id.clone();
|
||||
let d = *depth.read();
|
||||
let client_clone = client.clone();
|
||||
use_effect(move || {
|
||||
let center = center.clone();
|
||||
let client = client_clone.clone();
|
||||
spawn(async move {
|
||||
loading.set(true);
|
||||
error.set(None);
|
||||
match client.get_graph(center.as_deref(), Some(d)).await {
|
||||
Ok(resp) => {
|
||||
graph_data.set(Some(resp));
|
||||
},
|
||||
Err(e) => {
|
||||
error.set(Some(format!("Failed to load graph: {e}")));
|
||||
},
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
});
|
||||
|
||||
let is_loading = *loading.read();
|
||||
let current_depth = *depth.read();
|
||||
let data = graph_data.read();
|
||||
|
||||
rsx! {
|
||||
div { class: "graph-view",
|
||||
// Toolbar
|
||||
div { class: "graph-toolbar",
|
||||
span { class: "graph-title", "Note Graph" }
|
||||
div { class: "graph-controls",
|
||||
label { "Depth: " }
|
||||
select {
|
||||
value: "{current_depth}",
|
||||
onchange: move |evt| {
|
||||
if let Ok(d) = evt.value().parse::<u32>() {
|
||||
depth.set(d);
|
||||
}
|
||||
},
|
||||
option { value: "1", "1" }
|
||||
option { value: "2", "2" }
|
||||
option { value: "3", "3" }
|
||||
option { value: "4", "4" }
|
||||
option { value: "5", "5" }
|
||||
}
|
||||
}
|
||||
if let Some(ref data) = *data {
|
||||
div { class: "graph-stats", "{data.node_count} nodes, {data.edge_count} edges" }
|
||||
}
|
||||
}
|
||||
|
||||
// Graph container
|
||||
div { class: "graph-container",
|
||||
if is_loading {
|
||||
div { class: "graph-loading",
|
||||
div { class: "spinner" }
|
||||
"Loading graph..."
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref err) = *error.read() {
|
||||
div { class: "graph-error", "{err}" }
|
||||
}
|
||||
|
||||
if !is_loading && error.read().is_none() {
|
||||
if let Some(ref graph) = *data {
|
||||
if graph.nodes.is_empty() {
|
||||
div { class: "graph-empty",
|
||||
"No linked notes found. Start creating links between your notes!"
|
||||
}
|
||||
} else {
|
||||
ForceDirectedGraph {
|
||||
nodes: graph.nodes.clone(),
|
||||
edges: graph.edges.clone(),
|
||||
selected_node: selected_node,
|
||||
on_node_click: move |id: String| {
|
||||
selected_node.set(Some(id.clone()));
|
||||
},
|
||||
on_node_double_click: move |id: String| {
|
||||
on_navigate.call(id);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Node details panel
|
||||
if let Some(ref node_id) = *selected_node.read() {
|
||||
if let Some(ref graph) = *data {
|
||||
if let Some(node) = graph.nodes.iter().find(|n| &n.id == node_id) {
|
||||
NodeDetailsPanel {
|
||||
node: node.clone(),
|
||||
on_close: move |_| selected_node.set(None),
|
||||
on_navigate: move |id| {
|
||||
on_navigate.call(id);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Node with physics simulation state
|
||||
#[derive(Clone, Debug)]
|
||||
struct PhysicsNode {
|
||||
id: String,
|
||||
label: String,
|
||||
title: Option<String>,
|
||||
link_count: usize,
|
||||
backlink_count: usize,
|
||||
x: f64,
|
||||
y: f64,
|
||||
vx: f64,
|
||||
vy: f64,
|
||||
}
|
||||
|
||||
/// Force-directed graph with physics simulation
|
||||
#[component]
|
||||
fn ForceDirectedGraph(
|
||||
nodes: Vec<GraphNodeResponse>,
|
||||
edges: Vec<GraphEdgeResponse>,
|
||||
selected_node: Signal<Option<String>>,
|
||||
on_node_click: EventHandler<String>,
|
||||
on_node_double_click: EventHandler<String>,
|
||||
) -> Element {
|
||||
// Physics parameters (adjustable via controls)
|
||||
let mut repulsion_strength = use_signal(|| 1000.0f64);
|
||||
let mut link_strength = use_signal(|| 0.5f64);
|
||||
let mut link_distance = use_signal(|| 100.0f64);
|
||||
let mut center_strength = use_signal(|| 0.1f64);
|
||||
let mut damping = use_signal(|| 0.8f64);
|
||||
let mut show_controls = use_signal(|| false);
|
||||
let mut simulation_active = use_signal(|| true);
|
||||
|
||||
// View state
|
||||
let mut zoom = use_signal(|| 1.0f64);
|
||||
let mut pan_x = use_signal(|| 0.0f64);
|
||||
let mut pan_y = use_signal(|| 0.0f64);
|
||||
let mut is_dragging_canvas = use_signal(|| false);
|
||||
let mut drag_start_x = use_signal(|| 0.0f64);
|
||||
let mut drag_start_y = use_signal(|| 0.0f64);
|
||||
let mut dragged_node = use_signal(|| Option::<String>::None);
|
||||
|
||||
// Initialize physics nodes with random positions
|
||||
let mut physics_nodes = use_signal(|| {
|
||||
nodes
|
||||
.iter()
|
||||
.map(|n| {
|
||||
let angle = rand::random::<f64>() * 2.0 * std::f64::consts::PI;
|
||||
let radius = 100.0 + rand::random::<f64>() * 200.0;
|
||||
PhysicsNode {
|
||||
id: n.id.clone(),
|
||||
label: n.label.clone(),
|
||||
title: n.title.clone(),
|
||||
link_count: n.link_count as usize,
|
||||
backlink_count: n.backlink_count as usize,
|
||||
x: radius * angle.cos(),
|
||||
y: radius * angle.sin(),
|
||||
vx: 0.0,
|
||||
vy: 0.0,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
// Animation loop
|
||||
let edges_for_sim = edges.clone();
|
||||
use_future(move || {
|
||||
let edges_for_sim = edges_for_sim.clone();
|
||||
async move {
|
||||
loop {
|
||||
// Check simulation state
|
||||
let is_active = *simulation_active.peek();
|
||||
let is_dragging = dragged_node.peek().is_some();
|
||||
|
||||
if is_active && !is_dragging {
|
||||
let mut nodes = physics_nodes.write();
|
||||
let node_count = nodes.len();
|
||||
|
||||
if node_count > 0 {
|
||||
// Read physics parameters each frame
|
||||
let rep_strength = *repulsion_strength.peek();
|
||||
let link_strength_val = *link_strength.peek();
|
||||
let link_distance = *link_distance.peek();
|
||||
let center_strength_val = *center_strength.peek();
|
||||
let damping_val = *damping.peek();
|
||||
|
||||
// Apply forces
|
||||
for i in 0..node_count {
|
||||
let mut fx = 0.0;
|
||||
let mut fy = 0.0;
|
||||
|
||||
// Repulsion between all nodes
|
||||
for j in 0..node_count {
|
||||
if i != j {
|
||||
let dx = nodes[i].x - nodes[j].x;
|
||||
let dy = nodes[i].y - nodes[j].y;
|
||||
let dist_sq = (dx * dx + dy * dy).max(1.0);
|
||||
let dist = dist_sq.sqrt();
|
||||
let force = rep_strength / dist_sq;
|
||||
fx += (dx / dist) * force;
|
||||
fy += (dy / dist) * force;
|
||||
}
|
||||
}
|
||||
|
||||
// Center force (pull towards origin)
|
||||
fx -= nodes[i].x * center_strength_val;
|
||||
fy -= nodes[i].y * center_strength_val;
|
||||
|
||||
// Store force temporarily
|
||||
nodes[i].vx = fx;
|
||||
nodes[i].vy = fy;
|
||||
}
|
||||
|
||||
// Attraction along edges
|
||||
for edge in &edges_for_sim {
|
||||
if let (Some(i), Some(j)) = (
|
||||
nodes.iter().position(|n| n.id == edge.source),
|
||||
nodes.iter().position(|n| n.id == edge.target),
|
||||
) {
|
||||
let dx = nodes[j].x - nodes[i].x;
|
||||
let dy = nodes[j].y - nodes[i].y;
|
||||
let dist = (dx * dx + dy * dy).sqrt().max(1.0);
|
||||
let force = (dist - link_distance) * link_strength_val;
|
||||
|
||||
let fx = (dx / dist) * force;
|
||||
let fy = (dy / dist) * force;
|
||||
|
||||
nodes[i].vx += fx;
|
||||
nodes[i].vy += fy;
|
||||
nodes[j].vx -= fx;
|
||||
nodes[j].vy -= fy;
|
||||
}
|
||||
}
|
||||
|
||||
// Update positions with velocity and damping
|
||||
let mut total_kinetic_energy = 0.0;
|
||||
for node in nodes.iter_mut() {
|
||||
node.x += node.vx * 0.01;
|
||||
node.y += node.vy * 0.01;
|
||||
node.vx *= damping_val;
|
||||
node.vy *= damping_val;
|
||||
|
||||
// Calculate kinetic energy (1/2 * m * v^2, assume m=1)
|
||||
let speed_sq = node.vx * node.vx + node.vy * node.vy;
|
||||
total_kinetic_energy += speed_sq;
|
||||
}
|
||||
|
||||
// If total kinetic energy is below threshold, pause simulation
|
||||
let avg_kinetic_energy = total_kinetic_energy / node_count as f64;
|
||||
if avg_kinetic_energy < 0.01 {
|
||||
simulation_active.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sleep for ~16ms (60 FPS)
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(16)).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let selected = selected_node.read();
|
||||
let current_zoom = *zoom.read();
|
||||
let current_pan_x = *pan_x.read();
|
||||
let current_pan_y = *pan_y.read();
|
||||
|
||||
// Create id to position map
|
||||
let nodes_read = physics_nodes.read();
|
||||
let id_to_pos: FxHashMap<&str, (f64, f64)> = nodes_read
|
||||
.iter()
|
||||
.map(|n| (n.id.as_str(), (n.x, n.y)))
|
||||
.collect();
|
||||
|
||||
rsx! {
|
||||
div { class: "graph-svg-container",
|
||||
// Zoom and physics controls
|
||||
div { class: "graph-zoom-controls",
|
||||
button {
|
||||
class: "zoom-btn",
|
||||
title: "Zoom In",
|
||||
onclick: move |_| {
|
||||
let new_zoom = (*zoom.read() * 1.2).min(5.0);
|
||||
zoom.set(new_zoom);
|
||||
},
|
||||
"+"
|
||||
}
|
||||
button {
|
||||
class: "zoom-btn",
|
||||
title: "Zoom Out",
|
||||
onclick: move |_| {
|
||||
let new_zoom = (*zoom.read() / 1.2).max(0.1);
|
||||
zoom.set(new_zoom);
|
||||
},
|
||||
"−"
|
||||
}
|
||||
button {
|
||||
class: "zoom-btn",
|
||||
title: "Reset View",
|
||||
onclick: move |_| {
|
||||
zoom.set(1.0);
|
||||
pan_x.set(0.0);
|
||||
pan_y.set(0.0);
|
||||
},
|
||||
"⊙"
|
||||
}
|
||||
button {
|
||||
class: "zoom-btn",
|
||||
title: "Physics Settings",
|
||||
onclick: move |_| {
|
||||
let current = *show_controls.read();
|
||||
show_controls.set(!current);
|
||||
},
|
||||
"⚙"
|
||||
}
|
||||
}
|
||||
|
||||
// Physics control panel
|
||||
if *show_controls.read() {
|
||||
div { class: "physics-controls-panel",
|
||||
h4 { "Physics Settings" }
|
||||
|
||||
div { class: "control-group",
|
||||
label { "Repulsion Strength" }
|
||||
input {
|
||||
r#type: "range",
|
||||
min: "100",
|
||||
max: "5000",
|
||||
step: "100",
|
||||
value: "{*repulsion_strength.read()}",
|
||||
oninput: move |evt| {
|
||||
if let Ok(v) = evt.value().parse::<f64>() {
|
||||
repulsion_strength.set(v);
|
||||
simulation_active.set(true);
|
||||
}
|
||||
},
|
||||
}
|
||||
span { class: "control-value", "{*repulsion_strength.read():.0}" }
|
||||
}
|
||||
|
||||
div { class: "control-group",
|
||||
label { "Link Strength" }
|
||||
input {
|
||||
r#type: "range",
|
||||
min: "0.1",
|
||||
max: "2.0",
|
||||
step: "0.1",
|
||||
value: "{*link_strength.read()}",
|
||||
oninput: move |evt| {
|
||||
if let Ok(v) = evt.value().parse::<f64>() {
|
||||
link_strength.set(v);
|
||||
simulation_active.set(true);
|
||||
}
|
||||
},
|
||||
}
|
||||
span { class: "control-value", "{*link_strength.read():.1}" }
|
||||
}
|
||||
|
||||
div { class: "control-group",
|
||||
label { "Link Distance" }
|
||||
input {
|
||||
r#type: "range",
|
||||
min: "50",
|
||||
max: "300",
|
||||
step: "10",
|
||||
value: "{*link_distance.read()}",
|
||||
oninput: move |evt| {
|
||||
if let Ok(v) = evt.value().parse::<f64>() {
|
||||
link_distance.set(v);
|
||||
simulation_active.set(true);
|
||||
}
|
||||
},
|
||||
}
|
||||
span { class: "control-value", "{*link_distance.read():.0}" }
|
||||
}
|
||||
|
||||
div { class: "control-group",
|
||||
label { "Center Gravity" }
|
||||
input {
|
||||
r#type: "range",
|
||||
min: "0.01",
|
||||
max: "0.5",
|
||||
step: "0.01",
|
||||
value: "{*center_strength.read()}",
|
||||
oninput: move |evt| {
|
||||
if let Ok(v) = evt.value().parse::<f64>() {
|
||||
center_strength.set(v);
|
||||
simulation_active.set(true);
|
||||
}
|
||||
},
|
||||
}
|
||||
span { class: "control-value", "{*center_strength.read():.2}" }
|
||||
}
|
||||
|
||||
div { class: "control-group",
|
||||
label { "Damping" }
|
||||
input {
|
||||
r#type: "range",
|
||||
min: "0.5",
|
||||
max: "0.95",
|
||||
step: "0.05",
|
||||
value: "{*damping.read()}",
|
||||
oninput: move |evt| {
|
||||
if let Ok(v) = evt.value().parse::<f64>() {
|
||||
damping.set(v);
|
||||
simulation_active.set(true);
|
||||
}
|
||||
},
|
||||
}
|
||||
span { class: "control-value", "{*damping.read():.2}" }
|
||||
}
|
||||
|
||||
div { class: "control-group",
|
||||
label { "Simulation Status" }
|
||||
span { style: if *simulation_active.read() { "color: #4ade80;" } else { "color: #94a3b8;" },
|
||||
if *simulation_active.read() {
|
||||
"Running"
|
||||
} else {
|
||||
"Paused (settled)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
class: "btn btn-sm btn-secondary",
|
||||
onclick: move |_| {
|
||||
simulation_active.set(true);
|
||||
},
|
||||
disabled: *simulation_active.read(),
|
||||
"Restart Simulation"
|
||||
}
|
||||
|
||||
button {
|
||||
class: "btn btn-sm btn-secondary",
|
||||
onclick: move |_| {
|
||||
repulsion_strength.set(1000.0);
|
||||
link_strength.set(0.5);
|
||||
link_distance.set(100.0);
|
||||
center_strength.set(0.1);
|
||||
damping.set(0.8);
|
||||
simulation_active.set(true);
|
||||
},
|
||||
"Reset to Defaults"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SVG canvas - fills available space
|
||||
svg {
|
||||
class: "graph-svg",
|
||||
style: "width: 100%; height: 100%;",
|
||||
view_box: "-1000 -1000 2000 2000",
|
||||
onmousedown: move |evt| {
|
||||
// Check if clicking on background (not a node)
|
||||
is_dragging_canvas.set(true);
|
||||
drag_start_x.set(evt.page_coordinates().x);
|
||||
drag_start_y.set(evt.page_coordinates().y);
|
||||
},
|
||||
onmousemove: move |evt| {
|
||||
if *is_dragging_canvas.read() {
|
||||
let dx = (evt.page_coordinates().x - *drag_start_x.read()) / current_zoom;
|
||||
let dy = (evt.page_coordinates().y - *drag_start_y.read()) / current_zoom;
|
||||
pan_x.set(current_pan_x + dx);
|
||||
pan_y.set(current_pan_y + dy);
|
||||
drag_start_x.set(evt.page_coordinates().x);
|
||||
drag_start_y.set(evt.page_coordinates().y);
|
||||
}
|
||||
|
||||
// Handle node dragging
|
||||
if let Some(ref node_id) = *dragged_node.read() {
|
||||
let mut nodes = physics_nodes.write();
|
||||
if let Some(node) = nodes.iter_mut().find(|n| &n.id == node_id) {
|
||||
let dx = (evt.page_coordinates().x - *drag_start_x.read()) / current_zoom
|
||||
* 2.0;
|
||||
// Reset velocity when dragging
|
||||
let dy = (evt.page_coordinates().y - *drag_start_y.read()) / current_zoom
|
||||
* 2.0;
|
||||
node.x += dx;
|
||||
node.y += dy;
|
||||
node.vx = 0.0;
|
||||
node.vy = 0.0;
|
||||
drag_start_x.set(evt.page_coordinates().x);
|
||||
drag_start_y.set(evt.page_coordinates().y);
|
||||
}
|
||||
}
|
||||
},
|
||||
onmouseup: move |_| {
|
||||
is_dragging_canvas.set(false);
|
||||
dragged_node.set(None);
|
||||
},
|
||||
onmouseleave: move |_| {
|
||||
is_dragging_canvas.set(false);
|
||||
dragged_node.set(None);
|
||||
},
|
||||
onwheel: move |evt| {
|
||||
let delta = if evt.delta().strip_units().y < 0.0 { 1.1 } else { 0.9 };
|
||||
let new_zoom = (*zoom.read() * delta).clamp(0.1, 5.0);
|
||||
zoom.set(new_zoom);
|
||||
},
|
||||
|
||||
// Transform group for zoom and pan
|
||||
g { transform: "translate({current_pan_x}, {current_pan_y}) scale({current_zoom})",
|
||||
|
||||
// Draw edges first
|
||||
g { class: "graph-edges",
|
||||
for edge in &edges {
|
||||
if let (Some(&(x1, y1)), Some(&(x2, y2))) = (
|
||||
id_to_pos.get(edge.source.as_str()),
|
||||
id_to_pos.get(edge.target.as_str()),
|
||||
)
|
||||
{
|
||||
line {
|
||||
class: "graph-edge edge-type-{edge.link_type}",
|
||||
x1: "{x1}",
|
||||
y1: "{y1}",
|
||||
x2: "{x2}",
|
||||
y2: "{y2}",
|
||||
stroke: "#666",
|
||||
stroke_width: "{1.5 / current_zoom}",
|
||||
stroke_opacity: "0.6",
|
||||
marker_end: "url(#arrowhead)",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Arrow marker definition
|
||||
defs {
|
||||
marker {
|
||||
id: "arrowhead",
|
||||
marker_width: "10",
|
||||
marker_height: "7",
|
||||
ref_x: "9",
|
||||
ref_y: "3.5",
|
||||
orient: "auto",
|
||||
polygon {
|
||||
points: "0 0, 10 3.5, 0 7",
|
||||
fill: "#666",
|
||||
fill_opacity: "0.6",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw nodes
|
||||
g { class: "graph-nodes",
|
||||
for node in nodes_read.iter() {
|
||||
{
|
||||
let node_id = node.id.clone();
|
||||
let node_id2 = node.id.clone();
|
||||
let node_id3 = node.id.clone();
|
||||
let display_text = node.title.as_ref().unwrap_or(&node.label).clone();
|
||||
let is_selected = selected.as_ref() == Some(&node.id);
|
||||
|
||||
// Node size based on connections
|
||||
let total_links = node.link_count + node.backlink_count;
|
||||
let node_radius = 8.0 + (total_links as f64 * 1.5).min(20.0);
|
||||
let scaled_radius = node_radius / current_zoom;
|
||||
|
||||
rsx! {
|
||||
g {
|
||||
class: if is_selected { "graph-node selected" } else { "graph-node" },
|
||||
style: "cursor: pointer;",
|
||||
onclick: move |evt| {
|
||||
evt.stop_propagation();
|
||||
on_node_click.call(node_id.clone());
|
||||
},
|
||||
ondoubleclick: move |evt| {
|
||||
evt.stop_propagation();
|
||||
on_node_double_click.call(node_id2.clone());
|
||||
},
|
||||
onmousedown: move |evt| {
|
||||
evt.stop_propagation();
|
||||
dragged_node.set(Some(node_id3.clone()));
|
||||
drag_start_x.set(evt.page_coordinates().x);
|
||||
drag_start_y.set(evt.page_coordinates().y);
|
||||
},
|
||||
|
||||
|
||||
circle {
|
||||
cx: "{node.x}",
|
||||
cy: "{node.y}",
|
||||
r: "{scaled_radius}",
|
||||
fill: if is_selected { "#2196f3" } else { "#4caf50" },
|
||||
stroke: if is_selected { "#1565c0" } else { "#2e7d32" },
|
||||
stroke_width: "{2.0 / current_zoom}",
|
||||
}
|
||||
text {
|
||||
x: "{node.x}",
|
||||
y: "{node.y + scaled_radius + 15.0 / current_zoom}",
|
||||
text_anchor: "middle",
|
||||
font_size: "{12.0 / current_zoom}",
|
||||
fill: "#333",
|
||||
pointer_events: "none",
|
||||
"{display_text}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Panel showing details about the selected node.
|
||||
#[component]
|
||||
fn NodeDetailsPanel(
|
||||
node: GraphNodeResponse,
|
||||
on_close: EventHandler<()>,
|
||||
on_navigate: EventHandler<String>,
|
||||
) -> Element {
|
||||
let node_id = node.id.clone();
|
||||
|
||||
rsx! {
|
||||
div { class: "node-details-panel",
|
||||
div { class: "node-details-header",
|
||||
h3 { "{node.label}" }
|
||||
button { class: "close-btn", onclick: move |_| on_close.call(()), "×" }
|
||||
}
|
||||
div { class: "node-details-content",
|
||||
if let Some(ref title) = node.title {
|
||||
p { class: "node-title", "{title}" }
|
||||
}
|
||||
div { class: "node-stats",
|
||||
span { class: "stat",
|
||||
"Outgoing: "
|
||||
strong { "{node.link_count}" }
|
||||
}
|
||||
span { class: "stat",
|
||||
"Incoming: "
|
||||
strong { "{node.backlink_count}" }
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
onclick: move |_| on_navigate.call(node_id.clone()),
|
||||
"Open Note"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
252
packages/pinakes-ui/src/components/image_viewer.rs
Normal file
252
packages/pinakes-ui/src/components/image_viewer.rs
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
use dioxus::prelude::*;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum FitMode {
|
||||
FitScreen,
|
||||
FitWidth,
|
||||
Actual,
|
||||
}
|
||||
|
||||
impl FitMode {
|
||||
fn next(self) -> Self {
|
||||
match self {
|
||||
Self::FitScreen => Self::FitWidth,
|
||||
Self::FitWidth => Self::Actual,
|
||||
Self::Actual => Self::FitScreen,
|
||||
}
|
||||
}
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::FitScreen => "Fit",
|
||||
Self::FitWidth => "Width",
|
||||
Self::Actual => "100%",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn ImageViewer(
|
||||
src: String,
|
||||
alt: String,
|
||||
on_close: EventHandler<()>,
|
||||
#[props(default)] on_prev: Option<EventHandler<()>>,
|
||||
#[props(default)] on_next: Option<EventHandler<()>>,
|
||||
) -> Element {
|
||||
let mut zoom = use_signal(|| 1.0f64);
|
||||
let mut offset_x = use_signal(|| 0.0f64);
|
||||
let mut offset_y = use_signal(|| 0.0f64);
|
||||
let mut dragging = use_signal(|| false);
|
||||
let mut drag_start_x = use_signal(|| 0.0f64);
|
||||
let mut drag_start_y = use_signal(|| 0.0f64);
|
||||
let mut fit_mode = use_signal(|| FitMode::FitScreen);
|
||||
|
||||
let z = *zoom.read();
|
||||
let ox = *offset_x.read();
|
||||
let oy = *offset_y.read();
|
||||
let is_dragging = *dragging.read();
|
||||
let zoom_pct = (z * 100.0) as u32;
|
||||
let current_fit = *fit_mode.read();
|
||||
|
||||
let transform = format!("translate({ox}px, {oy}px) scale({z})");
|
||||
let cursor = if z > 1.0 {
|
||||
if is_dragging { "grabbing" } else { "grab" }
|
||||
} else {
|
||||
"default"
|
||||
};
|
||||
|
||||
// Compute image style based on fit mode
|
||||
let img_style = match current_fit {
|
||||
FitMode::FitScreen => {
|
||||
format!(
|
||||
"transform: {transform}; cursor: {cursor}; max-width: 100%; \
|
||||
max-height: 100%; object-fit: contain;"
|
||||
)
|
||||
},
|
||||
FitMode::FitWidth => {
|
||||
format!(
|
||||
"transform: {transform}; cursor: {cursor}; width: 100%; object-fit: \
|
||||
contain;"
|
||||
)
|
||||
},
|
||||
FitMode::Actual => format!("transform: {transform}; cursor: {cursor};"),
|
||||
};
|
||||
|
||||
let on_wheel = move |e: WheelEvent| {
|
||||
e.prevent_default();
|
||||
let delta = e.delta().strip_units();
|
||||
let factor = if delta.y < 0.0 { 1.1 } else { 1.0 / 1.1 };
|
||||
let new_zoom = (*zoom.read() * factor).clamp(0.1, 20.0);
|
||||
zoom.set(new_zoom);
|
||||
};
|
||||
|
||||
let on_mouse_down = move |e: MouseEvent| {
|
||||
if *zoom.read() > 1.0 {
|
||||
dragging.set(true);
|
||||
let coords = e.client_coordinates();
|
||||
drag_start_x.set(coords.x - *offset_x.read());
|
||||
drag_start_y.set(coords.y - *offset_y.read());
|
||||
}
|
||||
};
|
||||
|
||||
let on_mouse_move = move |e: MouseEvent| {
|
||||
if *dragging.read() {
|
||||
let coords = e.client_coordinates();
|
||||
offset_x.set(coords.x - *drag_start_x.read());
|
||||
offset_y.set(coords.y - *drag_start_y.read());
|
||||
}
|
||||
};
|
||||
|
||||
let on_mouse_up = move |_: MouseEvent| {
|
||||
dragging.set(false);
|
||||
};
|
||||
|
||||
let on_keydown = {
|
||||
move |evt: KeyboardEvent| {
|
||||
match evt.key() {
|
||||
Key::Escape => on_close.call(()),
|
||||
Key::Character(ref c) if c == "+" || c == "=" => {
|
||||
let new_zoom = (*zoom.read() * 1.2).min(20.0);
|
||||
zoom.set(new_zoom);
|
||||
},
|
||||
Key::Character(ref c) if c == "-" => {
|
||||
let new_zoom = (*zoom.read() / 1.2).max(0.1);
|
||||
zoom.set(new_zoom);
|
||||
},
|
||||
Key::Character(ref c) if c == "0" => {
|
||||
zoom.set(1.0);
|
||||
offset_x.set(0.0);
|
||||
offset_y.set(0.0);
|
||||
fit_mode.set(FitMode::FitScreen);
|
||||
},
|
||||
Key::ArrowLeft => {
|
||||
if let Some(ref prev) = on_prev {
|
||||
prev.call(());
|
||||
zoom.set(1.0);
|
||||
offset_x.set(0.0);
|
||||
offset_y.set(0.0);
|
||||
}
|
||||
},
|
||||
Key::ArrowRight => {
|
||||
if let Some(ref next) = on_next {
|
||||
next.call(());
|
||||
zoom.set(1.0);
|
||||
offset_x.set(0.0);
|
||||
offset_y.set(0.0);
|
||||
}
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let zoom_in = move |_| {
|
||||
let new_zoom = (*zoom.read() * 1.2).min(20.0);
|
||||
zoom.set(new_zoom);
|
||||
};
|
||||
|
||||
let zoom_out = move |_| {
|
||||
let new_zoom = (*zoom.read() / 1.2).max(0.1);
|
||||
zoom.set(new_zoom);
|
||||
};
|
||||
|
||||
let cycle_fit = move |_| {
|
||||
let next = fit_mode.read().next();
|
||||
fit_mode.set(next);
|
||||
zoom.set(1.0);
|
||||
offset_x.set(0.0);
|
||||
offset_y.set(0.0);
|
||||
};
|
||||
|
||||
let has_prev = on_prev.is_some();
|
||||
let has_next = on_next.is_some();
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: "image-viewer-overlay",
|
||||
tabindex: "0",
|
||||
onkeydown: on_keydown,
|
||||
|
||||
// Toolbar
|
||||
div { class: "image-viewer-toolbar",
|
||||
div { class: "image-viewer-toolbar-left",
|
||||
if has_prev {
|
||||
button {
|
||||
class: "iv-btn",
|
||||
onclick: move |_| {
|
||||
if let Some(ref prev) = on_prev {
|
||||
prev.call(());
|
||||
zoom.set(1.0);
|
||||
offset_x.set(0.0);
|
||||
offset_y.set(0.0);
|
||||
}
|
||||
},
|
||||
title: "Previous",
|
||||
"\u{25c0}"
|
||||
}
|
||||
}
|
||||
if has_next {
|
||||
button {
|
||||
class: "iv-btn",
|
||||
onclick: move |_| {
|
||||
if let Some(ref next) = on_next {
|
||||
next.call(());
|
||||
zoom.set(1.0);
|
||||
offset_x.set(0.0);
|
||||
offset_y.set(0.0);
|
||||
}
|
||||
},
|
||||
title: "Next",
|
||||
"\u{25b6}"
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "image-viewer-toolbar-center",
|
||||
button {
|
||||
class: "iv-btn",
|
||||
onclick: cycle_fit,
|
||||
title: "Cycle fit mode",
|
||||
"{current_fit.label()}"
|
||||
}
|
||||
button {
|
||||
class: "iv-btn",
|
||||
onclick: zoom_out,
|
||||
title: "Zoom out",
|
||||
"\u{2212}"
|
||||
}
|
||||
span { class: "iv-zoom-label", "{zoom_pct}%" }
|
||||
button { class: "iv-btn", onclick: zoom_in, title: "Zoom in", "+" }
|
||||
}
|
||||
div { class: "image-viewer-toolbar-right",
|
||||
button {
|
||||
class: "iv-btn iv-close",
|
||||
onclick: move |_| on_close.call(()),
|
||||
title: "Close",
|
||||
"\u{2715}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Image canvas
|
||||
div {
|
||||
class: "image-viewer-canvas",
|
||||
onwheel: on_wheel,
|
||||
onmousedown: on_mouse_down,
|
||||
onmousemove: on_mouse_move,
|
||||
onmouseup: on_mouse_up,
|
||||
onclick: move |e: MouseEvent| {
|
||||
// Close on background click (not on image)
|
||||
e.stop_propagation();
|
||||
},
|
||||
|
||||
img {
|
||||
src: "{src}",
|
||||
alt: "{alt}",
|
||||
style: "{img_style}",
|
||||
draggable: "false",
|
||||
onclick: move |e: MouseEvent| e.stop_propagation(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
829
packages/pinakes-ui/src/components/import.rs
Normal file
829
packages/pinakes-ui/src/components/import.rs
Normal file
|
|
@ -0,0 +1,829 @@
|
|||
use dioxus::prelude::*;
|
||||
use rustc_hash::FxHashSet;
|
||||
|
||||
use super::utils::{format_size, type_badge_class};
|
||||
use crate::client::{
|
||||
CollectionResponse,
|
||||
DirectoryPreviewFile,
|
||||
ImportEvent,
|
||||
ScanStatusResponse,
|
||||
TagResponse,
|
||||
};
|
||||
|
||||
/// Import event for batch: (paths, tag_ids, new_tags, collection_id)
|
||||
pub type BatchImportEvent =
|
||||
(Vec<String>, Vec<String>, Vec<String>, Option<String>);
|
||||
|
||||
#[component]
|
||||
pub fn Import(
|
||||
tags: Vec<TagResponse>,
|
||||
collections: Vec<CollectionResponse>,
|
||||
on_import_file: EventHandler<ImportEvent>,
|
||||
on_import_directory: EventHandler<ImportEvent>,
|
||||
on_import_batch: EventHandler<BatchImportEvent>,
|
||||
on_scan: EventHandler<()>,
|
||||
on_preview_directory: EventHandler<(String, bool)>,
|
||||
preview_files: Vec<DirectoryPreviewFile>,
|
||||
preview_total_size: u64,
|
||||
scan_progress: Option<ScanStatusResponse>,
|
||||
#[props(default = false)] is_importing: bool,
|
||||
// Extended import state
|
||||
#[props(default)] current_file: Option<String>,
|
||||
#[props(default)] import_queue: Vec<String>,
|
||||
#[props(default = (0, 0))] import_progress: (usize, usize),
|
||||
) -> Element {
|
||||
let mut import_mode = use_signal(|| 0usize);
|
||||
let mut file_path = use_signal(String::new);
|
||||
let mut dir_path = use_signal(String::new);
|
||||
let selected_tags = use_signal(Vec::<String>::new);
|
||||
let new_tags_input = use_signal(String::new);
|
||||
let selected_collection = use_signal(|| Option::<String>::None);
|
||||
|
||||
// Recursive toggle for directory preview
|
||||
let mut recursive = use_signal(|| true);
|
||||
|
||||
// Filter state for directory preview
|
||||
let mut filter_types =
|
||||
use_signal(|| vec![true, true, true, true, true, true]); // audio, video, image, document, text, other
|
||||
let mut filter_min_size = use_signal(|| 0u64);
|
||||
let mut filter_max_size = use_signal(|| 0u64); // 0 means no limit
|
||||
|
||||
// File selection state
|
||||
let mut selected_file_paths = use_signal(FxHashSet::<String>::default);
|
||||
|
||||
let current_mode = *import_mode.read();
|
||||
|
||||
rsx! {
|
||||
// Import status panel (shown when import is in progress)
|
||||
if is_importing {
|
||||
{
|
||||
let (completed, total) = import_progress;
|
||||
let has_progress = total > 0;
|
||||
let pct = (completed * 100).checked_div(total).unwrap_or(0);
|
||||
let queue_count = import_queue.len();
|
||||
rsx! {
|
||||
div { class: "import-status-panel",
|
||||
div { class: "import-status-header",
|
||||
div { class: "status-dot checking" }
|
||||
span {
|
||||
if has_progress {
|
||||
"Importing {completed}/{total}..."
|
||||
} else {
|
||||
"Import in progress..."
|
||||
}
|
||||
}
|
||||
}
|
||||
// Show current file being imported
|
||||
if let Some(ref file_name) = current_file {
|
||||
div { class: "import-current-file",
|
||||
span { class: "import-file-label", "Current: " }
|
||||
span { class: "import-file-name", "{file_name}" }
|
||||
}
|
||||
}
|
||||
// Show queue indicator
|
||||
if queue_count > 0 {
|
||||
div { class: "import-queue-indicator",
|
||||
span { class: "import-queue-badge", "{queue_count}" }
|
||||
span { class: "import-queue-text", " item(s) queued" }
|
||||
}
|
||||
}
|
||||
div { class: "progress-bar",
|
||||
if has_progress {
|
||||
div { class: "progress-fill", style: "width: {pct}%;" }
|
||||
} else {
|
||||
div { class: "progress-fill indeterminate" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tab bar
|
||||
div { class: "import-tabs",
|
||||
button {
|
||||
class: if current_mode == 0 { "import-tab active" } else { "import-tab" },
|
||||
onclick: move |_| import_mode.set(0),
|
||||
"Single File"
|
||||
}
|
||||
button {
|
||||
class: if current_mode == 1 { "import-tab active" } else { "import-tab" },
|
||||
onclick: move |_| import_mode.set(1),
|
||||
"Directory"
|
||||
}
|
||||
button {
|
||||
class: if current_mode == 2 { "import-tab active" } else { "import-tab" },
|
||||
onclick: move |_| import_mode.set(2),
|
||||
"Scan Roots"
|
||||
}
|
||||
}
|
||||
|
||||
// Mode 0: Single File
|
||||
if current_mode == 0 {
|
||||
div { class: "card mb-16",
|
||||
div { class: "card-header",
|
||||
h3 { class: "card-title", "Import Single File" }
|
||||
}
|
||||
|
||||
div { class: "form-group",
|
||||
label { class: "form-label", "File Path" }
|
||||
div { class: "form-row",
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "/path/to/file...",
|
||||
value: "{file_path}",
|
||||
oninput: move |e| file_path.set(e.value()),
|
||||
onkeypress: {
|
||||
let mut file_path = file_path;
|
||||
let mut selected_tags = selected_tags;
|
||||
let mut new_tags_input = new_tags_input;
|
||||
let mut selected_collection = selected_collection;
|
||||
move |e: KeyboardEvent| {
|
||||
if e.key() == Key::Enter {
|
||||
let path = file_path.read().clone();
|
||||
if !path.is_empty() {
|
||||
let tag_ids = selected_tags.read().clone();
|
||||
let new_tags = parse_new_tags(&new_tags_input.read());
|
||||
let col_id = selected_collection.read().clone();
|
||||
on_import_file.call((path, tag_ids, new_tags, col_id));
|
||||
file_path.set(String::new());
|
||||
selected_tags.set(Vec::new());
|
||||
new_tags_input.set(String::new());
|
||||
selected_collection.set(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
button {
|
||||
class: "btn btn-secondary",
|
||||
onclick: move |_| {
|
||||
let mut file_path = file_path;
|
||||
spawn(async move {
|
||||
if let Some(handle) = rfd::AsyncFileDialog::new().pick_file().await {
|
||||
file_path.set(handle.path().to_string_lossy().to_string());
|
||||
}
|
||||
});
|
||||
},
|
||||
"Browse..."
|
||||
}
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
disabled: is_importing,
|
||||
onclick: {
|
||||
let mut file_path = file_path;
|
||||
let mut selected_tags = selected_tags;
|
||||
let mut new_tags_input = new_tags_input;
|
||||
let mut selected_collection = selected_collection;
|
||||
move |_| {
|
||||
let path = file_path.read().clone();
|
||||
if !path.is_empty() {
|
||||
let tag_ids = selected_tags.read().clone();
|
||||
let new_tags = parse_new_tags(&new_tags_input.read());
|
||||
let col_id = selected_collection.read().clone();
|
||||
on_import_file.call((path, tag_ids, new_tags, col_id));
|
||||
file_path.set(String::new());
|
||||
selected_tags.set(Vec::new());
|
||||
new_tags_input.set(String::new());
|
||||
selected_collection.set(None);
|
||||
}
|
||||
}
|
||||
},
|
||||
if is_importing {
|
||||
"Importing..."
|
||||
} else {
|
||||
"Import"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ImportOptions {
|
||||
tags: tags.clone(),
|
||||
collections: collections.clone(),
|
||||
selected_tags,
|
||||
new_tags_input,
|
||||
selected_collection,
|
||||
}
|
||||
}
|
||||
|
||||
// Mode 1: Directory
|
||||
if current_mode == 1 {
|
||||
div { class: "card mb-16",
|
||||
div { class: "card-header",
|
||||
h3 { class: "card-title", "Import Directory" }
|
||||
}
|
||||
|
||||
div { class: "form-group",
|
||||
label { class: "form-label", "Directory Path" }
|
||||
div { class: "form-row",
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "/path/to/directory...",
|
||||
value: "{dir_path}",
|
||||
oninput: move |e| dir_path.set(e.value()),
|
||||
onkeypress: {
|
||||
let dir_path = dir_path;
|
||||
let recursive = recursive;
|
||||
move |e: KeyboardEvent| {
|
||||
if e.key() == Key::Enter {
|
||||
let path = dir_path.read().clone();
|
||||
if !path.is_empty() {
|
||||
on_preview_directory.call((path, *recursive.read()));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
button {
|
||||
class: "btn btn-secondary",
|
||||
onclick: move |_| {
|
||||
let mut dir_path = dir_path;
|
||||
let recursive = recursive;
|
||||
spawn(async move {
|
||||
if let Some(handle) = rfd::AsyncFileDialog::new().pick_folder().await {
|
||||
let path = handle.path().to_string_lossy().to_string();
|
||||
dir_path.set(path.clone());
|
||||
on_preview_directory.call((path, *recursive.read()));
|
||||
}
|
||||
});
|
||||
},
|
||||
"Browse..."
|
||||
}
|
||||
button {
|
||||
class: "btn btn-secondary",
|
||||
onclick: {
|
||||
let dir_path = dir_path;
|
||||
let recursive = recursive;
|
||||
move |_| {
|
||||
let path = dir_path.read().clone();
|
||||
if !path.is_empty() {
|
||||
on_preview_directory.call((path, *recursive.read()));
|
||||
}
|
||||
}
|
||||
},
|
||||
"Preview"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recursive toggle
|
||||
div { class: "form-group",
|
||||
label { class: "checkbox-label",
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
checked: *recursive.read(),
|
||||
onchange: move |_| recursive.toggle(),
|
||||
}
|
||||
span { "Recursive (include subdirectories)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Preview results
|
||||
if !preview_files.is_empty() {
|
||||
{
|
||||
// Read filter signals once before the loop to avoid per-item reads
|
||||
let types_snapshot = filter_types.read().clone();
|
||||
let min = *filter_min_size.read();
|
||||
let max = *filter_max_size.read();
|
||||
|
||||
let filtered: Vec<&DirectoryPreviewFile> = preview_files
|
||||
|
||||
// Read selection once for display
|
||||
.iter()
|
||||
|
||||
// Filter bar
|
||||
|
||||
// Selection toolbar
|
||||
|
||||
// Deselect all filtered
|
||||
// Select all filtered
|
||||
.filter(|f| {
|
||||
let type_idx = match type_badge_class(&f.media_type) {
|
||||
"type-audio" => 0,
|
||||
"type-video" => 1,
|
||||
"type-image" => 2,
|
||||
"type-document" => 3,
|
||||
"type-text" => 4,
|
||||
_ => 5,
|
||||
};
|
||||
if !types_snapshot[type_idx] {
|
||||
return false;
|
||||
}
|
||||
if min > 0 && f.file_size < min {
|
||||
return false;
|
||||
}
|
||||
if max > 0 && f.file_size > max {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
})
|
||||
.collect();
|
||||
let filtered_count = filtered.len();
|
||||
let total_count = preview_files.len();
|
||||
let selection = selected_file_paths.read().clone();
|
||||
let selected_count = selection.len();
|
||||
let all_filtered_selected = !filtered.is_empty()
|
||||
&& filtered.iter().all(|f| selection.contains(&f.path));
|
||||
let filtered_paths: Vec<String> = filtered
|
||||
.iter()
|
||||
.map(|f| f.path.clone())
|
||||
.collect();
|
||||
rsx! {
|
||||
div { class: "card mb-16",
|
||||
div { class: "card-header",
|
||||
h3 { class: "card-title", "Preview" }
|
||||
p { class: "text-muted text-sm",
|
||||
"{filtered_count} of {total_count} files shown, {format_size(preview_total_size)} total"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
div { class: "filter-bar",
|
||||
div { class: "filter-row",
|
||||
span { class: "filter-label", "Types" }
|
||||
label { class: if types_snapshot[0] { "filter-chip active" } else { "filter-chip" },
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
checked: types_snapshot[0],
|
||||
onchange: move |_| {
|
||||
let mut types = filter_types.read().clone();
|
||||
types[0] = !types[0];
|
||||
filter_types.set(types);
|
||||
},
|
||||
}
|
||||
"Audio"
|
||||
}
|
||||
label { class: if types_snapshot[1] { "filter-chip active" } else { "filter-chip" },
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
checked: types_snapshot[1],
|
||||
onchange: move |_| {
|
||||
let mut types = filter_types.read().clone();
|
||||
types[1] = !types[1];
|
||||
filter_types.set(types);
|
||||
},
|
||||
}
|
||||
"Video"
|
||||
}
|
||||
label { class: if types_snapshot[2] { "filter-chip active" } else { "filter-chip" },
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
checked: types_snapshot[2],
|
||||
onchange: move |_| {
|
||||
let mut types = filter_types.read().clone();
|
||||
types[2] = !types[2];
|
||||
filter_types.set(types);
|
||||
},
|
||||
}
|
||||
"Image"
|
||||
}
|
||||
label { class: if types_snapshot[3] { "filter-chip active" } else { "filter-chip" },
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
checked: types_snapshot[3],
|
||||
onchange: move |_| {
|
||||
let mut types = filter_types.read().clone();
|
||||
types[3] = !types[3];
|
||||
filter_types.set(types);
|
||||
},
|
||||
}
|
||||
"Document"
|
||||
}
|
||||
label { class: if types_snapshot[4] { "filter-chip active" } else { "filter-chip" },
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
checked: types_snapshot[4],
|
||||
onchange: move |_| {
|
||||
let mut types = filter_types.read().clone();
|
||||
types[4] = !types[4];
|
||||
filter_types.set(types);
|
||||
},
|
||||
}
|
||||
"Text"
|
||||
}
|
||||
label { class: if types_snapshot[5] { "filter-chip active" } else { "filter-chip" },
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
checked: types_snapshot[5],
|
||||
onchange: move |_| {
|
||||
let mut types = filter_types.read().clone();
|
||||
types[5] = !types[5];
|
||||
filter_types.set(types);
|
||||
},
|
||||
}
|
||||
"Other"
|
||||
}
|
||||
}
|
||||
div { class: "size-filters",
|
||||
div { class: "size-filter-group",
|
||||
label { "Min size" }
|
||||
input {
|
||||
r#type: "number",
|
||||
placeholder: "MB",
|
||||
value: if min > 0 { format!("{}", min / (1024 * 1024)) } else { String::new() },
|
||||
oninput: move |e| {
|
||||
if let Ok(mb) = e.value().parse::<u64>() {
|
||||
filter_min_size.set(mb * 1024 * 1024);
|
||||
} else {
|
||||
filter_min_size.set(0);
|
||||
}
|
||||
},
|
||||
}
|
||||
span { class: "text-muted text-sm", "MB" }
|
||||
}
|
||||
div { class: "size-filter-group",
|
||||
label { "Max size" }
|
||||
input {
|
||||
r#type: "number",
|
||||
placeholder: "MB",
|
||||
value: if max > 0 { format!("{}", max / (1024 * 1024)) } else { String::new() },
|
||||
oninput: move |e| {
|
||||
if let Ok(mb) = e.value().parse::<u64>() {
|
||||
filter_max_size.set(mb * 1024 * 1024);
|
||||
} else {
|
||||
filter_max_size.set(0);
|
||||
}
|
||||
},
|
||||
}
|
||||
span { class: "text-muted text-sm", "MB" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div {
|
||||
class: "flex-row mb-8",
|
||||
style: "gap: 8px; align-items: center; padding: 0 8px;",
|
||||
button {
|
||||
class: "btn btn-sm btn-secondary",
|
||||
onclick: {
|
||||
let filtered_paths = filtered_paths.clone();
|
||||
move |_| {
|
||||
let mut sel = selected_file_paths.read().clone();
|
||||
for p in &filtered_paths {
|
||||
sel.insert(p.clone());
|
||||
}
|
||||
selected_file_paths.set(sel);
|
||||
}
|
||||
},
|
||||
"Select All ({filtered_count})"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-sm btn-ghost",
|
||||
onclick: move |_| {
|
||||
selected_file_paths.set(FxHashSet::default());
|
||||
},
|
||||
"Deselect All"
|
||||
}
|
||||
if selected_count > 0 {
|
||||
span { class: "text-muted text-sm", "{selected_count} files selected" }
|
||||
}
|
||||
}
|
||||
|
||||
div { style: "max-height: 400px; overflow-y: auto;",
|
||||
table { class: "data-table",
|
||||
thead {
|
||||
tr {
|
||||
th { style: "width: 32px;",
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
checked: all_filtered_selected,
|
||||
onclick: {
|
||||
let filtered_paths = filtered_paths.clone();
|
||||
move |_| {
|
||||
if all_filtered_selected {
|
||||
let filtered_set: FxHashSet<String> = filtered_paths
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
let sel = selected_file_paths.read().clone();
|
||||
let new_sel: FxHashSet<String> = sel
|
||||
.difference(&filtered_set)
|
||||
.cloned()
|
||||
.collect();
|
||||
selected_file_paths.set(new_sel);
|
||||
} else {
|
||||
let mut sel = selected_file_paths.read().clone();
|
||||
for p in &filtered_paths {
|
||||
sel.insert(p.clone());
|
||||
}
|
||||
selected_file_paths.set(sel);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
th { "File Name" }
|
||||
th { "Type" }
|
||||
th { "Size" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for file in filtered.iter() {
|
||||
{
|
||||
let size = format_size(file.file_size);
|
||||
let badge_class = type_badge_class(&file.media_type);
|
||||
let is_selected = selection.contains(&file.path);
|
||||
let file_path_clone = file.path.clone();
|
||||
rsx! {
|
||||
tr { key: "{file.path}", class: if is_selected { "row-selected" } else { "" },
|
||||
td {
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
checked: is_selected,
|
||||
onclick: {
|
||||
let path = file_path_clone.clone();
|
||||
move |_| {
|
||||
let mut sel = selected_file_paths.read().clone();
|
||||
if sel.contains(&path) {
|
||||
sel.remove(&path);
|
||||
} else {
|
||||
sel.insert(path.clone());
|
||||
}
|
||||
selected_file_paths.set(sel);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
td { "{file.file_name}" }
|
||||
td {
|
||||
span { class: "type-badge {badge_class}", "{file.media_type}" }
|
||||
}
|
||||
td { "{size}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ImportOptions {
|
||||
tags: tags.clone(),
|
||||
collections: collections.clone(),
|
||||
selected_tags,
|
||||
new_tags_input,
|
||||
selected_collection,
|
||||
}
|
||||
|
||||
div { class: "flex-row mb-16", style: "gap: 8px;",
|
||||
// Import selected files only (batch import)
|
||||
{
|
||||
let sel_count = selected_file_paths.read().len();
|
||||
let has_selected = sel_count > 0;
|
||||
rsx! {
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
disabled: !has_selected || is_importing,
|
||||
onclick: {
|
||||
let mut selected_file_paths = selected_file_paths;
|
||||
let mut selected_tags = selected_tags;
|
||||
let mut new_tags_input = new_tags_input;
|
||||
let mut selected_collection = selected_collection;
|
||||
move |_| {
|
||||
let paths: Vec<String> = selected_file_paths
|
||||
.read()
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
if !paths.is_empty() {
|
||||
let tag_ids = selected_tags.read().clone();
|
||||
let new_tags = parse_new_tags(&new_tags_input.read());
|
||||
let col_id = selected_collection.read().clone();
|
||||
on_import_batch.call((paths, tag_ids, new_tags, col_id));
|
||||
selected_file_paths.set(FxHashSet::default());
|
||||
selected_tags.set(Vec::new());
|
||||
new_tags_input.set(String::new());
|
||||
selected_collection.set(None);
|
||||
}
|
||||
}
|
||||
},
|
||||
if is_importing {
|
||||
"Importing..."
|
||||
} else if has_selected {
|
||||
"Import Selected ({sel_count})"
|
||||
} else {
|
||||
"Import Selected"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import entire directory
|
||||
{
|
||||
let has_dir = !dir_path.read().is_empty();
|
||||
let has_preview = !preview_files.is_empty();
|
||||
let file_count = preview_files.len();
|
||||
rsx! {
|
||||
button {
|
||||
class: if has_dir { "btn btn-secondary" } else { "btn btn-secondary btn-disabled-hint" },
|
||||
disabled: is_importing || !has_dir,
|
||||
title: if !has_dir { "Select a directory first" } else { "" },
|
||||
onclick: {
|
||||
let mut dir_path = dir_path;
|
||||
let mut selected_tags = selected_tags;
|
||||
let mut new_tags_input = new_tags_input;
|
||||
let mut selected_collection = selected_collection;
|
||||
let mut selected_file_paths = selected_file_paths;
|
||||
move |_| {
|
||||
let path = dir_path.read().clone();
|
||||
if !path.is_empty() {
|
||||
let tag_ids = selected_tags.read().clone();
|
||||
let new_tags = parse_new_tags(&new_tags_input.read());
|
||||
let col_id = selected_collection.read().clone();
|
||||
on_import_directory.call((path, tag_ids, new_tags, col_id));
|
||||
dir_path.set(String::new());
|
||||
selected_tags.set(Vec::new());
|
||||
new_tags_input.set(String::new());
|
||||
selected_collection.set(None);
|
||||
selected_file_paths.set(FxHashSet::default());
|
||||
}
|
||||
}
|
||||
},
|
||||
if is_importing {
|
||||
"Importing..."
|
||||
} else if has_preview {
|
||||
"Import All ({file_count} files)"
|
||||
} else if has_dir {
|
||||
"Import Entire Directory"
|
||||
} else {
|
||||
"Select Directory First"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mode 2: Scan Roots
|
||||
if current_mode == 2 {
|
||||
div { class: "card mb-16",
|
||||
div { class: "card-header",
|
||||
h3 { class: "card-title", "Scan Root Directories" }
|
||||
}
|
||||
|
||||
div { class: "empty-state",
|
||||
p { class: "empty-subtitle",
|
||||
"Scan all configured root directories for media files. "
|
||||
"This will discover and import any new files found in your root paths."
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "mb-16", style: "text-align: center;",
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
disabled: is_importing,
|
||||
onclick: move |_| on_scan.call(()),
|
||||
if is_importing {
|
||||
"Scanning..."
|
||||
} else {
|
||||
"Scan All Roots"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref progress) = scan_progress {
|
||||
{
|
||||
let pct = (progress.files_processed * 100)
|
||||
.checked_div(progress.files_found)
|
||||
.unwrap_or(0);
|
||||
rsx! {
|
||||
div { class: "mb-16",
|
||||
div { class: "progress-bar",
|
||||
div { class: "progress-fill", style: "width: {pct}%;" }
|
||||
}
|
||||
p { class: "text-muted text-sm",
|
||||
"{progress.files_processed} / {progress.files_found} files processed"
|
||||
}
|
||||
if progress.error_count > 0 {
|
||||
p { class: "text-muted text-sm", "{progress.error_count} errors" }
|
||||
}
|
||||
if progress.scanning {
|
||||
p { class: "text-muted text-sm", "Scanning..." }
|
||||
} else {
|
||||
p { class: "text-muted text-sm", "Scan complete" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ImportOptions(
|
||||
tags: Vec<TagResponse>,
|
||||
collections: Vec<CollectionResponse>,
|
||||
selected_tags: Signal<Vec<String>>,
|
||||
new_tags_input: Signal<String>,
|
||||
selected_collection: Signal<Option<String>>,
|
||||
) -> Element {
|
||||
let selected_tags = selected_tags;
|
||||
let mut new_tags_input = new_tags_input;
|
||||
let selected_collection = selected_collection;
|
||||
|
||||
rsx! {
|
||||
div { class: "card mb-16",
|
||||
div { class: "card-header",
|
||||
h4 { class: "card-title", "Import Options" }
|
||||
}
|
||||
|
||||
div { class: "form-group",
|
||||
label { class: "form-label", "Tags" }
|
||||
if tags.is_empty() {
|
||||
p { class: "text-muted text-sm",
|
||||
"No tags available. Create tags from the Tags page."
|
||||
}
|
||||
} else {
|
||||
div { class: "tag-list",
|
||||
for tag in tags.iter() {
|
||||
{
|
||||
let tag_id = tag.id.clone();
|
||||
let tag_name = tag.name.clone();
|
||||
let is_selected = selected_tags.read().contains(&tag_id);
|
||||
let badge_class = if is_selected { "tag-badge selected" } else { "tag-badge" };
|
||||
rsx! {
|
||||
span {
|
||||
class: "{badge_class}",
|
||||
onclick: {
|
||||
let tag_id = tag_id.clone();
|
||||
let mut selected_tags = selected_tags;
|
||||
move |_| {
|
||||
let mut current = selected_tags.read().clone();
|
||||
if let Some(pos) = current.iter().position(|t| t == &tag_id) {
|
||||
current.remove(pos);
|
||||
} else {
|
||||
current.push(tag_id.clone());
|
||||
}
|
||||
selected_tags.set(current);
|
||||
}
|
||||
},
|
||||
"{tag_name}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "form-group",
|
||||
label { class: "form-label", "Create New Tags" }
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "tag1, tag2, tag3...",
|
||||
value: "{new_tags_input}",
|
||||
oninput: move |e| new_tags_input.set(e.value()),
|
||||
}
|
||||
p { class: "text-muted text-sm",
|
||||
"Comma-separated. Will be created if they don't exist."
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "form-group",
|
||||
label { class: "form-label", "Add to Collection" }
|
||||
select {
|
||||
value: "{selected_collection.read().clone().unwrap_or_default()}",
|
||||
onchange: {
|
||||
let mut selected_collection = selected_collection;
|
||||
move |e: Event<FormData>| {
|
||||
let val = e.value();
|
||||
if val.is_empty() {
|
||||
selected_collection.set(None);
|
||||
} else {
|
||||
selected_collection.set(Some(val));
|
||||
}
|
||||
}
|
||||
},
|
||||
option { value: "", "None" }
|
||||
for col in collections.iter() {
|
||||
{
|
||||
let col_id = col.id.clone();
|
||||
let col_name = col.name.clone();
|
||||
rsx! {
|
||||
option { value: "{col_id}", "{col_name}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_new_tags(input: &str) -> Vec<String> {
|
||||
input
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
871
packages/pinakes-ui/src/components/library.rs
Normal file
871
packages/pinakes-ui/src/components/library.rs
Normal file
|
|
@ -0,0 +1,871 @@
|
|||
use dioxus::prelude::*;
|
||||
|
||||
use super::{
|
||||
pagination::Pagination as PaginationControls,
|
||||
utils::{format_size, media_category, type_badge_class, type_icon},
|
||||
};
|
||||
use crate::client::{CollectionResponse, MediaResponse, TagResponse};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum ViewMode {
|
||||
Grid,
|
||||
Table,
|
||||
}
|
||||
|
||||
/// The set of type filter categories available to the user.
|
||||
const TYPE_FILTERS: &[&str] =
|
||||
&["all", "audio", "video", "image", "document", "text"];
|
||||
|
||||
/// Human-readable label for a type filter value.
|
||||
fn filter_label(f: &str) -> &str {
|
||||
match f {
|
||||
"all" => "All",
|
||||
"audio" => "Audio",
|
||||
"video" => "Video",
|
||||
"image" => "Image",
|
||||
"document" => "Document",
|
||||
"text" => "Text",
|
||||
_ => f,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the current sort field string into (column, direction) so table
|
||||
/// headers can show the correct arrow indicator.
|
||||
fn parse_sort(sort: &str) -> (&str, &str) {
|
||||
if let Some(col) = sort.strip_suffix("_asc") {
|
||||
(col, "asc")
|
||||
} else if let Some(col) = sort.strip_suffix("_desc") {
|
||||
(col, "desc")
|
||||
} else {
|
||||
(sort, "asc")
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the sort arrow indicator for a table column header. Returns an empty
|
||||
/// string when the column is not the active sort column.
|
||||
fn sort_arrow(current_sort: &str, column: &str) -> &'static str {
|
||||
let (col, dir) = parse_sort(current_sort);
|
||||
if col == column {
|
||||
if dir == "asc" {
|
||||
" \u{25b2}"
|
||||
} else {
|
||||
" \u{25bc}"
|
||||
}
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the next sort value when a table column header is clicked. If the
|
||||
/// column is already sorted ascending, flip to descending and vice-versa.
|
||||
/// Otherwise default to ascending.
|
||||
fn next_sort(current_sort: &str, column: &str) -> String {
|
||||
let (col, dir) = parse_sort(current_sort);
|
||||
if col == column {
|
||||
let new_dir = if dir == "asc" { "desc" } else { "asc" };
|
||||
format!("{column}_{new_dir}")
|
||||
} else {
|
||||
format!("{column}_asc")
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Library(
|
||||
media: Vec<MediaResponse>,
|
||||
tags: Vec<TagResponse>,
|
||||
collections: Vec<CollectionResponse>,
|
||||
total_count: u64,
|
||||
current_page: u64,
|
||||
page_size: u64,
|
||||
server_url: String,
|
||||
on_select: EventHandler<String>,
|
||||
on_delete: EventHandler<String>,
|
||||
on_batch_delete: EventHandler<Vec<String>>,
|
||||
on_batch_tag: EventHandler<(Vec<String>, Vec<String>)>,
|
||||
on_batch_collection: EventHandler<(Vec<String>, String)>,
|
||||
on_page_change: EventHandler<u64>,
|
||||
on_page_size_change: EventHandler<u64>,
|
||||
on_sort_change: EventHandler<String>,
|
||||
#[props(default)] on_select_all_global: Option<
|
||||
EventHandler<EventHandler<Vec<String>>>,
|
||||
>,
|
||||
#[props(default)] on_delete_all: Option<EventHandler<()>>,
|
||||
) -> Element {
|
||||
let mut selected_ids = use_signal(Vec::<String>::new);
|
||||
let mut select_all = use_signal(|| false);
|
||||
let mut confirm_delete = use_signal(|| Option::<String>::None);
|
||||
let mut confirm_batch_delete = use_signal(|| false);
|
||||
let mut confirm_delete_all = use_signal(|| false);
|
||||
let mut show_batch_tag = use_signal(|| false);
|
||||
let mut batch_tag_selection = use_signal(Vec::<String>::new);
|
||||
let mut show_batch_collection = use_signal(|| false);
|
||||
let mut batch_collection_id = use_signal(String::new);
|
||||
let mut view_mode = use_signal(|| ViewMode::Grid);
|
||||
let mut sort_field = use_signal(|| "created_at_desc".to_string());
|
||||
let mut type_filter = use_signal(|| "all".to_string());
|
||||
// Track the last-clicked index for shift+click range selection.
|
||||
let mut last_click_index = use_signal(|| Option::<usize>::None);
|
||||
// True when all items across all pages have been selected.
|
||||
let mut global_all_selected = use_signal(|| false);
|
||||
|
||||
if media.is_empty() && total_count == 0 {
|
||||
return rsx! {
|
||||
div { class: "empty-state",
|
||||
h3 { class: "empty-title", "No media found" }
|
||||
p { class: "empty-subtitle",
|
||||
"Import files or scan your root directories to get started."
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Apply client-side type filter.
|
||||
let active_filter = type_filter.read().clone();
|
||||
let filtered_media: Vec<MediaResponse> = if active_filter == "all" {
|
||||
media.clone()
|
||||
} else {
|
||||
media
|
||||
.iter()
|
||||
.filter(|m| media_category(&m.media_type) == active_filter.as_str())
|
||||
.cloned()
|
||||
.collect()
|
||||
};
|
||||
let filtered_count = filtered_media.len();
|
||||
|
||||
let all_ids: Vec<String> =
|
||||
filtered_media.iter().map(|m| m.id.clone()).collect();
|
||||
// Read selection once to avoid repeated signal reads in loops
|
||||
let current_selection: Vec<String> = selected_ids.read().clone();
|
||||
let selection_count = current_selection.len();
|
||||
let has_selection = selection_count > 0;
|
||||
let total_pages = total_count.div_ceil(page_size);
|
||||
|
||||
let toggle_select_all = {
|
||||
let all_ids = all_ids.clone();
|
||||
move |_| {
|
||||
let new_val = !*select_all.read();
|
||||
select_all.set(new_val);
|
||||
global_all_selected.set(false);
|
||||
if new_val {
|
||||
selected_ids.set(all_ids.clone());
|
||||
} else {
|
||||
selected_ids.set(Vec::new());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let is_all_selected = *select_all.read();
|
||||
let current_mode = *view_mode.read();
|
||||
let current_sort = sort_field.read().clone();
|
||||
|
||||
rsx! {
|
||||
// Confirmation dialog for single delete
|
||||
if confirm_delete.read().is_some() {
|
||||
div {
|
||||
class: "modal-overlay",
|
||||
onclick: move |_| confirm_delete.set(None),
|
||||
div {
|
||||
class: "modal",
|
||||
onclick: move |e: Event<MouseData>| e.stop_propagation(),
|
||||
h3 { class: "modal-title", "Confirm Delete" }
|
||||
p { class: "modal-body",
|
||||
"Are you sure you want to delete this media item? This cannot be undone."
|
||||
}
|
||||
div { class: "modal-actions",
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
onclick: move |_| confirm_delete.set(None),
|
||||
"Cancel"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-danger",
|
||||
onclick: move |_| {
|
||||
if let Some(id) = confirm_delete.read().clone() {
|
||||
on_delete.call(id);
|
||||
}
|
||||
confirm_delete.set(None);
|
||||
},
|
||||
"Delete"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Confirmation dialog for batch delete
|
||||
if *confirm_batch_delete.read() {
|
||||
div {
|
||||
class: "modal-overlay",
|
||||
onclick: move |_| confirm_batch_delete.set(false),
|
||||
div {
|
||||
class: "modal",
|
||||
onclick: move |e: Event<MouseData>| e.stop_propagation(),
|
||||
h3 { class: "modal-title", "Confirm Batch Delete" }
|
||||
p { class: "modal-body",
|
||||
"Are you sure you want to delete {selection_count} selected items? This cannot be undone."
|
||||
}
|
||||
div { class: "modal-actions",
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
onclick: move |_| confirm_batch_delete.set(false),
|
||||
"Cancel"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-danger",
|
||||
onclick: move |_| {
|
||||
let ids = selected_ids.read().clone();
|
||||
on_batch_delete.call(ids);
|
||||
selected_ids.set(Vec::new());
|
||||
select_all.set(false);
|
||||
confirm_batch_delete.set(false);
|
||||
},
|
||||
"Delete All"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Confirmation dialog for delete all
|
||||
if *confirm_delete_all.read() {
|
||||
div {
|
||||
class: "modal-overlay",
|
||||
onclick: move |_| confirm_delete_all.set(false),
|
||||
div {
|
||||
class: "modal",
|
||||
onclick: move |e: Event<MouseData>| e.stop_propagation(),
|
||||
h3 { class: "modal-title", "Delete All Media" }
|
||||
p { class: "modal-body",
|
||||
"Are you sure you want to delete ALL {total_count} items? This cannot be undone."
|
||||
}
|
||||
div { class: "modal-actions",
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
onclick: move |_| confirm_delete_all.set(false),
|
||||
"Cancel"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-danger",
|
||||
onclick: move |_| {
|
||||
if let Some(handler) = on_delete_all {
|
||||
handler.call(());
|
||||
}
|
||||
selected_ids.set(Vec::new());
|
||||
select_all.set(false);
|
||||
global_all_selected.set(false);
|
||||
confirm_delete_all.set(false);
|
||||
},
|
||||
"Delete Everything"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Batch tag dialog
|
||||
if *show_batch_tag.read() {
|
||||
div {
|
||||
class: "modal-overlay",
|
||||
onclick: move |_| {
|
||||
show_batch_tag.set(false);
|
||||
batch_tag_selection.set(Vec::new());
|
||||
},
|
||||
div {
|
||||
class: "modal",
|
||||
onclick: move |e: Event<MouseData>| e.stop_propagation(),
|
||||
h3 { class: "modal-title", "Tag Selected Items" }
|
||||
p { class: "modal-body text-muted text-sm",
|
||||
"Select tags to apply to {selection_count} items:"
|
||||
}
|
||||
if tags.is_empty() {
|
||||
p { class: "text-muted", "No tags available. Create tags first." }
|
||||
} else {
|
||||
div { class: "tag-list", style: "margin: 12px 0;",
|
||||
for tag in tags.iter() {
|
||||
{
|
||||
let tag_id = tag.id.clone();
|
||||
let tag_name = tag.name.clone();
|
||||
let is_selected = batch_tag_selection.read().contains(&tag_id);
|
||||
let badge_class = if is_selected { "tag-badge selected" } else { "tag-badge" };
|
||||
rsx! {
|
||||
span {
|
||||
class: "{badge_class}",
|
||||
onclick: {
|
||||
let tag_id = tag_id.clone();
|
||||
move |_| {
|
||||
let mut current = batch_tag_selection.read().clone();
|
||||
if let Some(pos) = current.iter().position(|t| t == &tag_id) {
|
||||
current.remove(pos);
|
||||
} else {
|
||||
current.push(tag_id.clone());
|
||||
}
|
||||
batch_tag_selection.set(current);
|
||||
}
|
||||
},
|
||||
"{tag_name}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "modal-actions",
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
onclick: move |_| {
|
||||
show_batch_tag.set(false);
|
||||
batch_tag_selection.set(Vec::new());
|
||||
},
|
||||
"Cancel"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
onclick: move |_| {
|
||||
let ids = selected_ids.read().clone();
|
||||
let tag_ids = batch_tag_selection.read().clone();
|
||||
if !tag_ids.is_empty() {
|
||||
on_batch_tag.call((ids, tag_ids));
|
||||
selected_ids.set(Vec::new());
|
||||
select_all.set(false);
|
||||
}
|
||||
show_batch_tag.set(false);
|
||||
batch_tag_selection.set(Vec::new());
|
||||
},
|
||||
"Apply Tags"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Batch collection dialog
|
||||
if *show_batch_collection.read() {
|
||||
div {
|
||||
class: "modal-overlay",
|
||||
onclick: move |_| {
|
||||
show_batch_collection.set(false);
|
||||
batch_collection_id.set(String::new());
|
||||
},
|
||||
div {
|
||||
class: "modal",
|
||||
onclick: move |e: Event<MouseData>| e.stop_propagation(),
|
||||
h3 { class: "modal-title", "Add to Collection" }
|
||||
p { class: "modal-body text-muted text-sm",
|
||||
"Choose a collection for {selection_count} items:"
|
||||
}
|
||||
if collections.is_empty() {
|
||||
p { class: "text-muted", "No collections available. Create one first." }
|
||||
} else {
|
||||
select {
|
||||
style: "width: 100%; margin: 12px 0;",
|
||||
value: "{batch_collection_id}",
|
||||
onchange: move |e: Event<FormData>| batch_collection_id.set(e.value()),
|
||||
option { value: "", "Select a collection..." }
|
||||
for col in collections.iter() {
|
||||
option { key: "{col.id}", value: "{col.id}", "{col.name}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "modal-actions",
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
onclick: move |_| {
|
||||
show_batch_collection.set(false);
|
||||
batch_collection_id.set(String::new());
|
||||
},
|
||||
"Cancel"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
onclick: move |_| {
|
||||
let ids = selected_ids.read().clone();
|
||||
let col_id = batch_collection_id.read().clone();
|
||||
if !col_id.is_empty() {
|
||||
on_batch_collection.call((ids, col_id));
|
||||
selected_ids.set(Vec::new());
|
||||
select_all.set(false);
|
||||
}
|
||||
show_batch_collection.set(false);
|
||||
batch_collection_id.set(String::new());
|
||||
},
|
||||
"Add to Collection"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Toolbar: view toggle, sort, batch actions
|
||||
div { class: "library-toolbar",
|
||||
div { class: "toolbar-left",
|
||||
// View mode toggle
|
||||
div { class: "view-toggle",
|
||||
button {
|
||||
class: if current_mode == ViewMode::Grid { "view-btn active" } else { "view-btn" },
|
||||
onclick: move |_| view_mode.set(ViewMode::Grid),
|
||||
title: "Grid view",
|
||||
"\u{25a6}"
|
||||
}
|
||||
button {
|
||||
class: if current_mode == ViewMode::Table { "view-btn active" } else { "view-btn" },
|
||||
onclick: move |_| view_mode.set(ViewMode::Table),
|
||||
title: "Table view",
|
||||
"\u{2630}"
|
||||
}
|
||||
}
|
||||
|
||||
// Sort selector
|
||||
div { class: "sort-control",
|
||||
select {
|
||||
value: "{sort_field}",
|
||||
onchange: move |e: Event<FormData>| {
|
||||
let val = e.value();
|
||||
sort_field.set(val.clone());
|
||||
on_sort_change.call(val);
|
||||
},
|
||||
option { value: "created_at_desc", "Newest first" }
|
||||
option { value: "created_at_asc", "Oldest first" }
|
||||
option { value: "file_name_asc", "Name A-Z" }
|
||||
option { value: "file_name_desc", "Name Z-A" }
|
||||
option { value: "file_size_desc", "Largest first" }
|
||||
option { value: "file_size_asc", "Smallest first" }
|
||||
option { value: "media_type_asc", "Type" }
|
||||
}
|
||||
}
|
||||
|
||||
// Page size
|
||||
div { class: "page-size-control",
|
||||
span { class: "text-muted text-sm", "Show:" }
|
||||
select {
|
||||
value: "{page_size}",
|
||||
onchange: move |e: Event<FormData>| {
|
||||
if let Ok(size) = e.value().parse::<u64>() {
|
||||
on_page_size_change.call(size);
|
||||
}
|
||||
},
|
||||
option { value: "24", "24" }
|
||||
option { value: "48", "48" }
|
||||
option { value: "96", "96" }
|
||||
option { value: "200", "200" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "toolbar-right",
|
||||
// Select All / Deselect All toggle (works in both grid and table)
|
||||
{
|
||||
let all_ids2 = all_ids.clone();
|
||||
rsx! {
|
||||
button {
|
||||
class: "btn btn-sm btn-ghost",
|
||||
onclick: move |_| {
|
||||
if is_all_selected {
|
||||
selected_ids.set(Vec::new());
|
||||
select_all.set(false);
|
||||
global_all_selected.set(false);
|
||||
} else {
|
||||
selected_ids.set(all_ids2.clone());
|
||||
select_all.set(true);
|
||||
}
|
||||
},
|
||||
if is_all_selected {
|
||||
"Deselect All"
|
||||
} else {
|
||||
"Select All"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if has_selection {
|
||||
div { class: "batch-actions",
|
||||
span { "{selection_count} selected" }
|
||||
button {
|
||||
class: "btn btn-sm btn-secondary",
|
||||
onclick: move |_| show_batch_tag.set(true),
|
||||
"Tag"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-sm btn-secondary",
|
||||
onclick: move |_| show_batch_collection.set(true),
|
||||
"Collection"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-sm btn-danger",
|
||||
onclick: move |_| confirm_batch_delete.set(true),
|
||||
"Delete"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-sm btn-ghost",
|
||||
onclick: move |_| {
|
||||
selected_ids.set(Vec::new());
|
||||
select_all.set(false);
|
||||
global_all_selected.set(false);
|
||||
},
|
||||
"Clear"
|
||||
}
|
||||
}
|
||||
}
|
||||
if on_delete_all.is_some() && total_count > 0 {
|
||||
button {
|
||||
class: "btn btn-sm btn-danger",
|
||||
onclick: move |_| confirm_delete_all.set(true),
|
||||
"Delete All"
|
||||
}
|
||||
}
|
||||
span { class: "text-muted text-sm", "{total_count} items" }
|
||||
}
|
||||
}
|
||||
|
||||
// Type filter chips
|
||||
div { class: "type-filter-row",
|
||||
for filter in TYPE_FILTERS.iter() {
|
||||
{
|
||||
let f = (*filter).to_string();
|
||||
let is_active = active_filter == f;
|
||||
let chip_class = if is_active { "filter-chip active" } else { "filter-chip" };
|
||||
let label = filter_label(filter);
|
||||
rsx! {
|
||||
button {
|
||||
key: "{f}",
|
||||
class: "{chip_class}",
|
||||
onclick: {
|
||||
let f = f.clone();
|
||||
move |_| {
|
||||
type_filter.set(f.clone());
|
||||
}
|
||||
},
|
||||
"{label}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stats summary row
|
||||
div { class: "library-stats",
|
||||
span { class: "text-muted text-sm",
|
||||
if active_filter != "all" {
|
||||
"Showing {filtered_count} of {total_count} items (filtered: {active_filter})"
|
||||
} else {
|
||||
"Showing {filtered_count} items"
|
||||
}
|
||||
}
|
||||
span { class: "text-muted text-sm", "Page {current_page + 1} of {total_pages}" }
|
||||
}
|
||||
|
||||
// Select-all banner: when all items on this page are selected and there
|
||||
// are more pages, offer to select everything across all pages.
|
||||
if is_all_selected && total_count > page_size && !*global_all_selected.read() {
|
||||
div { class: "select-all-banner",
|
||||
"All {filtered_count} items on this page are selected."
|
||||
if on_select_all_global.is_some() {
|
||||
button {
|
||||
onclick: move |_| {
|
||||
if let Some(handler) = on_select_all_global {
|
||||
handler
|
||||
.call(
|
||||
EventHandler::new(move |all_ids: Vec<String>| {
|
||||
selected_ids.set(all_ids);
|
||||
global_all_selected.set(true);
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
"Select all {total_count} items"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if *global_all_selected.read() {
|
||||
div { class: "select-all-banner",
|
||||
"All {selection_count} items across all pages are selected."
|
||||
button {
|
||||
onclick: move |_| {
|
||||
selected_ids.set(Vec::new());
|
||||
select_all.set(false);
|
||||
global_all_selected.set(false);
|
||||
},
|
||||
"Clear selection"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content: grid or table
|
||||
match current_mode {
|
||||
ViewMode::Grid => rsx! {
|
||||
div { class: "media-grid",
|
||||
for (idx , item) in filtered_media.iter().enumerate() {
|
||||
{
|
||||
let id = item.id.clone();
|
||||
let badge_class = type_badge_class(&item.media_type);
|
||||
let is_checked = current_selection.contains(&id);
|
||||
|
||||
// Build a list of all visible IDs for shift+click range selection.
|
||||
|
||||
// Shift+click: select range from last_click_index to current idx.
|
||||
// No previous click, just toggle this one.
|
||||
|
||||
// Thumbnail with CSS fallback: both the icon and img
|
||||
// are rendered. The img is absolutely positioned on
|
||||
// top. If the image fails to load, the icon beneath
|
||||
// shows through.
|
||||
|
||||
// Thumbnail with CSS fallback: icon always
|
||||
// rendered, img overlays when available.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
let card_click = {
|
||||
let id = item.id.clone();
|
||||
move |_| on_select.call(id.clone())
|
||||
};
|
||||
|
||||
let visible_ids: Vec<String> = filtered_media
|
||||
|
||||
.iter()
|
||||
.map(|m| m.id.clone())
|
||||
.collect();
|
||||
let toggle_id = {
|
||||
let id = id.clone();
|
||||
move |e: Event<MouseData>| {
|
||||
e.stop_propagation();
|
||||
let shift = e.modifiers().shift();
|
||||
let mut ids = selected_ids.read().clone();
|
||||
if shift {
|
||||
if let Some(last) = *last_click_index.read() {
|
||||
let start = last.min(idx);
|
||||
let end = last.max(idx);
|
||||
for i in start..=end {
|
||||
if let Some(range_id) = visible_ids.get(i)
|
||||
&& !ids.contains(range_id)
|
||||
{
|
||||
ids.push(range_id.clone());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !ids.contains(&id) {
|
||||
ids.push(id.clone());
|
||||
}
|
||||
}
|
||||
} else if ids.contains(&id) {
|
||||
ids.retain(|x| x != &id);
|
||||
} else {
|
||||
ids.push(id.clone());
|
||||
}
|
||||
last_click_index.set(Some(idx));
|
||||
selected_ids.set(ids);
|
||||
}
|
||||
};
|
||||
let thumb_url = if item.has_thumbnail {
|
||||
format!("{}/api/v1/media/{}/thumbnail", server_url, item.id)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let has_thumb = item.has_thumbnail;
|
||||
let media_type = item.media_type.clone();
|
||||
let card_class = if is_checked { "media-card selected" } else { "media-card" };
|
||||
let title_text = item.title.clone().unwrap_or_default();
|
||||
let artist_text = item.artist.clone().unwrap_or_default();
|
||||
rsx! {
|
||||
div { key: "{item.id}", class: "{card_class}", onclick: card_click,
|
||||
|
||||
|
||||
|
||||
div { class: "card-checkbox",
|
||||
input { r#type: "checkbox", checked: is_checked, onclick: toggle_id }
|
||||
}
|
||||
|
||||
div { class: "card-thumbnail",
|
||||
div { class: "card-type-icon {badge_class}", "{type_icon(&media_type)}" }
|
||||
if has_thumb {
|
||||
img {
|
||||
class: "card-thumb-img",
|
||||
src: "{thumb_url}",
|
||||
alt: "{item.file_name}",
|
||||
loading: "lazy",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "card-info",
|
||||
div { class: "card-name", title: "{item.file_name}", "{item.file_name}" }
|
||||
if !title_text.is_empty() {
|
||||
div { class: "card-title text-muted text-xs", "{title_text}" }
|
||||
}
|
||||
if !artist_text.is_empty() {
|
||||
div { class: "card-artist text-muted text-xs", "{artist_text}" }
|
||||
}
|
||||
div { class: "card-meta",
|
||||
span { class: "type-badge {badge_class}", "{item.media_type}" }
|
||||
span { class: "card-size", "{format_size(item.file_size)}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
ViewMode::Table => rsx! {
|
||||
table { class: "data-table",
|
||||
thead {
|
||||
tr {
|
||||
th {
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
checked: is_all_selected,
|
||||
onclick: toggle_select_all,
|
||||
}
|
||||
}
|
||||
th { "" }
|
||||
th {
|
||||
class: "sortable-header",
|
||||
onclick: {
|
||||
let cs = current_sort.clone();
|
||||
move |_| {
|
||||
let val = next_sort(&cs, "file_name");
|
||||
sort_field.set(val.clone());
|
||||
on_sort_change.call(val);
|
||||
}
|
||||
},
|
||||
"Name{sort_arrow(¤t_sort, \"file_name\")}"
|
||||
}
|
||||
th {
|
||||
class: "sortable-header",
|
||||
onclick: {
|
||||
let cs = current_sort.clone();
|
||||
move |_| {
|
||||
let val = next_sort(&cs, "media_type");
|
||||
sort_field.set(val.clone());
|
||||
on_sort_change.call(val);
|
||||
}
|
||||
},
|
||||
"Type{sort_arrow(¤t_sort, \"media_type\")}"
|
||||
}
|
||||
th { "Artist" }
|
||||
th {
|
||||
class: "sortable-header",
|
||||
onclick: {
|
||||
let cs = current_sort.clone();
|
||||
move |_| {
|
||||
let val = next_sort(&cs, "file_size");
|
||||
sort_field.set(val.clone());
|
||||
on_sort_change.call(val);
|
||||
}
|
||||
},
|
||||
"Size{sort_arrow(¤t_sort, \"file_size\")}"
|
||||
}
|
||||
th { "" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for (idx , item) in filtered_media.iter().enumerate() {
|
||||
{
|
||||
let id = item.id.clone();
|
||||
let artist = item.artist.clone().unwrap_or_default();
|
||||
let size = format_size(item.file_size);
|
||||
let badge_class = type_badge_class(&item.media_type);
|
||||
let is_checked = current_selection.contains(&id);
|
||||
|
||||
let visible_ids: Vec<String> = filtered_media
|
||||
|
||||
.iter()
|
||||
.map(|m| m.id.clone())
|
||||
.collect();
|
||||
let toggle_id = {
|
||||
let id = id.clone();
|
||||
move |e: Event<MouseData>| {
|
||||
e.stop_propagation();
|
||||
let shift = e.modifiers().shift();
|
||||
let mut ids = selected_ids.read().clone();
|
||||
if shift {
|
||||
if let Some(last) = *last_click_index.read() {
|
||||
let start = last.min(idx);
|
||||
let end = last.max(idx);
|
||||
for i in start..=end {
|
||||
if let Some(range_id) = visible_ids.get(i)
|
||||
&& !ids.contains(range_id)
|
||||
{
|
||||
ids.push(range_id.clone());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !ids.contains(&id) {
|
||||
ids.push(id.clone());
|
||||
}
|
||||
}
|
||||
} else if ids.contains(&id) {
|
||||
ids.retain(|x| x != &id);
|
||||
} else {
|
||||
ids.push(id.clone());
|
||||
}
|
||||
last_click_index.set(Some(idx));
|
||||
selected_ids.set(ids);
|
||||
}
|
||||
};
|
||||
let row_click = {
|
||||
let id = item.id.clone();
|
||||
move |_| on_select.call(id.clone())
|
||||
};
|
||||
let delete_click = {
|
||||
let id = item.id.clone();
|
||||
move |e: Event<MouseData>| {
|
||||
e.stop_propagation();
|
||||
confirm_delete.set(Some(id.clone()));
|
||||
}
|
||||
};
|
||||
let thumb_url = if item.has_thumbnail {
|
||||
format!("{}/api/v1/media/{}/thumbnail", server_url, item.id)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let has_thumb = item.has_thumbnail;
|
||||
let media_type_str = item.media_type.clone();
|
||||
rsx! {
|
||||
tr { key: "{item.id}", onclick: row_click,
|
||||
td {
|
||||
input { r#type: "checkbox", checked: is_checked, onclick: toggle_id }
|
||||
}
|
||||
td { class: "table-thumb-cell",
|
||||
span { class: "table-type-icon {badge_class}", "{type_icon(&media_type_str)}" }
|
||||
if has_thumb {
|
||||
img {
|
||||
class: "table-thumb table-thumb-overlay",
|
||||
src: "{thumb_url}",
|
||||
alt: "",
|
||||
loading: "lazy",
|
||||
}
|
||||
}
|
||||
}
|
||||
td { "{item.file_name}" }
|
||||
td {
|
||||
span { class: "type-badge {badge_class}", "{item.media_type}" }
|
||||
}
|
||||
td { "{artist}" }
|
||||
td { "{size}" }
|
||||
td {
|
||||
button { class: "btn btn-danger btn-sm", onclick: delete_click, "Delete" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Pagination controls
|
||||
PaginationControls {
|
||||
current_page,
|
||||
total_pages,
|
||||
on_page_change: move |page: u64| on_page_change.call(page),
|
||||
}
|
||||
}
|
||||
}
|
||||
59
packages/pinakes-ui/src/components/loading.rs
Normal file
59
packages/pinakes-ui/src/components/loading.rs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn SkeletonCard() -> Element {
|
||||
rsx! {
|
||||
div { class: "skeleton-card",
|
||||
div { class: "skeleton-thumb skeleton-pulse" }
|
||||
div { class: "skeleton-text skeleton-pulse" }
|
||||
div { class: "skeleton-text skeleton-text-short skeleton-pulse" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SkeletonRow() -> Element {
|
||||
rsx! {
|
||||
div { class: "skeleton-row",
|
||||
div { class: "skeleton-cell skeleton-cell-icon skeleton-pulse" }
|
||||
div { class: "skeleton-cell skeleton-cell-wide skeleton-pulse" }
|
||||
div { class: "skeleton-cell skeleton-pulse" }
|
||||
div { class: "skeleton-cell skeleton-pulse" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn LoadingOverlay(message: Option<String>) -> Element {
|
||||
let msg = message.unwrap_or_else(|| "Loading...".to_string());
|
||||
rsx! {
|
||||
div { class: "loading-overlay",
|
||||
div { class: "loading-spinner" }
|
||||
span { class: "loading-message", "{msg}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SkeletonGrid(count: Option<usize>) -> Element {
|
||||
let n = count.unwrap_or(12);
|
||||
rsx! {
|
||||
div { class: "media-grid",
|
||||
for i in 0..n {
|
||||
SkeletonCard { key: "skel-{i}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SkeletonList(count: Option<usize>) -> Element {
|
||||
let n = count.unwrap_or(10);
|
||||
rsx! {
|
||||
div { class: "media-list",
|
||||
for i in 0..n {
|
||||
SkeletonRow { key: "skel-row-{i}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
79
packages/pinakes-ui/src/components/login.rs
Normal file
79
packages/pinakes-ui/src/components/login.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn Login(
|
||||
on_login: EventHandler<(String, String)>,
|
||||
#[props(default)] error: Option<String>,
|
||||
#[props(default = false)] loading: bool,
|
||||
) -> Element {
|
||||
let mut username = use_signal(String::new);
|
||||
let mut password = use_signal(String::new);
|
||||
|
||||
let on_submit = {
|
||||
move |_| {
|
||||
let u = username.read().clone();
|
||||
let p = password.read().clone();
|
||||
if !u.is_empty() && !p.is_empty() {
|
||||
on_login.call((u, p));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let on_key = move |e: KeyboardEvent| {
|
||||
if e.key() == Key::Enter {
|
||||
let u = username.read().clone();
|
||||
let p = password.read().clone();
|
||||
if !u.is_empty() && !p.is_empty() {
|
||||
on_login.call((u, p));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "login-container",
|
||||
div { class: "login-card",
|
||||
h2 { class: "login-title", "Pinakes" }
|
||||
p { class: "login-subtitle", "Sign in to continue" }
|
||||
|
||||
if let Some(ref err) = error {
|
||||
div { class: "login-error", "{err}" }
|
||||
}
|
||||
|
||||
div { class: "login-form",
|
||||
div { class: "form-group",
|
||||
label { class: "form-label", "Username" }
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Enter username",
|
||||
value: "{username}",
|
||||
disabled: loading,
|
||||
oninput: move |e: Event<FormData>| username.set(e.value()),
|
||||
onkeypress: on_key,
|
||||
}
|
||||
}
|
||||
div { class: "form-group",
|
||||
label { class: "form-label", "Password" }
|
||||
input {
|
||||
r#type: "password",
|
||||
placeholder: "Enter password",
|
||||
value: "{password}",
|
||||
disabled: loading,
|
||||
oninput: move |e: Event<FormData>| password.set(e.value()),
|
||||
onkeypress: on_key,
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn btn-primary login-btn",
|
||||
disabled: loading,
|
||||
onclick: on_submit,
|
||||
if loading {
|
||||
"Signing in..."
|
||||
} else {
|
||||
"Sign In"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
426
packages/pinakes-ui/src/components/markdown_viewer.rs
Normal file
426
packages/pinakes-ui/src/components/markdown_viewer.rs
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
use dioxus::{document::eval, prelude::*};
|
||||
|
||||
/// Event handler for wikilink clicks. Called with the target note name.
|
||||
pub type WikilinkClickHandler = EventHandler<String>;
|
||||
|
||||
#[component]
|
||||
pub fn MarkdownViewer(
|
||||
content_url: String,
|
||||
media_type: String,
|
||||
#[props(default)] on_wikilink_click: Option<WikilinkClickHandler>,
|
||||
) -> Element {
|
||||
let mut rendered_html = use_signal(String::new);
|
||||
let mut frontmatter_html = use_signal(|| Option::<String>::None);
|
||||
let mut raw_content = use_signal(String::new);
|
||||
let mut loading = use_signal(|| true);
|
||||
let mut error = use_signal(|| Option::<String>::None);
|
||||
let mut show_preview = use_signal(|| true);
|
||||
|
||||
// Fetch content on mount
|
||||
let url = content_url.clone();
|
||||
let mtype = media_type.clone();
|
||||
use_effect(move || {
|
||||
let url = url.clone();
|
||||
let mtype = mtype.clone();
|
||||
spawn(async move {
|
||||
loading.set(true);
|
||||
error.set(None);
|
||||
match reqwest::get(&url).await {
|
||||
Ok(resp) => {
|
||||
match resp.text().await {
|
||||
Ok(text) => {
|
||||
raw_content.set(text.clone());
|
||||
if mtype == "md" || mtype == "markdown" {
|
||||
let (fm_html, body_html) =
|
||||
render_markdown_with_frontmatter(&text);
|
||||
frontmatter_html.set(fm_html);
|
||||
rendered_html.set(body_html);
|
||||
} else {
|
||||
frontmatter_html.set(None);
|
||||
rendered_html.set(render_plaintext(&text));
|
||||
};
|
||||
},
|
||||
Err(e) => error.set(Some(format!("Failed to read content: {e}"))),
|
||||
}
|
||||
},
|
||||
Err(e) => error.set(Some(format!("Failed to fetch: {e}"))),
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Set up global wikilink click handler that the inline onclick attributes
|
||||
// will call This bridges JavaScript -> Rust communication
|
||||
use_effect(move || {
|
||||
if let Some(handler) = on_wikilink_click {
|
||||
spawn(async move {
|
||||
// Set up a global function that wikilink onclick handlers can call
|
||||
// The function stores the clicked target in localStorage
|
||||
let setup_js = r#"
|
||||
window.__dioxus_wikilink_click = function(target) {
|
||||
console.log('Wikilink clicked:', target);
|
||||
localStorage.setItem('__wikilink_clicked', target);
|
||||
};
|
||||
"#;
|
||||
|
||||
let _ = eval(setup_js).await;
|
||||
|
||||
// Poll localStorage to detect wikilink clicks
|
||||
loop {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
|
||||
let check_js = r#"
|
||||
(function() {
|
||||
const target = localStorage.getItem('__wikilink_clicked');
|
||||
if (target) {
|
||||
localStorage.removeItem('__wikilink_clicked');
|
||||
return target;
|
||||
}
|
||||
return '';
|
||||
})();
|
||||
"#;
|
||||
|
||||
if let Ok(result) = eval(check_js).await
|
||||
&& let Some(target) = result.as_str()
|
||||
&& !target.is_empty()
|
||||
{
|
||||
handler.call(target.to_string());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let is_loading = *loading.read();
|
||||
let is_preview = *show_preview.read();
|
||||
|
||||
rsx! {
|
||||
div { class: "markdown-viewer",
|
||||
// View toggle toolbar
|
||||
if !is_loading && error.read().is_none() {
|
||||
div { class: "markdown-toolbar",
|
||||
button {
|
||||
class: if is_preview { "toolbar-btn active" } else { "toolbar-btn" },
|
||||
onclick: move |_| show_preview.set(true),
|
||||
title: "Preview Mode",
|
||||
"Preview"
|
||||
}
|
||||
button {
|
||||
class: if !is_preview { "toolbar-btn active" } else { "toolbar-btn" },
|
||||
onclick: move |_| show_preview.set(false),
|
||||
title: "Source Mode",
|
||||
"Source"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if is_loading {
|
||||
div { class: "loading-overlay",
|
||||
div { class: "spinner" }
|
||||
"Loading content..."
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref err) = *error.read() {
|
||||
div { class: "error-banner",
|
||||
span { class: "error-icon", "\u{26a0}" }
|
||||
"{err}"
|
||||
}
|
||||
}
|
||||
|
||||
if !is_loading && error.read().is_none() {
|
||||
if is_preview {
|
||||
// Preview mode - show rendered markdown
|
||||
if let Some(ref fm) = *frontmatter_html.read() {
|
||||
div {
|
||||
class: "frontmatter-card",
|
||||
dangerous_inner_html: "{fm}",
|
||||
}
|
||||
}
|
||||
div {
|
||||
class: "markdown-content",
|
||||
dangerous_inner_html: "{rendered_html}",
|
||||
}
|
||||
} else {
|
||||
// Source mode - show raw markdown
|
||||
pre { class: "markdown-source",
|
||||
code { "{raw_content}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse frontmatter and render markdown body. Returns (frontmatter_html,
|
||||
/// body_html).
|
||||
fn render_markdown_with_frontmatter(text: &str) -> (Option<String>, String) {
|
||||
use gray_matter::{Matter, engine::YAML};
|
||||
|
||||
let matter = Matter::<YAML>::new();
|
||||
let Ok(result) = matter.parse(text) else {
|
||||
// If frontmatter parsing fails, just render the whole text as markdown
|
||||
return (None, render_markdown(text));
|
||||
};
|
||||
|
||||
let fm_html = result.data.and_then(|data| render_frontmatter_card(&data));
|
||||
|
||||
let body_html = render_markdown(&result.content);
|
||||
(fm_html, body_html)
|
||||
}
|
||||
|
||||
/// Render frontmatter fields as an HTML card.
|
||||
fn render_frontmatter_card(data: &gray_matter::Pod) -> Option<String> {
|
||||
let gray_matter::Pod::Hash(map) = data else {
|
||||
return None;
|
||||
};
|
||||
|
||||
if map.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut html = String::from("<dl class=\"frontmatter-fields\">");
|
||||
|
||||
for (key, value) in map {
|
||||
let display_value = pod_to_display(value);
|
||||
let escaped_key = escape_html(key);
|
||||
html.push_str(&format!("<dt>{escaped_key}</dt><dd>{display_value}</dd>"));
|
||||
}
|
||||
|
||||
html.push_str("</dl>");
|
||||
Some(html)
|
||||
}
|
||||
|
||||
fn pod_to_display(pod: &gray_matter::Pod) -> String {
|
||||
match pod {
|
||||
gray_matter::Pod::String(s) => escape_html(s),
|
||||
gray_matter::Pod::Integer(n) => n.to_string(),
|
||||
gray_matter::Pod::Float(f) => f.to_string(),
|
||||
gray_matter::Pod::Boolean(b) => b.to_string(),
|
||||
gray_matter::Pod::Array(arr) => {
|
||||
let items: Vec<String> = arr.iter().map(pod_to_display).collect();
|
||||
items.join(", ")
|
||||
},
|
||||
gray_matter::Pod::Hash(map) => {
|
||||
let items: Vec<String> = map
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}: {}", escape_html(k), pod_to_display(v)))
|
||||
.collect();
|
||||
items.join("; ")
|
||||
},
|
||||
gray_matter::Pod::Null => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_markdown(text: &str) -> String {
|
||||
use pulldown_cmark::{Options, Parser, html};
|
||||
|
||||
// First, convert wikilinks to standard markdown links
|
||||
let text_with_links = convert_wikilinks(text);
|
||||
|
||||
let mut options = Options::empty();
|
||||
options.insert(Options::ENABLE_TABLES);
|
||||
options.insert(Options::ENABLE_STRIKETHROUGH);
|
||||
options.insert(Options::ENABLE_TASKLISTS);
|
||||
options.insert(Options::ENABLE_FOOTNOTES);
|
||||
options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
|
||||
|
||||
let parser = Parser::new_ext(&text_with_links, options);
|
||||
let mut html_output = String::new();
|
||||
html::push_html(&mut html_output, parser);
|
||||
|
||||
// Sanitize HTML using ammonia with a safe allowlist
|
||||
sanitize_html(&html_output)
|
||||
}
|
||||
|
||||
/// Convert wikilinks [[target]] and [[target|display]] to styled HTML links.
|
||||
/// Uses a special URL scheme that can be intercepted by click handlers.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Never panics because the regex patterns are hardcoded and syntactically
|
||||
/// valid.
|
||||
#[expect(clippy::expect_used)]
|
||||
fn convert_wikilinks(text: &str) -> String {
|
||||
use regex::Regex;
|
||||
|
||||
// Match embeds ![[target]] first, convert to a placeholder image/embed span
|
||||
let embed_re = Regex::new(r"!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]")
|
||||
.expect("invalid regex pattern for wikilink embeds");
|
||||
let text = embed_re.replace_all(text, |caps: ®ex::Captures| {
|
||||
let target = caps
|
||||
.get(1)
|
||||
.expect("capture group 1 always exists for wikilink embeds")
|
||||
.as_str()
|
||||
.trim();
|
||||
let alt = caps.get(2).map(|m| m.as_str().trim()).unwrap_or(target);
|
||||
format!(
|
||||
"<span class=\"wikilink-embed\" data-target=\"{}\" title=\"Embed: \
|
||||
{}\">[Embed: {}]</span>",
|
||||
escape_html_attr(target),
|
||||
escape_html_attr(target),
|
||||
escape_html(alt)
|
||||
)
|
||||
});
|
||||
|
||||
// Match wikilinks [[target]] or [[target|display]]
|
||||
let wikilink_re = Regex::new(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]")
|
||||
.expect("invalid regex pattern for wikilinks");
|
||||
let text = wikilink_re.replace_all(&text, |caps: ®ex::Captures| {
|
||||
let target = caps
|
||||
.get(1)
|
||||
.expect("capture group 1 always exists for wikilinks")
|
||||
.as_str()
|
||||
.trim();
|
||||
let display = caps.get(2).map(|m| m.as_str().trim()).unwrap_or(target);
|
||||
// Create a styled link that uses a special pseudo-protocol scheme
|
||||
// This makes it easier to intercept clicks via JavaScript
|
||||
format!(
|
||||
"<a href=\"javascript:void(0)\" class=\"wikilink\" \
|
||||
data-wikilink-target=\"{target}\" \
|
||||
onclick=\"if(window.__dioxus_wikilink_click){{window.\
|
||||
__dioxus_wikilink_click('{target_escaped}')}}\">{display}</a>",
|
||||
target = escape_html_attr(target),
|
||||
target_escaped =
|
||||
escape_html_attr(&target.replace('\\', "\\\\").replace('\'', "\\'")),
|
||||
display = escape_html(display)
|
||||
)
|
||||
});
|
||||
|
||||
text.to_string()
|
||||
}
|
||||
|
||||
fn render_plaintext(text: &str) -> String {
|
||||
let escaped = escape_html(text);
|
||||
format!("<pre><code>{escaped}</code></pre>")
|
||||
}
|
||||
|
||||
/// Escape text for display in HTML content.
|
||||
fn escape_html(text: &str) -> String {
|
||||
text
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
/// Escape text for use in HTML attributes (includes single quotes).
|
||||
fn escape_html_attr(text: &str) -> String {
|
||||
text
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
/// Sanitize HTML using ammonia with a safe allowlist.
|
||||
/// This prevents XSS attacks by removing dangerous elements and attributes.
|
||||
#[expect(
|
||||
clippy::disallowed_types,
|
||||
reason = "ammonia::Builder requires std HashSet"
|
||||
)]
|
||||
fn sanitize_html(html: &str) -> String {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use ammonia::Builder;
|
||||
|
||||
// Build a custom sanitizer that allows safe markdown elements
|
||||
// but strips all event handlers and dangerous elements
|
||||
let mut builder = Builder::default();
|
||||
|
||||
// Allow common markdown elements
|
||||
let allowed_tags: HashSet<&str> = [
|
||||
"a",
|
||||
"abbr",
|
||||
"acronym",
|
||||
"b",
|
||||
"blockquote",
|
||||
"br",
|
||||
"code",
|
||||
"dd",
|
||||
"del",
|
||||
"details",
|
||||
"div",
|
||||
"dl",
|
||||
"dt",
|
||||
"em",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"hr",
|
||||
"i",
|
||||
"img",
|
||||
"ins",
|
||||
"kbd",
|
||||
"li",
|
||||
"mark",
|
||||
"ol",
|
||||
"p",
|
||||
"pre",
|
||||
"q",
|
||||
"s",
|
||||
"samp",
|
||||
"small",
|
||||
"span",
|
||||
"strong",
|
||||
"sub",
|
||||
"summary",
|
||||
"sup",
|
||||
"table",
|
||||
"tbody",
|
||||
"td",
|
||||
"tfoot",
|
||||
"th",
|
||||
"thead",
|
||||
"tr",
|
||||
"u",
|
||||
"ul",
|
||||
"var",
|
||||
// Task list support
|
||||
"input",
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
// Allow safe attributes
|
||||
let allowed_attrs: HashSet<&str> = [
|
||||
"href",
|
||||
"src",
|
||||
"alt",
|
||||
"title",
|
||||
"class",
|
||||
"id",
|
||||
"name",
|
||||
"width",
|
||||
"height",
|
||||
"align",
|
||||
"valign",
|
||||
"colspan",
|
||||
"rowspan",
|
||||
"scope",
|
||||
// Data attributes for wikilinks (safe - no code execution)
|
||||
"data-target",
|
||||
"data-wikilink-target",
|
||||
// Task list checkbox support
|
||||
"type",
|
||||
"checked",
|
||||
"disabled",
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
builder
|
||||
.tags(allowed_tags)
|
||||
.generic_attributes(allowed_attrs)
|
||||
// Allow relative URLs and fragment-only URLs for internal links
|
||||
.url_schemes(["http", "https", "mailto"].into_iter().collect())
|
||||
.link_rel(Some("noopener noreferrer"))
|
||||
// Strip all event handler attributes (onclick, onerror, etc.)
|
||||
.strip_comments(true)
|
||||
.clean(html)
|
||||
.to_string()
|
||||
}
|
||||
776
packages/pinakes-ui/src/components/media_player.rs
Normal file
776
packages/pinakes-ui/src/components/media_player.rs
Normal file
|
|
@ -0,0 +1,776 @@
|
|||
use dioxus::{document::eval, prelude::*};
|
||||
|
||||
use super::utils::format_duration;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct QueueItem {
|
||||
pub media_id: String,
|
||||
pub title: String,
|
||||
pub artist: Option<String>,
|
||||
pub duration_secs: Option<f64>,
|
||||
pub media_type: String,
|
||||
pub stream_url: String,
|
||||
pub thumbnail_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize,
|
||||
)]
|
||||
pub enum RepeatMode {
|
||||
Off,
|
||||
One,
|
||||
All,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PlayQueue {
|
||||
pub items: Vec<QueueItem>,
|
||||
pub current_index: usize,
|
||||
pub repeat: RepeatMode,
|
||||
pub shuffle: bool,
|
||||
}
|
||||
|
||||
impl Default for PlayQueue {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
items: Vec::new(),
|
||||
current_index: 0,
|
||||
repeat: RepeatMode::Off,
|
||||
shuffle: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayQueue {
|
||||
/// Check if the queue is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items.is_empty()
|
||||
}
|
||||
|
||||
/// Get the current item in the queue.
|
||||
pub fn current(&self) -> Option<&QueueItem> {
|
||||
self.items.get(self.current_index)
|
||||
}
|
||||
|
||||
/// Advance to the next item based on repeat mode.
|
||||
pub fn next(&mut self) -> Option<&QueueItem> {
|
||||
if self.items.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match self.repeat {
|
||||
RepeatMode::One => self.items.get(self.current_index),
|
||||
RepeatMode::All => {
|
||||
self.current_index = (self.current_index + 1) % self.items.len();
|
||||
self.items.get(self.current_index)
|
||||
},
|
||||
RepeatMode::Off => {
|
||||
if self.current_index + 1 < self.items.len() {
|
||||
self.current_index += 1;
|
||||
self.items.get(self.current_index)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Go to the previous item based on repeat mode.
|
||||
pub fn previous(&mut self) -> Option<&QueueItem> {
|
||||
if self.items.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if self.current_index > 0 {
|
||||
self.current_index -= 1;
|
||||
} else if self.repeat == RepeatMode::All {
|
||||
self.current_index = self.items.len() - 1;
|
||||
}
|
||||
self.items.get(self.current_index)
|
||||
}
|
||||
|
||||
/// Add an item to the queue.
|
||||
pub fn add(&mut self, item: QueueItem) {
|
||||
self.items.push(item);
|
||||
}
|
||||
|
||||
/// Remove an item from the queue by index.
|
||||
pub fn remove(&mut self, index: usize) {
|
||||
if index < self.items.len() {
|
||||
self.items.remove(index);
|
||||
if self.current_index >= self.items.len() && !self.items.is_empty() {
|
||||
self.current_index = self.items.len() - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all items from the queue.
|
||||
pub fn clear(&mut self) {
|
||||
self.items.clear();
|
||||
self.current_index = 0;
|
||||
}
|
||||
|
||||
/// Toggle between repeat modes: Off -> All -> One -> Off.
|
||||
pub fn toggle_repeat(&mut self) {
|
||||
self.repeat = match self.repeat {
|
||||
RepeatMode::Off => RepeatMode::All,
|
||||
RepeatMode::All => RepeatMode::One,
|
||||
RepeatMode::One => RepeatMode::Off,
|
||||
};
|
||||
}
|
||||
|
||||
/// Toggle shuffle mode on/off.
|
||||
pub fn toggle_shuffle(&mut self) {
|
||||
self.shuffle = !self.shuffle;
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a URL in the system's default media player.
|
||||
///
|
||||
/// Uses `xdg-open` on Linux, `open` on macOS, and `cmd /c start` on Windows.
|
||||
/// The call is fire-and-forget; failures are logged as warnings.
|
||||
fn open_in_system_player(url: &str) {
|
||||
#[cfg(target_os = "linux")]
|
||||
let result = std::process::Command::new("xdg-open").arg(url).spawn();
|
||||
#[cfg(target_os = "macos")]
|
||||
let result = std::process::Command::new("open").arg(url).spawn();
|
||||
#[cfg(target_os = "windows")]
|
||||
let result = std::process::Command::new("cmd")
|
||||
.args(["/c", "start", "", url])
|
||||
.spawn();
|
||||
#[cfg(not(any(
|
||||
target_os = "linux",
|
||||
target_os = "macos",
|
||||
target_os = "windows"
|
||||
)))]
|
||||
let result: std::io::Result<std::process::Child> =
|
||||
Err(std::io::Error::other("unsupported platform"));
|
||||
|
||||
if let Err(e) = result {
|
||||
tracing::warn!("failed to open system player: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn MediaPlayer(
|
||||
src: String,
|
||||
media_type: String,
|
||||
#[props(default)] title: Option<String>,
|
||||
#[props(default)] thumbnail_url: Option<String>,
|
||||
#[props(default = false)] autoplay: bool,
|
||||
#[props(default)] on_track_ended: Option<EventHandler<()>>,
|
||||
) -> Element {
|
||||
let mut playing = use_signal(|| false);
|
||||
let mut current_time = use_signal(|| 0.0f64);
|
||||
let mut duration = use_signal(|| 0.0f64);
|
||||
let mut volume = use_signal(|| 1.0f64);
|
||||
let mut muted = use_signal(|| false);
|
||||
|
||||
let is_video = media_type == "video";
|
||||
// HLS adaptive streams (.m3u8) cannot be decoded natively by webkit2gtk on
|
||||
// Linux. Detect them and show an external-player button instead of attempting
|
||||
// in-process playback via a JavaScript library.
|
||||
let is_hls = src.ends_with(".m3u8") || src.contains("/stream/hls/");
|
||||
let is_playing = *playing.read();
|
||||
let cur_time = *current_time.read();
|
||||
let dur = *duration.read();
|
||||
let vol = *volume.read();
|
||||
let is_muted = *muted.read();
|
||||
let time_str = format_duration(cur_time);
|
||||
let dur_str = format_duration(dur);
|
||||
let vol_pct = (vol * 100.0) as u32;
|
||||
|
||||
// Poll playback state every 250ms
|
||||
let src_clone = src.clone();
|
||||
let on_ended = on_track_ended;
|
||||
use_effect(move || {
|
||||
let _ = &src_clone;
|
||||
let on_ended = on_ended;
|
||||
spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||
let result = eval(
|
||||
r#"
|
||||
let el = document.getElementById('pinakes-player');
|
||||
if (el) {
|
||||
return JSON.stringify({
|
||||
currentTime: el.currentTime,
|
||||
duration: el.duration || 0,
|
||||
paused: el.paused,
|
||||
volume: el.volume,
|
||||
muted: el.muted,
|
||||
ended: el.ended
|
||||
});
|
||||
}
|
||||
return "null";
|
||||
"#,
|
||||
)
|
||||
.await;
|
||||
if let Ok(val) = result
|
||||
&& let Some(s) = val.as_str()
|
||||
&& s != "null"
|
||||
&& let Ok(state) = serde_json::from_str::<serde_json::Value>(s)
|
||||
{
|
||||
if let Some(ct) = state["currentTime"].as_f64() {
|
||||
current_time.set(ct);
|
||||
}
|
||||
if let Some(d) = state["duration"].as_f64()
|
||||
&& d.is_finite()
|
||||
{
|
||||
duration.set(d);
|
||||
}
|
||||
if let Some(p) = state["paused"].as_bool() {
|
||||
playing.set(!p);
|
||||
}
|
||||
if let Some(true) = state["ended"].as_bool()
|
||||
&& let Some(ref handler) = on_ended
|
||||
{
|
||||
handler.call(());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Autoplay on mount
|
||||
if autoplay {
|
||||
let src_auto = src.clone();
|
||||
use_effect(move || {
|
||||
let _ = &src_auto;
|
||||
spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
let _ = eval("document.getElementById('pinakes-player')?.play()").await;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let toggle_play = move |_| {
|
||||
spawn(async move {
|
||||
if *playing.read() {
|
||||
let _ =
|
||||
eval("document.getElementById('pinakes-player')?.pause()").await;
|
||||
} else {
|
||||
let _ = eval("document.getElementById('pinakes-player')?.play()").await;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let toggle_mute = move |_| {
|
||||
let new_muted = !*muted.read();
|
||||
muted.set(new_muted);
|
||||
let js = format!(
|
||||
"let e = document.getElementById('pinakes-player'); if(e) e.muted = {};",
|
||||
new_muted
|
||||
);
|
||||
spawn(async move {
|
||||
let _ = eval(&js).await;
|
||||
});
|
||||
};
|
||||
|
||||
let on_seek = move |e: Event<FormData>| {
|
||||
if let Ok(t) = e.value().parse::<f64>() {
|
||||
current_time.set(t);
|
||||
let js = format!(
|
||||
"let e = document.getElementById('pinakes-player'); if(e) \
|
||||
e.currentTime = {};",
|
||||
t
|
||||
);
|
||||
spawn(async move {
|
||||
let _ = eval(&js).await;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let on_volume = move |e: Event<FormData>| {
|
||||
if let Ok(v) = e.value().parse::<f64>() {
|
||||
let vol_val = v / 100.0;
|
||||
volume.set(vol_val);
|
||||
let js = format!(
|
||||
"let e = document.getElementById('pinakes-player'); if(e) e.volume = \
|
||||
{};",
|
||||
vol_val
|
||||
);
|
||||
spawn(async move {
|
||||
let _ = eval(&js).await;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let on_fullscreen = move |_| {
|
||||
spawn(async move {
|
||||
let _ = eval(
|
||||
"let e = document.getElementById('pinakes-player'); if(e) { \
|
||||
if(document.fullscreenElement) document.exitFullscreen(); else \
|
||||
e.requestFullscreen(); }",
|
||||
)
|
||||
.await;
|
||||
});
|
||||
};
|
||||
|
||||
// Keyboard controls
|
||||
let on_keydown = move |evt: KeyboardEvent| {
|
||||
let key = evt.key();
|
||||
match key {
|
||||
Key::Character(ref c) if c == " " => {
|
||||
evt.prevent_default();
|
||||
spawn(async move {
|
||||
if *playing.read() {
|
||||
let _ =
|
||||
eval("document.getElementById('pinakes-player')?.pause()").await;
|
||||
} else {
|
||||
let _ =
|
||||
eval("document.getElementById('pinakes-player')?.play()").await;
|
||||
}
|
||||
});
|
||||
},
|
||||
Key::ArrowLeft => {
|
||||
evt.prevent_default();
|
||||
spawn(async move {
|
||||
let _ = eval(
|
||||
"let e = document.getElementById('pinakes-player'); if(e) \
|
||||
e.currentTime = Math.max(0, e.currentTime - 5);",
|
||||
)
|
||||
.await;
|
||||
});
|
||||
},
|
||||
Key::ArrowRight => {
|
||||
evt.prevent_default();
|
||||
spawn(async move {
|
||||
let _ = eval(
|
||||
"let e = document.getElementById('pinakes-player'); if(e) \
|
||||
e.currentTime = Math.min(e.duration || 0, e.currentTime + 5);",
|
||||
)
|
||||
.await;
|
||||
});
|
||||
},
|
||||
Key::ArrowUp => {
|
||||
evt.prevent_default();
|
||||
let new_vol = (vol + 0.1).min(1.0);
|
||||
volume.set(new_vol);
|
||||
let js = format!(
|
||||
"let e = document.getElementById('pinakes-player'); if(e) e.volume \
|
||||
= {};",
|
||||
new_vol
|
||||
);
|
||||
spawn(async move {
|
||||
let _ = eval(&js).await;
|
||||
});
|
||||
},
|
||||
Key::ArrowDown => {
|
||||
evt.prevent_default();
|
||||
let new_vol = (vol - 0.1).max(0.0);
|
||||
volume.set(new_vol);
|
||||
let js = format!(
|
||||
"let e = document.getElementById('pinakes-player'); if(e) e.volume \
|
||||
= {};",
|
||||
new_vol
|
||||
);
|
||||
spawn(async move {
|
||||
let _ = eval(&js).await;
|
||||
});
|
||||
},
|
||||
Key::Character(ref c) if c == "m" || c == "M" => {
|
||||
let new_muted = !*muted.read();
|
||||
muted.set(new_muted);
|
||||
let js = format!(
|
||||
"let e = document.getElementById('pinakes-player'); if(e) e.muted = \
|
||||
{};",
|
||||
new_muted
|
||||
);
|
||||
spawn(async move {
|
||||
let _ = eval(&js).await;
|
||||
});
|
||||
},
|
||||
Key::Character(ref c) if c == "f" || c == "F" => {
|
||||
spawn(async move {
|
||||
let _ = eval(
|
||||
"let e = document.getElementById('pinakes-player'); if(e) { \
|
||||
if(document.fullscreenElement) document.exitFullscreen(); else \
|
||||
e.requestFullscreen(); }",
|
||||
)
|
||||
.await;
|
||||
});
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
};
|
||||
|
||||
let play_icon = if is_playing { "\u{23f8}" } else { "\u{25b6}" };
|
||||
let mute_icon = if is_muted { "\u{1f507}" } else { "\u{1f50a}" };
|
||||
|
||||
let open_src = src.clone();
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: if is_video { "media-player media-player-video" } else { "media-player media-player-audio" },
|
||||
tabindex: "0",
|
||||
onkeydown: on_keydown,
|
||||
|
||||
// HLS adaptive streams cannot be decoded natively in the desktop WebView.
|
||||
// Show a prompt to open in the system's default media player instead.
|
||||
if is_hls {
|
||||
div { class: "player-hls-notice",
|
||||
p { class: "text-muted",
|
||||
"Adaptive (HLS) streams require an external media player."
|
||||
}
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
onclick: move |_| open_in_system_player(&open_src),
|
||||
"Open in system player"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
if is_video {
|
||||
video {
|
||||
id: "pinakes-player",
|
||||
class: "player-native-video",
|
||||
src: src.clone(),
|
||||
preload: "metadata",
|
||||
}
|
||||
} else {
|
||||
audio {
|
||||
id: "pinakes-player",
|
||||
class: "player-native-audio",
|
||||
src: src.clone(),
|
||||
preload: "metadata",
|
||||
}
|
||||
}
|
||||
|
||||
// Album art for audio
|
||||
if !is_video {
|
||||
div { class: "player-artwork",
|
||||
if let Some(ref thumb) = thumbnail_url {
|
||||
img { src: "{thumb}", alt: "Cover art" }
|
||||
} else {
|
||||
div { class: "player-artwork-placeholder", "\u{266b}" }
|
||||
}
|
||||
if let Some(ref t) = title {
|
||||
div { class: "player-title", "{t}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom controls
|
||||
div { class: "player-controls",
|
||||
button {
|
||||
class: "play-btn",
|
||||
onclick: toggle_play,
|
||||
title: if is_playing { "Pause" } else { "Play" },
|
||||
"{play_icon}"
|
||||
}
|
||||
span { class: "player-time", "{time_str}" }
|
||||
input {
|
||||
r#type: "range",
|
||||
class: "seek-bar",
|
||||
min: "0",
|
||||
max: "{dur}",
|
||||
step: "0.1",
|
||||
value: "{cur_time}",
|
||||
oninput: on_seek,
|
||||
}
|
||||
span { class: "player-time", "{dur_str}" }
|
||||
button {
|
||||
class: "mute-btn",
|
||||
onclick: toggle_mute,
|
||||
title: if is_muted { "Unmute" } else { "Mute" },
|
||||
"{mute_icon}"
|
||||
}
|
||||
input {
|
||||
r#type: "range",
|
||||
class: "volume-slider",
|
||||
min: "0",
|
||||
max: "100",
|
||||
value: "{vol_pct}",
|
||||
oninput: on_volume,
|
||||
}
|
||||
if is_video {
|
||||
button {
|
||||
class: "fullscreen-btn",
|
||||
onclick: on_fullscreen,
|
||||
title: "Fullscreen",
|
||||
"\u{26f6}"
|
||||
}
|
||||
}
|
||||
}
|
||||
} // end else (non-HLS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn QueuePanel(
|
||||
queue: PlayQueue,
|
||||
on_select: EventHandler<usize>,
|
||||
on_remove: EventHandler<usize>,
|
||||
on_clear: EventHandler<()>,
|
||||
on_toggle_repeat: EventHandler<()>,
|
||||
on_toggle_shuffle: EventHandler<()>,
|
||||
on_next: EventHandler<()>,
|
||||
on_previous: EventHandler<()>,
|
||||
) -> Element {
|
||||
let repeat_label = match queue.repeat {
|
||||
RepeatMode::Off => "Repeat: Off",
|
||||
RepeatMode::One => "Repeat: One",
|
||||
RepeatMode::All => "Repeat: All",
|
||||
};
|
||||
let shuffle_label = if queue.shuffle {
|
||||
"Shuffle: On"
|
||||
} else {
|
||||
"Shuffle: Off"
|
||||
};
|
||||
let current_idx = queue.current_index;
|
||||
|
||||
rsx! {
|
||||
div { class: "queue-panel",
|
||||
div { class: "queue-header",
|
||||
h3 { "Play Queue ({queue.items.len()})" }
|
||||
div { class: "queue-controls",
|
||||
button {
|
||||
class: "btn btn-sm btn-ghost",
|
||||
onclick: move |_| on_previous.call(()),
|
||||
title: "Previous (P)",
|
||||
"\u{23ee}"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-sm btn-ghost",
|
||||
onclick: move |_| on_next.call(()),
|
||||
title: "Next (N)",
|
||||
"\u{23ed}"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-sm btn-ghost",
|
||||
onclick: move |_| on_toggle_repeat.call(()),
|
||||
title: "{repeat_label}",
|
||||
"\u{1f501}"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-sm btn-ghost",
|
||||
onclick: move |_| on_toggle_shuffle.call(()),
|
||||
title: "{shuffle_label}",
|
||||
"\u{1f500}"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-sm btn-ghost",
|
||||
onclick: move |_| on_clear.call(()),
|
||||
title: "Clear Queue",
|
||||
"\u{1f5d1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if queue.items.is_empty() {
|
||||
div { class: "queue-empty", "Queue is empty. Add items from the library." }
|
||||
} else {
|
||||
div { class: "queue-list",
|
||||
for (i , item) in queue.items.iter().enumerate() {
|
||||
{
|
||||
let is_current = i == current_idx;
|
||||
let item_class = if is_current {
|
||||
"queue-item queue-item-active"
|
||||
} else {
|
||||
"queue-item"
|
||||
};
|
||||
let title = item.title.clone();
|
||||
let artist = item.artist.clone().unwrap_or_default();
|
||||
rsx! {
|
||||
div {
|
||||
key: "q-{i}",
|
||||
class: "{item_class}",
|
||||
onclick: move |_| on_select.call(i),
|
||||
div { class: "queue-item-info",
|
||||
span { class: "queue-item-title", "{title}" }
|
||||
if !artist.is_empty() {
|
||||
span { class: "queue-item-artist", "{artist}" }
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn btn-sm btn-ghost queue-item-remove",
|
||||
onclick: move |e: Event<MouseData>| {
|
||||
e.stop_propagation();
|
||||
on_remove.call(i);
|
||||
},
|
||||
"\u{2715}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_play_queue_is_empty() {
|
||||
let queue = PlayQueue::default();
|
||||
assert!(queue.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_play_queue_add() {
|
||||
let mut queue = PlayQueue::default();
|
||||
queue.add(QueueItem {
|
||||
media_id: "test1".to_string(),
|
||||
title: "Test Song".to_string(),
|
||||
artist: Some("Test Artist".to_string()),
|
||||
duration_secs: Some(180.0),
|
||||
media_type: "audio".to_string(),
|
||||
stream_url: "/stream/test1".to_string(),
|
||||
thumbnail_url: None,
|
||||
});
|
||||
assert!(!queue.is_empty());
|
||||
assert_eq!(queue.items.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_play_queue_current() {
|
||||
let mut queue = PlayQueue::default();
|
||||
assert!(queue.current().is_none());
|
||||
|
||||
queue.add(QueueItem {
|
||||
media_id: "test1".to_string(),
|
||||
title: "Test Song".to_string(),
|
||||
artist: None,
|
||||
duration_secs: None,
|
||||
media_type: "audio".to_string(),
|
||||
stream_url: "/stream/test1".to_string(),
|
||||
thumbnail_url: None,
|
||||
});
|
||||
|
||||
assert!(queue.current().is_some());
|
||||
assert_eq!(queue.current().unwrap().media_id, "test1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_play_queue_next() {
|
||||
let mut queue = PlayQueue::default();
|
||||
queue.repeat = RepeatMode::Off;
|
||||
|
||||
queue.add(QueueItem {
|
||||
media_id: "test1".to_string(),
|
||||
title: "Song 1".to_string(),
|
||||
artist: None,
|
||||
duration_secs: None,
|
||||
media_type: "audio".to_string(),
|
||||
stream_url: "/stream/test1".to_string(),
|
||||
thumbnail_url: None,
|
||||
});
|
||||
queue.add(QueueItem {
|
||||
media_id: "test2".to_string(),
|
||||
title: "Song 2".to_string(),
|
||||
artist: None,
|
||||
duration_secs: None,
|
||||
media_type: "audio".to_string(),
|
||||
stream_url: "/stream/test2".to_string(),
|
||||
thumbnail_url: None,
|
||||
});
|
||||
|
||||
let next = queue.next();
|
||||
assert!(next.is_some());
|
||||
assert_eq!(next.unwrap().media_id, "test2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_play_queue_previous() {
|
||||
let mut queue = PlayQueue::default();
|
||||
queue.add(QueueItem {
|
||||
media_id: "test1".to_string(),
|
||||
title: "Song 1".to_string(),
|
||||
artist: None,
|
||||
duration_secs: None,
|
||||
media_type: "audio".to_string(),
|
||||
stream_url: "/stream/test1".to_string(),
|
||||
thumbnail_url: None,
|
||||
});
|
||||
queue.add(QueueItem {
|
||||
media_id: "test2".to_string(),
|
||||
title: "Song 2".to_string(),
|
||||
artist: None,
|
||||
duration_secs: None,
|
||||
media_type: "audio".to_string(),
|
||||
stream_url: "/stream/test2".to_string(),
|
||||
thumbnail_url: None,
|
||||
});
|
||||
|
||||
queue.current_index = 1;
|
||||
let prev = queue.previous();
|
||||
assert!(prev.is_some());
|
||||
assert_eq!(prev.unwrap().media_id, "test1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_play_queue_remove() {
|
||||
let mut queue = PlayQueue::default();
|
||||
queue.add(QueueItem {
|
||||
media_id: "test1".to_string(),
|
||||
title: "Song 1".to_string(),
|
||||
artist: None,
|
||||
duration_secs: None,
|
||||
media_type: "audio".to_string(),
|
||||
stream_url: "/stream/test1".to_string(),
|
||||
thumbnail_url: None,
|
||||
});
|
||||
queue.add(QueueItem {
|
||||
media_id: "test2".to_string(),
|
||||
title: "Song 2".to_string(),
|
||||
artist: None,
|
||||
duration_secs: None,
|
||||
media_type: "audio".to_string(),
|
||||
stream_url: "/stream/test2".to_string(),
|
||||
thumbnail_url: None,
|
||||
});
|
||||
|
||||
queue.remove(0);
|
||||
assert_eq!(queue.items.len(), 1);
|
||||
assert_eq!(queue.items[0].media_id, "test2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_play_queue_clear() {
|
||||
let mut queue = PlayQueue::default();
|
||||
queue.add(QueueItem {
|
||||
media_id: "test1".to_string(),
|
||||
title: "Song 1".to_string(),
|
||||
artist: None,
|
||||
duration_secs: None,
|
||||
media_type: "audio".to_string(),
|
||||
stream_url: "/stream/test1".to_string(),
|
||||
thumbnail_url: None,
|
||||
});
|
||||
|
||||
queue.clear();
|
||||
assert!(queue.is_empty());
|
||||
assert_eq!(queue.current_index, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_play_queue_toggle_repeat() {
|
||||
let mut queue = PlayQueue::default();
|
||||
assert_eq!(queue.repeat, RepeatMode::Off);
|
||||
|
||||
queue.toggle_repeat();
|
||||
assert_eq!(queue.repeat, RepeatMode::All);
|
||||
|
||||
queue.toggle_repeat();
|
||||
assert_eq!(queue.repeat, RepeatMode::One);
|
||||
|
||||
queue.toggle_repeat();
|
||||
assert_eq!(queue.repeat, RepeatMode::Off);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_play_queue_toggle_shuffle() {
|
||||
let mut queue = PlayQueue::default();
|
||||
assert!(!queue.shuffle);
|
||||
|
||||
queue.toggle_shuffle();
|
||||
assert!(queue.shuffle);
|
||||
|
||||
queue.toggle_shuffle();
|
||||
assert!(!queue.shuffle);
|
||||
}
|
||||
}
|
||||
25
packages/pinakes-ui/src/components/mod.rs
Normal file
25
packages/pinakes-ui/src/components/mod.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
pub mod audit;
|
||||
pub mod backlinks_panel;
|
||||
pub mod books;
|
||||
pub mod breadcrumb;
|
||||
pub mod collections;
|
||||
pub mod database;
|
||||
pub mod detail;
|
||||
pub mod duplicates;
|
||||
pub mod graph_view;
|
||||
pub mod image_viewer;
|
||||
pub mod import;
|
||||
pub mod library;
|
||||
pub mod loading;
|
||||
pub mod login;
|
||||
pub mod markdown_viewer;
|
||||
pub mod media_player;
|
||||
pub mod pagination;
|
||||
pub mod pdf_viewer;
|
||||
pub mod playlists;
|
||||
pub mod search;
|
||||
pub mod settings;
|
||||
pub mod statistics;
|
||||
pub mod tags;
|
||||
pub mod tasks;
|
||||
pub mod utils;
|
||||
102
packages/pinakes-ui/src/components/pagination.rs
Normal file
102
packages/pinakes-ui/src/components/pagination.rs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn Pagination(
|
||||
current_page: u64,
|
||||
total_pages: u64,
|
||||
on_page_change: EventHandler<u64>,
|
||||
) -> Element {
|
||||
if total_pages <= 1 {
|
||||
return rsx! {};
|
||||
}
|
||||
|
||||
let pages = pagination_range(current_page, total_pages);
|
||||
|
||||
rsx! {
|
||||
div { class: "pagination",
|
||||
button {
|
||||
class: "btn btn-sm btn-secondary",
|
||||
disabled: current_page == 0,
|
||||
onclick: move |_| {
|
||||
if current_page > 0 {
|
||||
on_page_change.call(current_page - 1);
|
||||
}
|
||||
},
|
||||
"Prev"
|
||||
}
|
||||
|
||||
for page in pages {
|
||||
if page == u64::MAX {
|
||||
span { class: "page-ellipsis", "..." }
|
||||
} else {
|
||||
{
|
||||
let btn_class = if page == current_page {
|
||||
"btn btn-sm btn-primary page-btn"
|
||||
} else {
|
||||
"btn btn-sm btn-ghost page-btn"
|
||||
};
|
||||
rsx! {
|
||||
button {
|
||||
key: "page-{page}",
|
||||
class: "{btn_class}",
|
||||
onclick: move |_| on_page_change.call(page),
|
||||
"{page + 1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
class: "btn btn-sm btn-secondary",
|
||||
disabled: current_page >= total_pages - 1,
|
||||
onclick: move |_| {
|
||||
if current_page < total_pages - 1 {
|
||||
on_page_change.call(current_page + 1);
|
||||
}
|
||||
},
|
||||
"Next"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute a range of page numbers to display (with ellipsis as u64::MAX).
|
||||
pub fn pagination_range(current: u64, total: u64) -> Vec<u64> {
|
||||
let mut pages = Vec::new();
|
||||
if total <= 7 {
|
||||
for i in 0..total {
|
||||
pages.push(i);
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
pages.push(0);
|
||||
|
||||
if current > 2 {
|
||||
pages.push(u64::MAX);
|
||||
}
|
||||
|
||||
let start = if current <= 2 { 1 } else { current - 1 };
|
||||
let end = if current >= total - 3 {
|
||||
total - 1
|
||||
} else {
|
||||
current + 2
|
||||
};
|
||||
|
||||
for i in start..end {
|
||||
if !pages.contains(&i) {
|
||||
pages.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
if current < total - 3 {
|
||||
pages.push(u64::MAX);
|
||||
}
|
||||
|
||||
if !pages.contains(&(total - 1)) {
|
||||
pages.push(total - 1);
|
||||
}
|
||||
|
||||
pages
|
||||
}
|
||||
112
packages/pinakes-ui/src/components/pdf_viewer.rs
Normal file
112
packages/pinakes-ui/src/components/pdf_viewer.rs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn PdfViewer(
|
||||
src: String,
|
||||
#[props(default = 1)] initial_page: usize,
|
||||
#[props(default = 100)] initial_zoom: usize,
|
||||
) -> Element {
|
||||
let current_page = use_signal(|| initial_page);
|
||||
let mut zoom_level = use_signal(|| initial_zoom);
|
||||
let mut loading = use_signal(|| true);
|
||||
let mut error = use_signal(|| Option::<String>::None);
|
||||
|
||||
// For navigation controls
|
||||
let zoom = *zoom_level.read();
|
||||
let page = *current_page.read();
|
||||
|
||||
rsx! {
|
||||
div { class: "pdf-viewer",
|
||||
// Toolbar
|
||||
div { class: "pdf-toolbar",
|
||||
div { class: "pdf-toolbar-group",
|
||||
button {
|
||||
class: "pdf-toolbar-btn",
|
||||
title: "Zoom out",
|
||||
disabled: zoom <= 50,
|
||||
onclick: move |_| {
|
||||
let new_zoom = (*zoom_level.read()).saturating_sub(25).max(50);
|
||||
zoom_level.set(new_zoom);
|
||||
},
|
||||
"\u{2212}" // minus
|
||||
}
|
||||
span { class: "pdf-zoom-label", "{zoom}%" }
|
||||
button {
|
||||
class: "pdf-toolbar-btn",
|
||||
title: "Zoom in",
|
||||
disabled: zoom >= 200,
|
||||
onclick: move |_| {
|
||||
let new_zoom = (*zoom_level.read() + 25).min(200);
|
||||
zoom_level.set(new_zoom);
|
||||
},
|
||||
"+" // plus
|
||||
}
|
||||
}
|
||||
div { class: "pdf-toolbar-group",
|
||||
button {
|
||||
class: "pdf-toolbar-btn",
|
||||
title: "Fit to width",
|
||||
onclick: move |_| zoom_level.set(100),
|
||||
"\u{2194}" // left-right arrow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PDF embed container
|
||||
div { class: "pdf-container",
|
||||
if *loading.read() {
|
||||
div { class: "pdf-loading",
|
||||
div { class: "spinner" }
|
||||
span { "Loading PDF..." }
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref err) = *error.read() {
|
||||
div { class: "pdf-error",
|
||||
p { "{err}" }
|
||||
a {
|
||||
href: "{src}",
|
||||
target: "_blank",
|
||||
class: "btn btn-primary",
|
||||
"Download PDF"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use object/embed for PDF rendering
|
||||
// The webview should handle PDF rendering natively
|
||||
object {
|
||||
class: "pdf-object",
|
||||
r#type: "application/pdf",
|
||||
data: "{src}#zoom={zoom}&page={page}",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
onload: move |_| {
|
||||
loading.set(false);
|
||||
error.set(None);
|
||||
},
|
||||
onerror: move |_| {
|
||||
loading.set(false);
|
||||
error
|
||||
.set(
|
||||
Some(
|
||||
"Unable to display PDF. Your browser may not support embedded PDF viewing."
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
},
|
||||
// Fallback content
|
||||
div { class: "pdf-fallback",
|
||||
p { "PDF preview is not available in this browser." }
|
||||
a {
|
||||
href: "{src}",
|
||||
target: "_blank",
|
||||
class: "btn btn-primary",
|
||||
"Download PDF"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
411
packages/pinakes-ui/src/components/playlists.rs
Normal file
411
packages/pinakes-ui/src/components/playlists.rs
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
use dioxus::prelude::*;
|
||||
|
||||
use super::utils::{format_size, type_badge_class};
|
||||
use crate::client::{ApiClient, MediaResponse, PlaylistResponse};
|
||||
|
||||
#[component]
|
||||
pub fn Playlists(client: Signal<ApiClient>) -> Element {
|
||||
let mut playlists: Signal<Vec<PlaylistResponse>> = use_signal(Vec::new);
|
||||
let mut items: Signal<Vec<MediaResponse>> = use_signal(Vec::new);
|
||||
let mut selected_id: Signal<Option<String>> = use_signal(|| None);
|
||||
let mut loading = use_signal(|| false);
|
||||
let mut error: Signal<Option<String>> = use_signal(|| None);
|
||||
|
||||
let mut new_name = use_signal(String::new);
|
||||
let mut new_desc = use_signal(String::new);
|
||||
let mut confirm_delete: Signal<Option<String>> = use_signal(|| None);
|
||||
let mut renaming_id: Signal<Option<String>> = use_signal(|| None);
|
||||
let mut rename_input = use_signal(String::new);
|
||||
let mut add_media_id = use_signal(String::new);
|
||||
|
||||
// Load playlists on mount
|
||||
use_effect(move || {
|
||||
spawn(async move {
|
||||
loading.set(true);
|
||||
match client.read().list_playlists().await {
|
||||
Ok(list) => playlists.set(list),
|
||||
Err(e) => error.set(Some(format!("Failed to load playlists: {e}"))),
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
});
|
||||
|
||||
let load_items = move |pid: String| {
|
||||
spawn(async move {
|
||||
match client.read().get_playlist_items(&pid).await {
|
||||
Ok(list) => items.set(list),
|
||||
Err(e) => error.set(Some(format!("Failed to load items: {e}"))),
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let on_create = move |_| {
|
||||
let name = new_name.read().clone();
|
||||
if name.is_empty() {
|
||||
return;
|
||||
}
|
||||
let desc = {
|
||||
let d = new_desc.read().clone();
|
||||
if d.is_empty() { None } else { Some(d) }
|
||||
};
|
||||
spawn(async move {
|
||||
match client.read().create_playlist(&name, desc.as_deref()).await {
|
||||
Ok(pl) => {
|
||||
playlists.write().push(pl);
|
||||
new_name.set(String::new());
|
||||
new_desc.set(String::new());
|
||||
},
|
||||
Err(e) => error.set(Some(format!("Failed to create playlist: {e}"))),
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let on_shuffle = move |pid: String| {
|
||||
spawn(async move {
|
||||
match client.read().shuffle_playlist(&pid).await {
|
||||
Ok(_) => {
|
||||
// Reload items if this is the selected playlist
|
||||
if selected_id.read().as_deref() == Some(&pid) {
|
||||
match client.read().get_playlist_items(&pid).await {
|
||||
Ok(list) => items.set(list),
|
||||
Err(e) => error.set(Some(format!("Failed to reload: {e}"))),
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => error.set(Some(format!("Shuffle failed: {e}"))),
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Detail view: show items for selected playlist
|
||||
if let Some(ref pid) = selected_id.read().clone() {
|
||||
let pl_name = playlists
|
||||
.read()
|
||||
.iter()
|
||||
.find(|p| &p.id == pid)
|
||||
.map(|p| p.name.clone())
|
||||
.unwrap_or_else(|| pid.clone());
|
||||
|
||||
let pid_for_shuffle = pid.clone();
|
||||
let pid_for_back = pid.clone();
|
||||
|
||||
return rsx! {
|
||||
div { class: "form-row mb-16",
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
onclick: move |_| {
|
||||
let _ = &pid_for_back;
|
||||
selected_id.set(None);
|
||||
items.set(Vec::new());
|
||||
},
|
||||
"\u{2190} Back to Playlists"
|
||||
}
|
||||
}
|
||||
|
||||
h3 { class: "mb-16", "{pl_name}" }
|
||||
|
||||
if let Some(ref err) = *error.read() {
|
||||
div { class: "error-banner", "{err}" }
|
||||
}
|
||||
|
||||
div { class: "form-row mb-16",
|
||||
button {
|
||||
class: "btn btn-secondary btn-sm",
|
||||
onclick: move |_| on_shuffle(pid_for_shuffle.clone()),
|
||||
"\u{1f500} Shuffle"
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "form-row mb-16",
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Media ID to add...",
|
||||
value: "{add_media_id}",
|
||||
oninput: move |e| add_media_id.set(e.value()),
|
||||
}
|
||||
button {
|
||||
class: "btn btn-primary btn-sm",
|
||||
onclick: {
|
||||
let pid = pid.clone();
|
||||
move |_| {
|
||||
let mid = add_media_id.read().clone();
|
||||
if mid.is_empty() {
|
||||
return;
|
||||
}
|
||||
let pid = pid.clone();
|
||||
spawn(async move {
|
||||
match client.read().add_to_playlist(&pid, &mid).await {
|
||||
Ok(_) => {
|
||||
add_media_id.set(String::new());
|
||||
match client.read().get_playlist_items(&pid).await {
|
||||
Ok(list) => items.set(list),
|
||||
Err(e) => error.set(Some(format!("Reload failed: {e}"))),
|
||||
}
|
||||
}
|
||||
Err(e) => error.set(Some(format!("Add failed: {e}"))),
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
"Add Media"
|
||||
}
|
||||
}
|
||||
|
||||
if *loading.read() {
|
||||
div { class: "loading-overlay",
|
||||
div { class: "spinner" }
|
||||
"Loading..."
|
||||
}
|
||||
} else if items.read().is_empty() {
|
||||
div { class: "empty-state",
|
||||
p { class: "empty-subtitle", "No items in this playlist." }
|
||||
}
|
||||
} else {
|
||||
table { class: "data-table",
|
||||
thead {
|
||||
tr {
|
||||
th { "Name" }
|
||||
th { "Type" }
|
||||
th { "Artist" }
|
||||
th { "Size" }
|
||||
th { "" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for item in items.read().clone() {
|
||||
{
|
||||
let pid_rm = pid.clone();
|
||||
let mid = item.id.clone();
|
||||
let artist = item.artist.clone().unwrap_or_default();
|
||||
let size = format_size(item.file_size);
|
||||
let badge_class = type_badge_class(&item.media_type);
|
||||
rsx! {
|
||||
tr { key: "{mid}",
|
||||
td { "{item.file_name}" }
|
||||
td {
|
||||
span { class: "type-badge {badge_class}", "{item.media_type}" }
|
||||
}
|
||||
td { "{artist}" }
|
||||
td { "{size}" }
|
||||
td {
|
||||
button {
|
||||
class: "btn btn-danger btn-sm",
|
||||
onclick: move |_| {
|
||||
let pid_rm = pid_rm.clone();
|
||||
let mid = mid.clone();
|
||||
spawn(async move {
|
||||
match client.read().remove_from_playlist(&pid_rm, &mid).await {
|
||||
Ok(_) => {
|
||||
match client.read().get_playlist_items(&pid_rm).await {
|
||||
Ok(list) => items.set(list),
|
||||
Err(e) => error.set(Some(format!("Reload failed: {e}"))),
|
||||
}
|
||||
}
|
||||
Err(e) => error.set(Some(format!("Remove failed: {e}"))),
|
||||
}
|
||||
});
|
||||
},
|
||||
"Remove"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// List view
|
||||
rsx! {
|
||||
div { class: "card",
|
||||
div { class: "card-header",
|
||||
h3 { class: "card-title", "Playlists" }
|
||||
}
|
||||
|
||||
if let Some(ref err) = *error.read() {
|
||||
div { class: "error-banner mb-16", "{err}" }
|
||||
}
|
||||
|
||||
div { class: "form-row mb-16",
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Playlist name...",
|
||||
value: "{new_name}",
|
||||
oninput: move |e| new_name.set(e.value()),
|
||||
}
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Description (optional)...",
|
||||
value: "{new_desc}",
|
||||
oninput: move |e| new_desc.set(e.value()),
|
||||
}
|
||||
button { class: "btn btn-primary", onclick: on_create, "Create" }
|
||||
}
|
||||
|
||||
if *loading.read() {
|
||||
div { class: "loading-overlay",
|
||||
div { class: "spinner" }
|
||||
"Loading..."
|
||||
}
|
||||
} else if playlists.read().is_empty() {
|
||||
div { class: "empty-state",
|
||||
p { class: "empty-subtitle", "No playlists yet. Create one above." }
|
||||
}
|
||||
} else {
|
||||
table { class: "data-table",
|
||||
thead {
|
||||
tr {
|
||||
th { "Name" }
|
||||
th { "Description" }
|
||||
th { "Items" }
|
||||
th { "" }
|
||||
th { "" }
|
||||
th { "" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for pl in playlists.read().clone() {
|
||||
{
|
||||
let pl_id = pl.id.clone();
|
||||
let pl_id_open = pl.id.clone();
|
||||
let pl_id_rename = pl.id.clone();
|
||||
let desc = pl.description.clone().unwrap_or_default();
|
||||
let count = pl.item_count.map(|c| c.to_string()).unwrap_or_default();
|
||||
let is_confirming = confirm_delete
|
||||
.read()
|
||||
.as_ref()
|
||||
.map(|id| id == &pl.id)
|
||||
.unwrap_or(false);
|
||||
let is_renaming = renaming_id
|
||||
.read()
|
||||
.as_ref()
|
||||
.map(|id| id == &pl.id)
|
||||
.unwrap_or(false);
|
||||
rsx! {
|
||||
tr { key: "{pl_id}",
|
||||
td {
|
||||
if is_renaming {
|
||||
div { class: "form-row",
|
||||
input {
|
||||
r#type: "text",
|
||||
value: "{rename_input}",
|
||||
oninput: move |e| rename_input.set(e.value()),
|
||||
}
|
||||
button {
|
||||
class: "btn btn-primary btn-sm",
|
||||
onclick: {
|
||||
let rid = pl_id_rename.clone();
|
||||
move |_| {
|
||||
let rid = rid.clone();
|
||||
let new_name_val = rename_input.read().clone();
|
||||
if new_name_val.is_empty() {
|
||||
return;
|
||||
}
|
||||
spawn(async move {
|
||||
match client.read().update_playlist(&rid, &new_name_val).await {
|
||||
Ok(updated) => {
|
||||
let mut list = playlists.write();
|
||||
if let Some(p) = list.iter_mut().find(|p| p.id == rid) {
|
||||
*p = updated;
|
||||
}
|
||||
renaming_id.set(None);
|
||||
}
|
||||
Err(e) => error.set(Some(format!("Rename failed: {e}"))),
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
"Save"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-ghost btn-sm",
|
||||
onclick: move |_| renaming_id.set(None),
|
||||
"Cancel"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
"{pl.name}"
|
||||
}
|
||||
}
|
||||
td { "{desc}" }
|
||||
td { "{count}" }
|
||||
td {
|
||||
button {
|
||||
class: "btn btn-sm btn-secondary",
|
||||
onclick: move |_| {
|
||||
let pid = pl_id_open.clone();
|
||||
selected_id.set(Some(pid.clone()));
|
||||
load_items(pid);
|
||||
},
|
||||
"Open"
|
||||
}
|
||||
}
|
||||
td {
|
||||
if !is_renaming {
|
||||
button {
|
||||
class: "btn btn-sm btn-ghost",
|
||||
onclick: {
|
||||
let rid = pl_id.clone();
|
||||
let rname = pl.name.clone();
|
||||
move |_| {
|
||||
rename_input.set(rname.clone());
|
||||
renaming_id.set(Some(rid.clone()));
|
||||
}
|
||||
},
|
||||
"Rename"
|
||||
}
|
||||
}
|
||||
}
|
||||
td {
|
||||
if is_confirming {
|
||||
button {
|
||||
class: "btn btn-danger btn-sm",
|
||||
onclick: {
|
||||
let id = pl_id.clone();
|
||||
move |_| {
|
||||
let id = id.clone();
|
||||
spawn(async move {
|
||||
match client.read().delete_playlist(&id).await {
|
||||
Ok(_) => {
|
||||
playlists.write().retain(|p| p.id != id);
|
||||
confirm_delete.set(None);
|
||||
}
|
||||
Err(e) => {
|
||||
error.set(Some(format!("Delete failed: {e}")));
|
||||
confirm_delete.set(None);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
"Confirm"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-ghost btn-sm",
|
||||
onclick: move |_| confirm_delete.set(None),
|
||||
"Cancel"
|
||||
}
|
||||
} else {
|
||||
button {
|
||||
class: "btn btn-danger btn-sm",
|
||||
onclick: {
|
||||
let id = pl_id.clone();
|
||||
move |_| confirm_delete.set(Some(id.clone()))
|
||||
},
|
||||
"Delete"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
433
packages/pinakes-ui/src/components/search.rs
Normal file
433
packages/pinakes-ui/src/components/search.rs
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
use dioxus::prelude::*;
|
||||
|
||||
use super::{
|
||||
pagination::Pagination as PaginationControls,
|
||||
utils::{format_size, type_badge_class, type_icon},
|
||||
};
|
||||
use crate::client::{MediaResponse, SavedSearchResponse};
|
||||
|
||||
#[component]
|
||||
pub fn Search(
|
||||
results: Vec<MediaResponse>,
|
||||
total_count: u64,
|
||||
search_page: u64,
|
||||
page_size: u64,
|
||||
on_search: EventHandler<(String, Option<String>)>,
|
||||
on_select: EventHandler<String>,
|
||||
on_page_change: EventHandler<u64>,
|
||||
server_url: String,
|
||||
#[props(default)] saved_searches: Vec<SavedSearchResponse>,
|
||||
#[props(default)] on_save_search: Option<
|
||||
EventHandler<(String, String, Option<String>)>,
|
||||
>,
|
||||
#[props(default)] on_delete_saved_search: Option<EventHandler<String>>,
|
||||
#[props(default)] on_load_saved_search: Option<
|
||||
EventHandler<SavedSearchResponse>,
|
||||
>,
|
||||
) -> Element {
|
||||
let mut query = use_signal(String::new);
|
||||
let mut sort_by = use_signal(|| String::from("relevance"));
|
||||
let mut show_help = use_signal(|| false);
|
||||
let mut show_save_dialog = use_signal(|| false);
|
||||
let mut save_name = use_signal(String::new);
|
||||
let mut show_saved_list = use_signal(|| false);
|
||||
// 0 = table, 1 = grid
|
||||
let mut view_mode = use_signal(|| 0u8);
|
||||
|
||||
let do_search = {
|
||||
let query = query;
|
||||
let sort_by = sort_by;
|
||||
move |_| {
|
||||
let q = query.read().clone();
|
||||
let s = sort_by.read().clone();
|
||||
let sort = if s == "relevance" || s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s)
|
||||
};
|
||||
on_search.call((q, sort));
|
||||
}
|
||||
};
|
||||
|
||||
let on_key = {
|
||||
let query = query;
|
||||
let sort_by = sort_by;
|
||||
move |e: KeyboardEvent| {
|
||||
if e.key() == Key::Enter {
|
||||
let q = query.read().clone();
|
||||
let s = sort_by.read().clone();
|
||||
let sort = if s == "relevance" || s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s)
|
||||
};
|
||||
on_search.call((q, sort));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let toggle_help = move |_| {
|
||||
let current = *show_help.read();
|
||||
show_help.set(!current);
|
||||
};
|
||||
|
||||
let help_visible = *show_help.read();
|
||||
let current_mode = *view_mode.read();
|
||||
let total_pages = if page_size > 0 {
|
||||
total_count.div_ceil(page_size)
|
||||
} else {
|
||||
1
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "form-row mb-16",
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Search media...",
|
||||
value: "{query}",
|
||||
oninput: move |e| query.set(e.value()),
|
||||
onkeypress: on_key,
|
||||
}
|
||||
select { value: "{sort_by}", onchange: move |e| sort_by.set(e.value()),
|
||||
option { value: "relevance", "Relevance" }
|
||||
option { value: "date_desc", "Newest" }
|
||||
option { value: "date_asc", "Oldest" }
|
||||
option { value: "name_asc", "Name A-Z" }
|
||||
option { value: "name_desc", "Name Z-A" }
|
||||
option { value: "size_desc", "Size (largest)" }
|
||||
option { value: "size_asc", "Size (smallest)" }
|
||||
}
|
||||
button { class: "btn btn-primary", onclick: do_search, "Search" }
|
||||
button { class: "btn btn-ghost", onclick: toggle_help, "Syntax Help" }
|
||||
|
||||
// Save/Load search buttons
|
||||
if on_save_search.is_some() {
|
||||
button {
|
||||
class: "btn btn-secondary",
|
||||
disabled: query.read().is_empty(),
|
||||
onclick: move |_| show_save_dialog.set(true),
|
||||
"Save"
|
||||
}
|
||||
}
|
||||
if !saved_searches.is_empty() {
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
onclick: move |_| show_saved_list.toggle(),
|
||||
"Saved ({saved_searches.len()})"
|
||||
}
|
||||
}
|
||||
|
||||
// View mode toggle
|
||||
div { class: "view-toggle",
|
||||
button {
|
||||
class: if current_mode == 1 { "view-btn active" } else { "view-btn" },
|
||||
onclick: move |_| view_mode.set(1),
|
||||
title: "Grid view",
|
||||
"\u{25a6}"
|
||||
}
|
||||
button {
|
||||
class: if current_mode == 0 { "view-btn active" } else { "view-btn" },
|
||||
onclick: move |_| view_mode.set(0),
|
||||
title: "Table view",
|
||||
"\u{2630}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if help_visible {
|
||||
div { class: "card mb-16",
|
||||
h4 { "Search Syntax" }
|
||||
ul {
|
||||
li {
|
||||
code { "hello world" }
|
||||
" -- full text search (implicit AND)"
|
||||
}
|
||||
li {
|
||||
code { "artist:Beatles" }
|
||||
" -- field match"
|
||||
}
|
||||
li {
|
||||
code { "type:pdf" }
|
||||
" -- filter by media type"
|
||||
}
|
||||
li {
|
||||
code { "tag:music" }
|
||||
" -- filter by tag"
|
||||
}
|
||||
li {
|
||||
code { "hello OR world" }
|
||||
" -- OR operator"
|
||||
}
|
||||
li {
|
||||
code { "-excluded" }
|
||||
" -- NOT (exclude term)"
|
||||
}
|
||||
li {
|
||||
code { "hel*" }
|
||||
" -- prefix search"
|
||||
}
|
||||
li {
|
||||
code { "hello~" }
|
||||
" -- fuzzy search"
|
||||
}
|
||||
li {
|
||||
code { "\"exact phrase\"" }
|
||||
" -- quoted exact match"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save search dialog
|
||||
if *show_save_dialog.read() {
|
||||
div {
|
||||
class: "modal-overlay",
|
||||
onclick: move |_| show_save_dialog.set(false),
|
||||
div {
|
||||
class: "modal-content",
|
||||
onclick: move |evt: MouseEvent| evt.stop_propagation(),
|
||||
h3 { "Save Search" }
|
||||
div { class: "form-field",
|
||||
label { "Name" }
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Enter a name for this search...",
|
||||
value: "{save_name}",
|
||||
oninput: move |e| save_name.set(e.value()),
|
||||
onkeypress: {
|
||||
let query = query.read().clone();
|
||||
let sort = sort_by.read().clone();
|
||||
let handler = on_save_search;
|
||||
move |e: KeyboardEvent| {
|
||||
if e.key() == Key::Enter {
|
||||
let name = save_name.read().clone();
|
||||
if !name.is_empty() {
|
||||
let sort_opt = if sort == "relevance" {
|
||||
None
|
||||
} else {
|
||||
Some(sort.clone())
|
||||
};
|
||||
if let Some(ref h) = handler {
|
||||
h.call((name, query.clone(), sort_opt));
|
||||
}
|
||||
show_save_dialog.set(false);
|
||||
save_name.set(String::new());
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
p { class: "text-muted text-sm", "Query: {query}" }
|
||||
div { class: "modal-actions",
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
onclick: move |_| {
|
||||
show_save_dialog.set(false);
|
||||
save_name.set(String::new());
|
||||
},
|
||||
"Cancel"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
disabled: save_name.read().is_empty(),
|
||||
onclick: {
|
||||
let query_val = query.read().clone();
|
||||
let sort_val = sort_by.read().clone();
|
||||
let handler = on_save_search;
|
||||
move |_| {
|
||||
let name = save_name.read().clone();
|
||||
if !name.is_empty() {
|
||||
let sort_opt = if sort_val == "relevance" {
|
||||
None
|
||||
} else {
|
||||
Some(sort_val.clone())
|
||||
};
|
||||
if let Some(ref h) = handler {
|
||||
h.call((name, query_val.clone(), sort_opt));
|
||||
}
|
||||
show_save_dialog.set(false);
|
||||
save_name.set(String::new());
|
||||
}
|
||||
}
|
||||
},
|
||||
"Save"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Saved searches list
|
||||
if *show_saved_list.read() && !saved_searches.is_empty() {
|
||||
div { class: "card mb-16",
|
||||
div { class: "card-header",
|
||||
h4 { "Saved Searches" }
|
||||
button {
|
||||
class: "btn btn-ghost btn-sm",
|
||||
onclick: move |_| show_saved_list.set(false),
|
||||
"Close"
|
||||
}
|
||||
}
|
||||
div { class: "saved-searches-list",
|
||||
for search in saved_searches.iter() {
|
||||
{
|
||||
let search_clone = search.clone();
|
||||
let id_for_delete = search.id.clone();
|
||||
let load_handler = on_load_saved_search;
|
||||
let delete_handler = on_delete_saved_search;
|
||||
rsx! {
|
||||
div { class: "saved-search-item", key: "{search.id}",
|
||||
div {
|
||||
class: "saved-search-info",
|
||||
onclick: {
|
||||
let sc = search_clone.clone();
|
||||
move |_| {
|
||||
if let Some(ref h) = load_handler {
|
||||
h.call(sc.clone());
|
||||
}
|
||||
query.set(sc.query.clone());
|
||||
if let Some(ref s) = sc.sort_order {
|
||||
sort_by.set(s.clone());
|
||||
} else {
|
||||
sort_by.set("relevance".to_string());
|
||||
}
|
||||
show_saved_list.set(false);
|
||||
}
|
||||
},
|
||||
span { class: "saved-search-name", "{search.name}" }
|
||||
span { class: "saved-search-query text-muted", "{search.query}" }
|
||||
}
|
||||
button {
|
||||
class: "btn btn-danger btn-sm",
|
||||
onclick: {
|
||||
let id = id_for_delete.clone();
|
||||
move |evt: MouseEvent| {
|
||||
evt.stop_propagation();
|
||||
if let Some(ref h) = delete_handler {
|
||||
h.call(id.clone());
|
||||
}
|
||||
}
|
||||
},
|
||||
"Delete"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p { class: "text-muted text-sm mb-8", "Results: {total_count}" }
|
||||
|
||||
if results.is_empty() && query.read().is_empty() {
|
||||
div { class: "empty-state",
|
||||
h3 { class: "empty-title", "Search your media" }
|
||||
p { class: "empty-subtitle",
|
||||
"Enter a query above to find files by name, metadata, tags, or type."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if results.is_empty() && !query.read().is_empty() {
|
||||
div { class: "empty-state",
|
||||
h3 { class: "empty-title", "No results found" }
|
||||
p { class: "empty-subtitle", "Try a different query or check the syntax help." }
|
||||
}
|
||||
}
|
||||
|
||||
// Content: grid or table
|
||||
match current_mode {
|
||||
1 => rsx! {
|
||||
div { class: "media-grid",
|
||||
for item in results.iter() {
|
||||
{
|
||||
let badge_class = type_badge_class(&item.media_type);
|
||||
let card_click = {
|
||||
let id = item.id.clone();
|
||||
move |_| on_select.call(id.clone())
|
||||
};
|
||||
|
||||
|
||||
|
||||
let thumb_url = if item.has_thumbnail {
|
||||
format!("{}/api/v1/media/{}/thumbnail", server_url, item.id)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let has_thumb = item.has_thumbnail;
|
||||
let media_type = item.media_type.clone();
|
||||
|
||||
rsx! {
|
||||
|
||||
|
||||
|
||||
div { key: "{item.id}", class: "media-card", onclick: card_click,
|
||||
|
||||
div { class: "card-thumbnail",
|
||||
if has_thumb {
|
||||
img {
|
||||
src: "{thumb_url}",
|
||||
alt: "{item.file_name}",
|
||||
loading: "lazy",
|
||||
}
|
||||
} else {
|
||||
div { class: "card-type-icon {badge_class}", "{type_icon(&media_type)}" }
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "card-info",
|
||||
div { class: "card-name", title: "{item.file_name}", "{item.file_name}" }
|
||||
div { class: "card-meta",
|
||||
span { class: "type-badge {badge_class}", "{item.media_type}" }
|
||||
span { class: "card-size", "{format_size(item.file_size)}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => rsx! {
|
||||
table { class: "data-table",
|
||||
thead {
|
||||
tr {
|
||||
th { "Name" }
|
||||
th { "Type" }
|
||||
th { "Artist" }
|
||||
th { "Size" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for item in results.iter() {
|
||||
{
|
||||
let artist = item.artist.clone().unwrap_or_default();
|
||||
let size = format_size(item.file_size);
|
||||
let badge_class = type_badge_class(&item.media_type);
|
||||
let row_click = {
|
||||
let id = item.id.clone();
|
||||
move |_| on_select.call(id.clone())
|
||||
};
|
||||
rsx! {
|
||||
tr { key: "{item.id}", onclick: row_click,
|
||||
td { "{item.file_name}" }
|
||||
td {
|
||||
span { class: "type-badge {badge_class}", "{item.media_type}" }
|
||||
}
|
||||
td { "{artist}" }
|
||||
td { "{size}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Pagination controls
|
||||
PaginationControls { current_page: search_page, total_pages, on_page_change }
|
||||
}
|
||||
}
|
||||
1036
packages/pinakes-ui/src/components/settings.rs
Normal file
1036
packages/pinakes-ui/src/components/settings.rs
Normal file
File diff suppressed because it is too large
Load diff
278
packages/pinakes-ui/src/components/statistics.rs
Normal file
278
packages/pinakes-ui/src/components/statistics.rs
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::{
|
||||
Icon,
|
||||
icons::fa_solid_icons::{
|
||||
FaChartBar,
|
||||
FaCircle,
|
||||
FaClock,
|
||||
FaDatabase,
|
||||
FaFolder,
|
||||
FaLink,
|
||||
FaTags,
|
||||
},
|
||||
};
|
||||
|
||||
use super::utils::format_size;
|
||||
use crate::client::LibraryStatisticsResponse;
|
||||
|
||||
#[component]
|
||||
pub fn Statistics(
|
||||
stats: Option<LibraryStatisticsResponse>,
|
||||
#[props(default)] error: Option<String>,
|
||||
on_refresh: EventHandler<()>,
|
||||
) -> Element {
|
||||
rsx! {
|
||||
div { class: "statistics-page",
|
||||
div { class: "card",
|
||||
div { class: "card-header",
|
||||
h3 { class: "card-title", "Library Statistics" }
|
||||
button {
|
||||
class: "btn btn-sm btn-secondary",
|
||||
onclick: move |_| on_refresh.call(()),
|
||||
"\u{21bb} Refresh"
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref err) = error {
|
||||
div { class: "alert alert-error mb-8",
|
||||
span { "{err}" }
|
||||
button {
|
||||
class: "btn btn-sm btn-secondary ml-8",
|
||||
onclick: move |_| on_refresh.call(()),
|
||||
"Retry"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match stats.as_ref() {
|
||||
Some(s) => {
|
||||
let total_size = format_size(s.total_size_bytes);
|
||||
let avg_size = format_size(s.avg_file_size_bytes);
|
||||
rsx! {
|
||||
div { class: "stats-overview",
|
||||
div { class: "stat-card stat-primary",
|
||||
div { class: "stat-icon",
|
||||
Icon { icon: FaFolder, width: 20, height: 20 }
|
||||
}
|
||||
div { class: "stat-content",
|
||||
div { class: "stat-value", "{s.total_media}" }
|
||||
div { class: "stat-label", "Total Media" }
|
||||
}
|
||||
}
|
||||
div { class: "stat-card stat-success",
|
||||
div { class: "stat-icon",
|
||||
Icon { icon: FaDatabase, width: 20, height: 20 }
|
||||
}
|
||||
div { class: "stat-content",
|
||||
div { class: "stat-value", "{total_size}" }
|
||||
div { class: "stat-label", "Total Size" }
|
||||
}
|
||||
}
|
||||
div { class: "stat-card stat-info",
|
||||
div { class: "stat-icon",
|
||||
Icon { icon: FaChartBar, width: 20, height: 20 }
|
||||
}
|
||||
div { class: "stat-content",
|
||||
div { class: "stat-value", "{avg_size}" }
|
||||
div { class: "stat-label", "Average Size" }
|
||||
}
|
||||
}
|
||||
div { class: "stat-card stat-warning",
|
||||
div { class: "stat-icon",
|
||||
Icon { icon: FaTags, width: 20, height: 20 }
|
||||
}
|
||||
div { class: "stat-content",
|
||||
div { class: "stat-value", "{s.total_tags}" }
|
||||
div { class: "stat-label", "Tags" }
|
||||
}
|
||||
}
|
||||
div { class: "stat-card stat-purple",
|
||||
div { class: "stat-icon",
|
||||
Icon { icon: FaCircle, width: 20, height: 20 }
|
||||
}
|
||||
div { class: "stat-content",
|
||||
div { class: "stat-value", "{s.total_collections}" }
|
||||
div { class: "stat-label", "Collections" }
|
||||
}
|
||||
}
|
||||
div { class: "stat-card stat-danger",
|
||||
div { class: "stat-icon",
|
||||
Icon { icon: FaLink, width: 20, height: 20 }
|
||||
}
|
||||
div { class: "stat-content",
|
||||
div { class: "stat-value", "{s.total_duplicates}" }
|
||||
div { class: "stat-label", "Duplicates" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
if !s.media_by_type.is_empty() {
|
||||
{
|
||||
let max_count = s.media_by_type.iter().map(|i| i.count).max().unwrap_or(1)
|
||||
|
||||
as f64;
|
||||
rsx! {
|
||||
div { class: "stats-section",
|
||||
h4 { class: "section-title",
|
||||
Icon {
|
||||
icon: FaChartBar,
|
||||
width: 16,
|
||||
height: 16,
|
||||
style: "margin-right: 8px; vertical-align: middle;",
|
||||
}
|
||||
"Media by Type"
|
||||
}
|
||||
div { class: "chart-bars",
|
||||
for item in s.media_by_type.iter() {
|
||||
{
|
||||
let percentage = (item.count as f64 / max_count) * 100.0;
|
||||
let name = item.name.clone();
|
||||
let count = item.count;
|
||||
rsx! {
|
||||
div { key: "{name}", class: "bar-item",
|
||||
div { class: "bar-label", "{name}" }
|
||||
div { class: "bar-track",
|
||||
div { class: "bar-fill bar-primary", style: "width: {percentage}%" }
|
||||
}
|
||||
div { class: "bar-value", "{count}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !s.storage_by_type.is_empty() {
|
||||
{
|
||||
let max_size = s.storage_by_type.iter().map(|i| i.count).max().unwrap_or(1)
|
||||
|
||||
as f64;
|
||||
rsx! {
|
||||
div { class: "stats-section",
|
||||
h4 { class: "section-title",
|
||||
Icon {
|
||||
icon: FaDatabase,
|
||||
width: 16,
|
||||
height: 16,
|
||||
style: "margin-right: 8px; vertical-align: middle;",
|
||||
}
|
||||
"Storage by Type"
|
||||
}
|
||||
div { class: "chart-bars",
|
||||
for item in s.storage_by_type.iter() {
|
||||
{
|
||||
let percentage = (item.count as f64 / max_size) * 100.0;
|
||||
let name = item.name.clone();
|
||||
let size_str = format_size(item.count);
|
||||
rsx! {
|
||||
div { key: "{name}", class: "bar-item",
|
||||
div { class: "bar-label", "{name}" }
|
||||
div { class: "bar-track",
|
||||
div { class: "bar-fill bar-success", style: "width: {percentage}%" }
|
||||
}
|
||||
div { class: "bar-value", "{size_str}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !s.top_tags.is_empty() {
|
||||
div { class: "stats-section",
|
||||
h4 { class: "section-title",
|
||||
Icon {
|
||||
icon: FaTags,
|
||||
width: 16,
|
||||
height: 16,
|
||||
style: "margin-right: 8px; vertical-align: middle;",
|
||||
}
|
||||
"Top Tags"
|
||||
}
|
||||
div { class: "tag-list",
|
||||
for item in s.top_tags.iter() {
|
||||
div { class: "tag-item",
|
||||
span { class: "tag-badge", "{item.name}" }
|
||||
span { class: "tag-count", "{item.count}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !s.top_collections.is_empty() {
|
||||
div { class: "stats-section",
|
||||
h4 { class: "section-title",
|
||||
Icon {
|
||||
icon: FaCircle,
|
||||
width: 16,
|
||||
height: 16,
|
||||
style: "margin-right: 8px; vertical-align: middle;",
|
||||
}
|
||||
"Top Collections"
|
||||
}
|
||||
div { class: "collection-list",
|
||||
for item in s.top_collections.iter() {
|
||||
div { class: "collection-item",
|
||||
Icon {
|
||||
icon: FaFolder,
|
||||
width: 16,
|
||||
height: 16,
|
||||
class: "collection-icon",
|
||||
}
|
||||
span { class: "collection-name", "{item.name}" }
|
||||
span { class: "collection-count", "{item.count}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "stats-section",
|
||||
h4 { class: "section-title",
|
||||
Icon {
|
||||
icon: FaClock,
|
||||
width: 16,
|
||||
height: 16,
|
||||
style: "margin-right: 8px; vertical-align: middle;",
|
||||
}
|
||||
"Date Range"
|
||||
}
|
||||
div { class: "date-range",
|
||||
div { class: "date-item",
|
||||
Icon { icon: FaClock, width: 16, height: 16 }
|
||||
div { class: "date-content",
|
||||
div { class: "date-label", "Oldest Item" }
|
||||
div { class: "date-value", "{s.oldest_item.as_deref().unwrap_or(\"N/A\")}" }
|
||||
}
|
||||
}
|
||||
div { class: "date-item",
|
||||
Icon { icon: FaClock, width: 16, height: 16 }
|
||||
div { class: "date-content",
|
||||
div { class: "date-label", "Newest Item" }
|
||||
div { class: "date-value", "{s.newest_item.as_deref().unwrap_or(\"N/A\")}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => rsx! {
|
||||
div { class: "empty-state",
|
||||
div { class: "spinner" }
|
||||
p { "Loading statistics..." }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
278
packages/pinakes-ui/src/components/tags.rs
Normal file
278
packages/pinakes-ui/src/components/tags.rs
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
use dioxus::prelude::*;
|
||||
|
||||
use crate::client::TagResponse;
|
||||
|
||||
#[component]
|
||||
pub fn Tags(
|
||||
tags: Vec<TagResponse>,
|
||||
on_create: EventHandler<(String, Option<String>)>,
|
||||
on_delete: EventHandler<String>,
|
||||
) -> Element {
|
||||
let mut new_tag_name = use_signal(String::new);
|
||||
let mut parent_tag = use_signal(String::new);
|
||||
let mut confirm_delete: Signal<Option<String>> = use_signal(|| None);
|
||||
|
||||
let create_click = move |_| {
|
||||
let name = new_tag_name.read().clone();
|
||||
if name.is_empty() {
|
||||
return;
|
||||
}
|
||||
let parent = {
|
||||
let p = parent_tag.read().clone();
|
||||
if p.is_empty() { None } else { Some(p) }
|
||||
};
|
||||
on_create.call((name, parent));
|
||||
new_tag_name.set(String::new());
|
||||
parent_tag.set(String::new());
|
||||
};
|
||||
|
||||
let create_key = move |e: KeyboardEvent| {
|
||||
if e.key() == Key::Enter {
|
||||
let name = new_tag_name.read().clone();
|
||||
if name.is_empty() {
|
||||
return;
|
||||
}
|
||||
let parent = {
|
||||
let p = parent_tag.read().clone();
|
||||
if p.is_empty() { None } else { Some(p) }
|
||||
};
|
||||
on_create.call((name, parent));
|
||||
new_tag_name.set(String::new());
|
||||
parent_tag.set(String::new());
|
||||
}
|
||||
};
|
||||
|
||||
// Separate root tags and child tags
|
||||
let root_tags: Vec<&TagResponse> =
|
||||
tags.iter().filter(|t| t.parent_id.is_none()).collect();
|
||||
let child_tags: Vec<&TagResponse> =
|
||||
tags.iter().filter(|t| t.parent_id.is_some()).collect();
|
||||
|
||||
rsx! {
|
||||
div { class: "card",
|
||||
div { class: "card-header",
|
||||
h3 { class: "card-title", "Tags" }
|
||||
}
|
||||
|
||||
div { class: "form-row mb-16",
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "New tag name...",
|
||||
value: "{new_tag_name}",
|
||||
oninput: move |e| new_tag_name.set(e.value()),
|
||||
onkeypress: create_key,
|
||||
}
|
||||
select {
|
||||
value: "{parent_tag}",
|
||||
onchange: move |e| parent_tag.set(e.value()),
|
||||
option { value: "", "No Parent" }
|
||||
for tag in tags.iter() {
|
||||
option { key: "{tag.id}", value: "{tag.id}", "{tag.name}" }
|
||||
}
|
||||
}
|
||||
button { class: "btn btn-primary", onclick: create_click, "Create" }
|
||||
}
|
||||
|
||||
if tags.is_empty() {
|
||||
div { class: "empty-state",
|
||||
p { class: "empty-subtitle", "No tags yet. Create one above." }
|
||||
}
|
||||
} else {
|
||||
div { class: "tag-list",
|
||||
// Root tags
|
||||
for tag in root_tags.iter() {
|
||||
{
|
||||
let tag_id = tag.id.clone();
|
||||
let tag_name = tag.name.clone();
|
||||
let children: Vec<&TagResponse> = child_tags
|
||||
.iter()
|
||||
.filter(|c| c.parent_id.as_deref() == Some(tag_id.as_str()))
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
let is_confirming = confirm_delete.read().as_deref() == Some(tag_id.as_str());
|
||||
|
||||
rsx! {
|
||||
div { key: "{tag_id}", class: "tag-group",
|
||||
span { class: "tag-badge",
|
||||
"{tag_name}"
|
||||
if is_confirming {
|
||||
{
|
||||
let confirm_id = tag_id.clone();
|
||||
rsx! {
|
||||
span { class: "tag-confirm-delete",
|
||||
" Are you sure? "
|
||||
span {
|
||||
class: "tag-confirm-yes",
|
||||
onclick: move |_| {
|
||||
on_delete.call(confirm_id.clone());
|
||||
confirm_delete.set(None);
|
||||
},
|
||||
"Confirm"
|
||||
}
|
||||
" "
|
||||
span {
|
||||
class: "tag-confirm-no",
|
||||
onclick: move |_| {
|
||||
confirm_delete.set(None);
|
||||
},
|
||||
"Cancel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
{
|
||||
let remove_id = tag_id.clone();
|
||||
rsx! {
|
||||
span {
|
||||
class: "tag-remove",
|
||||
onclick: move |_| {
|
||||
confirm_delete.set(Some(remove_id.clone()));
|
||||
},
|
||||
"\u{00d7}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !children.is_empty() {
|
||||
div {
|
||||
|
||||
|
||||
|
||||
class: "tag-children",
|
||||
style: "margin-left: 16px; margin-top: 4px;",
|
||||
for child in children.iter() {
|
||||
{
|
||||
let child_id = child.id.clone();
|
||||
let child_name = child.name.clone();
|
||||
let child_is_confirming = confirm_delete.read().as_deref()
|
||||
|
||||
== Some(child_id.as_str());
|
||||
rsx! {
|
||||
span { key: "{child_id}", class: "tag-badge",
|
||||
"{child_name}"
|
||||
if child_is_confirming {
|
||||
{
|
||||
let confirm_id = child_id.clone();
|
||||
rsx! {
|
||||
span { class: "tag-confirm-delete",
|
||||
" Are you sure? "
|
||||
span {
|
||||
class: "tag-confirm-yes",
|
||||
onclick: move |_| {
|
||||
on_delete.call(confirm_id.clone());
|
||||
confirm_delete.set(None);
|
||||
},
|
||||
"Confirm"
|
||||
}
|
||||
" "
|
||||
span {
|
||||
class: "tag-confirm-no",
|
||||
onclick: move |_| {
|
||||
confirm_delete.set(None);
|
||||
},
|
||||
"Cancel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
{
|
||||
let remove_id = child_id.clone();
|
||||
rsx! {
|
||||
span {
|
||||
class: "tag-remove",
|
||||
onclick: move |_| {
|
||||
confirm_delete.set(Some(remove_id.clone()));
|
||||
},
|
||||
"\u{00d7}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Orphan child tags (parent not found in current list)
|
||||
for tag in child_tags.iter() {
|
||||
{
|
||||
let parent_exists = root_tags
|
||||
.iter()
|
||||
|
||||
.any(|r| Some(r.id.as_str()) == tag.parent_id.as_deref())
|
||||
|| child_tags
|
||||
.iter()
|
||||
.any(|c| {
|
||||
c.id != tag.id && Some(c.id.as_str()) == tag.parent_id.as_deref()
|
||||
});
|
||||
if !parent_exists {
|
||||
let orphan_id = tag.id.clone();
|
||||
let orphan_name = tag.name.clone();
|
||||
let parent_label = tag.parent_id.clone().unwrap_or_default();
|
||||
let is_confirming = confirm_delete.read().as_deref()
|
||||
== Some(orphan_id.as_str());
|
||||
rsx! {
|
||||
span { key: "{orphan_id}", class: "tag-badge",
|
||||
"{orphan_name}"
|
||||
span { class: "text-muted text-sm", " (parent: {parent_label})" }
|
||||
if is_confirming {
|
||||
{
|
||||
let confirm_id = orphan_id.clone();
|
||||
rsx! {
|
||||
span { class: "tag-confirm-delete",
|
||||
" Are you sure? "
|
||||
span {
|
||||
class: "tag-confirm-yes",
|
||||
onclick: move |_| {
|
||||
on_delete.call(confirm_id.clone());
|
||||
confirm_delete.set(None);
|
||||
},
|
||||
"Confirm"
|
||||
}
|
||||
" "
|
||||
span {
|
||||
class: "tag-confirm-no",
|
||||
onclick: move |_| {
|
||||
confirm_delete.set(None);
|
||||
},
|
||||
"Cancel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
{
|
||||
let remove_id = orphan_id.clone();
|
||||
rsx! {
|
||||
span {
|
||||
class: "tag-remove",
|
||||
onclick: move |_| {
|
||||
confirm_delete.set(Some(remove_id.clone()));
|
||||
},
|
||||
"\u{00d7}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
rsx! {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
175
packages/pinakes-ui/src/components/tasks.rs
Normal file
175
packages/pinakes-ui/src/components/tasks.rs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::{
|
||||
Icon,
|
||||
icons::fa_solid_icons::{
|
||||
FaArrowsRotate,
|
||||
FaCalendar,
|
||||
FaCircleCheck,
|
||||
FaClock,
|
||||
FaPause,
|
||||
FaPlay,
|
||||
},
|
||||
};
|
||||
|
||||
use crate::client::ScheduledTaskResponse;
|
||||
|
||||
#[component]
|
||||
pub fn Tasks(
|
||||
tasks: Vec<ScheduledTaskResponse>,
|
||||
#[props(default)] error: Option<String>,
|
||||
on_refresh: EventHandler<()>,
|
||||
on_toggle: EventHandler<String>,
|
||||
on_run_now: EventHandler<String>,
|
||||
) -> Element {
|
||||
rsx! {
|
||||
div { class: "tasks-container",
|
||||
div { class: "card mb-16",
|
||||
div { class: "card-header",
|
||||
h3 { class: "card-title", "Scheduled Tasks" }
|
||||
button {
|
||||
class: "btn btn-sm btn-secondary",
|
||||
onclick: move |_| on_refresh.call(()),
|
||||
Icon { icon: FaArrowsRotate, width: 14, height: 14 }
|
||||
" Refresh"
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref err) = error {
|
||||
div { class: "alert alert-error mb-8",
|
||||
span { "{err}" }
|
||||
button {
|
||||
class: "btn btn-sm btn-secondary ml-8",
|
||||
onclick: move |_| on_refresh.call(()),
|
||||
"Retry"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if tasks.is_empty() {
|
||||
div { class: "empty-state",
|
||||
div { class: "empty-icon",
|
||||
Icon { icon: FaCalendar, width: 48, height: 48 }
|
||||
}
|
||||
p { "No scheduled tasks configured." }
|
||||
p { class: "text-muted",
|
||||
"Tasks will appear here once configured on the server."
|
||||
}
|
||||
}
|
||||
} else {
|
||||
div { class: "tasks-grid",
|
||||
for task in tasks.iter() {
|
||||
{
|
||||
let task_id_toggle = task.id.clone();
|
||||
let task_id_run = task.id.clone();
|
||||
let last_run = task.last_run.clone().unwrap_or_else(|| "Never".to_string());
|
||||
let next_run = task
|
||||
|
||||
// Header with status and actions
|
||||
|
||||
// Task info grid
|
||||
|
||||
// Actions
|
||||
.next_run
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Not scheduled".to_string());
|
||||
let last_status = task
|
||||
.last_status
|
||||
.clone()
|
||||
.unwrap_or_else(|| "No runs yet".to_string());
|
||||
let is_enabled = task.enabled;
|
||||
let task_name = task.name.clone();
|
||||
let schedule = task.schedule.clone();
|
||||
rsx! {
|
||||
div { class: if is_enabled { "task-card task-card-enabled" } else { "task-card task-card-disabled" },
|
||||
|
||||
|
||||
|
||||
div { class: "task-card-header",
|
||||
div { class: "task-header-left",
|
||||
div { class: "task-name", "{task_name}" }
|
||||
div { class: "task-schedule",
|
||||
span { class: "schedule-icon",
|
||||
Icon { icon: FaClock, width: 14, height: 14 }
|
||||
}
|
||||
"{schedule}"
|
||||
}
|
||||
}
|
||||
div { class: "task-status-badge",
|
||||
if is_enabled {
|
||||
span { class: "status-badge status-enabled",
|
||||
span { class: "status-dot" }
|
||||
"Active"
|
||||
}
|
||||
} else {
|
||||
span { class: "status-badge status-disabled",
|
||||
span { class: "status-dot" }
|
||||
"Disabled"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "task-info-grid",
|
||||
div { class: "task-info-item",
|
||||
div { class: "task-info-icon",
|
||||
Icon { icon: FaClock, width: 16, height: 16 }
|
||||
}
|
||||
div { class: "task-info-content",
|
||||
div { class: "task-info-label", "Last Run" }
|
||||
div { class: "task-info-value", "{last_run}" }
|
||||
}
|
||||
}
|
||||
div { class: "task-info-item",
|
||||
div { class: "task-info-icon",
|
||||
Icon { icon: FaClock, width: 16, height: 16 }
|
||||
}
|
||||
div { class: "task-info-content",
|
||||
div { class: "task-info-label", "Next Run" }
|
||||
div { class: "task-info-value", "{next_run}" }
|
||||
}
|
||||
}
|
||||
div { class: "task-info-item",
|
||||
div { class: "task-info-icon",
|
||||
Icon { icon: FaCircleCheck, width: 16, height: 16 }
|
||||
}
|
||||
div { class: "task-info-content",
|
||||
div { class: "task-info-label", "Last Status" }
|
||||
div { class: "task-info-value", "{last_status}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "task-card-actions",
|
||||
button {
|
||||
class: if is_enabled { "btn btn-sm btn-secondary" } else { "btn btn-sm btn-primary" },
|
||||
onclick: move |_| on_toggle.call(task_id_toggle.clone()),
|
||||
if is_enabled {
|
||||
span {
|
||||
Icon { icon: FaPause, width: 14, height: 14 }
|
||||
" Disable"
|
||||
}
|
||||
} else {
|
||||
span {
|
||||
Icon { icon: FaPlay, width: 14, height: 14 }
|
||||
" Enable"
|
||||
}
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn btn-sm btn-primary",
|
||||
onclick: move |_| on_run_now.call(task_id_run.clone()),
|
||||
disabled: !is_enabled,
|
||||
Icon { icon: FaPlay, width: 14, height: 14 }
|
||||
" Run Now"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
69
packages/pinakes-ui/src/components/utils.rs
Normal file
69
packages/pinakes-ui/src/components/utils.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
pub fn format_size(bytes: u64) -> String {
|
||||
if bytes < 1024 {
|
||||
format!("{bytes} B")
|
||||
} else if bytes < 1024 * 1024 {
|
||||
format!("{:.1} KB", bytes as f64 / 1024.0)
|
||||
} else if bytes < 1024 * 1024 * 1024 {
|
||||
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
|
||||
} else {
|
||||
format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn type_badge_class(media_type: &str) -> &'static str {
|
||||
match media_type {
|
||||
"mp3" | "flac" | "ogg" | "wav" => "type-audio",
|
||||
"mp4" | "mkv" | "avi" | "webm" => "type-video",
|
||||
"jpeg" | "jpg" | "png" | "gif" | "webp" | "avif" => "type-image",
|
||||
"pdf" | "epub" | "djvu" => "type-document",
|
||||
"md" | "markdown" => "type-text",
|
||||
_ => "type-other",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn type_icon(media_type: &str) -> &'static str {
|
||||
match media_type {
|
||||
"mp3" | "flac" | "ogg" | "wav" => "\u{266b}",
|
||||
"mp4" | "mkv" | "avi" | "webm" => "\u{25b6}",
|
||||
"jpeg" | "jpg" | "png" | "gif" | "webp" | "avif" => "\u{1f5bc}",
|
||||
"pdf" | "epub" | "djvu" => "\u{1f4c4}",
|
||||
"md" | "markdown" => "\u{270e}",
|
||||
_ => "\u{1f4c1}",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_timestamp(ts: &str) -> String {
|
||||
let trimmed = ts.replace('T', " ");
|
||||
if let Some(dot_pos) = trimmed.find('.') {
|
||||
trimmed[..dot_pos].to_string()
|
||||
} else if let Some(z_pos) = trimmed.find('Z') {
|
||||
trimmed[..z_pos].to_string()
|
||||
} else if trimmed.len() > 19 {
|
||||
trimmed[..19].to_string()
|
||||
} else {
|
||||
trimmed
|
||||
}
|
||||
}
|
||||
|
||||
pub fn media_category(media_type: &str) -> &'static str {
|
||||
match media_type {
|
||||
"mp3" | "flac" | "ogg" | "wav" => "audio",
|
||||
"mp4" | "mkv" | "avi" | "webm" => "video",
|
||||
"jpeg" | "jpg" | "png" | "gif" | "webp" | "avif" => "image",
|
||||
"pdf" | "epub" | "djvu" => "document",
|
||||
"md" | "markdown" => "text",
|
||||
_ => "other",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_duration(secs: f64) -> String {
|
||||
let total = secs as u64;
|
||||
let hours = total / 3600;
|
||||
let mins = (total % 3600) / 60;
|
||||
let s = total % 60;
|
||||
if hours > 0 {
|
||||
format!("{hours}:{mins:02}:{s:02}")
|
||||
} else {
|
||||
format!("{mins:02}:{s:02}")
|
||||
}
|
||||
}
|
||||
49
packages/pinakes-ui/src/main.rs
Normal file
49
packages/pinakes-ui/src/main.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
use clap::Parser;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
mod app;
|
||||
mod client;
|
||||
mod components;
|
||||
mod plugin_ui;
|
||||
mod state;
|
||||
mod styles;
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Pinakes desktop UI client
|
||||
#[derive(Parser)]
|
||||
#[command(name = "pinakes-ui", version, about)]
|
||||
struct Cli {
|
||||
/// Server URL to connect to
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
env = "PINAKES_SERVER_URL",
|
||||
default_value = "http://localhost:3000"
|
||||
)]
|
||||
server: String,
|
||||
|
||||
/// Set log level (trace, debug, info, warn, error)
|
||||
#[arg(long, default_value = "warn")]
|
||||
log_level: String,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
let env_filter = EnvFilter::try_new(&cli.log_level)
|
||||
.unwrap_or_else(|_| EnvFilter::new("warn"));
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(env_filter)
|
||||
.compact()
|
||||
.init();
|
||||
|
||||
// SAFETY: Called before any threads are spawned (single-threaded at this
|
||||
// point).
|
||||
unsafe { std::env::set_var("PINAKES_SERVER_URL", &cli.server) };
|
||||
|
||||
tracing::info!(server = %cli.server, "starting pinakes desktop UI");
|
||||
|
||||
launch(app::App);
|
||||
}
|
||||
329
packages/pinakes-ui/src/plugin_ui/actions.rs
Normal file
329
packages/pinakes-ui/src/plugin_ui/actions.rs
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
//! Action execution system for plugin UI pages
|
||||
//!
|
||||
//! This module provides the action execution system that handles
|
||||
//! user interactions with plugin UI elements.
|
||||
|
||||
use pinakes_plugin_api::{
|
||||
ActionDefinition,
|
||||
ActionRef,
|
||||
Expression,
|
||||
SpecialAction,
|
||||
UiElement,
|
||||
};
|
||||
use rustc_hash::FxHashMap;
|
||||
|
||||
use super::data::to_reqwest_method;
|
||||
use crate::client::ApiClient;
|
||||
|
||||
/// Result of an action execution
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ActionResult {
|
||||
/// Action completed successfully
|
||||
Success(serde_json::Value),
|
||||
/// Action failed
|
||||
Error(String),
|
||||
/// Navigation action
|
||||
Navigate(String),
|
||||
/// No meaningful result (e.g. 204 No Content)
|
||||
None,
|
||||
/// Re-fetch all data sources for the current page
|
||||
Refresh,
|
||||
/// Update a local state key; value is kept as an unevaluated expression so
|
||||
/// the renderer can resolve it against the full data context.
|
||||
UpdateState {
|
||||
key: String,
|
||||
value_expr: Expression,
|
||||
},
|
||||
/// Open a modal overlay containing the given element
|
||||
OpenModal(UiElement),
|
||||
/// Close the currently open modal overlay
|
||||
CloseModal,
|
||||
}
|
||||
|
||||
/// Execute an action defined in the UI schema
|
||||
///
|
||||
/// `page_actions` is the map of named actions from the current page definition.
|
||||
/// `ActionRef::Name` entries are resolved against this map.
|
||||
pub async fn execute_action(
|
||||
client: &ApiClient,
|
||||
action_ref: &ActionRef,
|
||||
page_actions: &FxHashMap<String, ActionDefinition>,
|
||||
form_data: Option<&serde_json::Value>,
|
||||
) -> Result<ActionResult, String> {
|
||||
match action_ref {
|
||||
ActionRef::Special(special) => {
|
||||
match special {
|
||||
SpecialAction::Refresh => Ok(ActionResult::Refresh),
|
||||
SpecialAction::Navigate { to } => {
|
||||
Ok(ActionResult::Navigate(to.clone()))
|
||||
},
|
||||
SpecialAction::Emit { event, payload } => {
|
||||
if let Err(e) = client.post_plugin_event(event, payload).await {
|
||||
tracing::warn!(event = %event, "plugin emit failed: {e}");
|
||||
}
|
||||
Ok(ActionResult::None)
|
||||
},
|
||||
SpecialAction::UpdateState { key, value } => {
|
||||
Ok(ActionResult::UpdateState {
|
||||
key: key.clone(),
|
||||
value_expr: value.clone(),
|
||||
})
|
||||
},
|
||||
SpecialAction::OpenModal { content } => {
|
||||
Ok(ActionResult::OpenModal(*content.clone()))
|
||||
},
|
||||
SpecialAction::CloseModal => Ok(ActionResult::CloseModal),
|
||||
}
|
||||
},
|
||||
ActionRef::Name(name) => {
|
||||
if let Some(action) = page_actions.get(name) {
|
||||
execute_inline_action(client, action, form_data).await
|
||||
} else {
|
||||
tracing::warn!(action = %name, "Unknown action - not defined in page actions");
|
||||
Ok(ActionResult::None)
|
||||
}
|
||||
},
|
||||
ActionRef::Inline(action) => {
|
||||
execute_inline_action(client, action, form_data).await
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute an inline action definition
|
||||
async fn execute_inline_action(
|
||||
client: &ApiClient,
|
||||
action: &ActionDefinition,
|
||||
form_data: Option<&serde_json::Value>,
|
||||
) -> Result<ActionResult, String> {
|
||||
// Merge action params with form data into query string for GET, body for
|
||||
// others
|
||||
let method = to_reqwest_method(&action.method);
|
||||
|
||||
let mut request = client.raw_request(method.clone(), &action.path);
|
||||
|
||||
// For GET, merge params into query string; for mutating methods, send as
|
||||
// JSON body
|
||||
if method == reqwest::Method::GET {
|
||||
let query_pairs: Vec<(String, String)> = action
|
||||
.params
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
let val = match v {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
(k.clone(), val)
|
||||
})
|
||||
.collect();
|
||||
if !query_pairs.is_empty() {
|
||||
request = request.query(&query_pairs);
|
||||
}
|
||||
} else {
|
||||
// Build body: merge action.params with form_data
|
||||
let mut merged: serde_json::Map<String, serde_json::Value> = action
|
||||
.params
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
|
||||
// action.params take precedence; form_data only fills in missing keys
|
||||
if let Some(obj) = form_data.and_then(serde_json::Value::as_object) {
|
||||
for (k, v) in obj {
|
||||
merged.entry(k.clone()).or_insert_with(|| v.clone());
|
||||
}
|
||||
}
|
||||
if !merged.is_empty() {
|
||||
request = request.json(&merged);
|
||||
}
|
||||
}
|
||||
|
||||
let response = request.send().await.map_err(|e| e.to_string())?;
|
||||
let status = response.status();
|
||||
|
||||
if !status.is_success() {
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
return Ok(ActionResult::Error(format!(
|
||||
"Action failed: {} - {}",
|
||||
status.as_u16(),
|
||||
error_text
|
||||
)));
|
||||
}
|
||||
|
||||
if status.as_u16() == 204 {
|
||||
// Navigate on success if configured
|
||||
if let Some(route) = &action.navigate_to {
|
||||
return Ok(ActionResult::Navigate(route.clone()));
|
||||
}
|
||||
return Ok(ActionResult::None);
|
||||
}
|
||||
|
||||
let value: serde_json::Value =
|
||||
response.json().await.unwrap_or(serde_json::Value::Null);
|
||||
|
||||
if let Some(route) = &action.navigate_to {
|
||||
return Ok(ActionResult::Navigate(route.clone()));
|
||||
}
|
||||
|
||||
Ok(ActionResult::Success(value))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pinakes_plugin_api::ActionRef;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_action_result_variants() {
|
||||
let _ = ActionResult::None;
|
||||
let _ = ActionResult::Success(serde_json::json!({"ok": true}));
|
||||
let _ = ActionResult::Error("error".to_string());
|
||||
let _ = ActionResult::Navigate("/page".to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_result_clone() {
|
||||
let original = ActionResult::Success(serde_json::json!({"key": "value"}));
|
||||
let cloned = original.clone();
|
||||
if let (ActionResult::Success(a), ActionResult::Success(b)) =
|
||||
(original, cloned)
|
||||
{
|
||||
assert_eq!(a, b);
|
||||
} else {
|
||||
panic!("clone produced wrong variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_result_error_clone() {
|
||||
let original = ActionResult::Error("something went wrong".to_string());
|
||||
let cloned = original.clone();
|
||||
if let (ActionResult::Error(a), ActionResult::Error(b)) = (original, cloned)
|
||||
{
|
||||
assert_eq!(a, b);
|
||||
} else {
|
||||
panic!("clone produced wrong variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_result_navigate_clone() {
|
||||
let original = ActionResult::Navigate("/dashboard".to_string());
|
||||
let cloned = original.clone();
|
||||
if let (ActionResult::Navigate(a), ActionResult::Navigate(b)) =
|
||||
(original, cloned)
|
||||
{
|
||||
assert_eq!(a, b);
|
||||
} else {
|
||||
panic!("clone produced wrong variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_named_action_unknown_returns_none() {
|
||||
let client = crate::client::ApiClient::default();
|
||||
let action_ref = ActionRef::Name("my-action".to_string());
|
||||
let result =
|
||||
execute_action(&client, &action_ref, &FxHashMap::default(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(result, ActionResult::None));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_named_action_resolves_from_map() {
|
||||
use pinakes_plugin_api::ActionDefinition;
|
||||
|
||||
let client = crate::client::ApiClient::default();
|
||||
let mut page_actions = FxHashMap::default();
|
||||
page_actions.insert("do-thing".to_string(), ActionDefinition {
|
||||
method: pinakes_plugin_api::HttpMethod::Post,
|
||||
path: "/api/v1/nonexistent-endpoint".to_string(),
|
||||
params: FxHashMap::default(),
|
||||
success_message: None,
|
||||
error_message: None,
|
||||
navigate_to: None,
|
||||
});
|
||||
|
||||
let action_ref = ActionRef::Name("do-thing".to_string());
|
||||
|
||||
// The action is resolved; will error because there's no server, but
|
||||
// ActionResult::None would mean it was NOT resolved
|
||||
let result =
|
||||
execute_action(&client, &action_ref, &page_actions, None).await;
|
||||
// It should NOT be Ok(None); it should be either Error or a network error
|
||||
match result {
|
||||
Ok(ActionResult::None) => {
|
||||
panic!("Named action was not resolved from page_actions")
|
||||
},
|
||||
_ => {}, /* Any other result (error, network failure) means it was
|
||||
* resolved */
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_special_action_refresh() {
|
||||
use pinakes_plugin_api::SpecialAction;
|
||||
|
||||
let client = crate::client::ApiClient::default();
|
||||
let action_ref = ActionRef::Special(SpecialAction::Refresh);
|
||||
let result =
|
||||
execute_action(&client, &action_ref, &FxHashMap::default(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(result, ActionResult::Refresh));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_special_action_navigate() {
|
||||
use pinakes_plugin_api::SpecialAction;
|
||||
|
||||
let client = crate::client::ApiClient::default();
|
||||
let action_ref = ActionRef::Special(SpecialAction::Navigate {
|
||||
to: "/dashboard".to_string(),
|
||||
});
|
||||
let result =
|
||||
execute_action(&client, &action_ref, &FxHashMap::default(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(result, ActionResult::Navigate(ref p) if p == "/dashboard")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_special_action_update_state_preserves_expression() {
|
||||
use pinakes_plugin_api::{Expression, SpecialAction};
|
||||
|
||||
let client = crate::client::ApiClient::default();
|
||||
let expr = Expression::Literal(serde_json::json!(42));
|
||||
let action_ref = ActionRef::Special(SpecialAction::UpdateState {
|
||||
key: "count".to_string(),
|
||||
value: expr.clone(),
|
||||
});
|
||||
let result =
|
||||
execute_action(&client, &action_ref, &FxHashMap::default(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
match result {
|
||||
ActionResult::UpdateState { key, value_expr } => {
|
||||
assert_eq!(key, "count");
|
||||
assert_eq!(value_expr, expr);
|
||||
},
|
||||
other => panic!("expected UpdateState, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_special_action_close_modal() {
|
||||
use pinakes_plugin_api::SpecialAction;
|
||||
|
||||
let client = crate::client::ApiClient::default();
|
||||
let action_ref = ActionRef::Special(SpecialAction::CloseModal);
|
||||
let result =
|
||||
execute_action(&client, &action_ref, &FxHashMap::default(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(result, ActionResult::CloseModal));
|
||||
}
|
||||
}
|
||||
766
packages/pinakes-ui/src/plugin_ui/data.rs
Normal file
766
packages/pinakes-ui/src/plugin_ui/data.rs
Normal file
|
|
@ -0,0 +1,766 @@
|
|||
//! Data fetching system for plugin UI pages
|
||||
//!
|
||||
//! Provides data fetching and caching for plugin data sources.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_core::Task;
|
||||
use pinakes_plugin_api::{DataSource, Expression, HttpMethod};
|
||||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
|
||||
use super::expr::{evaluate_expression, value_to_display_string};
|
||||
use crate::client::ApiClient;
|
||||
|
||||
/// Cached data for a plugin page
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct PluginPageData {
|
||||
data: FxHashMap<String, serde_json::Value>,
|
||||
loading: FxHashSet<String>,
|
||||
errors: FxHashMap<String, String>,
|
||||
}
|
||||
|
||||
impl PluginPageData {
|
||||
/// Get data for a specific source
|
||||
#[must_use]
|
||||
pub fn get(&self, source: &str) -> Option<&serde_json::Value> {
|
||||
self.data.get(source)
|
||||
}
|
||||
|
||||
/// Check if a source is currently loading
|
||||
#[must_use]
|
||||
pub fn is_loading(&self, source: &str) -> bool {
|
||||
self.loading.contains(source)
|
||||
}
|
||||
|
||||
/// Get error for a specific source
|
||||
#[must_use]
|
||||
pub fn error(&self, source: &str) -> Option<&str> {
|
||||
self.errors.get(source).map(String::as_str)
|
||||
}
|
||||
|
||||
/// Check if there is data for a specific source
|
||||
#[must_use]
|
||||
pub fn has_data(&self, source: &str) -> bool {
|
||||
self.data.contains_key(source)
|
||||
}
|
||||
|
||||
/// Set data for a source
|
||||
pub fn set_data(&mut self, source: String, value: serde_json::Value) {
|
||||
self.data.insert(source, value);
|
||||
}
|
||||
|
||||
/// Set loading state for a source
|
||||
pub fn set_loading(&mut self, source: &str, loading: bool) {
|
||||
if loading {
|
||||
self.loading.insert(source.to_string());
|
||||
self.errors.remove(source);
|
||||
} else {
|
||||
self.loading.remove(source);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set error for a source
|
||||
pub fn set_error(&mut self, source: String, error: String) {
|
||||
self.errors.insert(source, error);
|
||||
}
|
||||
|
||||
/// Convert all resolved data to a single JSON object for expression
|
||||
/// evaluation
|
||||
#[must_use]
|
||||
pub fn as_json(&self) -> serde_json::Value {
|
||||
serde_json::Value::Object(
|
||||
self
|
||||
.data
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Clear all data
|
||||
pub fn clear(&mut self) {
|
||||
self.data.clear();
|
||||
self.loading.clear();
|
||||
self.errors.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a plugin `HttpMethod` to a `reqwest::Method`.
|
||||
pub(super) const fn to_reqwest_method(method: &HttpMethod) -> reqwest::Method {
|
||||
match method {
|
||||
HttpMethod::Get => reqwest::Method::GET,
|
||||
HttpMethod::Post => reqwest::Method::POST,
|
||||
HttpMethod::Put => reqwest::Method::PUT,
|
||||
HttpMethod::Patch => reqwest::Method::PATCH,
|
||||
HttpMethod::Delete => reqwest::Method::DELETE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch data from an endpoint, evaluating any params expressions against
|
||||
/// the given context.
|
||||
async fn fetch_endpoint(
|
||||
client: &ApiClient,
|
||||
path: &str,
|
||||
method: HttpMethod,
|
||||
params: &FxHashMap<String, Expression>,
|
||||
ctx: &serde_json::Value,
|
||||
allowed_endpoints: &[String],
|
||||
) -> Result<serde_json::Value, String> {
|
||||
if !allowed_endpoints.is_empty()
|
||||
&& !allowed_endpoints.iter().any(|ep| path == ep.as_str())
|
||||
{
|
||||
return Err(format!(
|
||||
"Endpoint '{path}' is not in plugin's declared required_endpoints"
|
||||
));
|
||||
}
|
||||
|
||||
let reqwest_method = to_reqwest_method(&method);
|
||||
|
||||
let mut request = client.raw_request(reqwest_method.clone(), path);
|
||||
|
||||
if !params.is_empty() {
|
||||
if reqwest_method == reqwest::Method::GET {
|
||||
// Evaluate each param expression and add as query string
|
||||
let query_pairs: Vec<(String, String)> = params
|
||||
.iter()
|
||||
.map(|(k, expr)| {
|
||||
let v = evaluate_expression(expr, ctx);
|
||||
(k.clone(), value_to_display_string(&v))
|
||||
})
|
||||
.collect();
|
||||
request = request.query(&query_pairs);
|
||||
} else {
|
||||
// Evaluate params and send as JSON body
|
||||
let body: serde_json::Map<String, serde_json::Value> = params
|
||||
.iter()
|
||||
.map(|(k, expr)| (k.clone(), evaluate_expression(expr, ctx)))
|
||||
.collect();
|
||||
request = request.json(&body);
|
||||
}
|
||||
}
|
||||
|
||||
// Send request and parse response
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Request failed: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("HTTP {status}: {body}"));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse JSON: {e}"))
|
||||
}
|
||||
|
||||
/// Fetch all data sources for a page
|
||||
///
|
||||
/// Endpoint sources are deduplicated by `(path, method, params)`: if multiple
|
||||
/// sources share the same triplet, a single HTTP request is made and the raw
|
||||
/// response is shared, with each source's own `transform` applied
|
||||
/// independently. All unique Endpoint and Static sources are fetched
|
||||
/// concurrently. Transform sources are applied after, in iteration order,
|
||||
/// against the full result set.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if any data source fails to fetch
|
||||
pub async fn fetch_page_data(
|
||||
client: &ApiClient,
|
||||
data_sources: &FxHashMap<String, DataSource>,
|
||||
allowed_endpoints: &[String],
|
||||
) -> Result<FxHashMap<String, serde_json::Value>, String> {
|
||||
// Group non-Transform sources into dedup groups.
|
||||
//
|
||||
// For Endpoint sources, two entries are in the same group when they share
|
||||
// the same (path, method, params) - i.e., they would produce an identical
|
||||
// HTTP request. The per-source `transform` expression is kept separate so
|
||||
// each name can apply its own transform to the shared raw response.
|
||||
//
|
||||
// Static sources never share an HTTP request so each becomes its own group.
|
||||
//
|
||||
// Each group is: (names_and_transforms, representative_source)
|
||||
// where names_and_transforms is Vec<(name, Option<Expression>)> for Endpoint,
|
||||
// or Vec<(name, ())> for Static (transform is baked in).
|
||||
struct Group {
|
||||
// (source name, per-name transform expression for Endpoint sources)
|
||||
members: Vec<(String, Option<Expression>)>,
|
||||
// The representative source used to fire the request (transform ignored
|
||||
// for Endpoint - we apply per-member transforms after fetching)
|
||||
source: DataSource,
|
||||
}
|
||||
|
||||
let mut groups: Vec<Group> = Vec::new();
|
||||
|
||||
for (name, source) in data_sources {
|
||||
if matches!(source, DataSource::Transform { .. }) {
|
||||
continue;
|
||||
}
|
||||
|
||||
match source {
|
||||
DataSource::Endpoint {
|
||||
path,
|
||||
method,
|
||||
params,
|
||||
transform,
|
||||
poll_interval,
|
||||
} => {
|
||||
// Find an existing group with the same (path, method, params).
|
||||
let existing = groups.iter_mut().find(|g| {
|
||||
matches!(
|
||||
&g.source,
|
||||
DataSource::Endpoint {
|
||||
path: ep,
|
||||
method: em,
|
||||
params: epa,
|
||||
..
|
||||
} if ep == path && em == method && epa == params
|
||||
)
|
||||
});
|
||||
|
||||
if let Some(group) = existing {
|
||||
group.members.push((name.clone(), transform.clone()));
|
||||
} else {
|
||||
groups.push(Group {
|
||||
members: vec![(name.clone(), transform.clone())],
|
||||
source: DataSource::Endpoint {
|
||||
path: path.clone(),
|
||||
method: method.clone(),
|
||||
params: params.clone(),
|
||||
poll_interval: *poll_interval,
|
||||
transform: None,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
DataSource::Static { .. } => {
|
||||
// Static sources are trivially unique per name; no dedup needed.
|
||||
groups.push(Group {
|
||||
members: vec![(name.clone(), None)],
|
||||
source: source.clone(),
|
||||
});
|
||||
},
|
||||
DataSource::Transform { .. } => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
// Fire one future per group concurrently.
|
||||
let futs: Vec<_> = groups
|
||||
.into_iter()
|
||||
.map(|group| {
|
||||
let client = client.clone();
|
||||
let allowed = allowed_endpoints.to_vec();
|
||||
async move {
|
||||
// Fetch the raw value for this group.
|
||||
let raw = match &group.source {
|
||||
DataSource::Endpoint {
|
||||
path,
|
||||
method,
|
||||
params,
|
||||
..
|
||||
} => {
|
||||
let empty_ctx = serde_json::json!({});
|
||||
fetch_endpoint(
|
||||
&client,
|
||||
path,
|
||||
method.clone(),
|
||||
params,
|
||||
&empty_ctx,
|
||||
&allowed,
|
||||
)
|
||||
.await?
|
||||
},
|
||||
DataSource::Static { value } => value.clone(),
|
||||
DataSource::Transform { .. } => unreachable!(),
|
||||
};
|
||||
|
||||
// Apply per-member transforms and collect (name, value) pairs.
|
||||
let pairs: Vec<(String, serde_json::Value)> = group
|
||||
.members
|
||||
.into_iter()
|
||||
.map(|(name, transform)| {
|
||||
let value = if let Some(expr) = &transform {
|
||||
evaluate_expression(expr, &raw)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
(name, value)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok::<_, String>(pairs)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut results: FxHashMap<String, serde_json::Value> = FxHashMap::default();
|
||||
for group_result in futures::future::join_all(futs).await {
|
||||
for (name, value) in group_result? {
|
||||
results.insert(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Process Transform sources in dependency order. HashMap iteration order is
|
||||
// non-deterministic, so a Transform referencing another Transform could see
|
||||
// null if the upstream was not yet resolved. The pending loop below defers
|
||||
// any Transform whose upstream is not yet in results, making progress on
|
||||
// each pass until all are resolved. UiPage::validate guarantees no cycles,
|
||||
// so the loop always terminates.
|
||||
let mut pending: Vec<(&String, &String, &Expression)> = data_sources
|
||||
.iter()
|
||||
.filter_map(|(name, source)| {
|
||||
match source {
|
||||
DataSource::Transform {
|
||||
source_name,
|
||||
expression,
|
||||
} => Some((name, source_name, expression)),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
while !pending.is_empty() {
|
||||
let prev_len = pending.len();
|
||||
let mut i = 0;
|
||||
while i < pending.len() {
|
||||
let (name, source_name, expression) = pending[i];
|
||||
if results.contains_key(source_name.as_str()) {
|
||||
let ctx = serde_json::Value::Object(
|
||||
results
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect(),
|
||||
);
|
||||
results.insert(name.clone(), evaluate_expression(expression, &ctx));
|
||||
pending.swap_remove(i);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if pending.len() == prev_len {
|
||||
// No progress: upstream source is missing (should be caught by
|
||||
// UiPage::validate, but handled defensively here).
|
||||
tracing::warn!(
|
||||
"plugin transform dependency unresolvable; processing remaining in \
|
||||
iteration order"
|
||||
);
|
||||
for (name, _, expression) in pending {
|
||||
let ctx = serde_json::Value::Object(
|
||||
results
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect(),
|
||||
);
|
||||
results.insert(name.clone(), evaluate_expression(expression, &ctx));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Hook to fetch and cache plugin page data
|
||||
///
|
||||
/// Returns a signal containing the data state. If any data source has a
|
||||
/// non-zero `poll_interval`, a background loop re-fetches automatically at the
|
||||
/// minimum interval. The `refresh` counter can be incremented to trigger an
|
||||
/// immediate re-fetch outside of the polling interval.
|
||||
pub fn use_plugin_data(
|
||||
client: Signal<ApiClient>,
|
||||
data_sources: FxHashMap<String, DataSource>,
|
||||
refresh: Signal<u32>,
|
||||
allowed_endpoints: Vec<String>,
|
||||
) -> Signal<PluginPageData> {
|
||||
let mut data = use_signal(PluginPageData::default);
|
||||
let mut poll_task: Signal<Option<Task>> = use_signal(|| None);
|
||||
|
||||
use_effect(move || {
|
||||
// Subscribe to the refresh counter; incrementing it triggers a re-run.
|
||||
let _rev = refresh.read();
|
||||
let sources = data_sources.clone();
|
||||
let allowed = allowed_endpoints.clone();
|
||||
|
||||
// Cancel the previous polling task before spawning a new one. Use
|
||||
// write() rather than read() so the effect does not subscribe to
|
||||
// poll_task and trigger an infinite re-run loop.
|
||||
if let Some(t) = poll_task.write().take() {
|
||||
t.cancel();
|
||||
}
|
||||
|
||||
// Determine minimum poll interval (0 = no polling)
|
||||
let min_poll_secs: u64 = sources
|
||||
.values()
|
||||
.filter_map(|s| {
|
||||
if let DataSource::Endpoint { poll_interval, .. } = s {
|
||||
if *poll_interval > 0 {
|
||||
Some(*poll_interval)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.min()
|
||||
.unwrap_or(0);
|
||||
|
||||
let handle = spawn(async move {
|
||||
// Clear previous data
|
||||
data.write().clear();
|
||||
|
||||
// Mark all sources as loading
|
||||
for name in sources.keys() {
|
||||
data.write().set_loading(name, true);
|
||||
}
|
||||
|
||||
// Initial fetch; clone to release the signal read borrow before await.
|
||||
let cl = client.peek().clone();
|
||||
match fetch_page_data(&cl, &sources, &allowed).await {
|
||||
Ok(results) => {
|
||||
for (name, value) in results {
|
||||
data.write().set_loading(&name, false);
|
||||
data.write().set_data(name, value);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
for name in sources.keys() {
|
||||
data.write().set_loading(name, false);
|
||||
data.write().set_error(name.clone(), e.clone());
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Polling loop; only runs if at least one source has poll_interval > 0
|
||||
if min_poll_secs > 0 {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(min_poll_secs)).await;
|
||||
|
||||
let cl = client.peek().clone();
|
||||
match fetch_page_data(&cl, &sources, &allowed).await {
|
||||
Ok(results) => {
|
||||
for (name, value) in results {
|
||||
// Only write if data is new or has changed to avoid spurious
|
||||
// signal updates that would force a re-render
|
||||
let changed = !data.read().has_data(&name)
|
||||
|| data.read().get(&name) != Some(&value);
|
||||
if changed {
|
||||
data.write().set_data(name, value);
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("Poll fetch failed: {e}");
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
*poll_task.write() = Some(handle);
|
||||
});
|
||||
|
||||
data
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_plugin_page_data() {
|
||||
let mut data = PluginPageData::default();
|
||||
|
||||
// Test empty state
|
||||
assert!(!data.has_data("test"));
|
||||
assert!(!data.is_loading("test"));
|
||||
assert!(data.error("test").is_none());
|
||||
|
||||
// Test setting data
|
||||
data.set_data("test".to_string(), serde_json::json!({"key": "value"}));
|
||||
assert!(data.has_data("test"));
|
||||
assert_eq!(data.get("test"), Some(&serde_json::json!({"key": "value"})));
|
||||
|
||||
// Test loading state
|
||||
data.set_loading("loading", true);
|
||||
assert!(data.is_loading("loading"));
|
||||
data.set_loading("loading", false);
|
||||
assert!(!data.is_loading("loading"));
|
||||
|
||||
// Test error state
|
||||
data.set_error("error".to_string(), "oops".to_string());
|
||||
assert_eq!(data.error("error"), Some("oops"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_as_json_empty() {
|
||||
let data = PluginPageData::default();
|
||||
assert_eq!(data.as_json(), serde_json::json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_as_json_with_data() {
|
||||
let mut data = PluginPageData::default();
|
||||
data.set_data("users".to_string(), serde_json::json!([{"id": 1}]));
|
||||
data.set_data("count".to_string(), serde_json::json!(42));
|
||||
let json = data.as_json();
|
||||
assert_eq!(json["users"], serde_json::json!([{"id": 1}]));
|
||||
assert_eq!(json["count"], serde_json::json!(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_loading_true_clears_error() {
|
||||
let mut data = PluginPageData::default();
|
||||
data.set_error("src".to_string(), "oops".to_string());
|
||||
assert!(data.error("src").is_some());
|
||||
data.set_loading("src", true);
|
||||
assert!(data.error("src").is_none());
|
||||
assert!(data.is_loading("src"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_loading_false_removes_flag() {
|
||||
let mut data = PluginPageData::default();
|
||||
data.set_loading("src", true);
|
||||
assert!(data.is_loading("src"));
|
||||
data.set_loading("src", false);
|
||||
assert!(!data.is_loading("src"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_resets_all_state() {
|
||||
let mut data = PluginPageData::default();
|
||||
data.set_data("x".to_string(), serde_json::json!(1));
|
||||
data.set_loading("x", true);
|
||||
data.set_error("y".to_string(), "err".to_string());
|
||||
data.clear();
|
||||
assert!(!data.has_data("x"));
|
||||
assert!(!data.is_loading("x"));
|
||||
assert!(data.error("y").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_partial_eq() {
|
||||
let mut a = PluginPageData::default();
|
||||
let mut b = PluginPageData::default();
|
||||
assert_eq!(a, b);
|
||||
a.set_data("k".to_string(), serde_json::json!(1));
|
||||
assert_ne!(a, b);
|
||||
b.set_data("k".to_string(), serde_json::json!(1));
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_page_data_static_only() {
|
||||
use pinakes_plugin_api::DataSource;
|
||||
|
||||
use crate::client::ApiClient;
|
||||
|
||||
let client = ApiClient::default();
|
||||
let mut sources = FxHashMap::default();
|
||||
sources.insert("nums".to_string(), DataSource::Static {
|
||||
value: serde_json::json!([1, 2, 3]),
|
||||
});
|
||||
sources.insert("flag".to_string(), DataSource::Static {
|
||||
value: serde_json::json!(true),
|
||||
});
|
||||
|
||||
let results = super::fetch_page_data(&client, &sources, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(results["nums"], serde_json::json!([1, 2, 3]));
|
||||
assert_eq!(results["flag"], serde_json::json!(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_page_data_transform_evaluates_expression() {
|
||||
use pinakes_plugin_api::{DataSource, Expression};
|
||||
|
||||
use crate::client::ApiClient;
|
||||
|
||||
let client = ApiClient::default();
|
||||
let mut sources = FxHashMap::default();
|
||||
// The Transform expression accesses "raw" from the context
|
||||
sources.insert("derived".to_string(), DataSource::Transform {
|
||||
source_name: "raw".to_string(),
|
||||
expression: Expression::Path("raw".to_string()),
|
||||
});
|
||||
sources.insert("raw".to_string(), DataSource::Static {
|
||||
value: serde_json::json!({"ok": true}),
|
||||
});
|
||||
|
||||
let results = super::fetch_page_data(&client, &sources, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(results["raw"], serde_json::json!({"ok": true}));
|
||||
// derived should return the value of "raw" from context
|
||||
assert_eq!(results["derived"], serde_json::json!({"ok": true}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_page_data_transform_literal_expression() {
|
||||
use pinakes_plugin_api::{DataSource, Expression};
|
||||
|
||||
use crate::client::ApiClient;
|
||||
|
||||
let client = ApiClient::default();
|
||||
let mut sources = FxHashMap::default();
|
||||
sources.insert("raw".to_string(), DataSource::Static {
|
||||
value: serde_json::json!(42),
|
||||
});
|
||||
sources.insert("derived".to_string(), DataSource::Transform {
|
||||
source_name: "raw".to_string(),
|
||||
expression: Expression::Literal(serde_json::json!("constant")),
|
||||
});
|
||||
|
||||
let results = super::fetch_page_data(&client, &sources, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
// A Literal expression returns the literal value, not the source data
|
||||
assert_eq!(results["derived"], serde_json::json!("constant"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_page_data_deduplicates_identical_endpoints() {
|
||||
use pinakes_plugin_api::DataSource;
|
||||
|
||||
use crate::client::ApiClient;
|
||||
|
||||
let client = ApiClient::default();
|
||||
let mut sources = FxHashMap::default();
|
||||
// Two Static sources with the same payload; dedup is for Endpoint sources,
|
||||
// but both names must appear in the output regardless.
|
||||
sources.insert("a".to_string(), DataSource::Static {
|
||||
value: serde_json::json!(1),
|
||||
});
|
||||
sources.insert("b".to_string(), DataSource::Static {
|
||||
value: serde_json::json!(1),
|
||||
});
|
||||
let results = super::fetch_page_data(&client, &sources, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(results["a"], serde_json::json!(1));
|
||||
assert_eq!(results["b"], serde_json::json!(1));
|
||||
assert_eq!(results.len(), 2);
|
||||
}
|
||||
|
||||
// Verifies that endpoint sources with identical (path, method, params) are
|
||||
// deduplicated correctly. Because there is no real server, the allowlist
|
||||
// rejection fires before any network call; both names seeing the same error
|
||||
// proves they were grouped and that the single rejection propagated to all.
|
||||
#[tokio::test]
|
||||
async fn test_dedup_groups_endpoint_sources_with_same_key() {
|
||||
use pinakes_plugin_api::{DataSource, Expression, HttpMethod};
|
||||
|
||||
use crate::client::ApiClient;
|
||||
|
||||
let client = ApiClient::default();
|
||||
let mut sources = FxHashMap::default();
|
||||
// Two endpoints with identical (path, method, params=empty) but different
|
||||
// transforms. Both should produce the same error when the path is blocked.
|
||||
sources.insert("x".to_string(), DataSource::Endpoint {
|
||||
path: "/api/v1/media".to_string(),
|
||||
method: HttpMethod::Get,
|
||||
params: Default::default(),
|
||||
poll_interval: 0,
|
||||
transform: Some(Expression::Literal(serde_json::json!("from_x"))),
|
||||
});
|
||||
sources.insert("y".to_string(), DataSource::Endpoint {
|
||||
path: "/api/v1/media".to_string(),
|
||||
method: HttpMethod::Get,
|
||||
params: Default::default(),
|
||||
poll_interval: 0,
|
||||
transform: Some(Expression::Literal(serde_json::json!("from_y"))),
|
||||
});
|
||||
|
||||
// Both sources point to the same blocked endpoint; expect an error.
|
||||
let allowed = vec!["/api/v1/tags".to_string()];
|
||||
let result = super::fetch_page_data(&client, &sources, &allowed).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"fetch_page_data must return Err for blocked deduplicated endpoints"
|
||||
);
|
||||
let msg = result.unwrap_err();
|
||||
assert!(
|
||||
msg.contains("not in plugin's declared required_endpoints"),
|
||||
"unexpected error: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// Verifies the transform fan-out behavior: each member of a dedup group
|
||||
// applies its own transform to the shared raw value independently. This
|
||||
// mirrors what Endpoint dedup does after a single shared HTTP request.
|
||||
//
|
||||
// Testing Endpoint dedup with real per-member transforms requires a mock HTTP
|
||||
// server and belongs in an integration test.
|
||||
#[tokio::test]
|
||||
async fn test_dedup_transform_applied_per_source() {
|
||||
use pinakes_plugin_api::{DataSource, Expression};
|
||||
|
||||
use crate::client::ApiClient;
|
||||
|
||||
let client = ApiClient::default();
|
||||
let mut sources = FxHashMap::default();
|
||||
sources.insert("raw_data".to_string(), DataSource::Static {
|
||||
value: serde_json::json!({"count": 42, "name": "test"}),
|
||||
});
|
||||
// Two Transform sources referencing "raw_data" with different expressions;
|
||||
// each must produce its own independently derived value.
|
||||
sources.insert("derived_count".to_string(), DataSource::Transform {
|
||||
source_name: "raw_data".to_string(),
|
||||
expression: Expression::Path("raw_data.count".to_string()),
|
||||
});
|
||||
sources.insert("derived_name".to_string(), DataSource::Transform {
|
||||
source_name: "raw_data".to_string(),
|
||||
expression: Expression::Path("raw_data.name".to_string()),
|
||||
});
|
||||
|
||||
let results = super::fetch_page_data(&client, &sources, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
results["raw_data"],
|
||||
serde_json::json!({"count": 42, "name": "test"})
|
||||
);
|
||||
assert_eq!(results["derived_count"], serde_json::json!(42));
|
||||
assert_eq!(results["derived_name"], serde_json::json!("test"));
|
||||
assert_eq!(results.len(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_endpoint_blocked_when_not_in_allowlist() {
|
||||
use pinakes_plugin_api::{DataSource, HttpMethod};
|
||||
|
||||
use crate::client::ApiClient;
|
||||
|
||||
let client = ApiClient::default();
|
||||
let mut sources = FxHashMap::default();
|
||||
sources.insert("items".to_string(), DataSource::Endpoint {
|
||||
path: "/api/v1/media".to_string(),
|
||||
method: HttpMethod::Get,
|
||||
params: Default::default(),
|
||||
poll_interval: 0,
|
||||
transform: None,
|
||||
});
|
||||
|
||||
// Provide a non-empty allowlist that does NOT include the endpoint path.
|
||||
let allowed = vec!["/api/v1/tags".to_string()];
|
||||
let result = super::fetch_page_data(&client, &sources, &allowed).await;
|
||||
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"fetch_page_data must return Err when endpoint is not in \
|
||||
allowed_endpoints"
|
||||
);
|
||||
let msg = result.unwrap_err();
|
||||
assert!(
|
||||
msg.contains("not in plugin's declared required_endpoints"),
|
||||
"error must explain that the endpoint is not declared, got: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
1015
packages/pinakes-ui/src/plugin_ui/expr.rs
Normal file
1015
packages/pinakes-ui/src/plugin_ui/expr.rs
Normal file
File diff suppressed because it is too large
Load diff
41
packages/pinakes-ui/src/plugin_ui/mod.rs
Normal file
41
packages/pinakes-ui/src/plugin_ui/mod.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
//! Plugin UI system for Pinakes Desktop/Web UI
|
||||
//!
|
||||
//! This module provides a declarative UI plugin system that allows plugins
|
||||
//! to define custom pages without needing to compile against the UI crate.
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! - [`registry`] - Plugin page registry and context provider
|
||||
//! - [`data`] - Data fetching and caching for plugin data sources
|
||||
//! - [`actions`] - Action execution system for plugin interactions
|
||||
//! - [`renderer`] - Schema-to-Dioxus rendering components
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! Plugins define their UI as JSON schemas in the plugin manifest:
|
||||
//!
|
||||
//! ```toml
|
||||
//! [[ui.pages]]
|
||||
//! id = "my-plugin-page"
|
||||
//! title = "My Plugin"
|
||||
//! route = "/plugins/my-plugin"
|
||||
//! icon = "cog"
|
||||
//!
|
||||
//! [ui.pages.layout]
|
||||
//! type = "container"
|
||||
//! # ... more layout definition
|
||||
//! ```
|
||||
//!
|
||||
//! The UI schema is fetched from the server and rendered using the
|
||||
//! components in this module.
|
||||
|
||||
pub mod actions;
|
||||
pub mod data;
|
||||
pub mod expr;
|
||||
pub mod registry;
|
||||
pub mod renderer;
|
||||
pub mod widget;
|
||||
|
||||
pub use registry::PluginRegistry;
|
||||
pub use renderer::PluginViewRenderer;
|
||||
pub use widget::{WidgetContainer, WidgetLocation};
|
||||
565
packages/pinakes-ui/src/plugin_ui/registry.rs
Normal file
565
packages/pinakes-ui/src/plugin_ui/registry.rs
Normal file
|
|
@ -0,0 +1,565 @@
|
|||
//! Plugin UI Registry
|
||||
//!
|
||||
//! Manages plugin-provided UI pages and provides hooks for accessing
|
||||
//! page definitions at runtime.
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! // Initialize registry with API client
|
||||
//! let registry = PluginRegistry::new(api_client);
|
||||
//! registry.refresh().await?;
|
||||
//!
|
||||
//! // Access pages
|
||||
//! if let Some(page) = registry.get_page("my-plugin", "demo") {
|
||||
//! println!("Page: {}", page.page.title);
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use dioxus::prelude::*;
|
||||
use pinakes_plugin_api::{UiPage, UiWidget};
|
||||
use rustc_hash::FxHashMap;
|
||||
|
||||
use crate::client::ApiClient;
|
||||
|
||||
/// Information about a plugin-provided UI page
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PluginPage {
|
||||
/// Plugin ID that provides this page
|
||||
pub plugin_id: String,
|
||||
/// Page definition from schema
|
||||
pub page: UiPage,
|
||||
/// Endpoint paths this plugin is allowed to fetch (empty means no
|
||||
/// restriction)
|
||||
pub allowed_endpoints: Vec<String>,
|
||||
}
|
||||
|
||||
/// Registry of all plugin-provided UI pages and widgets
|
||||
///
|
||||
/// This is typically stored as a signal in the Dioxus tree.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PluginRegistry {
|
||||
/// API client for fetching pages from server
|
||||
client: ApiClient,
|
||||
/// Cached pages: (`plugin_id`, `page_id`) -> `PluginPage`
|
||||
pages: FxHashMap<(String, String), PluginPage>,
|
||||
/// Cached widgets: (`plugin_id`, `widget_id`) -> `UiWidget`
|
||||
widgets: Vec<(String, UiWidget)>,
|
||||
/// Merged CSS custom property overrides from all enabled plugins
|
||||
theme_vars: FxHashMap<String, String>,
|
||||
}
|
||||
|
||||
impl PluginRegistry {
|
||||
/// Create a new empty registry
|
||||
pub fn new(client: ApiClient) -> Self {
|
||||
Self {
|
||||
client,
|
||||
pages: FxHashMap::default(),
|
||||
widgets: Vec::new(),
|
||||
theme_vars: FxHashMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get merged CSS custom property overrides from all loaded plugins.
|
||||
pub fn theme_vars(&self) -> &FxHashMap<String, String> {
|
||||
&self.theme_vars
|
||||
}
|
||||
|
||||
/// Register a page from a plugin
|
||||
///
|
||||
/// Pages that fail schema validation are silently skipped with a warning log.
|
||||
pub fn register_page(
|
||||
&mut self,
|
||||
plugin_id: String,
|
||||
page: UiPage,
|
||||
allowed_endpoints: Vec<String>,
|
||||
) {
|
||||
if let Err(e) = page.validate() {
|
||||
tracing::warn!(
|
||||
plugin_id = %plugin_id,
|
||||
page_id = %page.id,
|
||||
"Skipping invalid page '{}' from '{}': {e}",
|
||||
page.id,
|
||||
plugin_id,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let page_id = page.id.clone();
|
||||
// Check for duplicate page_id across different plugins. Same-plugin
|
||||
// re-registration of the same page is allowed to overwrite.
|
||||
let has_duplicate = self.pages.values().any(|existing| {
|
||||
existing.page.id == page_id && existing.plugin_id != plugin_id
|
||||
});
|
||||
if has_duplicate {
|
||||
tracing::warn!(
|
||||
plugin_id = %plugin_id,
|
||||
page_id = %page_id,
|
||||
"skipping plugin page: page ID conflicts with an existing page from another plugin"
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.pages.insert((plugin_id.clone(), page_id), PluginPage {
|
||||
plugin_id,
|
||||
page,
|
||||
allowed_endpoints,
|
||||
});
|
||||
}
|
||||
|
||||
/// Get a specific page by plugin ID and page ID
|
||||
pub fn get_page(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
page_id: &str,
|
||||
) -> Option<&PluginPage> {
|
||||
self
|
||||
.pages
|
||||
.get(&(plugin_id.to_string(), page_id.to_string()))
|
||||
}
|
||||
|
||||
/// Register a widget from a plugin
|
||||
///
|
||||
/// Widgets that fail schema validation are silently skipped with a warning
|
||||
/// log.
|
||||
pub fn register_widget(&mut self, plugin_id: String, widget: UiWidget) {
|
||||
if let Err(e) = widget.validate() {
|
||||
tracing::warn!(
|
||||
plugin_id = %plugin_id,
|
||||
widget_id = %widget.id,
|
||||
"Skipping invalid widget '{}' from '{}': {e}",
|
||||
widget.id,
|
||||
plugin_id,
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.widgets.push((plugin_id, widget));
|
||||
}
|
||||
|
||||
/// Get all widgets (for use with `WidgetContainer`)
|
||||
pub fn all_widgets(&self) -> Vec<(String, UiWidget)> {
|
||||
self.widgets.clone()
|
||||
}
|
||||
|
||||
/// Get all pages
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "used in tests and may be needed by future callers"
|
||||
)]
|
||||
pub fn all_pages(&self) -> Vec<&PluginPage> {
|
||||
self.pages.values().collect()
|
||||
}
|
||||
|
||||
/// Check if any pages are registered
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.pages.is_empty()
|
||||
}
|
||||
|
||||
/// Number of registered pages
|
||||
pub fn len(&self) -> usize {
|
||||
self.pages.len()
|
||||
}
|
||||
|
||||
/// Get all page routes for navigation
|
||||
///
|
||||
/// Returns `(plugin_id, page_id, full_route)` triples.
|
||||
pub fn routes(&self) -> Vec<(String, String, String)> {
|
||||
self
|
||||
.pages
|
||||
.values()
|
||||
.map(|p| (p.plugin_id.clone(), p.page.id.clone(), p.page.route.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Refresh pages and widgets from server
|
||||
pub async fn refresh(&mut self) -> Result<(), String> {
|
||||
let pages = self
|
||||
.client
|
||||
.get_plugin_ui_pages()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to refresh plugin pages: {e}"))?;
|
||||
|
||||
// Build into a temporary registry to avoid a window where state appears
|
||||
// empty during the two async fetches.
|
||||
let mut tmp = Self::new(self.client.clone());
|
||||
for (plugin_id, page, endpoints) in pages {
|
||||
tmp.register_page(plugin_id, page, endpoints);
|
||||
}
|
||||
match self.client.get_plugin_ui_widgets().await {
|
||||
Ok(widgets) => {
|
||||
for (plugin_id, widget) in widgets {
|
||||
tmp.register_widget(plugin_id, widget);
|
||||
}
|
||||
},
|
||||
Err(e) => tracing::warn!("Failed to refresh plugin widgets: {e}"),
|
||||
}
|
||||
match self.client.get_plugin_ui_theme_extensions().await {
|
||||
Ok(vars) => tmp.theme_vars = vars,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to refresh plugin theme extensions: {e}")
|
||||
},
|
||||
}
|
||||
|
||||
// Atomic swap: no window where the registry appears empty.
|
||||
self.pages = tmp.pages;
|
||||
self.widgets = tmp.widgets;
|
||||
self.theme_vars = tmp.theme_vars;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PluginRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new(ApiClient::default())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pinakes_plugin_api::UiElement;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn create_test_page(id: &str, title: &str) -> UiPage {
|
||||
UiPage {
|
||||
id: id.to_string(),
|
||||
title: title.to_string(),
|
||||
route: format!("/plugins/test/{id}"),
|
||||
icon: None,
|
||||
root_element: UiElement::Container {
|
||||
children: vec![],
|
||||
gap: 16,
|
||||
padding: None,
|
||||
},
|
||||
data_sources: FxHashMap::default(),
|
||||
actions: FxHashMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_empty() {
|
||||
let client = ApiClient::default();
|
||||
let registry = PluginRegistry::new(client);
|
||||
assert!(registry.is_empty());
|
||||
assert_eq!(registry.all_pages().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_and_get_page() {
|
||||
let client = ApiClient::default();
|
||||
let mut registry = PluginRegistry::new(client);
|
||||
let page = create_test_page("demo", "Demo Page");
|
||||
|
||||
registry.register_page("my-plugin".to_string(), page.clone(), vec![]);
|
||||
|
||||
assert!(!registry.is_empty());
|
||||
assert_eq!(registry.all_pages().len(), 1);
|
||||
|
||||
let retrieved = registry.get_page("my-plugin", "demo");
|
||||
assert!(retrieved.is_some());
|
||||
assert_eq!(retrieved.unwrap().page.id, "demo");
|
||||
assert_eq!(retrieved.unwrap().page.title, "Demo Page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_page_not_found() {
|
||||
let client = ApiClient::default();
|
||||
let registry = PluginRegistry::new(client);
|
||||
|
||||
let result = registry.get_page("nonexistent", "page");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_pages() {
|
||||
let client = ApiClient::default();
|
||||
let mut registry = PluginRegistry::new(client);
|
||||
|
||||
registry.register_page(
|
||||
"plugin1".to_string(),
|
||||
create_test_page("page1", "Page 1"),
|
||||
vec![],
|
||||
);
|
||||
registry.register_page(
|
||||
"plugin2".to_string(),
|
||||
create_test_page("page2", "Page 2"),
|
||||
vec![],
|
||||
);
|
||||
|
||||
let all = registry.all_pages();
|
||||
assert_eq!(all.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_widget_and_all_widgets() {
|
||||
let client = ApiClient::default();
|
||||
let mut registry = PluginRegistry::new(client);
|
||||
|
||||
let widget: UiWidget = serde_json::from_value(serde_json::json!({
|
||||
"id": "my-widget",
|
||||
"target": "library_header",
|
||||
"content": { "type": "badge", "text": "hello", "variant": "default" }
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert!(registry.all_widgets().is_empty());
|
||||
registry.register_widget("test-plugin".to_string(), widget.clone());
|
||||
let widgets = registry.all_widgets();
|
||||
assert_eq!(widgets.len(), 1);
|
||||
assert_eq!(widgets[0].0, "test-plugin");
|
||||
assert_eq!(widgets[0].1.id, "my-widget");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_page_overwrites_same_key() {
|
||||
let client = ApiClient::default();
|
||||
let mut registry = PluginRegistry::new(client);
|
||||
|
||||
registry.register_page(
|
||||
"plugin1".to_string(),
|
||||
create_test_page("p", "Original"),
|
||||
vec![],
|
||||
);
|
||||
registry.register_page(
|
||||
"plugin1".to_string(),
|
||||
create_test_page("p", "Updated"),
|
||||
vec![],
|
||||
);
|
||||
|
||||
assert_eq!(registry.all_pages().len(), 1);
|
||||
assert_eq!(
|
||||
registry.get_page("plugin1", "p").unwrap().page.title,
|
||||
"Updated"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_registry_is_empty() {
|
||||
let registry = PluginRegistry::default();
|
||||
assert!(registry.is_empty());
|
||||
assert_eq!(registry.all_pages().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_len() {
|
||||
let client = ApiClient::default();
|
||||
let mut registry = PluginRegistry::new(client);
|
||||
assert_eq!(registry.len(), 0);
|
||||
registry.register_page("p".to_string(), create_test_page("a", "A"), vec![]);
|
||||
assert_eq!(registry.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_page_route() {
|
||||
let client = ApiClient::default();
|
||||
let mut registry = PluginRegistry::new(client);
|
||||
registry.register_page(
|
||||
"my-plugin".to_string(),
|
||||
create_test_page("demo", "Demo Page"),
|
||||
vec![],
|
||||
);
|
||||
let plugin_page = registry.get_page("my-plugin", "demo").unwrap();
|
||||
assert_eq!(plugin_page.page.route, "/plugins/test/demo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_routes() {
|
||||
let client = ApiClient::default();
|
||||
let mut registry = PluginRegistry::new(client);
|
||||
registry.register_page(
|
||||
"plugin1".to_string(),
|
||||
create_test_page("page1", "Page 1"),
|
||||
vec![],
|
||||
);
|
||||
let routes = registry.routes();
|
||||
assert_eq!(routes.len(), 1);
|
||||
assert_eq!(routes[0].0, "plugin1");
|
||||
assert_eq!(routes[0].1, "page1");
|
||||
assert_eq!(routes[0].2, "/plugins/test/page1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_pages_builds_registry() {
|
||||
let client = ApiClient::default();
|
||||
let pages = vec![
|
||||
("plugin1".to_string(), create_test_page("page1", "Page 1")),
|
||||
("plugin2".to_string(), create_test_page("page2", "Page 2")),
|
||||
];
|
||||
// Build via register_page loop (equivalent to old with_pages)
|
||||
let mut registry = PluginRegistry::new(client);
|
||||
for (plugin_id, page) in pages {
|
||||
registry.register_page(plugin_id, page, vec![]);
|
||||
}
|
||||
assert_eq!(registry.len(), 2);
|
||||
assert!(registry.get_page("plugin1", "page1").is_some());
|
||||
assert!(registry.get_page("plugin2", "page2").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_pages_returns_references() {
|
||||
let client = ApiClient::default();
|
||||
let mut registry = PluginRegistry::new(client);
|
||||
registry.register_page(
|
||||
"p1".to_string(),
|
||||
create_test_page("a", "A"),
|
||||
vec![],
|
||||
);
|
||||
registry.register_page(
|
||||
"p2".to_string(),
|
||||
create_test_page("b", "B"),
|
||||
vec![],
|
||||
);
|
||||
|
||||
let pages = registry.all_pages();
|
||||
assert_eq!(pages.len(), 2);
|
||||
let titles: Vec<&str> =
|
||||
pages.iter().map(|p| p.page.title.as_str()).collect();
|
||||
assert!(titles.contains(&"A"));
|
||||
assert!(titles.contains(&"B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_plugins_same_page_id_second_rejected() {
|
||||
let client = ApiClient::default();
|
||||
let mut registry = PluginRegistry::new(client);
|
||||
|
||||
// First plugin registers "stats" - should succeed.
|
||||
registry.register_page(
|
||||
"plugin-a".to_string(),
|
||||
create_test_page("stats", "A Stats"),
|
||||
vec![],
|
||||
);
|
||||
// Second plugin attempts to register the same page ID "stats" - should be
|
||||
// rejected to avoid route collisions at /plugins/stats.
|
||||
registry.register_page(
|
||||
"plugin-b".to_string(),
|
||||
create_test_page("stats", "B Stats"),
|
||||
vec![],
|
||||
);
|
||||
|
||||
// Only one page should be registered; the second was rejected.
|
||||
assert_eq!(registry.all_pages().len(), 1);
|
||||
assert_eq!(
|
||||
registry.get_page("plugin-a", "stats").unwrap().page.title,
|
||||
"A Stats"
|
||||
);
|
||||
assert!(
|
||||
registry.get_page("plugin-b", "stats").is_none(),
|
||||
"plugin-b's page with duplicate ID should have been rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_same_plugin_same_page_id_overwrites() {
|
||||
// Same plugin re-registering the same page ID should still be allowed
|
||||
// (overwrite semantics, not a cross-plugin conflict).
|
||||
let client = ApiClient::default();
|
||||
let mut registry = PluginRegistry::new(client);
|
||||
|
||||
registry.register_page(
|
||||
"plugin-a".to_string(),
|
||||
create_test_page("stats", "A Stats v1"),
|
||||
vec![],
|
||||
);
|
||||
registry.register_page(
|
||||
"plugin-a".to_string(),
|
||||
create_test_page("stats", "A Stats v2"),
|
||||
vec![],
|
||||
);
|
||||
|
||||
assert_eq!(registry.all_pages().len(), 1);
|
||||
assert_eq!(
|
||||
registry.get_page("plugin-a", "stats").unwrap().page.title,
|
||||
"A Stats v2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_invalid_page_is_skipped() {
|
||||
use pinakes_plugin_api::UiElement;
|
||||
|
||||
let client = ApiClient::default();
|
||||
let mut registry = PluginRegistry::new(client);
|
||||
|
||||
// A page with an empty ID fails validation
|
||||
let invalid_page = UiPage {
|
||||
id: String::new(), // invalid: empty
|
||||
title: "Bad Page".to_string(),
|
||||
route: "/plugins/bad".to_string(),
|
||||
icon: None,
|
||||
root_element: UiElement::Container {
|
||||
children: vec![],
|
||||
gap: 16,
|
||||
padding: None,
|
||||
},
|
||||
data_sources: FxHashMap::default(),
|
||||
actions: FxHashMap::default(),
|
||||
};
|
||||
|
||||
registry.register_page("test-plugin".to_string(), invalid_page, vec![]);
|
||||
assert!(registry.is_empty(), "invalid page should have been skipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_valid_page_after_invalid() {
|
||||
let client = ApiClient::default();
|
||||
let mut registry = PluginRegistry::new(client);
|
||||
|
||||
use pinakes_plugin_api::UiElement;
|
||||
|
||||
// Invalid page
|
||||
let invalid_page = UiPage {
|
||||
id: String::new(),
|
||||
title: "Bad".to_string(),
|
||||
route: "/bad".to_string(),
|
||||
icon: None,
|
||||
root_element: UiElement::Container {
|
||||
children: vec![],
|
||||
gap: 0,
|
||||
padding: None,
|
||||
},
|
||||
data_sources: FxHashMap::default(),
|
||||
actions: FxHashMap::default(),
|
||||
};
|
||||
registry.register_page("p".to_string(), invalid_page, vec![]);
|
||||
assert_eq!(registry.all_pages().len(), 0);
|
||||
|
||||
// Valid page; should still register fine
|
||||
registry.register_page(
|
||||
"p".to_string(),
|
||||
create_test_page("good", "Good"),
|
||||
vec![],
|
||||
);
|
||||
assert_eq!(registry.all_pages().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_invalid_widget_is_skipped() {
|
||||
let client = ApiClient::default();
|
||||
let mut registry = PluginRegistry::new(client);
|
||||
|
||||
let widget: pinakes_plugin_api::UiWidget =
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"id": "my-widget",
|
||||
"target": "library_header",
|
||||
"content": { "type": "badge", "text": "hi", "variant": "default" }
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
// Mutate: create an invalid widget with empty id
|
||||
let invalid_widget = pinakes_plugin_api::UiWidget {
|
||||
id: String::new(), // invalid
|
||||
target: "library_header".to_string(),
|
||||
content: widget.content.clone(),
|
||||
};
|
||||
|
||||
assert!(registry.all_widgets().is_empty());
|
||||
registry.register_widget("test-plugin".to_string(), invalid_widget);
|
||||
assert!(
|
||||
registry.all_widgets().is_empty(),
|
||||
"invalid widget should have been skipped"
|
||||
);
|
||||
|
||||
// Valid widget is still accepted
|
||||
registry.register_widget("test-plugin".to_string(), widget);
|
||||
assert_eq!(registry.all_widgets().len(), 1);
|
||||
}
|
||||
}
|
||||
1853
packages/pinakes-ui/src/plugin_ui/renderer.rs
Normal file
1853
packages/pinakes-ui/src/plugin_ui/renderer.rs
Normal file
File diff suppressed because it is too large
Load diff
170
packages/pinakes-ui/src/plugin_ui/widget.rs
Normal file
170
packages/pinakes-ui/src/plugin_ui/widget.rs
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
//! Widget injection system for plugin UI
|
||||
//!
|
||||
//! Allows plugins to inject small UI elements into existing host pages at
|
||||
//! predefined locations. Unlike full pages, widgets have no data sources of
|
||||
//! their own and render with empty data context.
|
||||
|
||||
use dioxus::prelude::*;
|
||||
use pinakes_plugin_api::{ActionDefinition, UiWidget, widget_location};
|
||||
use rustc_hash::FxHashMap;
|
||||
|
||||
use super::{
|
||||
data::PluginPageData,
|
||||
renderer::{RenderContext, render_element},
|
||||
};
|
||||
use crate::client::ApiClient;
|
||||
|
||||
/// Predefined injection points in the host UI.
|
||||
///
|
||||
/// These correspond to the string constants in
|
||||
/// `pinakes_plugin_api::widget_location` and determine where a widget is
|
||||
/// rendered.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum WidgetLocation {
|
||||
LibraryHeader,
|
||||
LibrarySidebar,
|
||||
DetailPanel,
|
||||
SearchFilters,
|
||||
SettingsSection,
|
||||
}
|
||||
|
||||
impl WidgetLocation {
|
||||
/// Returns the canonical string identifier used in plugin manifests.
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::LibraryHeader => widget_location::LIBRARY_HEADER,
|
||||
Self::LibrarySidebar => widget_location::LIBRARY_SIDEBAR,
|
||||
Self::DetailPanel => widget_location::DETAIL_PANEL,
|
||||
Self::SearchFilters => widget_location::SEARCH_FILTERS,
|
||||
Self::SettingsSection => widget_location::SETTINGS_SECTION,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Props for [`WidgetContainer`].
|
||||
#[derive(Props, PartialEq, Clone)]
|
||||
pub struct WidgetContainerProps {
|
||||
/// Injection point to render widgets for.
|
||||
pub location: WidgetLocation,
|
||||
|
||||
/// All widgets from all plugins (`plugin_id`, widget) pairs.
|
||||
pub widgets: Vec<(String, UiWidget)>,
|
||||
|
||||
/// API client. It is actually unused by widgets themselves but threaded
|
||||
/// through for consistency with the rest of the plugin UI system.
|
||||
pub client: Signal<ApiClient>,
|
||||
}
|
||||
|
||||
/// Renders all widgets registered for a specific [`WidgetLocation`].
|
||||
///
|
||||
/// Returns `None` if no widgets target this location.
|
||||
///
|
||||
/// # Usage
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// // In a host component:
|
||||
/// WidgetContainer {
|
||||
/// location: WidgetLocation::LibraryHeader,
|
||||
/// widgets: plugin_registry.read().all_widgets(),
|
||||
/// client,
|
||||
/// }
|
||||
/// ```
|
||||
#[component]
|
||||
pub fn WidgetContainer(props: WidgetContainerProps) -> Element {
|
||||
let location_str = props.location.as_str();
|
||||
let matching: Vec<_> = props
|
||||
.widgets
|
||||
.iter()
|
||||
.filter(|(_, w)| w.target == location_str)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if matching.is_empty() {
|
||||
return rsx! {};
|
||||
}
|
||||
|
||||
rsx! {
|
||||
div { class: "plugin-widget-container", "data-location": location_str,
|
||||
for (plugin_id , widget) in &matching {
|
||||
WidgetViewRenderer {
|
||||
plugin_id: plugin_id.clone(),
|
||||
widget: widget.clone(),
|
||||
client: props.client,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Props for [`WidgetViewRenderer`].
|
||||
#[derive(Props, PartialEq, Clone)]
|
||||
pub struct WidgetViewRendererProps {
|
||||
/// Plugin that owns this widget.
|
||||
pub plugin_id: String,
|
||||
/// Widget definition to render.
|
||||
pub widget: UiWidget,
|
||||
/// API client signal.
|
||||
pub client: Signal<ApiClient>,
|
||||
}
|
||||
|
||||
/// Renders a single plugin widget with an empty data context.
|
||||
///
|
||||
/// Widgets do not declare data sources; they render statically (or use
|
||||
/// inline expressions with no external data).
|
||||
#[component]
|
||||
pub fn WidgetViewRenderer(props: WidgetViewRendererProps) -> Element {
|
||||
let empty_data = PluginPageData::default();
|
||||
let feedback = use_signal(|| None::<(String, bool)>);
|
||||
let navigate = use_signal(|| None::<String>);
|
||||
let refresh = use_signal(|| 0u32);
|
||||
let modal = use_signal(|| None::<pinakes_plugin_api::UiElement>);
|
||||
let local_state = use_signal(FxHashMap::<String, serde_json::Value>::default);
|
||||
let ctx = RenderContext {
|
||||
client: props.client,
|
||||
feedback,
|
||||
navigate,
|
||||
refresh,
|
||||
modal,
|
||||
local_state,
|
||||
};
|
||||
let empty_actions: FxHashMap<String, ActionDefinition> = FxHashMap::default();
|
||||
rsx! {
|
||||
div {
|
||||
class: "plugin-widget",
|
||||
"data-plugin-id": props.plugin_id.clone(),
|
||||
"data-widget-id": props.widget.id,
|
||||
{ render_element(&props.widget.content, &empty_data, &empty_actions, ctx) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rustc_hash::FxHashSet;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_widget_location_settings_section_str() {
|
||||
assert_eq!(WidgetLocation::SettingsSection.as_str(), "settings_section");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_widget_location_all_variants_unique() {
|
||||
let locations = [
|
||||
WidgetLocation::LibraryHeader,
|
||||
WidgetLocation::LibrarySidebar,
|
||||
WidgetLocation::DetailPanel,
|
||||
WidgetLocation::SearchFilters,
|
||||
WidgetLocation::SettingsSection,
|
||||
];
|
||||
let strings: Vec<&str> = locations.iter().map(|l| l.as_str()).collect();
|
||||
let unique: FxHashSet<_> = strings.iter().collect();
|
||||
assert_eq!(
|
||||
strings.len(),
|
||||
unique.len(),
|
||||
"all location strings must be unique"
|
||||
);
|
||||
}
|
||||
}
|
||||
1
packages/pinakes-ui/src/state.rs
Normal file
1
packages/pinakes-ui/src/state.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
// Reserved for future shared state utilities.
|
||||
7
packages/pinakes-ui/src/styles.rs
Normal file
7
packages/pinakes-ui/src/styles.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
//! Styles module for Pinakes UI
|
||||
//!
|
||||
//! Exports the SCSS asset for use with Dioxus.
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
pub static STYLES: Asset = asset!("/assets/styles/main.scss");
|
||||
Loading…
Add table
Add a link
Reference in a new issue