diff --git a/Cargo.lock b/Cargo.lock index 682fbca..165cf69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5446,6 +5446,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "toml 1.0.6+spec-1.1.0", + "tracing", "uuid", "wit-bindgen 0.53.1", ] @@ -5511,6 +5512,7 @@ dependencies = [ "chrono", "clap", "dioxus", + "dioxus-core", "dioxus-free-icons", "futures", "gloo-timers", diff --git a/Cargo.toml b/Cargo.toml index 53a54b3..f0103d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -100,6 +100,7 @@ crossterm = "0.29.0" # Desktop/Web UI dioxus = { version = "0.7.3", features = ["desktop", "router"] } +dioxus-core = { version = "0.7.3" } # Async trait (dyn-compatible async methods) async-trait = "0.1.89" @@ -187,6 +188,7 @@ undocumented_unsafe_blocks = "warn" unnecessary_safety_comment = "warn" unused_result_ok = "warn" unused_trait_names = "allow" +too_many_arguments = "allow" # False positive: # clippy's build script check doesn't recognize workspace-inherited metadata diff --git a/crates/pinakes-ui/Cargo.toml b/crates/pinakes-ui/Cargo.toml index 6dd8a0e..43bf1c6 100644 --- a/crates/pinakes-ui/Cargo.toml +++ b/crates/pinakes-ui/Cargo.toml @@ -15,6 +15,7 @@ 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 } diff --git a/crates/pinakes-ui/assets/css/main.css b/crates/pinakes-ui/assets/css/main.css index 3bddc22..30f105a 100644 --- a/crates/pinakes-ui/assets/css/main.css +++ b/crates/pinakes-ui/assets/css/main.css @@ -1 +1 @@ -@media (prefers-reduced-motion: reduce){*,*::before,*::after{animation-duration:.01ms !important;animation-iteration-count:1 !important;transition-duration:.01ms !important}}*{margin:0;padding:0;box-sizing:border-box;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.06) rgba(0,0,0,0)}*::-webkit-scrollbar{width:5px;height:5px}*::-webkit-scrollbar-track{background:rgba(0,0,0,0)}*::-webkit-scrollbar-thumb{background:rgba(255,255,255,.06);border-radius:3px}*::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.14)}:root{--bg-0: #111118;--bg-1: #18181f;--bg-2: #1f1f28;--bg-3: #26263a;--border-subtle: rgba(255,255,255,.06);--border: rgba(255,255,255,.09);--border-strong: rgba(255,255,255,.14);--text-0: #dcdce4;--text-1: #a0a0b8;--text-2: #6c6c84;--accent: #7c7ef5;--accent-dim: rgba(124,126,245,.15);--accent-text: #9698f7;--success: #3ec97a;--error: #e45858;--warning: #d4a037;--radius-sm: 3px;--radius: 5px;--radius-md: 7px;--shadow-sm: 0 1px 3px rgba(0,0,0,.3);--shadow: 0 2px 8px rgba(0,0,0,.35);--shadow-lg: 0 4px 20px rgba(0,0,0,.45)}body{font-family:"Inter",-apple-system,"Segoe UI",system-ui,sans-serif;background:var(--bg-0);color:var(--text-0);font-size:13px;line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden}:focus-visible:focus-visible{outline:2px solid #7c7ef5;outline-offset:2px}::selection{background:rgba(124,126,245,.15);color:#9698f7}a{color:#9698f7;text-decoration:none}a:hover{text-decoration:underline}code{padding:1px 5px;border-radius:3px;background:#111118;color:#9698f7;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px}ul{list-style:none;padding:0}ul li{padding:3px 0;font-size:12px;color:#a0a0b8}.text-muted{color:#a0a0b8}.text-sm{font-size:11px}.mono{font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px}.flex-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px}.flex-between{display:flex;justify-content:space-between;align-items:center}.mb-16{margin-bottom:16px}.mb-8{margin-bottom:12px}@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:.3}}@keyframes spin{to{transform:rotate(360deg)}}@keyframes skeleton-pulse{0%{opacity:.6}50%{opacity:.3}100%{opacity:.6}}@keyframes indeterminate{0%{transform:translateX(-100%)}100%{transform:translateX(400%)}}.app{display:flex;flex-direction:row;justify-content:flex-start;align-items:stretch;height:100vh;overflow:hidden}.sidebar{width:220px;min-width:220px;max-width:220px;background:#18181f;border-right:1px solid rgba(255,255,255,.09);display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;flex-shrink:0;user-select:none;overflow-y:auto;overflow-x:hidden;z-index:10;transition:width .15s,min-width .15s,max-width .15s}.sidebar.collapsed{width:48px;min-width:48px;max-width:48px}.sidebar.collapsed .nav-label,.sidebar.collapsed .sidebar-header .logo,.sidebar.collapsed .sidebar-header .version,.sidebar.collapsed .nav-badge,.sidebar.collapsed .nav-item-text,.sidebar.collapsed .sidebar-footer .status-text,.sidebar.collapsed .user-name,.sidebar.collapsed .role-badge,.sidebar.collapsed .user-info .btn,.sidebar.collapsed .sidebar-import-header span,.sidebar.collapsed .sidebar-import-file{display:none}.sidebar.collapsed .nav-item{justify-content:center;padding:8px;border-left:none;border-radius:3px}.sidebar.collapsed .nav-item.active{border-left:none}.sidebar.collapsed .nav-icon{width:auto;margin:0}.sidebar.collapsed .sidebar-header{padding:12px 8px;justify-content:center}.sidebar.collapsed .nav-section{padding:0 4px}.sidebar.collapsed .sidebar-footer{padding:8px}.sidebar.collapsed .sidebar-footer .user-info{justify-content:center;padding:4px}.sidebar.collapsed .sidebar-import-progress{padding:6px}.sidebar-header{padding:16px 16px 20px;display:flex;flex-direction:row;justify-content:flex-start;align-items:baseline;gap:8px}.sidebar-header .logo{font-size:15px;font-weight:700;letter-spacing:-.4px;color:#dcdce4}.sidebar-header .version{font-size:10px;color:#6c6c84}.sidebar-toggle{background:rgba(0,0,0,0);border:none;color:#6c6c84;padding:8px;font-size:18px;width:100%;text-align:center}.sidebar-toggle:hover{color:#dcdce4}.sidebar-spacer{flex:1}.sidebar-footer{padding:12px;border-top:1px solid rgba(255,255,255,.06);overflow:visible;min-width:0}.nav-section{padding:0 8px;margin-bottom:2px}.nav-label{padding:8px 8px 4px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#6c6c84}.nav-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:6px 8px;border-radius:3px;cursor:pointer;color:#a0a0b8;font-size:13px;font-weight:450;transition:color .1s,background .1s;border:none;background:none;width:100%;text-align:left;border-left:2px solid rgba(0,0,0,0);margin-left:0}.nav-item:hover{color:#dcdce4;background:rgba(255,255,255,.03)}.nav-item.active{color:#9698f7;border-left-color:#7c7ef5;background:rgba(124,126,245,.15)}.nav-item-text{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.sidebar:not(.collapsed) .nav-item-text{overflow:visible}.nav-icon{width:18px;text-align:center;font-size:14px;opacity:.7}.nav-badge{margin-left:auto;font-size:10px;font-weight:600;color:#6c6c84;background:#26263a;padding:1px 6px;border-radius:12px;min-width:20px;text-align:center;font-variant-numeric:tabular-nums}.status-indicator{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:6px;font-size:11px;font-weight:500;min-width:0;overflow:visible}.sidebar:not(.collapsed) .status-indicator{justify-content:flex-start}.status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}.status-dot.connected{background:#3ec97a}.status-dot.disconnected{background:#e45858}.status-dot.checking{background:#d4a037;animation:pulse 1.5s infinite}.status-text{color:#6c6c84;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.sidebar:not(.collapsed) .status-text{overflow:visible}.main{flex:1;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;overflow:hidden;min-width:0}.header{height:48px;min-height:48px;border-bottom:1px solid rgba(255,255,255,.06);display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:0 20px;background:#18181f}.page-title{font-size:14px;font-weight:600;color:#dcdce4}.header-spacer{flex:1}.content{flex:1;overflow-y:auto;padding:20px;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.06) rgba(0,0,0,0)}.sidebar-import-progress{padding:10px 12px;background:#1f1f28;border-top:1px solid rgba(255,255,255,.06);font-size:11px}.sidebar-import-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;margin-bottom:4px;color:#a0a0b8}.sidebar-import-file{color:#6c6c84;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px}.sidebar-import-progress .progress-bar{height:3px}.user-info{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:12px;overflow:hidden;min-width:0}.user-name{font-weight:500;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:90px;flex-shrink:1}.role-badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase}.role-badge.role-admin{background:rgba(139,92,246,.1);color:#9d8be0}.role-badge.role-editor{background:rgba(34,160,80,.1);color:#5cb97a}.role-badge.role-viewer{background:rgba(59,120,200,.1);color:#6ca0d4}.btn{padding:5px 12px;border-radius:3px;border:none;cursor:pointer;font-size:12px;font-weight:500;transition:all .1s;display:inline-flex;align-items:center;gap:5px;white-space:nowrap;line-height:1.5}.btn-primary{background:#7c7ef5;color:#fff}.btn-primary:hover{background:#8b8df7}.btn-secondary{background:#26263a;color:#dcdce4;border:1px solid rgba(255,255,255,.09)}.btn-secondary:hover{border-color:rgba(255,255,255,.14);background:rgba(255,255,255,.06)}.btn-danger{background:rgba(0,0,0,0);color:#e45858;border:1px solid rgba(228,88,88,.25)}.btn-danger:hover{background:rgba(228,88,88,.08)}.btn-ghost{background:rgba(0,0,0,0);border:none;color:#a0a0b8;padding:5px 8px}.btn-ghost:hover{color:#dcdce4;background:rgba(255,255,255,.04)}.btn-sm{padding:3px 8px;font-size:11px}.btn-icon{padding:4px;border-radius:3px;background:rgba(0,0,0,0);border:none;color:#6c6c84;cursor:pointer;transition:color .1s;font-size:13px}.btn-icon:hover{color:#dcdce4}.btn:disabled,.btn[disabled]{opacity:.4;cursor:not-allowed;pointer-events:none}.btn.btn-disabled-hint:disabled{opacity:.6;border-style:dashed;pointer-events:auto;cursor:help}.card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px}.card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.card-title{font-size:14px;font-weight:600}.data-table{width:100%;border-collapse:collapse;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden}.data-table thead th{padding:8px 14px;text-align:left;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#6c6c84;border-bottom:1px solid rgba(255,255,255,.09);background:#26263a}.data-table tbody td{padding:8px 14px;font-size:13px;border-bottom:1px solid rgba(255,255,255,.06);max-width:300px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.data-table tbody tr{cursor:pointer;transition:background .08s}.data-table tbody tr:hover{background:rgba(255,255,255,.02)}.data-table tbody tr.row-selected{background:rgba(99,102,241,.12)}.data-table tbody tr:last-child td{border-bottom:none}.sortable-header{cursor:pointer;user-select:none;transition:color .1s}.sortable-header:hover{color:#9698f7}input[type=text],textarea,select{padding:6px 10px;border-radius:3px;border:1px solid rgba(255,255,255,.09);background:#111118;color:#dcdce4;font-size:13px;outline:none;transition:border-color .15s;font-family:inherit}input[type=text]::placeholder,textarea::placeholder,select::placeholder{color:#6c6c84}input[type=text]:focus,textarea:focus,select:focus{border-color:#7c7ef5}input[type=text][type=number],textarea[type=number],select[type=number]{width:80px;padding:6px 8px;-moz-appearance:textfield}input[type=text][type=number]::-webkit-outer-spin-button,input[type=text][type=number]::-webkit-inner-spin-button,textarea[type=number]::-webkit-outer-spin-button,textarea[type=number]::-webkit-inner-spin-button,select[type=number]::-webkit-outer-spin-button,select[type=number]::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}textarea{min-height:64px;resize:vertical}select{appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%236c6c84' d='M5 7L1 3h8z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 8px center;padding-right:26px;min-width:100px}.form-group{margin-bottom:12px}.form-label{display:block;font-size:11px;font-weight:600;color:#a0a0b8;margin-bottom:4px;text-transform:uppercase;letter-spacing:.03em}.form-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:flex-end;gap:8px}.form-row input[type=text]{flex:1}.form-label-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:4px}.form-label-row .form-label{margin-bottom:0}input[type=checkbox]{appearance:none;-webkit-appearance:none;width:16px;height:16px;border:1px solid rgba(255,255,255,.14);border-radius:3px;background:#1f1f28;cursor:pointer;position:relative;flex-shrink:0;transition:all .15s ease}input[type=checkbox]:hover{border-color:#7c7ef5;background:#26263a}input[type=checkbox]:checked{background:#7c7ef5;border-color:#7c7ef5}input[type=checkbox]:checked::after{content:"";position:absolute;left:5px;top:2px;width:4px;height:8px;border:solid #111118;border-width:0 2px 2px 0;transform:rotate(45deg)}input[type=checkbox]:focus-visible:focus-visible{outline:2px solid #7c7ef5;outline-offset:2px}.checkbox-label{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:#a0a0b8;user-select:none}.checkbox-label:hover{color:#dcdce4}.checkbox-label input[type=checkbox]{margin:0}.toggle{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:#dcdce4}.toggle.disabled{opacity:.4;cursor:not-allowed}.toggle-track{width:32px;height:18px;border-radius:9px;background:#26263a;border:1px solid rgba(255,255,255,.09);position:relative;transition:background .15s;flex-shrink:0}.toggle-track.active{background:#7c7ef5;border-color:#7c7ef5}.toggle-track.active .toggle-thumb{transform:translateX(14px)}.toggle-thumb{width:14px;height:14px;border-radius:50%;background:#dcdce4;position:absolute;top:1px;left:1px;transition:transform .15s}.filter-bar{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:12px;padding:12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:5px;margin-bottom:12px}.filter-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.filter-label{font-size:11px;font-weight:500;color:#6c6c84;text-transform:uppercase;letter-spacing:.5px;margin-right:4px}.filter-chip{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:14px;cursor:pointer;font-size:11px;color:#a0a0b8;transition:all .15s ease;user-select:none}.filter-chip:hover{background:#26263a;border-color:rgba(255,255,255,.14);color:#dcdce4}.filter-chip.active{background:rgba(124,126,245,.15);border-color:#7c7ef5;color:#9698f7}.filter-chip input[type=checkbox]{width:12px;height:12px;margin:0}.filter-chip input[type=checkbox]:checked::after{left:3px;top:1px;width:3px;height:6px}.filter-group{display:flex;align-items:center;gap:6px}.filter-group label{display:flex;align-items:center;gap:3px;cursor:pointer;color:#a0a0b8;font-size:11px;white-space:nowrap}.filter-group label:hover{color:#dcdce4}.filter-separator{width:1px;height:20px;background:rgba(255,255,255,.09);flex-shrink:0}.view-toggle{display:flex;border:1px solid rgba(255,255,255,.09);border-radius:3px;overflow:hidden}.view-btn{padding:4px 10px;background:#1f1f28;border:none;color:#6c6c84;cursor:pointer;font-size:18px;line-height:1;transition:background .1s,color .1s}.view-btn:first-child{border-right:1px solid rgba(255,255,255,.09)}.view-btn:hover{color:#dcdce4;background:#26263a}.view-btn.active{background:rgba(124,126,245,.15);color:#9698f7}.breadcrumb{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px;padding:10px 16px;font-size:.85rem;color:#6c6c84}.breadcrumb-sep{color:#6c6c84;opacity:.5}.breadcrumb-link{color:#9698f7;text-decoration:none;cursor:pointer}.breadcrumb-link:hover{text-decoration:underline}.breadcrumb-current{color:#dcdce4;font-weight:500}.progress-bar{width:100%;height:8px;background:#26263a;border-radius:4px;overflow:hidden;margin-bottom:6px}.progress-fill{height:100%;background:#7c7ef5;border-radius:4px;transition:width .3s ease}.progress-fill.indeterminate{width:30%;animation:indeterminate 1.5s ease-in-out infinite}.loading-overlay{display:flex;align-items:center;justify-content:center;padding:48px 16px;color:#6c6c84;font-size:13px;gap:10px}.spinner{width:18px;height:18px;border:2px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.spinner-small{width:14px;height:14px;border:2px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.spinner-tiny{width:10px;height:10px;border:1.5px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:100;animation:fade-in .1s ease-out}.modal{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:20px;min-width:360px;max-width:480px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.modal.wide{max-width:600px;max-height:70vh;overflow-y:auto}.modal-title{font-size:15px;font-weight:600;margin-bottom:6px}.modal-body{font-size:12px;color:#a0a0b8;margin-bottom:16px;line-height:1.5}.modal-actions{display:flex;flex-direction:row;justify-content:flex-end;align-items:center;gap:6px}.tooltip-trigger{display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border-radius:50%;background:#26263a;color:#6c6c84;font-size:9px;font-weight:700;cursor:help;position:relative;flex-shrink:0;margin-left:4px}.tooltip-trigger:hover{background:rgba(124,126,245,.15);color:#9698f7}.tooltip-trigger:hover .tooltip-text{display:block}.tooltip-text{display:none;position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);padding:6px 10px;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#dcdce4;font-size:11px;font-weight:400;line-height:1.4;white-space:normal;width:220px;text-transform:none;letter-spacing:normal;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:100;pointer-events:none}.media-player{position:relative;background:#111118;border-radius:5px;overflow:hidden}.media-player:focus{outline:none}.media-player-audio .player-artwork{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:8px;padding:24px 16px 8px}.player-artwork img{max-width:200px;max-height:200px;border-radius:5px;object-fit:cover}.player-artwork-placeholder{width:120px;height:120px;display:flex;align-items:center;justify-content:center;background:#1f1f28;border-radius:5px;font-size:48px;opacity:.3}.player-title{font-size:13px;font-weight:500;color:#dcdce4;text-align:center}.player-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 14px;background:#1f1f28}.media-player-video .player-controls{position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,.7);opacity:0;transition:opacity .2s}.media-player-video:hover .player-controls{opacity:1}.play-btn,.mute-btn,.fullscreen-btn{background:none;border:none;color:#dcdce4;cursor:pointer;font-size:18px;padding:4px;line-height:1;transition:color .1s}.play-btn:hover,.mute-btn:hover,.fullscreen-btn:hover{color:#9698f7}.player-time{font-size:11px;color:#6c6c84;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;min-width:36px;text-align:center;user-select:none}.seek-bar{flex:1;-webkit-appearance:none;appearance:none;height:4px;border-radius:4px;background:#26263a;outline:none;cursor:pointer}.seek-bar::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:12px;height:12px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none}.seek-bar::-moz-range-thumb{width:12px;height:12px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none}.volume-slider{width:70px;-webkit-appearance:none;appearance:none;height:4px;border-radius:4px;background:#26263a;outline:none;cursor:pointer}.volume-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:10px;height:10px;border-radius:50%;background:#a0a0b8;cursor:pointer;border:none}.volume-slider::-moz-range-thumb{width:10px;height:10px;border-radius:50%;background:#a0a0b8;cursor:pointer;border:none}.image-viewer-overlay{position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:150;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;animation:fade-in .15s ease-out}.image-viewer-overlay:focus{outline:none}.image-viewer-toolbar{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;background:rgba(0,0,0,.5);border-bottom:1px solid rgba(255,255,255,.08);z-index:2;user-select:none}.image-viewer-toolbar-left,.image-viewer-toolbar-center,.image-viewer-toolbar-right{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px}.iv-btn{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);color:#dcdce4;border-radius:3px;padding:4px 10px;font-size:12px;cursor:pointer;transition:background .1s}.iv-btn:hover{background:rgba(255,255,255,.12)}.iv-btn.iv-close{color:#e45858;font-weight:600}.iv-zoom-label{font-size:11px;color:#a0a0b8;min-width:40px;text-align:center;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace}.image-viewer-canvas{flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative}.image-viewer-canvas img{max-width:100%;max-height:100%;object-fit:contain;user-select:none;-webkit-user-drag:none}.pdf-viewer{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;height:100%;min-height:500px;background:#111118;border-radius:5px;overflow:hidden}.pdf-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:10px 12px;background:#18181f;border-bottom:1px solid rgba(255,255,255,.09)}.pdf-toolbar-group{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px}.pdf-toolbar-btn{display:flex;align-items:center;justify-content:center;width:28px;height:28px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#a0a0b8;font-size:14px;cursor:pointer;transition:all .15s}.pdf-toolbar-btn:hover:not(:disabled){background:#26263a;color:#dcdce4}.pdf-toolbar-btn:disabled{opacity:.4;cursor:not-allowed}.pdf-zoom-label{min-width:45px;text-align:center;font-size:12px;color:#a0a0b8}.pdf-container{flex:1;position:relative;overflow:hidden;background:#1f1f28}.pdf-object{width:100%;height:100%;border:none}.pdf-loading,.pdf-error{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:12px;background:#18181f;color:#a0a0b8}.pdf-error{padding:12px;text-align:center}.pdf-fallback{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:16px;padding:48px 12px;text-align:center;color:#6c6c84}.markdown-viewer{padding:16px;text-align:left;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:12px}.markdown-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:8px;background:#1f1f28;border-radius:5px;border:1px solid rgba(255,255,255,.09)}.toolbar-btn{padding:6px 12px;border:1px solid rgba(255,255,255,.09);border-radius:3px;background:#18181f;color:#a0a0b8;font-size:13px;font-weight:500;cursor:pointer;transition:all .15s}.toolbar-btn:hover{background:#26263a;border-color:rgba(255,255,255,.14)}.toolbar-btn.active{background:#7c7ef5;color:#fff;border-color:#7c7ef5}.markdown-source{max-width:100%;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px;overflow-x:auto;font-family:"Menlo","Monaco","Courier New",monospace;font-size:13px;line-height:1.7;color:#dcdce4;white-space:pre-wrap;word-wrap:break-word}.markdown-source code{font-family:inherit;background:none;padding:0;border:none}.markdown-content{max-width:800px;color:#dcdce4;line-height:1.7;font-size:14px;text-align:left}.markdown-content h1{font-size:1.8em;font-weight:700;margin:1em 0 .5em;border-bottom:1px solid rgba(255,255,255,.06);padding-bottom:.3em}.markdown-content h2{font-size:1.5em;font-weight:600;margin:.8em 0 .4em;border-bottom:1px solid rgba(255,255,255,.06);padding-bottom:.2em}.markdown-content h3{font-size:1.25em;font-weight:600;margin:.6em 0 .3em}.markdown-content h4{font-size:1.1em;font-weight:600;margin:.5em 0 .25em}.markdown-content h5,.markdown-content h6{font-size:1em;font-weight:600;margin:.4em 0 .2em;color:#a0a0b8}.markdown-content p{margin:0 0 1em}.markdown-content a{color:#7c7ef5;text-decoration:none}.markdown-content a:hover{text-decoration:underline}.markdown-content pre{background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;padding:12px 16px;overflow-x:auto;margin:0 0 1em;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px;line-height:1.5}.markdown-content code{background:#26263a;padding:1px 5px;border-radius:3px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:.9em}.markdown-content pre code{background:none;padding:0}.markdown-content blockquote{border-left:3px solid #7c7ef5;padding:4px 16px;margin:0 0 1em;color:#a0a0b8;background:rgba(124,126,245,.04)}.markdown-content table{width:100%;border-collapse:collapse;margin:0 0 1em}.markdown-content th,.markdown-content td{padding:6px 12px;border:1px solid rgba(255,255,255,.09);font-size:13px}.markdown-content th{background:#26263a;font-weight:600;text-align:left}.markdown-content tr:nth-child(even){background:#1f1f28}.markdown-content ul,.markdown-content ol{margin:0 0 1em;padding-left:16px}.markdown-content ul{list-style:disc}.markdown-content ol{list-style:decimal}.markdown-content li{padding:2px 0;font-size:14px;color:#dcdce4}.markdown-content hr{border:none;border-top:1px solid rgba(255,255,255,.09);margin:1.5em 0}.markdown-content img{max-width:100%;border-radius:5px}.markdown-content .footnote-definition{font-size:.85em;color:#a0a0b8;margin-top:.5em;padding-left:1.5em}.markdown-content .footnote-definition sup{color:#7c7ef5;margin-right:4px}.markdown-content sup a{color:#7c7ef5;text-decoration:none;font-size:.8em}.wikilink{color:#9698f7;text-decoration:none;border-bottom:1px dashed #7c7ef5;cursor:pointer;transition:border-color .1s,color .1s}.wikilink:hover{color:#7c7ef5;border-bottom-style:solid}.wikilink-embed{display:inline-block;padding:2px 8px;background:rgba(139,92,246,.08);border:1px dashed rgba(139,92,246,.3);border-radius:3px;color:#9d8be0;font-size:12px;cursor:default}.media-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr));gap:12px}.media-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden;cursor:pointer;transition:border-color .12s,box-shadow .12s;position:relative}.media-card:hover{border-color:rgba(255,255,255,.14);box-shadow:0 1px 3px rgba(0,0,0,.3)}.media-card.selected{border-color:#7c7ef5;box-shadow:0 0 0 1px #7c7ef5}.card-checkbox{position:absolute;top:6px;left:6px;z-index:2;opacity:0;transition:opacity .1s}.card-checkbox input[type=checkbox]{width:16px;height:16px;cursor:pointer;filter:drop-shadow(0 1px 2px rgba(0,0,0,.5))}.media-card:hover .card-checkbox,.media-card.selected .card-checkbox{opacity:1}.card-thumbnail{width:100%;aspect-ratio:1;background:#111118;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative}.card-thumbnail img,.card-thumbnail .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:.4;display:flex;align-items:center;justify-content:center;width:100%;height:100%;position:absolute;top:0;left:0;z-index:0}.card-info{padding:8px 10px}.card-name{font-size:12px;font-weight:500;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px}.card-title,.card-artist{font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.3}.card-meta{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:10px}.card-size{color:#6c6c84;font-size:10px}.table-thumb-cell{width:36px;padding:4px 6px !important;position:relative}.table-thumb{width:28px;height:28px;object-fit:cover;border-radius:3px;display:block}.table-thumb-overlay{position:absolute;top:4px;left:6px;z-index:1}.table-type-icon{display:flex;align-items:center;justify-content:center;width:28px;height:28px;font-size:14px;opacity:.5;border-radius:3px;background:#111118;z-index:0}.type-badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}.type-badge.type-audio{background:rgba(139,92,246,.1);color:#9d8be0}.type-badge.type-video{background:rgba(200,72,130,.1);color:#d07eaa}.type-badge.type-image{background:rgba(34,160,80,.1);color:#5cb97a}.type-badge.type-document{background:rgba(59,120,200,.1);color:#6ca0d4}.type-badge.type-text{background:rgba(200,160,36,.1);color:#c4a840}.type-badge.type-other{background:rgba(128,128,160,.08);color:#6c6c84}.tag-list{display:flex;flex-wrap:wrap;gap:4px}.tag-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 10px;background:rgba(124,126,245,.15);color:#9698f7;border-radius:12px;font-size:11px;font-weight:500}.tag-badge.selected{background:#7c7ef5;color:#fff;cursor:pointer}.tag-badge:not(.selected){cursor:pointer}.tag-badge .tag-remove{cursor:pointer;opacity:.4;font-size:13px;line-height:1;transition:opacity .1s}.tag-badge .tag-remove:hover{opacity:1}.tag-group{margin-bottom:6px}.tag-children{margin-left:16px;margin-top:4px;display:flex;flex-wrap:wrap;gap:4px}.tag-confirm-delete{display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#a0a0b8}.tag-confirm-yes{cursor:pointer;color:#e45858;font-weight:600}.tag-confirm-yes:hover{text-decoration:underline}.tag-confirm-no{cursor:pointer;color:#6c6c84;font-weight:500}.tag-confirm-no:hover{text-decoration:underline}.detail-actions{display:flex;gap:6px;margin-bottom:16px}.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.detail-field{padding:10px 12px;background:#111118;border-radius:3px;border:1px solid rgba(255,255,255,.06)}.detail-field.full-width{grid-column:1/-1}.detail-field input[type=text],.detail-field textarea,.detail-field select{width:100%;margin-top:4px}.detail-field textarea{min-height:64px;resize:vertical}.detail-label{font-size:10px;font-weight:600;color:#6c6c84;text-transform:uppercase;letter-spacing:.04em;margin-bottom:2px}.detail-value{font-size:13px;color:#dcdce4;word-break:break-all}.detail-value.mono{font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px;color:#a0a0b8}.detail-preview{margin-bottom:16px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:5px;overflow:hidden;text-align:center}.detail-preview:has(.markdown-viewer){max-height:none;overflow-y:auto;text-align:left}.detail-preview:not(:has(.markdown-viewer)){max-height:450px}.detail-preview img{max-width:100%;max-height:400px;object-fit:contain;display:block;margin:0 auto}.detail-preview audio{width:100%;padding:16px}.detail-preview video{max-width:100%;max-height:400px;display:block;margin:0 auto}.detail-no-preview{padding:16px 16px;text-align:center;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px}.frontmatter-card{max-width:800px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:12px 16px;margin-bottom:16px}.frontmatter-fields{display:grid;grid-template-columns:auto 1fr;gap:4px 12px;margin:0}.frontmatter-fields dt{font-weight:600;font-size:12px;color:#a0a0b8;text-transform:capitalize}.frontmatter-fields dd{font-size:13px;color:#dcdce4;margin:0}.empty-state{text-align:center;padding:48px 12px;color:#6c6c84}.empty-state .empty-icon{font-size:32px;margin-bottom:12px;opacity:.3}.empty-title{font-size:15px;font-weight:600;color:#a0a0b8;margin-bottom:4px}.empty-subtitle{font-size:12px;max-width:320px;margin:0 auto;line-height:1.5}.toast-container{position:fixed;bottom:16px;right:16px;z-index:300;display:flex;flex-direction:column-reverse;gap:6px;align-items:flex-end}.toast-container .toast{position:static;transform:none}.toast{position:fixed;bottom:16px;right:16px;padding:10px 16px;border-radius:5px;background:#26263a;border:1px solid rgba(255,255,255,.09);color:#dcdce4;font-size:12px;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:300;animation:slide-up .15s ease-out;max-width:420px}.toast.success{border-left:3px solid #3ec97a}.toast.error{border-left:3px solid #e45858}.offline-banner,.error-banner{background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);border-radius:3px;padding:10px 12px;margin-bottom:12px;font-size:12px;color:#d47070;display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px}.offline-banner .offline-icon,.offline-banner .error-icon,.error-banner .offline-icon,.error-banner .error-icon{font-size:14px;flex-shrink:0}.error-banner{padding:10px 14px}.readonly-banner{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 12px;background:rgba(212,160,55,.06);border:1px solid rgba(212,160,55,.15);border-radius:3px;margin-bottom:16px;font-size:12px;color:#d4a037}.batch-actions{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:8px 10px;background:rgba(124,126,245,.15);border:1px solid rgba(124,126,245,.2);border-radius:3px;margin-bottom:12px;font-size:12px;font-weight:500;color:#9698f7}.select-all-banner{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:8px;padding:10px 16px;background:rgba(99,102,241,.08);border-radius:6px;margin-bottom:8px;font-size:.85rem;color:#a0a0b8}.select-all-banner button{background:none;border:none;color:#7c7ef5;cursor:pointer;font-weight:600;text-decoration:underline;font-size:.85rem;padding:0}.select-all-banner button:hover{color:#dcdce4}.import-status-panel{background:#1f1f28;border:1px solid #7c7ef5;border-radius:5px;padding:12px 16px;margin-bottom:16px}.import-status-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:8px;font-size:13px;color:#dcdce4}.import-current-file{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:6px;font-size:12px;overflow:hidden}.import-file-label{color:#6c6c84;flex-shrink:0}.import-file-name{color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:monospace;font-size:11px}.import-queue-indicator{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:8px;font-size:11px}.import-queue-badge{display:flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 6px;background:rgba(124,126,245,.15);color:#9698f7;border-radius:9px;font-weight:600;font-size:10px}.import-queue-text{color:#6c6c84}.import-tabs{display:flex;gap:0;margin-bottom:16px;border-bottom:1px solid rgba(255,255,255,.09)}.import-tab{padding:10px 16px;background:none;border:none;border-bottom:2px solid rgba(0,0,0,0);color:#6c6c84;font-size:12px;font-weight:500;cursor:pointer;transition:color .1s,border-color .1s}.import-tab:hover{color:#dcdce4}.import-tab.active{color:#9698f7;border-bottom-color:#7c7ef5}.queue-panel{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;border-left:1px solid rgba(255,255,255,.09);background:#18181f;min-width:280px;max-width:320px}.queue-header{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid rgba(255,255,255,.06)}.queue-header h3{margin:0;font-size:.9rem;color:#dcdce4}.queue-controls{display:flex;gap:2px}.queue-list{overflow-y:auto;flex:1}.queue-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;padding:8px 16px;cursor:pointer;border-bottom:1px solid rgba(255,255,255,.06);transition:background .15s}.queue-item:hover{background:#1f1f28}.queue-item:hover .queue-item-remove{opacity:1}.queue-item-active{background:rgba(124,126,245,.15);border-left:3px solid #7c7ef5}.queue-item-info{flex:1;min-width:0}.queue-item-title{display:block;font-size:.85rem;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.queue-item-artist{display:block;font-size:.75rem;color:#6c6c84}.queue-item-remove{opacity:0;transition:opacity .15s}.queue-empty{padding:16px 16px;text-align:center;color:#6c6c84;font-size:.85rem}.statistics-page{padding:20px}.stats-overview,.stats-grid{display:grid;grid-template-columns:repeat(3, 1fr);gap:16px;margin-bottom:24px}@media (max-width: 768px){.stats-overview,.stats-grid{grid-template-columns:1fr}}.stat-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:20px;display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:16px}.stat-card.stat-primary{border-left:3px solid #7c7ef5}.stat-card.stat-success{border-left:3px solid #3ec97a}.stat-card.stat-info{border-left:3px solid #6ca0d4}.stat-card.stat-warning{border-left:3px solid #d4a037}.stat-card.stat-purple{border-left:3px solid #9d8be0}.stat-card.stat-danger{border-left:3px solid #e45858}.stat-icon{flex-shrink:0;color:#6c6c84}.stat-content{flex:1}.stat-value{font-size:28px;font-weight:700;color:#dcdce4;line-height:1.2;font-variant-numeric:tabular-nums}.stat-label{font-size:12px;color:#6c6c84;margin-top:4px;font-weight:500}.stats-section{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px;margin-bottom:20px}.section-title{font-size:16px;font-weight:600;color:#dcdce4;margin-bottom:20px}.section-title.small{font-size:14px;margin-bottom:12px;padding-bottom:6px;border-bottom:1px solid rgba(255,255,255,.06)}.chart-bars{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:16px}.bar-item{display:grid;grid-template-columns:120px 1fr 80px;align-items:center;gap:16px}.bar-label{font-size:13px;font-weight:500;color:#a0a0b8;text-align:right}.bar-track{height:28px;background:#26263a;border-radius:3px;overflow:hidden;position:relative}.bar-fill{height:100%;transition:width .6s cubic-bezier(.4, 0, .2, 1);border-radius:3px}.bar-fill.bar-primary{background:linear-gradient(90deg, #7c7ef5 0%, #7c7ef3 100%)}.bar-fill.bar-success{background:linear-gradient(90deg, #3ec97a 0%, #66bb6a 100%)}.bar-value{font-size:13px;font-weight:600;color:#a0a0b8;text-align:right;font-variant-numeric:tabular-nums}.settings-section{margin-bottom:16px}.settings-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:20px;margin-bottom:16px}.settings-card.danger-card{border:1px solid rgba(228,88,88,.25)}.settings-card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid rgba(255,255,255,.06)}.settings-card-title{font-size:14px;font-weight:600}.settings-card-body{padding-top:2px}.settings-field{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.06)}.settings-field:last-child{border-bottom:none}.settings-field select{min-width:120px}.config-path{font-size:11px;color:#6c6c84;margin-bottom:12px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;padding:6px 10px;background:#111118;border-radius:3px;border:1px solid rgba(255,255,255,.06)}.config-status{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600}.config-status.writable{background:rgba(62,201,122,.1);color:#3ec97a}.config-status.readonly{background:rgba(228,88,88,.1);color:#e45858}.root-list{list-style:none}.root-item{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:3px;margin-bottom:4px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px;color:#a0a0b8}.info-row{display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid rgba(255,255,255,.06);font-size:13px}.info-row:last-child{border-bottom:none}.info-label{color:#a0a0b8;font-weight:500}.info-value{color:#dcdce4}.tasks-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(400px, 1fr));gap:16px;padding:12px}.task-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden;transition:all .2s}.task-card:hover{border-color:rgba(255,255,255,.14);box-shadow:0 4px 12px rgba(0,0,0,.08);transform:translateY(-2px)}.task-card-enabled{border-left:3px solid #3ec97a}.task-card-disabled{border-left:3px solid #4a4a5e;opacity:.7}.task-card-header{display:flex;justify-content:space-between;align-items:center;align-items:flex-start;padding:16px;border-bottom:1px solid rgba(255,255,255,.06)}.task-header-left{flex:1;min-width:0}.task-name{font-size:16px;font-weight:600;color:#dcdce4;margin-bottom:2px}.task-schedule{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:12px;color:#6c6c84;font-family:"Menlo","Monaco","Courier New",monospace}.schedule-icon{font-size:14px}.task-status-badge{flex-shrink:0}.status-badge{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:2px 10px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}.status-badge.status-enabled{background:rgba(76,175,80,.12);color:#3ec97a}.status-badge.status-enabled .status-dot{animation:pulse 1.5s infinite}.status-badge.status-disabled{background:#26263a;color:#6c6c84}.status-badge .status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;background:currentColor}.task-info-grid{display:grid;grid-template-columns:repeat(auto-fit, minmax(120px, 1fr));gap:12px;padding:16px}.task-info-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:flex-start;gap:10px}.task-info-icon{font-size:18px;color:#6c6c84;flex-shrink:0}.task-info-content{flex:1;min-width:0}.task-info-label{font-size:10px;color:#6c6c84;font-weight:600;text-transform:uppercase;letter-spacing:.03em;margin-bottom:2px}.task-info-value{font-size:12px;color:#a0a0b8;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.task-card-actions{display:flex;gap:8px;padding:10px 16px;background:#18181f;border-top:1px solid rgba(255,255,255,.06)}.task-card-actions button{flex:1}.db-actions{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:16px;padding:10px}.db-action-row{display:flex;flex-direction:row;justify-content:space-between;align-items:center;gap:16px;padding:10px;border-radius:6px;background:rgba(0,0,0,.06)}.db-action-info{flex:1}.db-action-info h4{font-size:.95rem;font-weight:600;color:#dcdce4;margin-bottom:2px}.db-action-confirm{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;flex-shrink:0}.library-toolbar{display:flex;justify-content:space-between;align-items:center;padding:8px 0;margin-bottom:12px;gap:12px;flex-wrap:wrap}.toolbar-left{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:10px}.toolbar-right{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:10px}.sort-control select,.page-size-control select{padding:4px 24px 4px 8px;font-size:11px;background:#1f1f28}.page-size-control{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px}.library-stats{display:flex;justify-content:space-between;align-items:center;padding:2px 0 6px 0;font-size:11px}.type-filter-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:4px 0;margin-bottom:6px;flex-wrap:wrap}.pagination{display:flex;align-items:center;justify-content:center;gap:4px;margin-top:16px;padding:8px 0}.page-btn{min-width:28px;text-align:center;font-variant-numeric:tabular-nums}.page-ellipsis{color:#6c6c84;padding:0 4px;font-size:12px;user-select:none}.audit-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:12px}.filter-select{padding:4px 24px 4px 8px;font-size:11px;background:#1f1f28}.action-danger{background:rgba(228,88,88,.1);color:#d47070}.action-updated{background:rgba(59,120,200,.1);color:#6ca0d4}.action-collection{background:rgba(34,160,80,.1);color:#5cb97a}.action-collection-remove{background:rgba(212,160,55,.1);color:#c4a840}.action-opened{background:rgba(139,92,246,.1);color:#9d8be0}.action-scanned{background:rgba(128,128,160,.08);color:#6c6c84}.clickable{cursor:pointer;color:#9698f7}.clickable:hover{text-decoration:underline}.clickable-row{cursor:pointer}.clickable-row:hover{background:rgba(255,255,255,.03)}.duplicates-view{padding:0}.duplicates-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.duplicates-header h3{margin:0}.duplicates-summary{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px}.duplicate-group{border:1px solid rgba(255,255,255,.09);border-radius:5px;margin-bottom:8px;overflow:hidden}.duplicate-group-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;width:100%;padding:10px 14px;background:#1f1f28;border:none;cursor:pointer;text-align:left;color:#dcdce4;font-size:13px}.duplicate-group-header:hover{background:#26263a}.expand-icon{font-size:10px;width:14px;flex-shrink:0}.group-name{font-weight:600;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.group-badge{background:#7c7ef5;color:#fff;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;flex-shrink:0}.group-size{flex-shrink:0;font-size:12px}.group-hash{font-size:11px;flex-shrink:0}.duplicate-items{border-top:1px solid rgba(255,255,255,.09)}.duplicate-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.06)}.duplicate-item:last-child{border-bottom:none}.duplicate-item-keep{background:rgba(76,175,80,.06)}.dup-thumb{width:48px;height:48px;flex-shrink:0;border-radius:3px;overflow:hidden}.dup-thumb-img{width:100%;height:100%;object-fit:cover}.dup-thumb-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#26263a;font-size:20px;color:#6c6c84}.dup-info{flex:1;min-width:0}.dup-filename{font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dup-path{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dup-meta{font-size:12px;margin-top:2px}.dup-actions{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;flex-shrink:0}.keep-badge{background:rgba(76,175,80,.12);color:#4caf50;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:600}.saved-searches-list{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:4px;max-height:300px;overflow-y:auto}.saved-search-item{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:#18181f;border-radius:3px;cursor:pointer;transition:background .15s ease}.saved-search-item:hover{background:#1f1f28}.saved-search-info{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:2px;flex:1;min-width:0}.saved-search-name{font-weight:500;color:#dcdce4}.saved-search-query{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.backlinks-panel,.outgoing-links-panel{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;margin-top:16px;overflow:hidden}.backlinks-header,.outgoing-links-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 14px;background:#26263a;cursor:pointer;user-select:none;transition:background .1s}.backlinks-header:hover,.outgoing-links-header:hover{background:rgba(255,255,255,.04)}.backlinks-toggle,.outgoing-links-toggle{font-size:10px;color:#6c6c84;width:12px;text-align:center}.backlinks-title,.outgoing-links-title{font-size:12px;font-weight:600;color:#dcdce4;flex:1}.backlinks-count,.outgoing-links-count{font-size:11px;color:#6c6c84}.backlinks-reindex-btn{display:flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;margin-left:auto;background:rgba(0,0,0,0);border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#6c6c84;font-size:12px;cursor:pointer;transition:background .1s,color .1s,border-color .1s}.backlinks-reindex-btn:hover:not(:disabled){background:#1f1f28;color:#dcdce4;border-color:rgba(255,255,255,.14)}.backlinks-reindex-btn:disabled{opacity:.5;cursor:not-allowed}.backlinks-content,.outgoing-links-content{padding:12px;border-top:1px solid rgba(255,255,255,.06)}.backlinks-loading,.outgoing-links-loading{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:12px;color:#6c6c84;font-size:12px}.backlinks-error,.outgoing-links-error{padding:8px 12px;background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);border-radius:3px;font-size:12px;color:#e45858}.backlinks-empty,.outgoing-links-empty{padding:16px;text-align:center;color:#6c6c84;font-size:12px;font-style:italic}.backlinks-list,.outgoing-links-list{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:6px}.backlink-item,.outgoing-link-item{padding:10px 12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:3px;cursor:pointer;transition:background .1s,border-color .1s}.backlink-item:hover,.outgoing-link-item:hover{background:#18181f;border-color:rgba(255,255,255,.09)}.backlink-item.unresolved,.outgoing-link-item.unresolved{opacity:.7;border-style:dashed}.backlink-source,.outgoing-link-target{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:2px}.backlink-title,.outgoing-link-text{font-size:13px;font-weight:500;color:#dcdce4;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.backlink-type-badge,.outgoing-link-type-badge{display:inline-block;padding:1px 6px;border-radius:12px;font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}.backlink-type-badge.backlink-type-wikilink,.backlink-type-badge.link-type-wikilink,.outgoing-link-type-badge.backlink-type-wikilink,.outgoing-link-type-badge.link-type-wikilink{background:rgba(124,126,245,.15);color:#9698f7}.backlink-type-badge.backlink-type-embed,.backlink-type-badge.link-type-embed,.outgoing-link-type-badge.backlink-type-embed,.outgoing-link-type-badge.link-type-embed{background:rgba(139,92,246,.1);color:#9d8be0}.backlink-type-badge.backlink-type-markdown_link,.backlink-type-badge.link-type-markdown_link,.outgoing-link-type-badge.backlink-type-markdown_link,.outgoing-link-type-badge.link-type-markdown_link{background:rgba(59,120,200,.1);color:#6ca0d4}.backlink-context{font-size:11px;color:#6c6c84;line-height:1.4;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.backlink-line{color:#a0a0b8;font-weight:500}.unresolved-badge{padding:1px 6px;border-radius:12px;font-size:9px;font-weight:600;background:rgba(212,160,55,.1);color:#d4a037}.outgoing-links-unresolved-badge{margin-left:8px;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:500;background:rgba(212,160,55,.12);color:#d4a037}.outgoing-links-global-unresolved{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;margin-top:12px;padding:10px 12px;background:rgba(212,160,55,.06);border:1px solid rgba(212,160,55,.15);border-radius:3px;font-size:11px;color:#6c6c84}.outgoing-links-global-unresolved .unresolved-icon{color:#d4a037}.backlinks-message{padding:8px 10px;margin-bottom:10px;border-radius:3px;font-size:11px}.backlinks-message.success{background:rgba(62,201,122,.08);border:1px solid rgba(62,201,122,.2);color:#3ec97a}.backlinks-message.error{background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);color:#e45858}.graph-view{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;height:100%;background:#18181f;border-radius:5px;overflow:hidden}.graph-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:16px;padding:12px 16px;background:#1f1f28;border-bottom:1px solid rgba(255,255,255,.09)}.graph-title{font-size:14px;font-weight:600;color:#dcdce4}.graph-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;font-size:12px;color:#a0a0b8}.graph-controls select{padding:4px 20px 4px 8px;font-size:11px;background:#26263a}.graph-stats{margin-left:auto;font-size:11px;color:#6c6c84}.graph-container{flex:1;position:relative;display:flex;align-items:center;justify-content:center;overflow:hidden;background:#111118}.graph-loading,.graph-error,.graph-empty{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px;padding:48px;color:#6c6c84;font-size:13px;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:16px;left:16px;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;z-index:5}.zoom-btn{width:36px;height:36px;border-radius:6px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);color:#dcdce4;font-size:18px;font-weight:bold;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:all .15s;box-shadow:0 1px 3px rgba(0,0,0,.3)}.zoom-btn:hover{background:#26263a;border-color:rgba(255,255,255,.14);transform:scale(1.05)}.zoom-btn:active{transform:scale(.95)}.graph-edges line{stroke:rgba(255,255,255,.14);stroke-width:1;opacity:.6}.graph-edges line.edge-type-wikilink{stroke:#7c7ef5}.graph-edges line.edge-type-embed{stroke:#9d8be0;stroke-dasharray:4 2}.graph-nodes .graph-node{cursor:pointer}.graph-nodes .graph-node circle{fill:#4caf50;stroke:#388e3c;stroke-width:2;transition:fill .15s,stroke .15s}.graph-nodes .graph-node:hover circle{fill:#66bb6a}.graph-nodes .graph-node.selected circle{fill:#7c7ef5;stroke:#5456d6}.graph-nodes .graph-node text{fill:#a0a0b8;font-size:11px;pointer-events:none;text-anchor:middle;dominant-baseline:central;transform:translateY(16px)}.node-details-panel{position:absolute;top:16px;right:16px;width:280px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:10}.node-details-header{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.06)}.node-details-header h3{font-size:13px;font-weight:600;color:#dcdce4;margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.node-details-header .close-btn{background:none;border:none;color:#6c6c84;cursor:pointer;font-size:14px;padding:2px 6px;line-height:1}.node-details-header .close-btn:hover{color:#dcdce4}.node-details-content{padding:14px}.node-details-content .node-title{font-size:12px;color:#a0a0b8;margin-bottom:12px}.node-stats{display:flex;gap:16px;margin-bottom:12px}.node-stats .stat{font-size:12px;color:#6c6c84}.node-stats .stat strong{color:#dcdce4}.physics-controls-panel{position:absolute;top:16px;right:16px;width:300px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;box-shadow:0 2px 8px rgba(0,0,0,.35);padding:16px;z-index:10}.physics-controls-panel h4{font-size:13px;font-weight:600;color:#dcdce4;margin:0 0 16px 0;padding-bottom:8px;border-bottom:1px solid rgba(255,255,255,.06)}.physics-controls-panel .btn{width:100%;margin-top:8px}.control-group{margin-bottom:14px}.control-group label{display:block;font-size:11px;font-weight:500;color:#a0a0b8;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px}.control-group input[type=range]{width:100%;height:4px;border-radius:4px;background:#26263a;outline:none;-webkit-appearance:none}.control-group input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;border-radius:50%;background:#7c7ef5;cursor:pointer;transition:transform .1s}.control-group input[type=range]::-webkit-slider-thumb:hover{transform:scale(1.15)}.control-group input[type=range]::-moz-range-thumb{width:14px;height:14px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none;transition:transform .1s}.control-group input[type=range]::-moz-range-thumb:hover{transform:scale(1.15)}.control-value{display:inline-block;margin-top:2px;font-size:11px;color:#6c6c84;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace}.theme-light{--bg-0: #f5f5f7;--bg-1: #eeeef0;--bg-2: #fff;--bg-3: #e8e8ec;--border-subtle: rgba(0,0,0,.06);--border: rgba(0,0,0,.1);--border-strong: rgba(0,0,0,.16);--text-0: #1a1a2e;--text-1: #555570;--text-2: #8888a0;--accent: #6366f1;--accent-dim: rgba(99,102,241,.1);--accent-text: #4f52e8;--shadow-sm: 0 1px 3px rgba(0,0,0,.08);--shadow: 0 2px 8px rgba(0,0,0,.1);--shadow-lg: 0 4px 20px rgba(0,0,0,.12)}.theme-light ::-webkit-scrollbar-thumb{background:rgba(0,0,0,.12)}.theme-light ::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.08)}.theme-light ::-webkit-scrollbar-track{background:rgba(0,0,0,.06)}.theme-light .graph-nodes .graph-node text{fill:#1a1a2e}.theme-light .graph-edges line{stroke:rgba(0,0,0,.12)}.theme-light .pdf-container{background:#e8e8ec}.skeleton-pulse{animation:skeleton-pulse 1.5s ease-in-out infinite;background:#26263a;border-radius:4px}.skeleton-card{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;padding:8px}.skeleton-thumb{width:100%;aspect-ratio:1;border-radius:6px}.skeleton-text{height:14px;width:80%}.skeleton-text-short{width:50%}.skeleton-row{display:flex;gap:12px;padding:10px 16px;align-items:center}.skeleton-cell{height:14px;flex:1;border-radius:4px}.skeleton-cell-icon{width:32px;height:32px;flex:none;border-radius:4px}.skeleton-cell-wide{flex:3}.loading-overlay{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px;background:rgba(0,0,0,.3);z-index:100;border-radius:8px}.loading-spinner{width:32px;height:32px;border:3px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .8s linear infinite}.loading-message{color:#a0a0b8;font-size:.9rem}.login-container{display:flex;align-items:center;justify-content:center;height:100vh;background:#111118}.login-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:24px;width:360px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.login-title{font-size:20px;font-weight:700;color:#dcdce4;text-align:center;margin-bottom:2px}.login-subtitle{font-size:13px;color:#6c6c84;text-align:center;margin-bottom:20px}.login-error{background:rgba(228,88,88,.08);border:1px solid rgba(228,88,88,.2);border-radius:3px;padding:8px 12px;margin-bottom:12px;font-size:12px;color:#e45858}.login-form input[type=text],.login-form input[type=password]{width:100%}.login-btn{width:100%;padding:8px 16px;font-size:13px;margin-top:2px}.pagination{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:2px;margin-top:16px;padding:8px 0}.page-btn{min-width:28px;text-align:center;font-variant-numeric:tabular-nums}.page-ellipsis{color:#6c6c84;padding:0 4px;font-size:12px;user-select:none}.help-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:200;animation:fade-in .1s ease-out}.help-dialog{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:16px;min-width:300px;max-width:400px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.help-dialog h3{font-size:16px;font-weight:600;margin-bottom:16px}.help-shortcuts{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;margin-bottom:16px}.shortcut-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px}.shortcut-row kbd{display:inline-block;padding:2px 8px;background:#111118;border:1px solid rgba(255,255,255,.09);border-radius:3px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px;color:#dcdce4;min-width:32px;text-align:center}.shortcut-row span{font-size:13px;color:#a0a0b8}.help-close{display:block;width:100%;padding:6px 12px;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#dcdce4;font-size:12px;cursor:pointer;text-align:center}.help-close:hover{background:rgba(255,255,255,.06)} \ No newline at end of file +@media (prefers-reduced-motion: reduce){*,*::before,*::after{animation-duration:.01ms !important;animation-iteration-count:1 !important;transition-duration:.01ms !important}}*{margin:0;padding:0;box-sizing:border-box;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.06) rgba(0,0,0,0)}*::-webkit-scrollbar{width:5px;height:5px}*::-webkit-scrollbar-track{background:rgba(0,0,0,0)}*::-webkit-scrollbar-thumb{background:rgba(255,255,255,.06);border-radius:3px}*::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.14)}:root{--bg-0: #111118;--bg-1: #18181f;--bg-2: #1f1f28;--bg-3: #26263a;--border-subtle: rgba(255,255,255,.06);--border: rgba(255,255,255,.09);--border-strong: rgba(255,255,255,.14);--text-0: #dcdce4;--text-1: #a0a0b8;--text-2: #6c6c84;--accent: #7c7ef5;--accent-dim: rgba(124,126,245,.15);--accent-text: #9698f7;--success: #3ec97a;--error: #e45858;--warning: #d4a037;--radius-sm: 3px;--radius: 5px;--radius-md: 7px;--shadow-sm: 0 1px 3px rgba(0,0,0,.3);--shadow: 0 2px 8px rgba(0,0,0,.35);--shadow-lg: 0 4px 20px rgba(0,0,0,.45)}body{font-family:"Inter",-apple-system,"Segoe UI",system-ui,sans-serif;background:var(--bg-0);color:var(--text-0);font-size:13px;line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden}:focus-visible:focus-visible{outline:2px solid #7c7ef5;outline-offset:2px}::selection{background:rgba(124,126,245,.15);color:#9698f7}a{color:#9698f7;text-decoration:none}a:hover{text-decoration:underline}code{padding:1px 5px;border-radius:3px;background:#111118;color:#9698f7;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px}ul{list-style:none;padding:0}ul li{padding:3px 0;font-size:12px;color:#a0a0b8}.text-muted{color:#a0a0b8}.text-sm{font-size:11px}.mono{font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px}.flex-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px}.flex-between{display:flex;justify-content:space-between;align-items:center}.mb-16{margin-bottom:16px}.mb-8{margin-bottom:12px}@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:.3}}@keyframes spin{to{transform:rotate(360deg)}}@keyframes skeleton-pulse{0%{opacity:.6}50%{opacity:.3}100%{opacity:.6}}@keyframes indeterminate{0%{transform:translateX(-100%)}100%{transform:translateX(400%)}}.app{display:flex;flex-direction:row;justify-content:flex-start;align-items:stretch;height:100vh;overflow:hidden}.sidebar{width:220px;min-width:220px;max-width:220px;background:#18181f;border-right:1px solid rgba(255,255,255,.09);display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;flex-shrink:0;user-select:none;overflow-y:auto;overflow-x:hidden;z-index:10;transition:width .15s,min-width .15s,max-width .15s}.sidebar.collapsed{width:48px;min-width:48px;max-width:48px}.sidebar.collapsed .nav-label,.sidebar.collapsed .sidebar-header .logo,.sidebar.collapsed .sidebar-header .version,.sidebar.collapsed .nav-badge,.sidebar.collapsed .nav-item-text,.sidebar.collapsed .sidebar-footer .status-text,.sidebar.collapsed .user-name,.sidebar.collapsed .role-badge,.sidebar.collapsed .user-info .btn,.sidebar.collapsed .sidebar-import-header span,.sidebar.collapsed .sidebar-import-file{display:none}.sidebar.collapsed .nav-item{justify-content:center;padding:8px;border-left:none;border-radius:3px}.sidebar.collapsed .nav-item.active{border-left:none}.sidebar.collapsed .nav-icon{width:auto;margin:0}.sidebar.collapsed .sidebar-header{padding:12px 8px;justify-content:center}.sidebar.collapsed .nav-section{padding:0 4px}.sidebar.collapsed .sidebar-footer{padding:8px}.sidebar.collapsed .sidebar-footer .user-info{justify-content:center;padding:4px}.sidebar.collapsed .sidebar-import-progress{padding:6px}.sidebar-header{padding:16px 16px 20px;display:flex;flex-direction:row;justify-content:flex-start;align-items:baseline;gap:8px}.sidebar-header .logo{font-size:15px;font-weight:700;letter-spacing:-.4px;color:#dcdce4}.sidebar-header .version{font-size:10px;color:#6c6c84}.sidebar-toggle{background:rgba(0,0,0,0);border:none;color:#6c6c84;padding:8px;font-size:18px;width:100%;text-align:center}.sidebar-toggle:hover{color:#dcdce4}.sidebar-spacer{flex:1}.sidebar-footer{padding:12px;border-top:1px solid rgba(255,255,255,.06);overflow:visible;min-width:0}.nav-section{padding:0 8px;margin-bottom:2px}.nav-label{padding:8px 8px 4px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#6c6c84}.nav-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:6px 8px;border-radius:3px;cursor:pointer;color:#a0a0b8;font-size:13px;font-weight:450;transition:color .1s,background .1s;border:none;background:none;width:100%;text-align:left;border-left:2px solid rgba(0,0,0,0);margin-left:0}.nav-item:hover{color:#dcdce4;background:rgba(255,255,255,.03)}.nav-item.active{color:#9698f7;border-left-color:#7c7ef5;background:rgba(124,126,245,.15)}.nav-item-text{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.sidebar:not(.collapsed) .nav-item-text{overflow:visible}.nav-icon{width:18px;text-align:center;font-size:14px;opacity:.7}.nav-badge{margin-left:auto;font-size:10px;font-weight:600;color:#6c6c84;background:#26263a;padding:1px 6px;border-radius:12px;min-width:20px;text-align:center;font-variant-numeric:tabular-nums}.status-indicator{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:6px;font-size:11px;font-weight:500;min-width:0;overflow:visible}.sidebar:not(.collapsed) .status-indicator{justify-content:flex-start}.status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}.status-dot.connected{background:#3ec97a}.status-dot.disconnected{background:#e45858}.status-dot.checking{background:#d4a037;animation:pulse 1.5s infinite}.status-text{color:#6c6c84;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.sidebar:not(.collapsed) .status-text{overflow:visible}.main{flex:1;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;overflow:hidden;min-width:0}.header{height:48px;min-height:48px;border-bottom:1px solid rgba(255,255,255,.06);display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:0 20px;background:#18181f}.page-title{font-size:14px;font-weight:600;color:#dcdce4}.header-spacer{flex:1}.content{flex:1;overflow-y:auto;padding:20px;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.06) rgba(0,0,0,0)}.sidebar-import-progress{padding:10px 12px;background:#1f1f28;border-top:1px solid rgba(255,255,255,.06);font-size:11px}.sidebar-import-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;margin-bottom:4px;color:#a0a0b8}.sidebar-import-file{color:#6c6c84;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px}.sidebar-import-progress .progress-bar{height:3px}.user-info{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:12px;overflow:hidden;min-width:0}.user-name{font-weight:500;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:90px;flex-shrink:1}.role-badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase}.role-badge.role-admin{background:rgba(139,92,246,.1);color:#9d8be0}.role-badge.role-editor{background:rgba(34,160,80,.1);color:#5cb97a}.role-badge.role-viewer{background:rgba(59,120,200,.1);color:#6ca0d4}.btn{padding:5px 12px;border-radius:3px;border:none;cursor:pointer;font-size:12px;font-weight:500;transition:all .1s;display:inline-flex;align-items:center;gap:5px;white-space:nowrap;line-height:1.5}.btn-primary{background:#7c7ef5;color:#fff}.btn-primary:hover{background:#8b8df7}.btn-secondary{background:#26263a;color:#dcdce4;border:1px solid rgba(255,255,255,.09)}.btn-secondary:hover{border-color:rgba(255,255,255,.14);background:rgba(255,255,255,.06)}.btn-danger{background:rgba(0,0,0,0);color:#e45858;border:1px solid rgba(228,88,88,.25)}.btn-danger:hover{background:rgba(228,88,88,.08)}.btn-ghost{background:rgba(0,0,0,0);border:none;color:#a0a0b8;padding:5px 8px}.btn-ghost:hover{color:#dcdce4;background:rgba(255,255,255,.04)}.btn-sm{padding:3px 8px;font-size:11px}.btn-icon{padding:4px;border-radius:3px;background:rgba(0,0,0,0);border:none;color:#6c6c84;cursor:pointer;transition:color .1s;font-size:13px}.btn-icon:hover{color:#dcdce4}.btn:disabled,.btn[disabled]{opacity:.4;cursor:not-allowed;pointer-events:none}.btn.btn-disabled-hint:disabled{opacity:.6;border-style:dashed;pointer-events:auto;cursor:help}.card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px}.card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.card-title{font-size:14px;font-weight:600}.data-table{width:100%;border-collapse:collapse;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden}.data-table thead th{padding:8px 14px;text-align:left;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#6c6c84;border-bottom:1px solid rgba(255,255,255,.09);background:#26263a}.data-table tbody td{padding:8px 14px;font-size:13px;border-bottom:1px solid rgba(255,255,255,.06);max-width:300px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.data-table tbody tr{cursor:pointer;transition:background .08s}.data-table tbody tr:hover{background:rgba(255,255,255,.02)}.data-table tbody tr.row-selected{background:rgba(99,102,241,.12)}.data-table tbody tr:last-child td{border-bottom:none}.sortable-header{cursor:pointer;user-select:none;transition:color .1s}.sortable-header:hover{color:#9698f7}input[type=text],textarea,select{padding:6px 10px;border-radius:3px;border:1px solid rgba(255,255,255,.09);background:#111118;color:#dcdce4;font-size:13px;outline:none;transition:border-color .15s;font-family:inherit}input[type=text]::placeholder,textarea::placeholder,select::placeholder{color:#6c6c84}input[type=text]:focus,textarea:focus,select:focus{border-color:#7c7ef5}input[type=text][type=number],textarea[type=number],select[type=number]{width:80px;padding:6px 8px;-moz-appearance:textfield}input[type=text][type=number]::-webkit-outer-spin-button,input[type=text][type=number]::-webkit-inner-spin-button,textarea[type=number]::-webkit-outer-spin-button,textarea[type=number]::-webkit-inner-spin-button,select[type=number]::-webkit-outer-spin-button,select[type=number]::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}textarea{min-height:64px;resize:vertical}select{appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%236c6c84' d='M5 7L1 3h8z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 8px center;padding-right:26px;min-width:100px}.form-group{margin-bottom:12px}.form-label{display:block;font-size:11px;font-weight:600;color:#a0a0b8;margin-bottom:4px;text-transform:uppercase;letter-spacing:.03em}.form-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:flex-end;gap:8px}.form-row input[type=text]{flex:1}.form-label-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:4px}.form-label-row .form-label{margin-bottom:0}input[type=checkbox]{appearance:none;-webkit-appearance:none;width:16px;height:16px;border:1px solid rgba(255,255,255,.14);border-radius:3px;background:#1f1f28;cursor:pointer;position:relative;flex-shrink:0;transition:all .15s ease}input[type=checkbox]:hover{border-color:#7c7ef5;background:#26263a}input[type=checkbox]:checked{background:#7c7ef5;border-color:#7c7ef5}input[type=checkbox]:checked::after{content:"";position:absolute;left:5px;top:2px;width:4px;height:8px;border:solid #111118;border-width:0 2px 2px 0;transform:rotate(45deg)}input[type=checkbox]:focus-visible:focus-visible{outline:2px solid #7c7ef5;outline-offset:2px}.checkbox-label{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:#a0a0b8;user-select:none}.checkbox-label:hover{color:#dcdce4}.checkbox-label input[type=checkbox]{margin:0}.toggle{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:#dcdce4}.toggle.disabled{opacity:.4;cursor:not-allowed}.toggle-track{width:32px;height:18px;border-radius:9px;background:#26263a;border:1px solid rgba(255,255,255,.09);position:relative;transition:background .15s;flex-shrink:0}.toggle-track.active{background:#7c7ef5;border-color:#7c7ef5}.toggle-track.active .toggle-thumb{transform:translateX(14px)}.toggle-thumb{width:14px;height:14px;border-radius:50%;background:#dcdce4;position:absolute;top:1px;left:1px;transition:transform .15s}.filter-bar{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:12px;padding:12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:5px;margin-bottom:12px}.filter-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.filter-label{font-size:11px;font-weight:500;color:#6c6c84;text-transform:uppercase;letter-spacing:.5px;margin-right:4px}.filter-chip{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:14px;cursor:pointer;font-size:11px;color:#a0a0b8;transition:all .15s ease;user-select:none}.filter-chip:hover{background:#26263a;border-color:rgba(255,255,255,.14);color:#dcdce4}.filter-chip.active{background:rgba(124,126,245,.15);border-color:#7c7ef5;color:#9698f7}.filter-chip input[type=checkbox]{width:12px;height:12px;margin:0}.filter-chip input[type=checkbox]:checked::after{left:3px;top:1px;width:3px;height:6px}.filter-group{display:flex;align-items:center;gap:6px}.filter-group label{display:flex;align-items:center;gap:3px;cursor:pointer;color:#a0a0b8;font-size:11px;white-space:nowrap}.filter-group label:hover{color:#dcdce4}.filter-separator{width:1px;height:20px;background:rgba(255,255,255,.09);flex-shrink:0}.view-toggle{display:flex;border:1px solid rgba(255,255,255,.09);border-radius:3px;overflow:hidden}.view-btn{padding:4px 10px;background:#1f1f28;border:none;color:#6c6c84;cursor:pointer;font-size:18px;line-height:1;transition:background .1s,color .1s}.view-btn:first-child{border-right:1px solid rgba(255,255,255,.09)}.view-btn:hover{color:#dcdce4;background:#26263a}.view-btn.active{background:rgba(124,126,245,.15);color:#9698f7}.breadcrumb{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px;padding:10px 16px;font-size:.85rem;color:#6c6c84}.breadcrumb-sep{color:#6c6c84;opacity:.5}.breadcrumb-link{color:#9698f7;text-decoration:none;cursor:pointer}.breadcrumb-link:hover{text-decoration:underline}.breadcrumb-current{color:#dcdce4;font-weight:500}.progress-bar{width:100%;height:8px;background:#26263a;border-radius:4px;overflow:hidden;margin-bottom:6px}.progress-fill{height:100%;background:#7c7ef5;border-radius:4px;transition:width .3s ease}.progress-fill.indeterminate{width:30%;animation:indeterminate 1.5s ease-in-out infinite}.loading-overlay{display:flex;align-items:center;justify-content:center;padding:48px 16px;color:#6c6c84;font-size:13px;gap:10px}.spinner{width:18px;height:18px;border:2px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.spinner-small{width:14px;height:14px;border:2px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.spinner-tiny{width:10px;height:10px;border:1.5px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:100;animation:fade-in .1s ease-out}.modal{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:20px;min-width:360px;max-width:480px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.modal.wide{max-width:600px;max-height:70vh;overflow-y:auto}.modal-title{font-size:15px;font-weight:600;margin-bottom:6px}.modal-body{font-size:12px;color:#a0a0b8;margin-bottom:16px;line-height:1.5}.modal-actions{display:flex;flex-direction:row;justify-content:flex-end;align-items:center;gap:6px}.tooltip-trigger{display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border-radius:50%;background:#26263a;color:#6c6c84;font-size:9px;font-weight:700;cursor:help;position:relative;flex-shrink:0;margin-left:4px}.tooltip-trigger:hover{background:rgba(124,126,245,.15);color:#9698f7}.tooltip-trigger:hover .tooltip-text{display:block}.tooltip-text{display:none;position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);padding:6px 10px;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#dcdce4;font-size:11px;font-weight:400;line-height:1.4;white-space:normal;width:220px;text-transform:none;letter-spacing:normal;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:100;pointer-events:none}.media-player{position:relative;background:#111118;border-radius:5px;overflow:hidden}.media-player:focus{outline:none}.media-player-audio .player-artwork{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:8px;padding:24px 16px 8px}.player-artwork img{max-width:200px;max-height:200px;border-radius:5px;object-fit:cover}.player-artwork-placeholder{width:120px;height:120px;display:flex;align-items:center;justify-content:center;background:#1f1f28;border-radius:5px;font-size:48px;opacity:.3}.player-title{font-size:13px;font-weight:500;color:#dcdce4;text-align:center}.player-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 14px;background:#1f1f28}.media-player-video .player-controls{position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,.7);opacity:0;transition:opacity .2s}.media-player-video:hover .player-controls{opacity:1}.play-btn,.mute-btn,.fullscreen-btn{background:none;border:none;color:#dcdce4;cursor:pointer;font-size:18px;padding:4px;line-height:1;transition:color .1s}.play-btn:hover,.mute-btn:hover,.fullscreen-btn:hover{color:#9698f7}.player-time{font-size:11px;color:#6c6c84;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;min-width:36px;text-align:center;user-select:none}.seek-bar{flex:1;-webkit-appearance:none;appearance:none;height:4px;border-radius:4px;background:#26263a;outline:none;cursor:pointer}.seek-bar::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:12px;height:12px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none}.seek-bar::-moz-range-thumb{width:12px;height:12px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none}.volume-slider{width:70px;-webkit-appearance:none;appearance:none;height:4px;border-radius:4px;background:#26263a;outline:none;cursor:pointer}.volume-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:10px;height:10px;border-radius:50%;background:#a0a0b8;cursor:pointer;border:none}.volume-slider::-moz-range-thumb{width:10px;height:10px;border-radius:50%;background:#a0a0b8;cursor:pointer;border:none}.image-viewer-overlay{position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:150;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;animation:fade-in .15s ease-out}.image-viewer-overlay:focus{outline:none}.image-viewer-toolbar{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;background:rgba(0,0,0,.5);border-bottom:1px solid rgba(255,255,255,.08);z-index:2;user-select:none}.image-viewer-toolbar-left,.image-viewer-toolbar-center,.image-viewer-toolbar-right{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px}.iv-btn{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);color:#dcdce4;border-radius:3px;padding:4px 10px;font-size:12px;cursor:pointer;transition:background .1s}.iv-btn:hover{background:rgba(255,255,255,.12)}.iv-btn.iv-close{color:#e45858;font-weight:600}.iv-zoom-label{font-size:11px;color:#a0a0b8;min-width:40px;text-align:center;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace}.image-viewer-canvas{flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative}.image-viewer-canvas img{max-width:100%;max-height:100%;object-fit:contain;user-select:none;-webkit-user-drag:none}.pdf-viewer{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;height:100%;min-height:500px;background:#111118;border-radius:5px;overflow:hidden}.pdf-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:10px 12px;background:#18181f;border-bottom:1px solid rgba(255,255,255,.09)}.pdf-toolbar-group{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px}.pdf-toolbar-btn{display:flex;align-items:center;justify-content:center;width:28px;height:28px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#a0a0b8;font-size:14px;cursor:pointer;transition:all .15s}.pdf-toolbar-btn:hover:not(:disabled){background:#26263a;color:#dcdce4}.pdf-toolbar-btn:disabled{opacity:.4;cursor:not-allowed}.pdf-zoom-label{min-width:45px;text-align:center;font-size:12px;color:#a0a0b8}.pdf-container{flex:1;position:relative;overflow:hidden;background:#1f1f28}.pdf-object{width:100%;height:100%;border:none}.pdf-loading,.pdf-error{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:12px;background:#18181f;color:#a0a0b8}.pdf-error{padding:12px;text-align:center}.pdf-fallback{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:16px;padding:48px 12px;text-align:center;color:#6c6c84}.markdown-viewer{padding:16px;text-align:left;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:12px}.markdown-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:8px;background:#1f1f28;border-radius:5px;border:1px solid rgba(255,255,255,.09)}.toolbar-btn{padding:6px 12px;border:1px solid rgba(255,255,255,.09);border-radius:3px;background:#18181f;color:#a0a0b8;font-size:13px;font-weight:500;cursor:pointer;transition:all .15s}.toolbar-btn:hover{background:#26263a;border-color:rgba(255,255,255,.14)}.toolbar-btn.active{background:#7c7ef5;color:#fff;border-color:#7c7ef5}.markdown-source{max-width:100%;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px;overflow-x:auto;font-family:"Menlo","Monaco","Courier New",monospace;font-size:13px;line-height:1.7;color:#dcdce4;white-space:pre-wrap;word-wrap:break-word}.markdown-source code{font-family:inherit;background:none;padding:0;border:none}.markdown-content{max-width:800px;color:#dcdce4;line-height:1.7;font-size:14px;text-align:left}.markdown-content h1{font-size:1.8em;font-weight:700;margin:1em 0 .5em;border-bottom:1px solid rgba(255,255,255,.06);padding-bottom:.3em}.markdown-content h2{font-size:1.5em;font-weight:600;margin:.8em 0 .4em;border-bottom:1px solid rgba(255,255,255,.06);padding-bottom:.2em}.markdown-content h3{font-size:1.25em;font-weight:600;margin:.6em 0 .3em}.markdown-content h4{font-size:1.1em;font-weight:600;margin:.5em 0 .25em}.markdown-content h5,.markdown-content h6{font-size:1em;font-weight:600;margin:.4em 0 .2em;color:#a0a0b8}.markdown-content p{margin:0 0 1em}.markdown-content a{color:#7c7ef5;text-decoration:none}.markdown-content a:hover{text-decoration:underline}.markdown-content pre{background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;padding:12px 16px;overflow-x:auto;margin:0 0 1em;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px;line-height:1.5}.markdown-content code{background:#26263a;padding:1px 5px;border-radius:3px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:.9em}.markdown-content pre code{background:none;padding:0}.markdown-content blockquote{border-left:3px solid #7c7ef5;padding:4px 16px;margin:0 0 1em;color:#a0a0b8;background:rgba(124,126,245,.04)}.markdown-content table{width:100%;border-collapse:collapse;margin:0 0 1em}.markdown-content th,.markdown-content td{padding:6px 12px;border:1px solid rgba(255,255,255,.09);font-size:13px}.markdown-content th{background:#26263a;font-weight:600;text-align:left}.markdown-content tr:nth-child(even){background:#1f1f28}.markdown-content ul,.markdown-content ol{margin:0 0 1em;padding-left:16px}.markdown-content ul{list-style:disc}.markdown-content ol{list-style:decimal}.markdown-content li{padding:2px 0;font-size:14px;color:#dcdce4}.markdown-content hr{border:none;border-top:1px solid rgba(255,255,255,.09);margin:1.5em 0}.markdown-content img{max-width:100%;border-radius:5px}.markdown-content .footnote-definition{font-size:.85em;color:#a0a0b8;margin-top:.5em;padding-left:1.5em}.markdown-content .footnote-definition sup{color:#7c7ef5;margin-right:4px}.markdown-content sup a{color:#7c7ef5;text-decoration:none;font-size:.8em}.wikilink{color:#9698f7;text-decoration:none;border-bottom:1px dashed #7c7ef5;cursor:pointer;transition:border-color .1s,color .1s}.wikilink:hover{color:#7c7ef5;border-bottom-style:solid}.wikilink-embed{display:inline-block;padding:2px 8px;background:rgba(139,92,246,.08);border:1px dashed rgba(139,92,246,.3);border-radius:3px;color:#9d8be0;font-size:12px;cursor:default}.media-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr));gap:12px}.media-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden;cursor:pointer;transition:border-color .12s,box-shadow .12s;position:relative}.media-card:hover{border-color:rgba(255,255,255,.14);box-shadow:0 1px 3px rgba(0,0,0,.3)}.media-card.selected{border-color:#7c7ef5;box-shadow:0 0 0 1px #7c7ef5}.card-checkbox{position:absolute;top:6px;left:6px;z-index:2;opacity:0;transition:opacity .1s}.card-checkbox input[type=checkbox]{width:16px;height:16px;cursor:pointer;filter:drop-shadow(0 1px 2px rgba(0,0,0,.5))}.media-card:hover .card-checkbox,.media-card.selected .card-checkbox{opacity:1}.card-thumbnail{width:100%;aspect-ratio:1;background:#111118;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative}.card-thumbnail img,.card-thumbnail .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:.4;display:flex;align-items:center;justify-content:center;width:100%;height:100%;position:absolute;top:0;left:0;z-index:0}.card-info{padding:8px 10px}.card-name{font-size:12px;font-weight:500;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px}.card-title,.card-artist{font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.3}.card-meta{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:10px}.card-size{color:#6c6c84;font-size:10px}.table-thumb-cell{width:36px;padding:4px 6px !important;position:relative}.table-thumb{width:28px;height:28px;object-fit:cover;border-radius:3px;display:block}.table-thumb-overlay{position:absolute;top:4px;left:6px;z-index:1}.table-type-icon{display:flex;align-items:center;justify-content:center;width:28px;height:28px;font-size:14px;opacity:.5;border-radius:3px;background:#111118;z-index:0}.type-badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}.type-badge.type-audio{background:rgba(139,92,246,.1);color:#9d8be0}.type-badge.type-video{background:rgba(200,72,130,.1);color:#d07eaa}.type-badge.type-image{background:rgba(34,160,80,.1);color:#5cb97a}.type-badge.type-document{background:rgba(59,120,200,.1);color:#6ca0d4}.type-badge.type-text{background:rgba(200,160,36,.1);color:#c4a840}.type-badge.type-other{background:rgba(128,128,160,.08);color:#6c6c84}.tag-list{display:flex;flex-wrap:wrap;gap:4px}.tag-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 10px;background:rgba(124,126,245,.15);color:#9698f7;border-radius:12px;font-size:11px;font-weight:500}.tag-badge.selected{background:#7c7ef5;color:#fff;cursor:pointer}.tag-badge:not(.selected){cursor:pointer}.tag-badge .tag-remove{cursor:pointer;opacity:.4;font-size:13px;line-height:1;transition:opacity .1s}.tag-badge .tag-remove:hover{opacity:1}.tag-group{margin-bottom:6px}.tag-children{margin-left:16px;margin-top:4px;display:flex;flex-wrap:wrap;gap:4px}.tag-confirm-delete{display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#a0a0b8}.tag-confirm-yes{cursor:pointer;color:#e45858;font-weight:600}.tag-confirm-yes:hover{text-decoration:underline}.tag-confirm-no{cursor:pointer;color:#6c6c84;font-weight:500}.tag-confirm-no:hover{text-decoration:underline}.detail-actions{display:flex;gap:6px;margin-bottom:16px}.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.detail-field{padding:10px 12px;background:#111118;border-radius:3px;border:1px solid rgba(255,255,255,.06)}.detail-field.full-width{grid-column:1/-1}.detail-field input[type=text],.detail-field textarea,.detail-field select{width:100%;margin-top:4px}.detail-field textarea{min-height:64px;resize:vertical}.detail-label{font-size:10px;font-weight:600;color:#6c6c84;text-transform:uppercase;letter-spacing:.04em;margin-bottom:2px}.detail-value{font-size:13px;color:#dcdce4;word-break:break-all}.detail-value.mono{font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px;color:#a0a0b8}.detail-preview{margin-bottom:16px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:5px;overflow:hidden;text-align:center}.detail-preview:has(.markdown-viewer){max-height:none;overflow-y:auto;text-align:left}.detail-preview:not(:has(.markdown-viewer)){max-height:450px}.detail-preview img{max-width:100%;max-height:400px;object-fit:contain;display:block;margin:0 auto}.detail-preview audio{width:100%;padding:16px}.detail-preview video{max-width:100%;max-height:400px;display:block;margin:0 auto}.detail-no-preview{padding:16px 16px;text-align:center;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px}.frontmatter-card{max-width:800px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:12px 16px;margin-bottom:16px}.frontmatter-fields{display:grid;grid-template-columns:auto 1fr;gap:4px 12px;margin:0}.frontmatter-fields dt{font-weight:600;font-size:12px;color:#a0a0b8;text-transform:capitalize}.frontmatter-fields dd{font-size:13px;color:#dcdce4;margin:0}.empty-state{text-align:center;padding:48px 12px;color:#6c6c84}.empty-state .empty-icon{font-size:32px;margin-bottom:12px;opacity:.3}.empty-title{font-size:15px;font-weight:600;color:#a0a0b8;margin-bottom:4px}.empty-subtitle{font-size:12px;max-width:320px;margin:0 auto;line-height:1.5}.toast-container{position:fixed;bottom:16px;right:16px;z-index:300;display:flex;flex-direction:column-reverse;gap:6px;align-items:flex-end}.toast-container .toast{position:static;transform:none}.toast{position:fixed;bottom:16px;right:16px;padding:10px 16px;border-radius:5px;background:#26263a;border:1px solid rgba(255,255,255,.09);color:#dcdce4;font-size:12px;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:300;animation:slide-up .15s ease-out;max-width:420px}.toast.success{border-left:3px solid #3ec97a}.toast.error{border-left:3px solid #e45858}.offline-banner,.error-banner{background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);border-radius:3px;padding:10px 12px;margin-bottom:12px;font-size:12px;color:#d47070;display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px}.offline-banner .offline-icon,.offline-banner .error-icon,.error-banner .offline-icon,.error-banner .error-icon{font-size:14px;flex-shrink:0}.error-banner{padding:10px 14px}.readonly-banner{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 12px;background:rgba(212,160,55,.06);border:1px solid rgba(212,160,55,.15);border-radius:3px;margin-bottom:16px;font-size:12px;color:#d4a037}.batch-actions{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:8px 10px;background:rgba(124,126,245,.15);border:1px solid rgba(124,126,245,.2);border-radius:3px;margin-bottom:12px;font-size:12px;font-weight:500;color:#9698f7}.select-all-banner{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:8px;padding:10px 16px;background:rgba(99,102,241,.08);border-radius:6px;margin-bottom:8px;font-size:.85rem;color:#a0a0b8}.select-all-banner button{background:none;border:none;color:#7c7ef5;cursor:pointer;font-weight:600;text-decoration:underline;font-size:.85rem;padding:0}.select-all-banner button:hover{color:#dcdce4}.import-status-panel{background:#1f1f28;border:1px solid #7c7ef5;border-radius:5px;padding:12px 16px;margin-bottom:16px}.import-status-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:8px;font-size:13px;color:#dcdce4}.import-current-file{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:6px;font-size:12px;overflow:hidden}.import-file-label{color:#6c6c84;flex-shrink:0}.import-file-name{color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:monospace;font-size:11px}.import-queue-indicator{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:8px;font-size:11px}.import-queue-badge{display:flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 6px;background:rgba(124,126,245,.15);color:#9698f7;border-radius:9px;font-weight:600;font-size:10px}.import-queue-text{color:#6c6c84}.import-tabs{display:flex;gap:0;margin-bottom:16px;border-bottom:1px solid rgba(255,255,255,.09)}.import-tab{padding:10px 16px;background:none;border:none;border-bottom:2px solid rgba(0,0,0,0);color:#6c6c84;font-size:12px;font-weight:500;cursor:pointer;transition:color .1s,border-color .1s}.import-tab:hover{color:#dcdce4}.import-tab.active{color:#9698f7;border-bottom-color:#7c7ef5}.queue-panel{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;border-left:1px solid rgba(255,255,255,.09);background:#18181f;min-width:280px;max-width:320px}.queue-header{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid rgba(255,255,255,.06)}.queue-header h3{margin:0;font-size:.9rem;color:#dcdce4}.queue-controls{display:flex;gap:2px}.queue-list{overflow-y:auto;flex:1}.queue-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;padding:8px 16px;cursor:pointer;border-bottom:1px solid rgba(255,255,255,.06);transition:background .15s}.queue-item:hover{background:#1f1f28}.queue-item:hover .queue-item-remove{opacity:1}.queue-item-active{background:rgba(124,126,245,.15);border-left:3px solid #7c7ef5}.queue-item-info{flex:1;min-width:0}.queue-item-title{display:block;font-size:.85rem;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.queue-item-artist{display:block;font-size:.75rem;color:#6c6c84}.queue-item-remove{opacity:0;transition:opacity .15s}.queue-empty{padding:16px 16px;text-align:center;color:#6c6c84;font-size:.85rem}.statistics-page{padding:20px}.stats-overview,.stats-grid{display:grid;grid-template-columns:repeat(3, 1fr);gap:16px;margin-bottom:24px}@media (max-width: 768px){.stats-overview,.stats-grid{grid-template-columns:1fr}}.stat-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:20px;display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:16px}.stat-card.stat-primary{border-left:3px solid #7c7ef5}.stat-card.stat-success{border-left:3px solid #3ec97a}.stat-card.stat-info{border-left:3px solid #6ca0d4}.stat-card.stat-warning{border-left:3px solid #d4a037}.stat-card.stat-purple{border-left:3px solid #9d8be0}.stat-card.stat-danger{border-left:3px solid #e45858}.stat-icon{flex-shrink:0;color:#6c6c84}.stat-content{flex:1}.stat-value{font-size:28px;font-weight:700;color:#dcdce4;line-height:1.2;font-variant-numeric:tabular-nums}.stat-label{font-size:12px;color:#6c6c84;margin-top:4px;font-weight:500}.stats-section{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px;margin-bottom:20px}.section-title{font-size:16px;font-weight:600;color:#dcdce4;margin-bottom:20px}.section-title.small{font-size:14px;margin-bottom:12px;padding-bottom:6px;border-bottom:1px solid rgba(255,255,255,.06)}.chart-bars{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:16px}.bar-item{display:grid;grid-template-columns:120px 1fr 80px;align-items:center;gap:16px}.bar-label{font-size:13px;font-weight:500;color:#a0a0b8;text-align:right}.bar-track{height:28px;background:#26263a;border-radius:3px;overflow:hidden;position:relative}.bar-fill{height:100%;transition:width .6s cubic-bezier(.4, 0, .2, 1);border-radius:3px}.bar-fill.bar-primary{background:linear-gradient(90deg, #7c7ef5 0%, #7c7ef3 100%)}.bar-fill.bar-success{background:linear-gradient(90deg, #3ec97a 0%, #66bb6a 100%)}.bar-value{font-size:13px;font-weight:600;color:#a0a0b8;text-align:right;font-variant-numeric:tabular-nums}.settings-section{margin-bottom:16px}.settings-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:20px;margin-bottom:16px}.settings-card.danger-card{border:1px solid rgba(228,88,88,.25)}.settings-card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid rgba(255,255,255,.06)}.settings-card-title{font-size:14px;font-weight:600}.settings-card-body{padding-top:2px}.settings-field{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.06)}.settings-field:last-child{border-bottom:none}.settings-field select{min-width:120px}.config-path{font-size:11px;color:#6c6c84;margin-bottom:12px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;padding:6px 10px;background:#111118;border-radius:3px;border:1px solid rgba(255,255,255,.06)}.config-status{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600}.config-status.writable{background:rgba(62,201,122,.1);color:#3ec97a}.config-status.readonly{background:rgba(228,88,88,.1);color:#e45858}.root-list{list-style:none}.root-item{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:3px;margin-bottom:4px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px;color:#a0a0b8}.info-row{display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid rgba(255,255,255,.06);font-size:13px}.info-row:last-child{border-bottom:none}.info-label{color:#a0a0b8;font-weight:500}.info-value{color:#dcdce4}.tasks-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(400px, 1fr));gap:16px;padding:12px}.task-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden;transition:all .2s}.task-card:hover{border-color:rgba(255,255,255,.14);box-shadow:0 4px 12px rgba(0,0,0,.08);transform:translateY(-2px)}.task-card-enabled{border-left:3px solid #3ec97a}.task-card-disabled{border-left:3px solid #4a4a5e;opacity:.7}.task-card-header{display:flex;justify-content:space-between;align-items:center;align-items:flex-start;padding:16px;border-bottom:1px solid rgba(255,255,255,.06)}.task-header-left{flex:1;min-width:0}.task-name{font-size:16px;font-weight:600;color:#dcdce4;margin-bottom:2px}.task-schedule{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:12px;color:#6c6c84;font-family:"Menlo","Monaco","Courier New",monospace}.schedule-icon{font-size:14px}.task-status-badge{flex-shrink:0}.status-badge{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:2px 10px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}.status-badge.status-enabled{background:rgba(76,175,80,.12);color:#3ec97a}.status-badge.status-enabled .status-dot{animation:pulse 1.5s infinite}.status-badge.status-disabled{background:#26263a;color:#6c6c84}.status-badge .status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;background:currentColor}.task-info-grid{display:grid;grid-template-columns:repeat(auto-fit, minmax(120px, 1fr));gap:12px;padding:16px}.task-info-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:flex-start;gap:10px}.task-info-icon{font-size:18px;color:#6c6c84;flex-shrink:0}.task-info-content{flex:1;min-width:0}.task-info-label{font-size:10px;color:#6c6c84;font-weight:600;text-transform:uppercase;letter-spacing:.03em;margin-bottom:2px}.task-info-value{font-size:12px;color:#a0a0b8;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.task-card-actions{display:flex;gap:8px;padding:10px 16px;background:#18181f;border-top:1px solid rgba(255,255,255,.06)}.task-card-actions button{flex:1}.db-actions{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:16px;padding:10px}.db-action-row{display:flex;flex-direction:row;justify-content:space-between;align-items:center;gap:16px;padding:10px;border-radius:6px;background:rgba(0,0,0,.06)}.db-action-info{flex:1}.db-action-info h4{font-size:.95rem;font-weight:600;color:#dcdce4;margin-bottom:2px}.db-action-confirm{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;flex-shrink:0}.library-toolbar{display:flex;justify-content:space-between;align-items:center;padding:8px 0;margin-bottom:12px;gap:12px;flex-wrap:wrap}.toolbar-left{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:10px}.toolbar-right{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:10px}.sort-control select,.page-size-control select{padding:4px 24px 4px 8px;font-size:11px;background:#1f1f28}.page-size-control{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px}.library-stats{display:flex;justify-content:space-between;align-items:center;padding:2px 0 6px 0;font-size:11px}.type-filter-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:4px 0;margin-bottom:6px;flex-wrap:wrap}.pagination{display:flex;align-items:center;justify-content:center;gap:4px;margin-top:16px;padding:8px 0}.page-btn{min-width:28px;text-align:center;font-variant-numeric:tabular-nums}.page-ellipsis{color:#6c6c84;padding:0 4px;font-size:12px;user-select:none}.audit-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:12px}.filter-select{padding:4px 24px 4px 8px;font-size:11px;background:#1f1f28}.action-danger{background:rgba(228,88,88,.1);color:#d47070}.action-updated{background:rgba(59,120,200,.1);color:#6ca0d4}.action-collection{background:rgba(34,160,80,.1);color:#5cb97a}.action-collection-remove{background:rgba(212,160,55,.1);color:#c4a840}.action-opened{background:rgba(139,92,246,.1);color:#9d8be0}.action-scanned{background:rgba(128,128,160,.08);color:#6c6c84}.clickable{cursor:pointer;color:#9698f7}.clickable:hover{text-decoration:underline}.clickable-row{cursor:pointer}.clickable-row:hover{background:rgba(255,255,255,.03)}.duplicates-view{padding:0}.duplicates-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.duplicates-header h3{margin:0}.duplicates-summary{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px}.duplicate-group{border:1px solid rgba(255,255,255,.09);border-radius:5px;margin-bottom:8px;overflow:hidden}.duplicate-group-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;width:100%;padding:10px 14px;background:#1f1f28;border:none;cursor:pointer;text-align:left;color:#dcdce4;font-size:13px}.duplicate-group-header:hover{background:#26263a}.expand-icon{font-size:10px;width:14px;flex-shrink:0}.group-name{font-weight:600;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.group-badge{background:#7c7ef5;color:#fff;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;flex-shrink:0}.group-size{flex-shrink:0;font-size:12px}.group-hash{font-size:11px;flex-shrink:0}.duplicate-items{border-top:1px solid rgba(255,255,255,.09)}.duplicate-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.06)}.duplicate-item:last-child{border-bottom:none}.duplicate-item-keep{background:rgba(76,175,80,.06)}.dup-thumb{width:48px;height:48px;flex-shrink:0;border-radius:3px;overflow:hidden}.dup-thumb-img{width:100%;height:100%;object-fit:cover}.dup-thumb-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#26263a;font-size:20px;color:#6c6c84}.dup-info{flex:1;min-width:0}.dup-filename{font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dup-path{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dup-meta{font-size:12px;margin-top:2px}.dup-actions{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;flex-shrink:0}.keep-badge{background:rgba(76,175,80,.12);color:#4caf50;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:600}.saved-searches-list{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:4px;max-height:300px;overflow-y:auto}.saved-search-item{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:#18181f;border-radius:3px;cursor:pointer;transition:background .15s ease}.saved-search-item:hover{background:#1f1f28}.saved-search-info{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:2px;flex:1;min-width:0}.saved-search-name{font-weight:500;color:#dcdce4}.saved-search-query{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.backlinks-panel,.outgoing-links-panel{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;margin-top:16px;overflow:hidden}.backlinks-header,.outgoing-links-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 14px;background:#26263a;cursor:pointer;user-select:none;transition:background .1s}.backlinks-header:hover,.outgoing-links-header:hover{background:rgba(255,255,255,.04)}.backlinks-toggle,.outgoing-links-toggle{font-size:10px;color:#6c6c84;width:12px;text-align:center}.backlinks-title,.outgoing-links-title{font-size:12px;font-weight:600;color:#dcdce4;flex:1}.backlinks-count,.outgoing-links-count{font-size:11px;color:#6c6c84}.backlinks-reindex-btn{display:flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;margin-left:auto;background:rgba(0,0,0,0);border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#6c6c84;font-size:12px;cursor:pointer;transition:background .1s,color .1s,border-color .1s}.backlinks-reindex-btn:hover:not(:disabled){background:#1f1f28;color:#dcdce4;border-color:rgba(255,255,255,.14)}.backlinks-reindex-btn:disabled{opacity:.5;cursor:not-allowed}.backlinks-content,.outgoing-links-content{padding:12px;border-top:1px solid rgba(255,255,255,.06)}.backlinks-loading,.outgoing-links-loading{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:12px;color:#6c6c84;font-size:12px}.backlinks-error,.outgoing-links-error{padding:8px 12px;background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);border-radius:3px;font-size:12px;color:#e45858}.backlinks-empty,.outgoing-links-empty{padding:16px;text-align:center;color:#6c6c84;font-size:12px;font-style:italic}.backlinks-list,.outgoing-links-list{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:6px}.backlink-item,.outgoing-link-item{padding:10px 12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:3px;cursor:pointer;transition:background .1s,border-color .1s}.backlink-item:hover,.outgoing-link-item:hover{background:#18181f;border-color:rgba(255,255,255,.09)}.backlink-item.unresolved,.outgoing-link-item.unresolved{opacity:.7;border-style:dashed}.backlink-source,.outgoing-link-target{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:2px}.backlink-title,.outgoing-link-text{font-size:13px;font-weight:500;color:#dcdce4;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.backlink-type-badge,.outgoing-link-type-badge{display:inline-block;padding:1px 6px;border-radius:12px;font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}.backlink-type-badge.backlink-type-wikilink,.backlink-type-badge.link-type-wikilink,.outgoing-link-type-badge.backlink-type-wikilink,.outgoing-link-type-badge.link-type-wikilink{background:rgba(124,126,245,.15);color:#9698f7}.backlink-type-badge.backlink-type-embed,.backlink-type-badge.link-type-embed,.outgoing-link-type-badge.backlink-type-embed,.outgoing-link-type-badge.link-type-embed{background:rgba(139,92,246,.1);color:#9d8be0}.backlink-type-badge.backlink-type-markdown_link,.backlink-type-badge.link-type-markdown_link,.outgoing-link-type-badge.backlink-type-markdown_link,.outgoing-link-type-badge.link-type-markdown_link{background:rgba(59,120,200,.1);color:#6ca0d4}.backlink-context{font-size:11px;color:#6c6c84;line-height:1.4;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.backlink-line{color:#a0a0b8;font-weight:500}.unresolved-badge{padding:1px 6px;border-radius:12px;font-size:9px;font-weight:600;background:rgba(212,160,55,.1);color:#d4a037}.outgoing-links-unresolved-badge{margin-left:8px;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:500;background:rgba(212,160,55,.12);color:#d4a037}.outgoing-links-global-unresolved{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;margin-top:12px;padding:10px 12px;background:rgba(212,160,55,.06);border:1px solid rgba(212,160,55,.15);border-radius:3px;font-size:11px;color:#6c6c84}.outgoing-links-global-unresolved .unresolved-icon{color:#d4a037}.backlinks-message{padding:8px 10px;margin-bottom:10px;border-radius:3px;font-size:11px}.backlinks-message.success{background:rgba(62,201,122,.08);border:1px solid rgba(62,201,122,.2);color:#3ec97a}.backlinks-message.error{background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);color:#e45858}.graph-view{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;height:100%;background:#18181f;border-radius:5px;overflow:hidden}.graph-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:16px;padding:12px 16px;background:#1f1f28;border-bottom:1px solid rgba(255,255,255,.09)}.graph-title{font-size:14px;font-weight:600;color:#dcdce4}.graph-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;font-size:12px;color:#a0a0b8}.graph-controls select{padding:4px 20px 4px 8px;font-size:11px;background:#26263a}.graph-stats{margin-left:auto;font-size:11px;color:#6c6c84}.graph-container{flex:1;position:relative;display:flex;align-items:center;justify-content:center;overflow:hidden;background:#111118}.graph-loading,.graph-error,.graph-empty{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px;padding:48px;color:#6c6c84;font-size:13px;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:16px;left:16px;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;z-index:5}.zoom-btn{width:36px;height:36px;border-radius:6px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);color:#dcdce4;font-size:18px;font-weight:bold;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:all .15s;box-shadow:0 1px 3px rgba(0,0,0,.3)}.zoom-btn:hover{background:#26263a;border-color:rgba(255,255,255,.14);transform:scale(1.05)}.zoom-btn:active{transform:scale(.95)}.graph-edges line{stroke:rgba(255,255,255,.14);stroke-width:1;opacity:.6}.graph-edges line.edge-type-wikilink{stroke:#7c7ef5}.graph-edges line.edge-type-embed{stroke:#9d8be0;stroke-dasharray:4 2}.graph-nodes .graph-node{cursor:pointer}.graph-nodes .graph-node circle{fill:#4caf50;stroke:#388e3c;stroke-width:2;transition:fill .15s,stroke .15s}.graph-nodes .graph-node:hover circle{fill:#66bb6a}.graph-nodes .graph-node.selected circle{fill:#7c7ef5;stroke:#5456d6}.graph-nodes .graph-node text{fill:#a0a0b8;font-size:11px;pointer-events:none;text-anchor:middle;dominant-baseline:central;transform:translateY(16px)}.node-details-panel{position:absolute;top:16px;right:16px;width:280px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:10}.node-details-header{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.06)}.node-details-header h3{font-size:13px;font-weight:600;color:#dcdce4;margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.node-details-header .close-btn{background:none;border:none;color:#6c6c84;cursor:pointer;font-size:14px;padding:2px 6px;line-height:1}.node-details-header .close-btn:hover{color:#dcdce4}.node-details-content{padding:14px}.node-details-content .node-title{font-size:12px;color:#a0a0b8;margin-bottom:12px}.node-stats{display:flex;gap:16px;margin-bottom:12px}.node-stats .stat{font-size:12px;color:#6c6c84}.node-stats .stat strong{color:#dcdce4}.physics-controls-panel{position:absolute;top:16px;right:16px;width:300px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;box-shadow:0 2px 8px rgba(0,0,0,.35);padding:16px;z-index:10}.physics-controls-panel h4{font-size:13px;font-weight:600;color:#dcdce4;margin:0 0 16px 0;padding-bottom:8px;border-bottom:1px solid rgba(255,255,255,.06)}.physics-controls-panel .btn{width:100%;margin-top:8px}.control-group{margin-bottom:14px}.control-group label{display:block;font-size:11px;font-weight:500;color:#a0a0b8;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px}.control-group input[type=range]{width:100%;height:4px;border-radius:4px;background:#26263a;outline:none;-webkit-appearance:none}.control-group input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;border-radius:50%;background:#7c7ef5;cursor:pointer;transition:transform .1s}.control-group input[type=range]::-webkit-slider-thumb:hover{transform:scale(1.15)}.control-group input[type=range]::-moz-range-thumb{width:14px;height:14px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none;transition:transform .1s}.control-group input[type=range]::-moz-range-thumb:hover{transform:scale(1.15)}.control-value{display:inline-block;margin-top:2px;font-size:11px;color:#6c6c84;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace}.theme-light{--bg-0: #f5f5f7;--bg-1: #eeeef0;--bg-2: #fff;--bg-3: #e8e8ec;--border-subtle: rgba(0,0,0,.06);--border: rgba(0,0,0,.1);--border-strong: rgba(0,0,0,.16);--text-0: #1a1a2e;--text-1: #555570;--text-2: #8888a0;--accent: #6366f1;--accent-dim: rgba(99,102,241,.1);--accent-text: #4f52e8;--shadow-sm: 0 1px 3px rgba(0,0,0,.08);--shadow: 0 2px 8px rgba(0,0,0,.1);--shadow-lg: 0 4px 20px rgba(0,0,0,.12)}.theme-light ::-webkit-scrollbar-thumb{background:rgba(0,0,0,.12)}.theme-light ::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.08)}.theme-light ::-webkit-scrollbar-track{background:rgba(0,0,0,.06)}.theme-light .graph-nodes .graph-node text{fill:#1a1a2e}.theme-light .graph-edges line{stroke:rgba(0,0,0,.12)}.theme-light .pdf-container{background:#e8e8ec}.skeleton-pulse{animation:skeleton-pulse 1.5s ease-in-out infinite;background:#26263a;border-radius:4px}.skeleton-card{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;padding:8px}.skeleton-thumb{width:100%;aspect-ratio:1;border-radius:6px}.skeleton-text{height:14px;width:80%}.skeleton-text-short{width:50%}.skeleton-row{display:flex;gap:12px;padding:10px 16px;align-items:center}.skeleton-cell{height:14px;flex:1;border-radius:4px}.skeleton-cell-icon{width:32px;height:32px;flex:none;border-radius:4px}.skeleton-cell-wide{flex:3}.loading-overlay{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px;background:rgba(0,0,0,.3);z-index:100;border-radius:8px}.loading-spinner{width:32px;height:32px;border:3px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .8s linear infinite}.loading-message{color:#a0a0b8;font-size:.9rem}.login-container{display:flex;align-items:center;justify-content:center;height:100vh;background:#111118}.login-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:24px;width:360px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.login-title{font-size:20px;font-weight:700;color:#dcdce4;text-align:center;margin-bottom:2px}.login-subtitle{font-size:13px;color:#6c6c84;text-align:center;margin-bottom:20px}.login-error{background:rgba(228,88,88,.08);border:1px solid rgba(228,88,88,.2);border-radius:3px;padding:8px 12px;margin-bottom:12px;font-size:12px;color:#e45858}.login-form input[type=text],.login-form input[type=password]{width:100%}.login-btn{width:100%;padding:8px 16px;font-size:13px;margin-top:2px}.pagination{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:2px;margin-top:16px;padding:8px 0}.page-btn{min-width:28px;text-align:center;font-variant-numeric:tabular-nums}.page-ellipsis{color:#6c6c84;padding:0 4px;font-size:12px;user-select:none}.help-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:200;animation:fade-in .1s ease-out}.help-dialog{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:16px;min-width:300px;max-width:400px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.help-dialog h3{font-size:16px;font-weight:600;margin-bottom:16px}.help-shortcuts{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;margin-bottom:16px}.shortcut-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px}.shortcut-row kbd{display:inline-block;padding:2px 8px;background:#111118;border:1px solid rgba(255,255,255,.09);border-radius:3px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px;color:#dcdce4;min-width:32px;text-align:center}.shortcut-row span{font-size:13px;color:#a0a0b8}.help-close{display:block;width:100%;padding:6px 12px;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#dcdce4;font-size:12px;cursor:pointer;text-align:center}.help-close:hover{background:rgba(255,255,255,.06)}.plugin-container{display:flex;flex-direction:column;gap:var(--plugin-gap, 0px);padding:var(--plugin-padding, 0)}.plugin-grid{display:grid;grid-template-columns:repeat(var(--plugin-columns, 1), 1fr);gap:var(--plugin-gap, 0px)}.plugin-flex{display:flex;gap:var(--plugin-gap, 0px)}.plugin-flex[data-direction=row]{flex-direction:row}.plugin-flex[data-direction=column]{flex-direction:column}.plugin-flex[data-justify=flex-start]{justify-content:flex-start}.plugin-flex[data-justify=flex-end]{justify-content:flex-end}.plugin-flex[data-justify=center]{justify-content:center}.plugin-flex[data-justify=space-between]{justify-content:space-between}.plugin-flex[data-justify=space-around]{justify-content:space-around}.plugin-flex[data-justify=space-evenly]{justify-content:space-evenly}.plugin-flex[data-align=flex-start]{align-items:flex-start}.plugin-flex[data-align=flex-end]{align-items:flex-end}.plugin-flex[data-align=center]{align-items:center}.plugin-flex[data-align=stretch]{align-items:stretch}.plugin-flex[data-align=baseline]{align-items:baseline}.plugin-flex[data-wrap=wrap]{flex-wrap:wrap}.plugin-flex[data-wrap=nowrap]{flex-wrap:nowrap}.plugin-split{display:flex}.plugin-split-sidebar{width:var(--plugin-sidebar-width, 200px);flex-shrink:0}.plugin-split-main{flex:1;min-width:0}.plugin-media-grid{display:grid;grid-template-columns:repeat(var(--plugin-columns, 2), 1fr);gap:var(--plugin-gap, 8px)}.plugin-col-constrained{width:var(--plugin-col-width)}.plugin-progress-bar{height:100%;background:#7c7ef5;border-radius:4px;transition:width .3s ease;width:var(--plugin-progress, 0%)}.plugin-chart{overflow:auto;height:var(--plugin-chart-height, 200px)} \ No newline at end of file diff --git a/crates/pinakes-ui/assets/styles/_plugins.scss b/crates/pinakes-ui/assets/styles/_plugins.scss new file mode 100644 index 0000000..c44762a --- /dev/null +++ b/crates/pinakes-ui/assets/styles/_plugins.scss @@ -0,0 +1,94 @@ +@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. + +// 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; +} + +// Media grid reuses the same column/gap variables as .plugin-grid. +.plugin-media-grid { + display: grid; + grid-template-columns: repeat(var(--plugin-columns, 2), 1fr); + gap: var(--plugin-gap, 8px); +} + +// Table column with a plugin-specified fixed width. +// The width is passed as --plugin-col-width on the th element. +.plugin-col-constrained { + width: var(--plugin-col-width); +} + +// Progress bar: the fill element carries --plugin-progress. +.plugin-progress-bar { + height: 100%; + background: $accent; + border-radius: 4px; + transition: width 0.3s ease; + width: var(--plugin-progress, 0%); +} + +// Chart wrapper: height is driven by --plugin-chart-height. +.plugin-chart { + overflow: auto; + height: var(--plugin-chart-height, 200px); +} diff --git a/crates/pinakes-ui/assets/styles/main.scss b/crates/pinakes-ui/assets/styles/main.scss index 930c7f5..7f93cf2 100644 --- a/crates/pinakes-ui/assets/styles/main.scss +++ b/crates/pinakes-ui/assets/styles/main.scss @@ -11,3 +11,4 @@ @use 'audit'; @use 'graph'; @use 'themes'; +@use 'plugins'; diff --git a/crates/pinakes-ui/src/app.rs b/crates/pinakes-ui/src/app.rs index 972da40..11026b9 100644 --- a/crates/pinakes-ui/src/app.rs +++ b/crates/pinakes-ui/src/app.rs @@ -59,7 +59,12 @@ use crate::{ tags, tasks, }, - plugin_ui::{PluginRegistry, PluginViewRenderer}, + plugin_ui::{ + PluginRegistry, + PluginViewRenderer, + WidgetContainer, + WidgetLocation, + }, styles, }; @@ -109,6 +114,41 @@ impl View { } } +/// Parse a route string from a plugin `navigate_to` action into an app +/// [`View`]. +/// +/// Supports all built-in routes (`/library`, `/search`, etc.) and plugin pages +/// (`/plugins/{plugin_id}/{page_id}`). Unknown routes fall back to Library with +/// a warning log. +fn parse_plugin_route(route: &str) -> View { + let parts: Vec<&str> = route.trim_start_matches('/').split('/').collect(); + match parts.as_slice() { + [] | [""] | ["library"] => View::Library, + ["search"] => View::Search, + ["settings"] => View::Settings, + ["tags"] => View::Tags, + ["collections"] => View::Collections, + ["books"] => View::Books, + ["audit"] => View::Audit, + ["import"] => View::Import, + ["duplicates"] => View::Duplicates, + ["statistics"] => View::Statistics, + ["tasks"] => View::Tasks, + ["database"] => View::Database, + ["graph"] => View::Graph, + ["plugins", plugin_id, page_id] => { + View::PluginView { + plugin_id: plugin_id.to_string(), + page_id: page_id.to_string(), + } + }, + _ => { + tracing::warn!(route = %route, "Unknown navigation route from plugin action"); + View::Library + }, + } +} + #[component] pub fn App() -> Element { let base_url = std::env::var("PINAKES_SERVER_URL") @@ -193,6 +233,7 @@ pub fn App() -> Element { let mut play_queue = use_signal(PlayQueue::default); let mut plugin_registry = use_signal(PluginRegistry::default); + let all_widgets = use_memo(move || plugin_registry.read().all_widgets()); let mut current_theme = use_signal(|| "dark".to_string()); let mut system_prefers_dark = use_signal(|| true); @@ -319,15 +360,27 @@ pub fn App() -> Element { use_effect(move || { let c = client.read().clone(); spawn(async move { - match c.get_plugin_ui_pages().await { - Ok(pages) => { - let mut reg = PluginRegistry::default(); - for (plugin_id, page) in pages { - reg.register_page(plugin_id, page); - } + let mut reg = PluginRegistry::new(c.clone()); + match reg.refresh().await { + Ok(()) => { + let vars = reg.theme_vars().clone(); plugin_registry.set(reg); + if !vars.is_empty() { + spawn(async move { + let js: String = vars + .iter() + .map(|(k, v)| { + format!( + "document.documentElement.style.setProperty('{}','{}');", + k, v + ) + }) + .collect(); + let _ = document::eval(&js).await; + }); + } }, - Err(e) => tracing::debug!("Plugin pages unavailable: {e}"), + Err(e) => tracing::debug!("Plugin UI unavailable: {e}"), } }); }); @@ -431,7 +484,19 @@ pub fn App() -> Element { } }; - let view_title = use_memo(move || current_view.read().title()); + let view_title = use_memo(move || { + let view = current_view.read(); + match &*view { + View::PluginView { plugin_id, page_id } => { + plugin_registry + .read() + .get_page(plugin_id, page_id) + .map(|p| p.page.title.clone()) + .unwrap_or_else(|| "Plugin".to_string()) + }, + v => v.title().to_string(), + } + }); let _total_pages = use_memo(move || { let ps = *media_page_size.read(); let tc = *media_total_count.read(); @@ -753,12 +818,17 @@ pub fn App() -> Element { if !plugin_registry.read().is_empty() { div { class: "nav-section", - div { class: "nav-label", "Plugins" } - for page in plugin_registry.read().all_pages() { + div { class: "nav-label", + "Plugins" + span { class: "nav-label-count", " ({plugin_registry.read().len()})" } + } + for (pid, pageid, route) in plugin_registry.read().routes() { { - let pid = page.plugin_id.clone(); - let pageid = page.page.id.clone(); - let title = page.page.title.clone(); + let title = plugin_registry + .read() + .get_page(&pid, &pageid) + .map(|p| p.page.title.clone()) + .unwrap_or_default(); let is_active = *current_view.read() == View::PluginView { plugin_id: pid.clone(), @@ -767,6 +837,7 @@ pub fn App() -> Element { rsx! { button { class: if is_active { "nav-item active" } else { "nav-item" }, + title: "{route}", onclick: move |_| { current_view.set(View::PluginView { plugin_id: pid.clone(), @@ -778,6 +849,17 @@ pub fn App() -> Element { } } } + { + let sync_time_opt = plugin_registry + .read() + .last_refresh() + .map(|ts| ts.format("%H:%M").to_string()); + rsx! { + if let Some(sync_time) = sync_time_opt { + div { class: "nav-sync-time", "Synced {sync_time}" } + } + } + } } } @@ -923,6 +1005,11 @@ pub fn App() -> Element { // Reload full config match *current_view.read() { View::Library => rsx! { + WidgetContainer { + location: WidgetLocation::LibraryHeader, + widgets: all_widgets.read().clone(), + client, + } div { class: "stats-grid", div { class: "stat-card", div { class: "stat-value", "{media_total_count}" } @@ -937,6 +1024,11 @@ pub fn App() -> Element { div { class: "stat-label", "Collections" } } } + WidgetContainer { + location: WidgetLocation::LibrarySidebar, + widgets: all_widgets.read().clone(), + client, + } library::Library { media: media_list.read().clone(), tags: tags_list.read().clone(), @@ -1133,6 +1225,11 @@ pub fn App() -> Element { } }, View::Search => rsx! { + WidgetContainer { + location: WidgetLocation::SearchFilters, + widgets: all_widgets.read().clone(), + client, + } search::Search { results: search_results.read().clone(), total_count: *search_total.read(), @@ -1265,6 +1362,11 @@ pub fn App() -> Element { let media_ref = selected_media.read(); match media_ref.as_ref() { Some(media) => rsx! { + WidgetContainer { + location: WidgetLocation::DetailPanel, + widgets: all_widgets.read().clone(), + client, + } detail::Detail { media: media.clone(), media_tags: media_tags.read().clone(), @@ -2558,6 +2660,11 @@ pub fn App() -> Element { let cfg_ref = config_data.read(); match cfg_ref.as_ref() { Some(cfg) => rsx! { + WidgetContainer { + location: WidgetLocation::SettingsSection, + widgets: all_widgets.read().clone(), + client, + } settings::Settings { config: cfg.clone(), on_add_root: { @@ -2712,6 +2819,11 @@ pub fn App() -> Element { plugin_id: pid, page: plugin_page.page, client, + allowed_endpoints: plugin_page.allowed_endpoints.clone(), + on_navigate: move |route: String| { + current_view + .set(parse_plugin_route(&route)); + }, } }, None => rsx! { diff --git a/crates/pinakes-ui/src/client.rs b/crates/pinakes-ui/src/client.rs index 3aaaf10..e411750 100644 --- a/crates/pinakes-ui/src/client.rs +++ b/crates/pinakes-ui/src/client.rs @@ -1617,14 +1617,16 @@ impl ApiClient { /// List all UI pages provided by loaded plugins. /// - /// Returns a vector of `(plugin_id, page)` tuples. + /// Returns a vector of `(plugin_id, page, allowed_endpoints)` tuples. pub async fn get_plugin_ui_pages( &self, - ) -> Result> { + ) -> Result)>> { #[derive(Deserialize)] struct PageEntry { - plugin_id: String, - page: pinakes_plugin_api::UiPage, + plugin_id: String, + page: pinakes_plugin_api::UiPage, + #[serde(default)] + allowed_endpoints: Vec, } let entries: Vec = self @@ -1636,7 +1638,80 @@ impl ApiClient { .json() .await?; - Ok(entries.into_iter().map(|e| (e.plugin_id, e.page)).collect()) + Ok( + entries + .into_iter() + .map(|e| (e.plugin_id, e.page, e.allowed_endpoints)) + .collect(), + ) + } + + /// List all UI widgets provided by loaded plugins. + /// + /// Returns a vector of `(plugin_id, widget)` tuples. + pub async fn get_plugin_ui_widgets( + &self, + ) -> Result> { + #[derive(Deserialize)] + struct WidgetEntry { + plugin_id: String, + widget: pinakes_plugin_api::UiWidget, + } + + let entries: Vec = self + .client + .get(self.url("/plugins/ui-widgets")) + .send() + .await? + .error_for_status()? + .json() + .await?; + + Ok( + entries + .into_iter() + .map(|e| (e.plugin_id, e.widget)) + .collect(), + ) + } + + /// Fetch merged CSS custom property overrides from all enabled plugins. + /// + /// Returns a map of CSS property names to values. + pub async fn get_plugin_ui_theme_extensions( + &self, + ) -> Result> { + Ok( + self + .client + .get(self.url("/plugins/ui-theme-extensions")) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + /// Emit a plugin event to the server-side event bus. + /// + /// # Errors + /// + /// Returns an error if the request fails or the server returns an error + /// status. + pub async fn post_plugin_event( + &self, + event: &str, + payload: &serde_json::Value, + ) -> Result<()> { + self + .client + .post(self.url("/plugins/events")) + .json(&serde_json::json!({ "event": event, "payload": payload })) + .send() + .await? + .error_for_status()?; + Ok(()) } /// Make a raw HTTP request to an API path. diff --git a/crates/pinakes-ui/src/plugin_ui/data.rs b/crates/pinakes-ui/src/plugin_ui/data.rs index 2f4e2d0..d3f42dc 100644 --- a/crates/pinakes-ui/src/plugin_ui/data.rs +++ b/crates/pinakes-ui/src/plugin_ui/data.rs @@ -2,15 +2,17 @@ //! //! Provides data fetching and caching for plugin data sources. -use std::collections::HashMap; +use std::{collections::HashMap, time::Duration}; use dioxus::prelude::*; -use pinakes_plugin_api::{DataSource, HttpMethod}; +use dioxus_core::Task; +use pinakes_plugin_api::{DataSource, Expression, HttpMethod}; +use super::expr::{evaluate_expression, value_to_display_string}; use crate::client::ApiClient; /// Cached data for a plugin page -#[derive(Debug, Clone, Default, PartialEq)] +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct PluginPageData { data: HashMap, loading: HashMap, @@ -36,7 +38,7 @@ impl PluginPageData { self.errors.get(source) } - /// Check if there's data for a specific source + /// Check if there is data for a specific source #[must_use] pub fn has_data(&self, source: &str) -> bool { self.data.contains_key(source) @@ -83,23 +85,62 @@ impl PluginPageData { } } -/// Fetch data from an endpoint -async fn fetch_endpoint( - client: &ApiClient, - path: &str, - method: HttpMethod, -) -> Result { - let reqwest_method = match method { +/// 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: &HashMap, + ctx: &serde_json::Value, + allowed_endpoints: &[String], +) -> Result { + 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 = params + .iter() + .map(|(k, expr)| (k.clone(), evaluate_expression(expr, ctx))) + .collect(); + request = request.json(&body); + } + } // Send request and parse response - let response = client - .raw_request(reqwest_method, path) + let response = request .send() .await .map_err(|e| format!("Request failed: {e}"))?; @@ -118,50 +159,159 @@ async fn fetch_endpoint( /// 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: &HashMap, + allowed_endpoints: &[String], ) -> Result, String> { - let mut results = HashMap::new(); + // 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)> 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)>, + // The representative source used to fire the request (transform ignored + // for Endpoint - we apply per-member transforms after fetching) + source: DataSource, + } - // Process non-Transform sources first so Transform sources can reference them - let mut ordered: Vec<(&String, &DataSource)> = data_sources - .iter() - .filter(|(_, s)| !matches!(s, DataSource::Transform { .. })) - .collect(); - ordered.extend( - data_sources - .iter() - .filter(|(_, s)| matches!(s, DataSource::Transform { .. })), - ); + let mut groups: Vec = Vec::new(); - for (name, source) in ordered { - let value = match source { - DataSource::Endpoint { path, method, .. } => { - // Fetch from endpoint (ignoring params, poll_interval, transform for - // now) - fetch_endpoint(client, path, method.clone()).await? - }, - DataSource::Static { value } => value.clone(), - DataSource::Transform { - source_name, - expression, + for (name, source) in data_sources { + if matches!(source, DataSource::Transform { .. }) { + continue; + } + + match source { + DataSource::Endpoint { + path, + method, + params, + transform, + poll_interval, } => { - // Get source data and apply transform - let source_data = results - .get(source_name) - .cloned() - .unwrap_or(serde_json::Value::Null); - // TODO: Actually evaluate expression against source_data - // For now, return source_data unchanged - let _ = expression; - source_data + // 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, + }, + }); + } }, - }; - results.insert(name.clone(), value); + 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: HashMap = HashMap::new(); + for group_result in futures::future::join_all(futs).await { + for (name, value) in group_result? { + results.insert(name, value); + } + } + + // Process Transform sources sequentially; they reference results above. + for (name, source) in data_sources { + if let DataSource::Transform { + source_name, + expression, + } = source + { + let ctx = serde_json::Value::Object( + results + .iter() + .map(|(k, v): (&String, &serde_json::Value)| (k.clone(), v.clone())) + .collect(), + ); + let _ = source_name; // accessible in ctx by its key + results.insert(name.clone(), evaluate_expression(expression, &ctx)); + } } Ok(results) @@ -169,17 +319,50 @@ pub async fn fetch_page_data( /// Hook to fetch and cache plugin page data /// -/// Returns a signal containing the data state +/// 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, data_sources: HashMap, + refresh: Signal, + allowed_endpoints: Vec, ) -> Signal { let mut data = use_signal(PluginPageData::default); + let mut poll_task: Signal> = 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(); - spawn(async move { + // 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(); @@ -188,8 +371,9 @@ pub fn use_plugin_data( data.write().set_loading(name, true); } - // Fetch data - match fetch_page_data(&client.read(), &sources).await { + // 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); @@ -203,38 +387,39 @@ pub fn use_plugin_data( } }, } + + // 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 } -/// Get a value from JSON by path (dot notation) -/// -/// Supports object keys and array indices -#[must_use] -pub fn get_json_path<'a>( - value: &'a serde_json::Value, - path: &str, -) -> Option<&'a serde_json::Value> { - let mut current = value; - - for key in path.split('.') { - match current { - serde_json::Value::Object(map) => { - current = map.get(key)?; - }, - serde_json::Value::Array(arr) => { - let idx = key.parse::().ok()?; - current = arr.get(idx)?; - }, - _ => return None, - } - } - - Some(current) -} - #[cfg(test)] mod tests { use super::*; @@ -264,51 +449,6 @@ mod tests { assert_eq!(data.error("error"), Some(&"oops".to_string())); } - #[test] - fn test_get_json_path_object() { - let data = serde_json::json!({ - "user": { - "name": "John", - "age": 30 - } - }); - - assert_eq!( - get_json_path(&data, "user.name"), - Some(&serde_json::Value::String("John".to_string())) - ); - } - - #[test] - fn test_get_json_path_array() { - let data = serde_json::json!({ - "items": ["a", "b", "c"] - }); - - assert_eq!( - get_json_path(&data, "items.1"), - Some(&serde_json::Value::String("b".to_string())) - ); - } - - #[test] - fn test_get_json_path_invalid() { - let data = serde_json::json!({"foo": "bar"}); - assert!(get_json_path(&data, "nonexistent").is_none()); - } - - #[test] - fn test_get_json_path_array_out_of_bounds() { - let data = serde_json::json!({"items": ["a"]}); - assert!(get_json_path(&data, "items.5").is_none()); - } - - #[test] - fn test_get_json_path_non_array_index() { - let data = serde_json::json!({"foo": "bar"}); - assert!(get_json_path(&data, "foo.0").is_none()); - } - #[test] fn test_as_json_empty() { let data = PluginPageData::default(); @@ -382,32 +522,195 @@ mod tests { value: serde_json::json!(true), }); - let results = super::fetch_page_data(&client, &sources).await.unwrap(); + 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_after_static() { + 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 = HashMap::new(); - // Insert Transform before Static in the map to test ordering + // The Transform expression accesses "raw" from the context sources.insert("derived".to_string(), DataSource::Transform { source_name: "raw".to_string(), - expression: Expression::Literal(serde_json::Value::Null), + 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(); - // raw must have been processed before derived + let results = super::fetch_page_data(&client, &sources, &[]).await.unwrap(); assert_eq!(results["raw"], serde_json::json!({"ok": true})); - // derived gets source_data from raw (transform is identity for now) + // 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 = HashMap::new(); + 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")); + } + + // Test: multiple Static sources with the same value each get their own + // result; dedup logic does not collapse distinct-named Static sources. + #[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 = HashMap::new(); + // 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); + } + + // Test: Endpoint sources with identical (path, method, params) but different + // transform expressions each get a correctly transformed result. Because the + // test runs without a real server the path is checked against the allowlist + // before any network call, so we verify the dedup key grouping through the + // allowlist rejection path: both names should see the same error message, + // proving they were grouped and the single rejection propagates to all names. + #[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 = HashMap::new(); + // 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}" + ); + } + + // Test: multiple Transform sources referencing the same upstream Static source + // with different expressions each receive their independently transformed + // result. This exercises the transform fan-out behavior that mirrors what + // the Endpoint dedup group does after a single shared HTTP request completes: + // each member of a group applies its own transform to the shared raw value. + // + // Testing the Endpoint dedup success path 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 = HashMap::new(); + 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); + } + + // Test: fetch_page_data returns an error when the endpoint data source path is + // not listed in the allowed_endpoints slice. + #[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 = HashMap::new(); + 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}" + ); + } } diff --git a/crates/pinakes-ui/src/plugin_ui/registry.rs b/crates/pinakes-ui/src/plugin_ui/registry.rs index a50c533..fb3d1b6 100644 --- a/crates/pinakes-ui/src/plugin_ui/registry.rs +++ b/crates/pinakes-ui/src/plugin_ui/registry.rs @@ -27,15 +27,18 @@ use crate::client::ApiClient; #[derive(Debug, Clone)] pub struct PluginPage { /// Plugin ID that provides this page - pub plugin_id: String, + pub plugin_id: String, /// Page definition from schema - pub page: UiPage, + pub page: UiPage, + /// Endpoint paths this plugin is allowed to fetch (empty means no + /// restriction) + pub allowed_endpoints: Vec, } impl PluginPage { - /// Full route including plugin prefix + /// The canonical route for this page, taken directly from the page schema. pub fn full_route(&self) -> String { - format!("/plugins/{}/{}", self.plugin_id, self.page.id) + self.page.route.clone() } } @@ -46,10 +49,12 @@ impl PluginPage { pub struct PluginRegistry { /// API client for fetching pages from server client: ApiClient, - /// Cached pages: (plugin_id, page_id) -> PluginPage + /// Cached pages: (`plugin_id`, `page_id`) -> `PluginPage` pages: HashMap<(String, String), PluginPage>, - /// Cached widgets: (plugin_id, widget_id) -> UiWidget + /// Cached widgets: (`plugin_id`, `widget_id`) -> `UiWidget` widgets: Vec<(String, UiWidget)>, + /// Merged CSS custom property overrides from all enabled plugins + theme_vars: HashMap, /// Last refresh timestamp last_refresh: Option>, } @@ -61,25 +66,57 @@ impl PluginRegistry { client, pages: HashMap::new(), widgets: Vec::new(), + theme_vars: HashMap::new(), last_refresh: None, } } - /// Create a new registry with pre-loaded pages - pub fn with_pages(client: ApiClient, pages: Vec<(String, UiPage)>) -> Self { - let mut registry = Self::new(client); - for (plugin_id, page) in pages { - registry.register_page(plugin_id, page); - } - registry + /// Get merged CSS custom property overrides from all loaded plugins. + pub fn theme_vars(&self) -> &HashMap { + &self.theme_vars } /// Register a page from a plugin - pub fn register_page(&mut self, plugin_id: String, page: UiPage) { + /// + /// 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, + ) { + 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(); - self - .pages - .insert((plugin_id.clone(), page_id), PluginPage { plugin_id, page }); + // 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 @@ -94,29 +131,37 @@ impl PluginRegistry { } /// 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) + /// 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() } - /// Get all page routes for navigation - pub fn routes(&self) -> Vec<(String, String, String)> { - self - .pages - .values() - .map(|p| (p.plugin_id.clone(), p.page.id.clone(), p.full_route())) - .collect() - } - /// Check if any pages are registered pub fn is_empty(&self) -> bool { self.pages.is_empty() @@ -127,20 +172,50 @@ impl PluginRegistry { self.pages.len() } - /// Refresh pages from server + /// 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.full_route())) + .collect() + } + + /// Refresh pages and widgets from server pub async fn refresh(&mut self) -> Result<(), String> { - match self.client.get_plugin_ui_pages().await { - Ok(pages) => { - self.pages.clear(); - self.widgets.clear(); - for (plugin_id, page) in pages { - self.register_page(plugin_id, page); - } - self.last_refresh = Some(chrono::Utc::now()); - Ok(()) - }, - Err(e) => Err(format!("Failed to refresh plugin pages: {e}")), + 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; + self.last_refresh = Some(chrono::Utc::now()); + Ok(()) } /// Get last refresh time @@ -173,6 +248,7 @@ mod tests { padding: None, }, data_sources: HashMap::new(), + actions: HashMap::new(), } } @@ -181,7 +257,7 @@ mod tests { let client = ApiClient::default(); let registry = PluginRegistry::new(client); assert!(registry.is_empty()); - assert_eq!(registry.len(), 0); + assert_eq!(registry.all_pages().len(), 0); } #[test] @@ -190,10 +266,10 @@ mod tests { let mut registry = PluginRegistry::new(client); let page = create_test_page("demo", "Demo Page"); - registry.register_page("my-plugin".to_string(), page.clone()); + registry.register_page("my-plugin".to_string(), page.clone(), vec![]); assert!(!registry.is_empty()); - assert_eq!(registry.len(), 1); + assert_eq!(registry.all_pages().len(), 1); let retrieved = registry.get_page("my-plugin", "demo"); assert!(retrieved.is_some()); @@ -210,18 +286,6 @@ mod tests { assert!(result.is_none()); } - #[test] - fn test_page_full_route() { - 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()); - - let plugin_page = registry.get_page("my-plugin", "demo").unwrap(); - assert_eq!(plugin_page.full_route(), "/plugins/my-plugin/demo"); - } - #[test] fn test_all_pages() { let client = ApiClient::default(); @@ -230,33 +294,18 @@ mod tests { 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_routes() { - let client = ApiClient::default(); - let mut registry = PluginRegistry::new(client); - - registry.register_page( - "plugin1".to_string(), - create_test_page("page1", "Page 1"), - ); - - 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/plugin1/page1"); - } - #[test] fn test_register_widget_and_all_widgets() { let client = ApiClient::default(); @@ -277,31 +326,23 @@ mod tests { assert_eq!(widgets[0].1.id, "my-widget"); } - #[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")), - ]; - - let registry = PluginRegistry::with_pages(client, pages); - assert_eq!(registry.len(), 2); - assert!(registry.get_page("plugin1", "page1").is_some()); - assert!(registry.get_page("plugin2", "page2").is_some()); - } - #[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")); - registry - .register_page("plugin1".to_string(), create_test_page("p", "Updated")); + 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.len(), 1); + assert_eq!(registry.all_pages().len(), 1); assert_eq!( registry.get_page("plugin1", "p").unwrap().page.title, "Updated" @@ -312,16 +353,73 @@ mod tests { fn test_default_registry_is_empty() { let registry = PluginRegistry::default(); assert!(registry.is_empty()); - assert_eq!(registry.len(), 0); + assert_eq!(registry.all_pages().len(), 0); assert!(registry.last_refresh().is_none()); } + #[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_full_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(); + // full_route() returns page.route directly; create_test_page sets it as + // "/plugins/test/{id}" + assert_eq!(plugin_page.full_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")); - registry.register_page("p2".to_string(), create_test_page("b", "B")); + 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); @@ -332,27 +430,145 @@ mod tests { } #[test] - fn test_different_plugins_same_page_id_both_stored() { + 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("home", "A Home"), + create_test_page("stats", "A Stats v1"), + vec![], ); registry.register_page( - "plugin-b".to_string(), - create_test_page("home", "B Home"), + "plugin-a".to_string(), + create_test_page("stats", "A Stats v2"), + vec![], ); - assert_eq!(registry.len(), 2); + assert_eq!(registry.all_pages().len(), 1); assert_eq!( - registry.get_page("plugin-a", "home").unwrap().page.title, - "A Home" - ); - assert_eq!( - registry.get_page("plugin-b", "home").unwrap().page.title, - "B Home" + 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: HashMap::new(), + actions: HashMap::new(), + }; + + 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: HashMap::new(), + actions: HashMap::new(), + }; + 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); + } } diff --git a/crates/pinakes-ui/src/plugin_ui/renderer.rs b/crates/pinakes-ui/src/plugin_ui/renderer.rs index 5f6f4c3..e8372fd 100644 --- a/crates/pinakes-ui/src/plugin_ui/renderer.rs +++ b/crates/pinakes-ui/src/plugin_ui/renderer.rs @@ -4,13 +4,16 @@ //! elements. Data-driven elements resolve their data from a [`PluginPageData`] //! context that is populated by the `use_plugin_data` hook. +use std::collections::HashMap; + use dioxus::prelude::*; use pinakes_plugin_api::{ + ActionDefinition, + ActionRef, AlignItems, BadgeVariant, ButtonVariant, ChartType, - Expression, FieldType, FlexDirection, JustifyContent, @@ -24,18 +27,65 @@ use pinakes_plugin_api::{ use super::{ actions::execute_action, data::{PluginPageData, use_plugin_data}, + expr::{ + evaluate_expression, + evaluate_expression_as_bool, + evaluate_expression_as_f64, + value_to_display_string, + }, }; use crate::client::ApiClient; +/// Mutable signals threaded through the element tree. +/// +/// All fields are `Signal` (which is `Copy`), so `RenderContext` is `Copy`. +/// `Eq` is not derived because `Signal>` cannot implement it +/// (`UiElement` contains `f64` fields). +#[derive(Clone, Copy, PartialEq)] +#[allow(clippy::derive_partial_eq_without_eq)] +pub struct RenderContext { + pub client: Signal, + pub feedback: Signal>, + pub navigate: Signal>, + pub refresh: Signal, + pub modal: Signal>, + pub local_state: Signal>, +} + +/// Build the expression evaluation context from page data and local state. +fn build_ctx( + data: &PluginPageData, + local_state: &HashMap, +) -> serde_json::Value { + let mut base = data.as_json(); + if let serde_json::Value::Object(ref mut obj) = base { + obj.insert( + "local".to_string(), + serde_json::Value::Object( + local_state + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + ), + ); + } + base +} + /// Props for [`PluginViewRenderer`] #[derive(Props, PartialEq, Clone)] pub struct PluginViewProps { /// Plugin ID that owns this page - pub plugin_id: String, + pub plugin_id: String, /// Page schema to render - pub page: UiPage, + pub page: UiPage, /// API client signal - pub client: Signal, + pub client: Signal, + /// Called when a plugin action requests navigation to a route + pub on_navigate: EventHandler, + /// Endpoint paths this plugin is allowed to fetch (empty means no + /// restriction) + pub allowed_endpoints: Vec, } /// Main component for rendering a plugin page. @@ -46,14 +96,65 @@ pub struct PluginViewProps { pub fn PluginViewRenderer(props: PluginViewProps) -> Element { let page = props.page.clone(); let data_sources = page.data_sources.clone(); - let page_data = use_plugin_data(props.client, data_sources); + let actions = page.actions.clone(); + let mut feedback = use_signal(|| None::<(String, bool)>); + let mut navigate = use_signal(|| None::); + let refresh = use_signal(|| 0u32); + let mut modal = use_signal(|| None::); + let local_state = use_signal(HashMap::::new); + let ctx = RenderContext { + client: props.client, + feedback, + navigate, + refresh, + modal, + local_state, + }; + let page_data = + use_plugin_data(props.client, data_sources, refresh, props.allowed_endpoints); + + // Consume pending navigation requests and forward to the parent + use_effect(move || { + let pending = navigate.read().clone(); + if let Some(route) = pending { + props.on_navigate.call(route); + navigate.set(None); + } + }); rsx! { div { class: "plugin-page", - "data-plugin-id": props.plugin_id.clone(), + "data-plugin-id": props.plugin_id, h2 { class: "plugin-page-title", "{page.title}" } - { render_element(&page.root_element, &page_data.read(), props.client) } + { render_element(&page.root_element, &page_data.read(), &actions, ctx) } + if let Some((msg, is_error)) = feedback.read().as_ref().cloned() { + div { + class: if is_error { "plugin-feedback error" } else { "plugin-feedback success" }, + "{msg}" + button { + class: "plugin-feedback-dismiss", + onclick: move |_| feedback.set(None), + "×" + } + } + } + if let Some(elem) = modal.read().as_ref().cloned() { + div { + class: "plugin-modal-overlay", + onclick: move |_| modal.set(None), + div { + class: "plugin-modal", + onclick: |e| e.stop_propagation(), + button { + class: "plugin-modal-close", + onclick: move |_| modal.set(None), + "×" + } + { render_element(&elem, &page_data.read(), &HashMap::new(), ctx) } + } + } + } } } } @@ -64,7 +165,8 @@ struct PluginTabsProps { tabs: Vec, default_tab: usize, data: PluginPageData, - client: Signal, + actions: HashMap, + ctx: RenderContext, } /// Renders a tabbed interface with interactive tab switching. @@ -101,15 +203,12 @@ fn PluginTabs(props: PluginTabsProps) -> Element { { let is_active = idx == active_idx; let content = tab.content.clone(); + let actions = props.actions.clone(); rsx! { div { - class: if is_active { - "plugin-tab-panel active" - } else { - "plugin-tab-panel" - }, + class: if is_active { "plugin-tab-panel active" } else { "plugin-tab-panel" }, hidden: !is_active, - { render_element(&content, &props.data, props.client) } + { render_element(&content, &props.data, &actions, props.ctx) } } } } @@ -119,11 +218,265 @@ fn PluginTabs(props: PluginTabsProps) -> Element { } } +/// Props for the stateful [`PluginDataTable`] component. +#[derive(Props, PartialEq, Clone)] +struct PluginDataTableProps { + columns: Vec, + source_key: String, + sortable: bool, + filterable: bool, + page_size: usize, + row_actions: Vec, + data: PluginPageData, + actions: HashMap, + ctx: RenderContext, +} + +/// Stateful data table with optional client-side filtering and pagination. +#[component] +fn PluginDataTable(props: PluginDataTableProps) -> Element { + let mut filter_text = use_signal(String::new); + let mut current_page = use_signal(|| 0usize); + + let all_rows = props + .data + .get(&props.source_key) + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + let filter = filter_text.read().to_lowercase(); + let filtered: Vec = if filter.is_empty() { + all_rows + } else { + all_rows + .into_iter() + .filter(|row| { + props.columns.iter().any(|col| { + extract_cell(row, &col.key).to_lowercase().contains(&filter) + }) + }) + .collect() + }; + + let total = filtered.len(); + let (page_rows, total_pages) = if props.page_size > 0 && total > 0 { + let total_pages = total.div_ceil(props.page_size); + let page = (*current_page.read()).min(total_pages.saturating_sub(1)); + let start = page * props.page_size; + let end = (start + props.page_size).min(total); + (filtered[start..end].to_vec(), total_pages) + } else { + (filtered, 1usize) + }; + + let page = *current_page.read(); + + rsx! { + div { class: "plugin-data-table-wrapper", + if props.data.is_loading(&props.source_key) { + div { class: "plugin-loading", "Loading…" } + } else if let Some(err) = props.data.error(&props.source_key) { + div { class: "plugin-error", "Error: {err}" } + } else { + if props.filterable { + div { class: "table-filter", + input { + r#type: "text", + placeholder: "Filter…", + value: "{filter_text}", + oninput: move |e| { + filter_text.set(e.value()); + current_page.set(0); + }, + } + } + } + table { + class: "plugin-data-table", + "data-sortable": if props.sortable { "true" } else { "false" }, + thead { + tr { + {props.columns.iter().map(|col| { + let col_width = col.width.as_deref().and_then(safe_col_width_css); + rsx! { + th { + style: col_width.as_deref().map(|v| format!("--plugin-col-width:{v};")).unwrap_or_default(), + class: if col_width.is_some() { "plugin-col-constrained" } else { "" }, + "{col.header}" + } + } + })} + if !props.row_actions.is_empty() { + th { "Actions" } + } + } + } + tbody { + for row in page_rows { + { + let row_val = row; + rsx! { + tr { + for col in props.columns.clone() { + td { "{extract_cell(&row_val, &col.key)}" } + } + if !props.row_actions.is_empty() { + td { class: "row-actions", + for act in props.row_actions.clone() { + { + let action = act.action.clone(); + let row_data = row_val.clone(); + let variant_class = + button_variant_class(&act.variant); + let page_actions = props.actions.clone(); + let success_msg: Option = + match &act.action { + ActionRef::Special(_) => None, + ActionRef::Name(name) => props + .actions + .get(name) + .and_then(|a| { + a.success_message.clone() + }), + ActionRef::Inline(a) => { + a.success_message.clone() + }, + }; + let error_msg: Option = + match &act.action { + ActionRef::Special(_) => None, + ActionRef::Name(name) => props + .actions + .get(name) + .and_then(|a| { + a.error_message.clone() + }), + ActionRef::Inline(a) => { + a.error_message.clone() + }, + }; + let ctx = props.ctx; + // Pre-compute data JSON at render time to + // avoid moving props.data into closures. + let data_json = props.data.as_json(); + rsx! { + button { + class: "plugin-button {variant_class}", + onclick: move |_| { + let a = action.clone(); + let fd = row_data.clone(); + let c = + ctx.client.read().clone(); + let pa = page_actions.clone(); + let sm = success_msg.clone(); + let em = error_msg.clone(); + // Combine pre-rendered data JSON + // with current local_state. + let mut data_snapshot = + data_json.clone(); + if let serde_json::Value::Object( + ref mut m, + ) = data_snapshot + { + m.insert( + "local".to_string(), + serde_json::Value::Object( + ctx.local_state.read().iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + ), + ); + } + spawn(async move { + let mut ctx = ctx; + match execute_action( + &c, &a, &pa, Some(&fd), + ) + .await + { + Ok(super::actions::ActionResult::Success(body)) => { + tracing::debug!(response = ?body, "plugin action succeeded"); + if let Some(msg) = sm { + ctx.feedback.set(Some((msg, false))); + } + }, + Ok(super::actions::ActionResult::Error(msg)) => { + ctx.feedback.set(Some(( + em.unwrap_or(msg), + true, + ))); + }, + Ok(super::actions::ActionResult::Navigate(route)) => { + ctx.navigate.set(Some(route)); + }, + Ok(super::actions::ActionResult::None) => {}, + Ok(super::actions::ActionResult::Refresh) => { + *ctx.refresh.write() += 1; + }, + Ok(super::actions::ActionResult::UpdateState { key, value_expr }) => { + let evaluated = evaluate_expression(&value_expr, &data_snapshot); + ctx.local_state.write().insert(key, evaluated); + }, + Ok(super::actions::ActionResult::OpenModal(element)) => { + ctx.modal.set(Some(element)); + }, + Ok(super::actions::ActionResult::CloseModal) => { + ctx.modal.set(None); + }, + Err(e) => { + ctx.feedback.set(Some((e, true))); + }, + } + }); + }, + "{act.label}" + } + } + } + } + } + } + } + } + } + } + } + } + if props.page_size > 0 && total_pages > 1 { + div { class: "table-pagination", + button { + class: "plugin-button", + disabled: page == 0, + onclick: move |_| { + let p = *current_page.read(); + if p > 0 { + current_page.set(p - 1); + } + }, + "←" + } + span { "Page {page + 1} of {total_pages} ({total} items)" } + button { + class: "plugin-button", + disabled: page + 1 >= total_pages, + onclick: move |_| { + let p = *current_page.read(); + current_page.set(p + 1); + }, + "→" + } + } + } + } + } + } +} + /// Render a single [`UiElement`] with the provided data context. -pub(crate) fn render_element( +pub fn render_element( element: &UiElement, data: &PluginPageData, - client: Signal, + actions: &HashMap, + ctx: RenderContext, ) -> Element { match element { // Layout containers @@ -136,15 +489,13 @@ pub(crate) fn render_element( || "0".to_string(), |p| format!("{}px {}px {}px {}px", p[0], p[1], p[2], p[3]), ); - let style = format!( - "display:flex;flex-direction:column;gap:{gap}px;padding:{padding_css};" - ); + let style = format!("--plugin-gap:{gap}px;--plugin-padding:{padding_css};"); rsx! { div { class: "plugin-container", style: "{style}", for child in children { - { render_element(child, data, client) } + { render_element(child, data, actions, ctx) } } } } @@ -155,15 +506,13 @@ pub(crate) fn render_element( columns, gap, } => { - let style = format!( - "display:grid;grid-template-columns:repeat({columns},1fr);gap:{gap}px;" - ); + let style = format!("--plugin-columns:{columns};--plugin-gap:{gap}px;"); rsx! { div { class: "plugin-grid", style: "{style}", for child in children { - { render_element(child, data, client) } + { render_element(child, data, actions, ctx) } } } } @@ -181,16 +530,17 @@ pub(crate) fn render_element( let jc = justify_content_css(justify); let ai = align_items_css(align); let fw = if *wrap { "wrap" } else { "nowrap" }; - let style = format!( - "display:flex;flex-direction:{dir};justify-content:{jc};align-items:\ - {ai};flex-wrap:{fw};gap:{gap}px;" - ); + let style = format!("--plugin-gap:{gap}px;"); rsx! { div { class: "plugin-flex", style: "{style}", + "data-direction": "{dir}", + "data-justify": "{jc}", + "data-align": "{ai}", + "data-wrap": "{fw}", for child in children { - { render_element(child, data, client) } + { render_element(child, data, actions, ctx) } } } } @@ -204,16 +554,14 @@ pub(crate) fn render_element( rsx! { div { class: "plugin-split", - style: "display:flex;", aside { class: "plugin-split-sidebar", - style: "width:{sidebar_width}px;flex-shrink:0;", - { render_element(sidebar, data, client) } + style: "--plugin-sidebar-width:{sidebar_width}px;", + { render_element(sidebar, data, actions, ctx) } } main { class: "plugin-split-main", - style: "flex:1;min-width:0;", - { render_element(main, data, client) } + { render_element(main, data, actions, ctx) } } } } @@ -225,15 +573,16 @@ pub(crate) fn render_element( tabs: tabs.clone(), default_tab: *default_tab, data: data.clone(), - client, + actions: actions.clone(), + ctx, } } }, // Typography UiElement::Heading { level, content, id } => { - let ctx = data.as_json(); - let text = resolve_text_content(content, &ctx); + let eval_ctx = data.as_json(); + let text = resolve_text_content(content, &eval_ctx); let class = format!("plugin-heading level-{level}"); let anchor = id.clone().unwrap_or_default(); match level.min(&6) { @@ -251,8 +600,8 @@ pub(crate) fn render_element( variant, allow_html, } => { - let ctx = data.as_json(); - let text = resolve_text_content(content, &ctx); + let eval_ctx = data.as_json(); + let text = resolve_text_content(content, &eval_ctx); let variant_class = text_variant_class(variant); if *allow_html { let sanitized = ammonia::clean(&text); @@ -297,73 +646,21 @@ pub(crate) fn render_element( columns, data: source_key, sortable, - filterable: _, - page_size: _, + filterable, + page_size, row_actions, } => { - let rows = data.get(source_key); rsx! { - div { class: "plugin-data-table-wrapper", - if data.is_loading(source_key) { - div { class: "plugin-loading", "Loading…" } - } else if let Some(err) = data.error(source_key) { - div { class: "plugin-error", "Error: {err}" } - } else { - table { - class: "plugin-data-table", - "data-sortable": if *sortable { "true" } else { "false" }, - thead { - tr { - for col in columns { - th { - style: col.width.as_ref().map(|w| format!("width:{w};")).unwrap_or_default(), - "{col.header}" - } - } - if !row_actions.is_empty() { - th { "Actions" } - } - } - } - tbody { - if let Some(arr) = rows.and_then(|v| v.as_array()) { - for row in arr { - tr { - for col in columns { - td { "{extract_cell(row, &col.key)}" } - } - if !row_actions.is_empty() { - td { - class: "row-actions", - for act in row_actions { - { - let action = act.action.clone(); - let row_data = row.clone(); - let variant_class = button_variant_class(&act.variant); - rsx! { - button { - class: "plugin-button {variant_class}", - onclick: move |_| { - let a = action.clone(); - let fd = row_data.clone(); - let c = client.read().clone(); - spawn(async move { - let _ = execute_action(&c, &a, Some(&fd)).await; - }); - }, - "{act.label}" - } - } - } - } - } - } - } - } - } - } - } - } + PluginDataTable { + columns: columns.clone(), + source_key: source_key.clone(), + sortable: *sortable, + filterable: *filterable, + page_size: *page_size, + row_actions: row_actions.clone(), + data: data.clone(), + actions: actions.clone(), + ctx, } } }, @@ -382,14 +679,14 @@ pub(crate) fn render_element( div { class: "plugin-card-content", for child in content { - { render_element(child, data, client) } + { render_element(child, data, actions, ctx) } } } if !footer.is_empty() { div { class: "plugin-card-footer", for child in footer { - { render_element(child, data, client) } + { render_element(child, data, actions, ctx) } } } } @@ -403,9 +700,7 @@ pub(crate) fn render_element( gap, } => { let items = data.get(source_key); - let style = format!( - "display:grid;grid-template-columns:repeat({columns},1fr);gap:{gap}px;" - ); + let style = format!("--plugin-columns:{columns};--plugin-gap:{gap}px;"); rsx! { div { class: "plugin-media-grid", style: "{style}", if data.is_loading(source_key) { @@ -414,9 +709,30 @@ pub(crate) fn render_element( div { class: "plugin-error", "Error: {err}" } } else if let Some(arr) = items.and_then(|v| v.as_array()) { for item in arr { - div { - class: "media-grid-item", - "{extract_cell(item, \"thumbnail\")}" + { + let url_opt = media_grid_image_url(item); + let label = media_grid_label(item); + rsx! { + div { class: "media-grid-item", + if let Some(url) = url_opt { + if pinakes_plugin_api::ui_schema::is_safe_href(&url) { + img { + class: "media-grid-img", + src: "{url}", + alt: "{label}", + loading: "lazy", + } + } else { + div { class: "media-grid-no-img", "{label}" } + } + } else { + div { class: "media-grid-no-img", "No image" } + } + if !label.is_empty() { + div { class: "media-grid-caption", "{label}" } + } + } + } } } } @@ -450,7 +766,7 @@ pub(crate) fn render_element( rsx! { li { class: "plugin-list-item", - { render_element(item_template, &item_data, client) } + { render_element(item_template, &item_data, actions, ctx) } if dividers { hr { class: "plugin-list-divider" } } @@ -476,15 +792,24 @@ pub(crate) fn render_element( } else { "plugin-description-list" }; + let pairs: Vec<(String, String)> = resolved + .and_then(|v| v.as_object()) + .map(|obj| { + obj + .iter() + .map(|(k, v)| (k.clone(), value_to_display_string(v))) + .collect() + }) + .unwrap_or_default(); rsx! { div { class: "plugin-description-list-wrapper", if data.is_loading(source_key) { div { class: "plugin-loading", "Loading…" } } else if let Some(err) = data.error(source_key) { div { class: "plugin-error", "Error: {err}" } - } else if let Some(obj) = resolved.and_then(|v| v.as_object()) { + } else if !pairs.is_empty() { dl { class: "{class}", - for (key, val) in obj { + for (key, val) in &pairs { dt { "{key}" } dd { "{val}" } } @@ -503,15 +828,67 @@ pub(crate) fn render_element( } => { let variant_class = button_variant_class(variant); let action_ref = action.clone(); + let page_actions = actions.clone(); + let success_msg: Option = match action { + ActionRef::Special(_) => None, + ActionRef::Name(name) => { + actions.get(name).and_then(|a| a.success_message.clone()) + }, + ActionRef::Inline(a) => a.success_message.clone(), + }; + let error_msg: Option = match action { + ActionRef::Special(_) => None, + ActionRef::Name(name) => { + actions.get(name).and_then(|a| a.error_message.clone()) + }, + ActionRef::Inline(a) => a.error_message.clone(), + }; + let data_snapshot = build_ctx(data, &ctx.local_state.read()); rsx! { button { class: "plugin-button {variant_class}", disabled: *disabled, onclick: move |_| { let a = action_ref.clone(); - let c = client.read().clone(); + let c = ctx.client.read().clone(); + let pa = page_actions.clone(); + let success_msg = success_msg.clone(); + let error_msg = error_msg.clone(); + let data_snapshot = data_snapshot.clone(); spawn(async move { - let _ = execute_action(&c, &a, None).await; + let mut ctx = ctx; + match execute_action(&c, &a, &pa, None).await { + Ok(super::actions::ActionResult::Success(body)) => { + tracing::debug!(response = ?body, "plugin action succeeded"); + if let Some(msg) = success_msg { + ctx.feedback.set(Some((msg, false))); + } + }, + Ok(super::actions::ActionResult::Error(msg)) => { + let display = error_msg.unwrap_or(msg); + ctx.feedback.set(Some((display, true))); + }, + Ok(super::actions::ActionResult::Navigate(route)) => { + ctx.navigate.set(Some(route)); + }, + Ok(super::actions::ActionResult::None) => {}, + Ok(super::actions::ActionResult::Refresh) => { + *ctx.refresh.write() += 1; + }, + Ok(super::actions::ActionResult::UpdateState { key, value_expr }) => { + let evaluated = evaluate_expression(&value_expr, &data_snapshot); + ctx.local_state.write().insert(key, evaluated); + }, + Ok(super::actions::ActionResult::OpenModal(element)) => { + ctx.modal.set(Some(element)); + }, + Ok(super::actions::ActionResult::CloseModal) => { + ctx.modal.set(None); + }, + Err(e) => { + ctx.feedback.set(Some((e, true))); + }, + } }); }, "{label}" @@ -526,12 +903,29 @@ pub(crate) fn render_element( cancel_label, } => { let action_ref = submit_action.clone(); + let page_actions = actions.clone(); + let success_msg: Option = match submit_action { + ActionRef::Special(_) => None, + ActionRef::Name(name) => { + actions.get(name).and_then(|a| a.success_message.clone()) + }, + ActionRef::Inline(a) => a.success_message.clone(), + }; + let error_msg: Option = match submit_action { + ActionRef::Special(_) => None, + ActionRef::Name(name) => { + actions.get(name).and_then(|a| a.error_message.clone()) + }, + ActionRef::Inline(a) => a.error_message.clone(), + }; + let data_snapshot = build_ctx(data, &ctx.local_state.read()); rsx! { form { class: "plugin-form", onsubmit: move |event| { event.prevent_default(); let a = action_ref.clone(); + let pa = page_actions.clone(); let form_values: serde_json::Value = { use dioxus::html::FormValue; let vals = event.data().values(); @@ -553,9 +947,44 @@ pub(crate) fn render_element( .collect(); serde_json::Value::Object(map) }; - let c = client.read().clone(); + let c = ctx.client.read().clone(); + let success_msg = success_msg.clone(); + let error_msg = error_msg.clone(); + let data_snapshot = data_snapshot.clone(); spawn(async move { - let _ = execute_action(&c, &a, Some(&form_values)).await; + let mut ctx = ctx; + match execute_action(&c, &a, &pa, Some(&form_values)).await { + Ok(super::actions::ActionResult::Success(body)) => { + tracing::debug!(response = ?body, "plugin action succeeded"); + if let Some(msg) = success_msg { + ctx.feedback.set(Some((msg, false))); + } + }, + Ok(super::actions::ActionResult::Error(msg)) => { + let display = error_msg.unwrap_or(msg); + ctx.feedback.set(Some((display, true))); + }, + Ok(super::actions::ActionResult::Navigate(route)) => { + ctx.navigate.set(Some(route)); + }, + Ok(super::actions::ActionResult::None) => {}, + Ok(super::actions::ActionResult::Refresh) => { + *ctx.refresh.write() += 1; + }, + Ok(super::actions::ActionResult::UpdateState { key, value_expr }) => { + let evaluated = evaluate_expression(&value_expr, &data_snapshot); + ctx.local_state.write().insert(key, evaluated); + }, + Ok(super::actions::ActionResult::OpenModal(element)) => { + ctx.modal.set(Some(element)); + }, + Ok(super::actions::ActionResult::CloseModal) => { + ctx.modal.set(None); + }, + Err(e) => { + ctx.feedback.set(Some((e, true))); + }, + } }); }, for field in fields { @@ -597,6 +1026,12 @@ pub(crate) fn render_element( href, external, } => { + if !pinakes_plugin_api::ui_schema::is_safe_href(href) { + // Refuse to render unsafe schemes (javascript:, data:, etc.) + return rsx! { + span { class: "plugin-link-blocked", title: "Blocked: unsafe URL scheme", "{text}" } + }; + } let target = if *external { "_blank" } else { "_self" }; let rel = if *external { "noopener noreferrer" } else { "" }; rsx! { @@ -615,9 +1050,13 @@ pub(crate) fn render_element( max, show_percentage, } => { - let ctx = data.as_json(); - let pct = evaluate_expression_as_f64(value, &ctx); - let fraction = (pct / max).clamp(0.0, 1.0); + let eval_ctx = data.as_json(); + let pct = evaluate_expression_as_f64(value, &eval_ctx); + let fraction = if *max > 0.0 { + (pct / max).clamp(0.0, 1.0) + } else { + 0.0 + }; let pct_int = (fraction * 100.0).round() as u32; rsx! { div { class: "plugin-progress", @@ -627,7 +1066,7 @@ pub(crate) fn render_element( aria_valuenow: "{pct_int}", aria_valuemin: "0", aria_valuemax: "100", - style: "width:{pct_int}%;", + style: "--plugin-progress:{pct_int}%;", } if *show_percentage { span { class: "plugin-progress-label", "{pct_int}%" } @@ -656,10 +1095,13 @@ pub(crate) fn render_element( height, } => { let chart_class = chart_type_class(chart_type); + let chart_data = data.get(source_key).cloned(); + let x_label = x_axis_label.as_deref().unwrap_or("").to_string(); + let y_label = y_axis_label.as_deref().unwrap_or("").to_string(); rsx! { div { class: "plugin-chart {chart_class}", - style: "height:{height}px;", + style: "--plugin-chart-height:{height}px;", if data.is_loading(source_key) { div { class: "plugin-loading", "Loading…" } } else if let Some(err) = data.error(source_key) { @@ -668,7 +1110,9 @@ pub(crate) fn render_element( if let Some(t) = title { div { class: "chart-title", "{t}" } } if let Some(x) = x_axis_label { div { class: "chart-x-label", "{x}" } } if let Some(y) = y_axis_label { div { class: "chart-y-label", "{y}" } } - div { class: "chart-canvas", "Chart rendering requires JavaScript" } + div { class: "chart-data-table", + { render_chart_data(chart_data.as_ref(), &x_label, &y_label) } + } } } } @@ -680,11 +1124,11 @@ pub(crate) fn render_element( then, else_element, } => { - let ctx = data.as_json(); - if evaluate_expression_as_bool(condition, &ctx) { - render_element(then, data, client) + let eval_ctx = data.as_json(); + if evaluate_expression_as_bool(condition, &eval_ctx) { + render_element(then, data, actions, ctx) } else if let Some(else_el) = else_element { - render_element(else_el, data, client) + render_element(else_el, data, actions, ctx) } else { rsx! {} } @@ -705,7 +1149,7 @@ pub(crate) fn render_element( if let Some(arr) = items.and_then(|v| v.as_array()) { if arr.is_empty() { if let Some(empty_el) = empty { - return render_element(empty_el, data, client); + return render_element(empty_el, data, actions, ctx); } return rsx! {}; } @@ -714,7 +1158,7 @@ pub(crate) fn render_element( .map(|item| { let mut item_data = data.clone(); item_data.set_data("item".to_string(), item.clone()); - render_element(template, &item_data, client) + render_element(template, &item_data, actions, ctx) }) .collect(); rsx! { for el in elements { {el} } } @@ -725,22 +1169,147 @@ pub(crate) fn render_element( } } +// Chart data renderer + +/// Render chart data as an HTML table (best available without a JS chart +/// library). +/// +/// - Array of objects: table with one column per unique key +/// - Array of primitives: two-column table (index, value) +/// - Object: two-column key/value table +fn render_chart_data( + data: Option<&serde_json::Value>, + x_label: &str, + y_label: &str, +) -> Element { + match data { + Some(serde_json::Value::Array(arr)) if !arr.is_empty() => { + if arr.first().map(|v| v.is_object()).unwrap_or(false) { + // Object rows: collect unique keys preserving insertion order + let mut seen = std::collections::HashSet::new(); + let cols: Vec = arr + .iter() + .filter_map(|r| r.as_object()) + .flat_map(|o| o.keys().cloned()) + .filter(|k| seen.insert(k.clone())) + .collect(); + rsx! { + table { class: "plugin-data-table", + thead { tr { for c in &cols { th { "{c}" } } } } + tbody { + for row in arr { + tr { + for c in &cols { + td { + "{value_to_display_string(row.get(c).unwrap_or(&serde_json::Value::Null))}" + } + } + } + } + } + } + } + } else { + // Primitive array: index vs value + let x = if x_label.is_empty() { "Index" } else { x_label }; + let y = if y_label.is_empty() { "Value" } else { y_label }; + rsx! { + table { class: "plugin-data-table", + thead { tr { th { "{x}" } th { "{y}" } } } + tbody { + for (i, v) in arr.iter().enumerate() { + tr { + td { "{i}" } + td { "{value_to_display_string(v)}" } + } + } + } + } + } + } + }, + Some(serde_json::Value::Object(map)) if !map.is_empty() => { + let x = if x_label.is_empty() { "Key" } else { x_label }; + let y = if y_label.is_empty() { "Value" } else { y_label }; + rsx! { + table { class: "plugin-data-table", + thead { tr { th { "{x}" } th { "{y}" } } } + tbody { + for (k, v) in map.iter() { + tr { + td { "{k}" } + td { "{value_to_display_string(v)}" } + } + } + } + } + } + }, + _ => rsx! { div { class: "chart-no-data", "No data available" } }, + } +} + +// MediaGrid helpers + +/// Probe a JSON object for common image URL fields. +fn media_grid_image_url(item: &serde_json::Value) -> Option { + for key in &[ + "thumbnail_url", + "thumbnail", + "url", + "image", + "cover", + "src", + "poster", + ] { + if let Some(url) = item.get(*key).and_then(|v| v.as_str()) { + if !url.is_empty() { + return Some(url.to_string()); + } + } + } + None +} + +/// Probe a JSON object for a human-readable label. +fn media_grid_label(item: &serde_json::Value) -> String { + for key in &["title", "name", "label", "caption"] { + if let Some(s) = item.get(*key).and_then(|v| v.as_str()) { + if !s.is_empty() { + return s.to_string(); + } + } + } + String::new() +} + // Form field helper fn render_form_field(field: &pinakes_plugin_api::FormField) -> Element { match &field.field_type { FieldType::Text { .. } => { + let default = field + .default_value + .as_ref() + .and_then(|v| v.as_str()) + .unwrap_or(""); rsx! { input { r#type: "text", id: "{field.id}", name: "{field.id}", + value: "{default}", placeholder: field.placeholder.as_deref().unwrap_or(""), required: field.required, } } }, FieldType::Textarea { rows } => { + let default = field + .default_value + .as_ref() + .and_then(|v| v.as_str()) + .unwrap_or(""); rsx! { textarea { id: "{field.id}", @@ -748,15 +1317,23 @@ fn render_form_field(field: &pinakes_plugin_api::FormField) -> Element { rows: *rows, placeholder: field.placeholder.as_deref().unwrap_or(""), required: field.required, + "{default}" } } }, FieldType::Number { min, max, step } => { + let default = field + .default_value + .as_ref() + .and_then(|v| v.as_f64()) + .map(|n| n.to_string()) + .unwrap_or_default(); rsx! { input { r#type: "number", id: "{field.id}", name: "{field.id}", + value: "{default}", min: min.map(|m| m.to_string()), max: max.map(|m| m.to_string()), step: step.map(|s| s.to_string()), @@ -765,52 +1342,89 @@ fn render_form_field(field: &pinakes_plugin_api::FormField) -> Element { } }, FieldType::Email => { + let default = field + .default_value + .as_ref() + .and_then(|v| v.as_str()) + .unwrap_or(""); rsx! { input { r#type: "email", id: "{field.id}", name: "{field.id}", + value: "{default}", placeholder: field.placeholder.as_deref().unwrap_or(""), required: field.required, } } }, FieldType::Url => { + let default = field + .default_value + .as_ref() + .and_then(|v| v.as_str()) + .unwrap_or(""); rsx! { input { r#type: "url", id: "{field.id}", name: "{field.id}", + value: "{default}", placeholder: field.placeholder.as_deref().unwrap_or(""), required: field.required, } } }, FieldType::Switch | FieldType::Checkbox { .. } => { + let checked = field + .default_value + .as_ref() + .and_then(|v| v.as_bool()) + .unwrap_or(false); rsx! { input { r#type: "checkbox", id: "{field.id}", name: "{field.id}", + checked, required: field.required, } } }, FieldType::Select { options, multiple } => { + let default = field + .default_value + .as_ref() + .and_then(|v| v.as_str()) + .unwrap_or(""); rsx! { select { id: "{field.id}", name: "{field.id}", multiple: *multiple, required: field.required, - option { value: "", disabled: true, selected: true, "Select…" } + option { + value: "", + disabled: true, + selected: default.is_empty(), + "Select…" + } for opt in options { - option { value: "{opt.value}", "{opt.label}" } + option { + value: "{opt.value}", + selected: opt.value == default, + "{opt.label}" + } } } } }, FieldType::Radio { options } => { + let default = field + .default_value + .as_ref() + .and_then(|v| v.as_str()) + .unwrap_or(""); rsx! { fieldset { id: "{field.id}", @@ -820,6 +1434,7 @@ fn render_form_field(field: &pinakes_plugin_api::FormField) -> Element { r#type: "radio", name: "{field.id}", value: "{opt.value}", + checked: opt.value == default, required: field.required, } " {opt.label}" @@ -829,21 +1444,33 @@ fn render_form_field(field: &pinakes_plugin_api::FormField) -> Element { } }, FieldType::Date => { + let default = field + .default_value + .as_ref() + .and_then(|v| v.as_str()) + .unwrap_or(""); rsx! { input { r#type: "date", id: "{field.id}", name: "{field.id}", + value: "{default}", required: field.required, } } }, FieldType::DateTime => { + let default = field + .default_value + .as_ref() + .and_then(|v| v.as_str()) + .unwrap_or(""); rsx! { input { r#type: "datetime-local", id: "{field.id}", name: "{field.id}", + value: "{default}", required: field.required, } } @@ -851,6 +1478,8 @@ fn render_form_field(field: &pinakes_plugin_api::FormField) -> Element { FieldType::File { accept, multiple, .. } => { + // File inputs cannot carry a default value (browser security + // restriction). rsx! { input { r#type: "file", @@ -944,408 +1573,48 @@ fn resolve_text_content( content: &TextContent, ctx: &serde_json::Value, ) -> String { + use super::expr::evaluate_expression; match content { TextContent::Static(s) => s.clone(), - TextContent::Expression(expr) => evaluate_expression(expr, ctx).to_string(), + TextContent::Expression(expr) => { + value_to_display_string(&evaluate_expression(expr, ctx)) + }, TextContent::Empty => String::new(), } } -fn evaluate_expression( - expr: &Expression, - ctx: &serde_json::Value, -) -> serde_json::Value { - match expr { - Expression::Literal(v) => v.clone(), - Expression::Path(path) => { - let mut current = ctx; - for key in path.split('.') { - match current { - serde_json::Value::Object(map) => { - if let Some(next) = map.get(key) { - current = next; - } else { - return serde_json::Value::Null; - } - }, - serde_json::Value::Array(arr) => { - if let Ok(idx) = key.parse::() { - if let Some(item) = arr.get(idx) { - current = item; - } else { - return serde_json::Value::Null; - } - } else { - return serde_json::Value::Null; - } - }, - _ => return serde_json::Value::Null, - } - } - current.clone() - }, - Expression::Operation { left, op, right } => { - use pinakes_plugin_api::Operator; - let l = evaluate_expression(left, ctx); - let r = evaluate_expression(right, ctx); - match op { - Operator::Eq => serde_json::Value::Bool(l == r), - Operator::Ne => serde_json::Value::Bool(l != r), - Operator::And => { - serde_json::Value::Bool( - l.as_bool().unwrap_or(false) && r.as_bool().unwrap_or(false), - ) - }, - Operator::Or => { - serde_json::Value::Bool( - l.as_bool().unwrap_or(false) || r.as_bool().unwrap_or(false), - ) - }, - Operator::Gt => { - let lf = l.as_f64().unwrap_or(0.0); - let rf = r.as_f64().unwrap_or(0.0); - serde_json::Value::Bool(lf > rf) - }, - Operator::Gte => { - let lf = l.as_f64().unwrap_or(0.0); - let rf = r.as_f64().unwrap_or(0.0); - serde_json::Value::Bool(lf >= rf) - }, - Operator::Lt => { - let lf = l.as_f64().unwrap_or(0.0); - let rf = r.as_f64().unwrap_or(0.0); - serde_json::Value::Bool(lf < rf) - }, - Operator::Lte => { - let lf = l.as_f64().unwrap_or(0.0); - let rf = r.as_f64().unwrap_or(0.0); - serde_json::Value::Bool(lf <= rf) - }, - _ => serde_json::Value::Null, - } - }, - Expression::Call { .. } => serde_json::Value::Null, - } -} - -fn evaluate_expression_as_bool( - expr: &Expression, - ctx: &serde_json::Value, -) -> bool { - evaluate_expression(expr, ctx).as_bool().unwrap_or(false) -} - -fn evaluate_expression_as_f64( - expr: &Expression, - ctx: &serde_json::Value, -) -> f64 { - evaluate_expression(expr, ctx).as_f64().unwrap_or(0.0) -} - fn extract_cell(row: &serde_json::Value, key: &str) -> String { - row - .as_object() - .and_then(|obj| obj.get(key)) - .map(|v| { - match v { - serde_json::Value::String(s) => s.clone(), - serde_json::Value::Number(n) => n.to_string(), - serde_json::Value::Bool(b) => b.to_string(), - serde_json::Value::Null => String::new(), - other => other.to_string(), - } - }) + // Use get_json_path so column keys support dot-notation (e.g. "author.name") + super::expr::get_json_path(row, key) + .map(value_to_display_string) .unwrap_or_default() } +/// Validate and normalize a plugin-supplied column width value. +/// Accepts: bare integer (adds px), `{n}px`, `{n}%`, or `"auto"`. +/// Rejects anything else to prevent CSS injection. +fn safe_col_width_css(w: &str) -> Option { + if w == "auto" { + return Some("auto".to_string()); + } + if let Ok(n) = w.parse::() { + return Some(format!("{n}px")); + } + if let Some(num) = w.strip_suffix("px").and_then(|n| n.parse::().ok()) { + return Some(format!("{num}px")); + } + if let Some(num) = w.strip_suffix('%').and_then(|n| n.parse::().ok()) { + return Some(format!("{num}%")); + } + None +} + #[cfg(test)] mod tests { - use pinakes_plugin_api::{Expression, Operator}; + use pinakes_plugin_api::Expression; use super::*; - fn lit(v: serde_json::Value) -> Box { - Box::new(Expression::Literal(v)) - } - - fn op_expr( - left: serde_json::Value, - op: Operator, - right: serde_json::Value, - ) -> Expression { - Expression::Operation { - left: lit(left), - op, - right: lit(right), - } - } - - #[test] - fn test_evaluate_expression_literal() { - let expr = Expression::Literal(serde_json::json!("hello")); - let result = evaluate_expression(&expr, &serde_json::json!({})); - assert_eq!(result, serde_json::json!("hello")); - } - - #[test] - fn test_evaluate_expression_literal_number() { - let expr = Expression::Literal(serde_json::json!(3.14)); - let result = evaluate_expression(&expr, &serde_json::json!({})); - assert_eq!(result, serde_json::json!(3.14)); - } - - #[test] - fn test_evaluate_expression_path() { - let expr = Expression::Path("foo.bar".to_string()); - let ctx = serde_json::json!({ "foo": { "bar": 42 } }); - let result = evaluate_expression(&expr, &ctx); - assert_eq!(result, serde_json::json!(42)); - } - - #[test] - fn test_evaluate_expression_path_missing_key() { - let expr = Expression::Path("foo.missing".to_string()); - let ctx = serde_json::json!({ "foo": { "bar": 1 } }); - let result = evaluate_expression(&expr, &ctx); - assert_eq!(result, serde_json::Value::Null); - } - - #[test] - fn test_evaluate_expression_path_array_index() { - let expr = Expression::Path("items.1".to_string()); - let ctx = serde_json::json!({ "items": ["a", "b", "c"] }); - let result = evaluate_expression(&expr, &ctx); - assert_eq!(result, serde_json::json!("b")); - } - - #[test] - fn test_evaluate_expression_path_array_out_of_bounds() { - let expr = Expression::Path("items.9".to_string()); - let ctx = serde_json::json!({ "items": ["a"] }); - let result = evaluate_expression(&expr, &ctx); - assert_eq!(result, serde_json::Value::Null); - } - - #[test] - fn test_evaluate_expression_path_on_scalar() { - let expr = Expression::Path("x.y".to_string()); - let ctx = serde_json::json!({ "x": 42 }); - let result = evaluate_expression(&expr, &ctx); - assert_eq!(result, serde_json::Value::Null); - } - - #[test] - fn test_evaluate_expression_eq_true() { - let expr = - op_expr(serde_json::json!(1), Operator::Eq, serde_json::json!(1)); - let result = evaluate_expression(&expr, &serde_json::json!({})); - assert_eq!(result, serde_json::json!(true)); - } - - #[test] - fn test_evaluate_expression_eq_false() { - let expr = - op_expr(serde_json::json!(1), Operator::Eq, serde_json::json!(2)); - let result = evaluate_expression(&expr, &serde_json::json!({})); - assert_eq!(result, serde_json::json!(false)); - } - - #[test] - fn test_evaluate_expression_ne() { - let expr = - op_expr(serde_json::json!("a"), Operator::Ne, serde_json::json!("b")); - assert_eq!( - evaluate_expression(&expr, &serde_json::json!({})), - serde_json::json!(true) - ); - let expr2 = - op_expr(serde_json::json!("a"), Operator::Ne, serde_json::json!("a")); - assert_eq!( - evaluate_expression(&expr2, &serde_json::json!({})), - serde_json::json!(false) - ); - } - - #[test] - fn test_evaluate_expression_gt() { - let expr = - op_expr(serde_json::json!(5), Operator::Gt, serde_json::json!(3)); - assert_eq!( - evaluate_expression(&expr, &serde_json::json!({})), - serde_json::json!(true) - ); - let expr2 = - op_expr(serde_json::json!(3), Operator::Gt, serde_json::json!(5)); - assert_eq!( - evaluate_expression(&expr2, &serde_json::json!({})), - serde_json::json!(false) - ); - } - - #[test] - fn test_evaluate_expression_gte() { - let expr = - op_expr(serde_json::json!(5), Operator::Gte, serde_json::json!(5)); - assert_eq!( - evaluate_expression(&expr, &serde_json::json!({})), - serde_json::json!(true) - ); - let expr2 = - op_expr(serde_json::json!(4), Operator::Gte, serde_json::json!(5)); - assert_eq!( - evaluate_expression(&expr2, &serde_json::json!({})), - serde_json::json!(false) - ); - } - - #[test] - fn test_evaluate_expression_lt() { - let expr = - op_expr(serde_json::json!(3), Operator::Lt, serde_json::json!(5)); - assert_eq!( - evaluate_expression(&expr, &serde_json::json!({})), - serde_json::json!(true) - ); - let expr2 = - op_expr(serde_json::json!(5), Operator::Lt, serde_json::json!(3)); - assert_eq!( - evaluate_expression(&expr2, &serde_json::json!({})), - serde_json::json!(false) - ); - } - - #[test] - fn test_evaluate_expression_lte() { - let expr = - op_expr(serde_json::json!(5), Operator::Lte, serde_json::json!(5)); - assert_eq!( - evaluate_expression(&expr, &serde_json::json!({})), - serde_json::json!(true) - ); - let expr2 = - op_expr(serde_json::json!(6), Operator::Lte, serde_json::json!(5)); - assert_eq!( - evaluate_expression(&expr2, &serde_json::json!({})), - serde_json::json!(false) - ); - } - - #[test] - fn test_evaluate_expression_and() { - let expr = op_expr( - serde_json::json!(true), - Operator::And, - serde_json::json!(true), - ); - assert_eq!( - evaluate_expression(&expr, &serde_json::json!({})), - serde_json::json!(true) - ); - let expr2 = op_expr( - serde_json::json!(true), - Operator::And, - serde_json::json!(false), - ); - assert_eq!( - evaluate_expression(&expr2, &serde_json::json!({})), - serde_json::json!(false) - ); - } - - #[test] - fn test_evaluate_expression_or() { - let expr = op_expr( - serde_json::json!(false), - Operator::Or, - serde_json::json!(true), - ); - assert_eq!( - evaluate_expression(&expr, &serde_json::json!({})), - serde_json::json!(true) - ); - let expr2 = op_expr( - serde_json::json!(false), - Operator::Or, - serde_json::json!(false), - ); - assert_eq!( - evaluate_expression(&expr2, &serde_json::json!({})), - serde_json::json!(false) - ); - } - - #[test] - fn test_evaluate_expression_unimplemented_ops_return_null() { - for op in [ - Operator::Concat, - Operator::Add, - Operator::Sub, - Operator::Mul, - Operator::Div, - ] { - let expr = op_expr(serde_json::json!(1), op, serde_json::json!(2)); - assert_eq!( - evaluate_expression(&expr, &serde_json::json!({})), - serde_json::Value::Null - ); - } - } - - #[test] - fn test_evaluate_expression_call_returns_null() { - let expr = Expression::Call { - function: "unknown".to_string(), - args: vec![], - }; - assert_eq!( - evaluate_expression(&expr, &serde_json::json!({})), - serde_json::Value::Null - ); - } - - #[test] - fn test_evaluate_expression_as_bool_true() { - let expr = Expression::Literal(serde_json::json!(true)); - assert!(evaluate_expression_as_bool(&expr, &serde_json::json!({}))); - } - - #[test] - fn test_evaluate_expression_as_bool_false() { - let expr = Expression::Literal(serde_json::json!(false)); - assert!(!evaluate_expression_as_bool(&expr, &serde_json::json!({}))); - } - - #[test] - fn test_evaluate_expression_as_bool_non_bool_returns_false() { - let expr = Expression::Literal(serde_json::json!("yes")); - assert!(!evaluate_expression_as_bool(&expr, &serde_json::json!({}))); - } - - #[test] - fn test_evaluate_expression_as_f64() { - let expr = Expression::Literal(serde_json::json!(7.5)); - assert_eq!( - evaluate_expression_as_f64(&expr, &serde_json::json!({})), - 7.5 - ); - } - - #[test] - fn test_evaluate_expression_as_f64_non_numeric_returns_zero() { - let expr = Expression::Literal(serde_json::json!("text")); - assert_eq!( - evaluate_expression_as_f64(&expr, &serde_json::json!({})), - 0.0 - ); - } - - #[test] - fn test_evaluate_expression_as_f64_from_path() { - let expr = Expression::Path("score".to_string()); - let ctx = serde_json::json!({ "score": 42 }); - assert_eq!(evaluate_expression_as_f64(&expr, &ctx), 42.0); - } - #[test] fn test_extract_cell_string() { let row = serde_json::json!({ "name": "Alice", "count": 5 }); @@ -1393,12 +1662,11 @@ mod tests { } #[test] - fn test_resolve_text_content_expression() { + fn test_resolve_text_content_expression_string() { + // String values are returned raw (no JSON quoting) let tc = TextContent::Expression(Expression::Path("user.name".to_string())); let ctx = serde_json::json!({ "user": { "name": "Bob" } }); - // evaluate_expression returns a serde_json::Value; .to_string() - // JSON-encodes it - assert_eq!(resolve_text_content(&tc, &ctx), "\"Bob\""); + assert_eq!(resolve_text_content(&tc, &ctx), "Bob"); } #[test] @@ -1409,9 +1677,10 @@ mod tests { } #[test] - fn test_resolve_text_content_expression_missing() { + fn test_resolve_text_content_expression_missing_returns_empty() { + // Null values resolve to empty string (not "null") let tc = TextContent::Expression(Expression::Path("missing".to_string())); - assert_eq!(resolve_text_content(&tc, &serde_json::json!({})), "null"); + assert_eq!(resolve_text_content(&tc, &serde_json::json!({})), ""); } #[test] @@ -1505,4 +1774,37 @@ mod tests { assert_eq!(text_variant_class(&TextVariant::Small), "text-small"); assert_eq!(text_variant_class(&TextVariant::Large), "text-large"); } + + #[test] + fn test_safe_col_width_css_auto() { + assert_eq!(safe_col_width_css("auto"), Some("auto".to_string())); + } + + #[test] + fn test_safe_col_width_css_bare_integer() { + assert_eq!(safe_col_width_css("100"), Some("100px".to_string())); + assert_eq!(safe_col_width_css("0"), Some("0px".to_string())); + } + + #[test] + fn test_safe_col_width_css_px_suffix() { + assert_eq!(safe_col_width_css("150px"), Some("150px".to_string())); + assert_eq!(safe_col_width_css("0px"), Some("0px".to_string())); + } + + #[test] + fn test_safe_col_width_css_percent_suffix() { + assert_eq!(safe_col_width_css("20%"), Some("20%".to_string())); + assert_eq!(safe_col_width_css("100%"), Some("100%".to_string())); + } + + #[test] + fn test_safe_col_width_css_rejects_unsafe() { + assert_eq!(safe_col_width_css(""), None); + assert_eq!(safe_col_width_css("1em"), None); + assert_eq!(safe_col_width_css("1rem"), None); + assert_eq!(safe_col_width_css("expression(alert(1))"), None); + assert_eq!(safe_col_width_css("-1px"), None); + assert_eq!(safe_col_width_css("100px; color: red"), None); + } }