pinakes: import in parallel; various UI improvements

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I1eb47cd79cd4145c56af966f6756fe1d6a6a6964
This commit is contained in:
raf 2026-02-03 10:31:20 +03:00
commit 116fe7b059
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
42 changed files with 4316 additions and 316 deletions

View file

@ -1,6 +1,8 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use dioxus::prelude::*;
use futures::future::join_all;
use crate::client::*;
use crate::components::{
@ -85,6 +87,9 @@ pub fn App() -> Element {
let mut last_search_query = use_signal(String::new);
let mut last_search_sort = use_signal(|| Option::<String>::None);
// Phase 3.6: Saved searches
let mut saved_searches = use_signal(Vec::<SavedSearchResponse>::new);
// Phase 6.1: Audit pagination & filter
let mut audit_page = use_signal(|| 0u64);
let audit_page_size = use_signal(|| 200u64);
@ -107,8 +112,44 @@ pub fn App() -> Element {
let mut login_loading = use_signal(|| false);
let mut auto_play_media = use_signal(|| false);
// Theme state (Phase 3.3)
let mut current_theme = use_signal(|| "dark".to_string());
let mut system_prefers_dark = use_signal(|| true);
// Detect system color scheme preference
use_effect(move || {
spawn(async move {
// Check system preference using JavaScript
let result =
document::eval(r#"window.matchMedia('(prefers-color-scheme: dark)').matches"#);
if let Ok(val) = result.await {
if let Some(prefers_dark) = val.as_bool() {
system_prefers_dark.set(prefers_dark);
}
}
});
});
// Compute effective theme based on preference
let effective_theme = use_memo(move || {
let theme = current_theme.read().clone();
if theme == "system" {
if *system_prefers_dark.read() {
"dark".to_string()
} else {
"light".to_string()
}
} else {
theme
}
});
// Import state for UI feedback
let mut import_in_progress = use_signal(|| false);
// Extended import state: current file name, queue of pending imports, progress (completed, total)
let mut import_current_file = use_signal(|| Option::<String>::None);
let mut import_queue = use_signal(Vec::<String>::new);
let mut import_progress = use_signal(|| (0usize, 0usize)); // (completed, total)
// Check auth on startup
let client_auth = client.read().clone();
@ -136,6 +177,7 @@ pub fn App() -> Element {
if let Ok(cfg) = client.get_config().await {
auto_play_media.set(cfg.ui.auto_play_media);
sidebar_collapsed.set(cfg.ui.sidebar_collapsed);
current_theme.set(cfg.ui.theme.clone());
if cfg.ui.default_page_size > 0 {
media_page_size.set(cfg.ui.default_page_size as u64);
}
@ -183,6 +225,10 @@ pub fn App() -> Element {
if let Ok(c) = client.list_collections().await {
collections_list.set(c);
}
// Phase 3.6: Load saved searches
if let Ok(ss) = client.list_saved_searches().await {
saved_searches.set(ss);
}
loading.set(false);
});
});
@ -310,14 +356,17 @@ pub fn App() -> Element {
} else {
// Phase 7.1: Keyboard shortcuts
div {
class: "app",
class: if *effective_theme.read() == "light" { "app theme-light" } else { "app" },
tabindex: "0",
onkeydown: {
move |evt: KeyboardEvent| {
let key = evt.key();
let ctrl = evt.modifiers().contains(Modifiers::CONTROL);
let meta = evt.modifiers().contains(Modifiers::META);
let shift = evt.modifiers().contains(Modifiers::SHIFT);
match key {
// Escape - close modal/go back
Key::Escape => {
if *show_help.read() {
show_help.set(false);
@ -325,6 +374,7 @@ pub fn App() -> Element {
current_view.set(View::Library);
}
}
// / or Ctrl+K - focus search
Key::Character(ref c) if c == "/" && !ctrl && !meta => {
evt.prevent_default();
current_view.set(View::Search);
@ -333,9 +383,43 @@ pub fn App() -> Element {
evt.prevent_default();
current_view.set(View::Search);
}
// ? - toggle help overlay
Key::Character(ref c) if c == "?" && !ctrl && !meta => {
show_help.toggle();
}
// Ctrl+, - open settings
Key::Character(ref c) if c == "," && (ctrl || meta) => {
evt.prevent_default();
current_view.set(View::Settings);
}
// Number keys 1-6 for quick view switching (without modifiers)
Key::Character(ref c) if c == "1" && !ctrl && !meta && !shift => {
evt.prevent_default();
current_view.set(View::Library);
}
Key::Character(ref c) if c == "2" && !ctrl && !meta && !shift => {
evt.prevent_default();
current_view.set(View::Search);
}
Key::Character(ref c) if c == "3" && !ctrl && !meta && !shift => {
evt.prevent_default();
current_view.set(View::Import);
}
Key::Character(ref c) if c == "4" && !ctrl && !meta && !shift => {
evt.prevent_default();
current_view.set(View::Tags);
}
Key::Character(ref c) if c == "5" && !ctrl && !meta && !shift => {
evt.prevent_default();
current_view.set(View::Collections);
}
Key::Character(ref c) if c == "6" && !ctrl && !meta && !shift => {
evt.prevent_default();
current_view.set(View::Audit);
}
// g then l - go to library (vim-style)
// Could implement g-prefix commands in the future
Key::Character(ref c) if c == "g" && !ctrl && !meta => {}
_ => {}
}
}
@ -492,6 +576,44 @@ pub fn App() -> Element {
div { class: "sidebar-spacer" }
// Show import progress in sidebar when not on import page
if *import_in_progress.read() && *current_view.read() != View::Import {
{
let (completed, total) = *import_progress.read();
let has_progress = total > 0;
let pct = if total > 0 { (completed * 100) / total } else { 0 };
let current = import_current_file.read().clone();
let queue_len = import_queue.read().len();
rsx! {
div { class: "sidebar-import-progress",
div { class: "sidebar-import-header",
div { class: "status-dot checking" }
span {
if has_progress {
"Importing {completed}/{total}"
} else {
"Importing..."
}
}
if queue_len > 0 {
span { class: "import-queue-badge", "+{queue_len}" }
}
}
if let Some(ref file_name) = current {
div { class: "sidebar-import-file", "{file_name}" }
}
div { class: "progress-bar",
if has_progress {
div { class: "progress-fill", style: "width: {pct}%;" }
} else {
div { class: "progress-fill indeterminate" }
}
}
}
}
}
}
// Sidebar collapse toggle
button {
class: "sidebar-toggle",
@ -867,6 +989,62 @@ pub fn App() -> Element {
});
}
},
// Phase 3.6: Saved searches
saved_searches: saved_searches.read().clone(),
on_save_search: {
let client = client.read().clone();
move |(name, query, sort): (String, String, Option<String>)| {
let client = client.clone();
spawn(async move {
match client.create_saved_search(&name, &query, sort.as_deref()).await {
Ok(ss) => {
saved_searches.write().push(ss);
show_toast(format!("Search '{}' saved", name), false);
}
Err(e) => show_toast(format!("Failed to save search: {e}"), true),
}
});
}
},
on_delete_saved_search: {
let client = client.read().clone();
move |id: String| {
let client = client.clone();
spawn(async move {
match client.delete_saved_search(&id).await {
Ok(_) => {
saved_searches.write().retain(|s| s.id != id);
show_toast("Search deleted".into(), false);
}
Err(e) => show_toast(format!("Failed to delete: {e}"), true),
}
});
}
},
on_load_saved_search: {
let client = client.read().clone();
move |ss: SavedSearchResponse| {
let client = client.clone();
let query = ss.query.clone();
let sort = ss.sort_order.clone();
search_page.set(0);
last_search_query.set(query.clone());
last_search_sort.set(sort.clone());
spawn(async move {
loading.set(true);
let offset = 0;
let limit = *search_page_size.read();
match client.search(&query, sort.as_deref(), offset, limit).await {
Ok(resp) => {
search_total.set(resp.total_count);
search_results.set(resp.items);
}
Err(e) => show_toast(format!("Search failed: {e}"), true),
}
loading.set(false);
});
}
},
}
},
View::Detail => {
@ -1225,10 +1403,54 @@ pub fn App() -> Element {
let refresh_media = refresh_media.clone();
let refresh_tags = refresh_tags.clone();
move |(path, tag_ids, new_tags, col_id): ImportEvent| {
// Extract file name from path
let file_name = path.rsplit('/').next().unwrap_or(&path).to_string();
// Check if already importing - if so, add to queue
// Extract directory name from path
// Check if already importing - if so, add to queue
if *import_in_progress.read() {
// Get preview files if available for per-file progress
// Use parallel import with per-batch progress
// Show first file in batch as current
// Process batch in parallel
// Update progress after batch
// Fallback: use server-side directory import (no per-file progress)
// Check if already importing - if so, add to queue
// Update progress from scan status
// Check if already importing - if so, add to queue
// Process files in parallel batches for better performance
// Show first file in batch as current
// Process batch in parallel
// Update progress after batch
// Extended import state
import_queue.write().push(file_name);
show_toast("Added to import queue".into(), false);
return;
}
let client = client.clone();
let refresh_media = refresh_media.clone();
let refresh_tags = refresh_tags.clone();
import_in_progress.set(true);
import_current_file.set(Some(file_name));
import_progress.set((0, 1));
spawn(async move {
if tag_ids.is_empty() && new_tags.is_empty() && col_id.is_none() {
match client.import_file(&path).await {
@ -1275,6 +1497,8 @@ pub fn App() -> Element {
Err(e) => show_toast(format!("Import failed: {e}"), true),
}
}
import_progress.set((1, 1));
import_current_file.set(None);
import_in_progress.set(false);
});
}
@ -1284,45 +1508,169 @@ pub fn App() -> Element {
let refresh_media = refresh_media.clone();
let refresh_tags = refresh_tags.clone();
move |(path, tag_ids, new_tags, col_id): ImportEvent| {
let dir_name = path.rsplit('/').next().unwrap_or(&path).to_string();
if *import_in_progress.read() {
import_queue.write().push(format!("{dir_name}/ (directory)"));
show_toast("Added directory to import queue".into(), false);
return;
}
let files_to_import: Vec<String> = preview_files
.read()
.iter()
.map(|f| f.path.clone())
.collect();
let client = client.clone();
let refresh_media = refresh_media.clone();
let refresh_tags = refresh_tags.clone();
import_in_progress.set(true);
spawn(async move {
match client
.import_directory(&path, &tag_ids, &new_tags, col_id.as_deref())
.await
{
Ok(resp) => {
show_toast(
format!(
"Done: {} imported, {} duplicates, {} errors",
resp.imported,
resp.duplicates,
resp.errors,
),
resp.errors > 0,
);
refresh_media();
if !new_tags.is_empty() {
refresh_tags();
if !files_to_import.is_empty() {
let file_count = files_to_import.len();
import_progress.set((0, file_count));
let client = Arc::new(client);
let tag_ids = Arc::new(tag_ids);
let new_tags = Arc::new(new_tags);
let col_id = Arc::new(col_id);
const BATCH_SIZE: usize = 6;
spawn(async move {
let imported = Arc::new(AtomicUsize::new(0));
let duplicates = Arc::new(AtomicUsize::new(0));
let errors = Arc::new(AtomicUsize::new(0));
let completed = Arc::new(AtomicUsize::new(0));
for chunk in files_to_import.chunks(BATCH_SIZE) {
if let Some(first_path) = chunk.first() {
let file_name = first_path
.rsplit('/')
.next()
.unwrap_or(first_path);
import_current_file.set(Some(file_name.to_string()));
}
preview_files.set(Vec::new());
preview_total_size.set(0);
let futures: Vec<_> = chunk
.iter()
.map(|file_path| {
let client = Arc::clone(&client);
let tag_ids = Arc::clone(&tag_ids);
let new_tags = Arc::clone(&new_tags);
let col_id = Arc::clone(&col_id);
let imported = Arc::clone(&imported);
let duplicates = Arc::clone(&duplicates);
let errors = Arc::clone(&errors);
let completed = Arc::clone(&completed);
let file_path = file_path.clone();
async move {
let result = if tag_ids.is_empty() && new_tags.is_empty()
&& col_id.is_none()
{
client.import_file(&file_path).await
} else {
client
.import_with_options(
&file_path,
&tag_ids,
&new_tags,
col_id.as_deref(),
)
.await
};
match result {
Ok(resp) => {
if resp.was_duplicate {
duplicates.fetch_add(1, Ordering::Relaxed);
} else {
imported.fetch_add(1, Ordering::Relaxed);
}
}
Err(_) => {
errors.fetch_add(1, Ordering::Relaxed);
}
}
completed.fetch_add(1, Ordering::Relaxed);
}
})
.collect();
join_all(futures).await;
let done = completed.load(Ordering::Relaxed);
import_progress.set((done, file_count));
}
Err(e) => show_toast(format!("Directory import failed: {e}"), true),
}
import_in_progress.set(false);
});
let imported = imported.load(Ordering::Relaxed);
let duplicates = duplicates.load(Ordering::Relaxed);
let errors = errors.load(Ordering::Relaxed);
show_toast(
format!(
"Done: {imported} imported, {duplicates} duplicates, {errors} errors",
),
errors > 0,
);
refresh_media();
if !new_tags.is_empty() {
refresh_tags();
}
preview_files.set(Vec::new());
preview_total_size.set(0);
import_progress.set((file_count, file_count));
import_current_file.set(None);
import_in_progress.set(false);
});
} else {
import_current_file.set(Some(format!("{dir_name}/")));
import_progress.set((0, 0));
spawn(async move {
match client
.import_directory(&path, &tag_ids, &new_tags, col_id.as_deref())
.await
{
Ok(resp) => {
show_toast(
format!(
"Done: {} imported, {} duplicates, {} errors",
resp.imported,
resp.duplicates,
resp.errors,
),
resp.errors > 0,
);
refresh_media();
if !new_tags.is_empty() {
refresh_tags();
}
preview_files.set(Vec::new());
preview_total_size.set(0);
}
Err(e) => {
show_toast(format!("Directory import failed: {e}"), true)
}
}
import_current_file.set(None);
import_progress.set((0, 0));
import_in_progress.set(false);
});
}
}
},
on_scan: {
let client = client.read().clone();
let refresh_media = refresh_media.clone();
move |_| {
if *import_in_progress.read() {
import_queue.write().push("Scan roots".to_string());
show_toast("Added scan to import queue".into(), false);
return;
}
let client = client.clone();
let refresh_media = refresh_media.clone();
import_in_progress.set(true);
import_current_file.set(Some("Scanning roots...".to_string()));
import_progress.set((0, 0)); // Will be updated from scan_progress
spawn(async move {
match client.trigger_scan().await {
Ok(_results) => {
@ -1330,6 +1678,23 @@ pub fn App() -> Element {
match client.scan_status().await {
Ok(status) => {
let done = !status.scanning;
import_progress
.set((
status.files_processed as usize,
status.files_found as usize,
));
if status.files_found > 0 {
import_current_file
.set(
Some(
format!(
"Scanning ({}/{})",
status.files_processed,
status.files_found,
),
),
);
}
scan_progress.set(Some(status.clone()));
if done {
let total = status.files_processed;
@ -1348,6 +1713,8 @@ pub fn App() -> Element {
}
Err(e) => show_toast(format!("Scan failed: {e}"), true),
}
import_current_file.set(None);
import_progress.set((0, 0));
import_in_progress.set(false);
});
}
@ -1357,40 +1724,105 @@ pub fn App() -> Element {
let refresh_media = refresh_media.clone();
let refresh_tags = refresh_tags.clone();
move |(paths, tag_ids, new_tags, col_id): import::BatchImportEvent| {
let client = client.clone();
let file_count = paths.len();
if *import_in_progress.read() {
import_queue.write().push(format!("{file_count} files (batch)"));
show_toast("Added batch to import queue".into(), false);
return;
}
let client = Arc::new(client.clone());
let refresh_media = refresh_media.clone();
let refresh_tags = refresh_tags.clone();
let file_count = paths.len();
let tag_ids = Arc::new(tag_ids);
let new_tags = Arc::new(new_tags);
let col_id = Arc::new(col_id);
import_in_progress.set(true);
import_progress.set((0, file_count));
const BATCH_SIZE: usize = 6;
spawn(async move {
match client
.batch_import(&paths, &tag_ids, &new_tags, col_id.as_deref())
.await
{
Ok(resp) => {
show_toast(
format!(
"Done: {} imported, {} duplicates, {} errors",
resp.imported,
resp.duplicates,
resp.errors,
),
resp.errors > 0,
);
refresh_media();
if !new_tags.is_empty() {
refresh_tags();
}
preview_files.set(Vec::new());
preview_total_size.set(0);
}
Err(e) => {
show_toast(
format!("Batch import failed ({file_count} files): {e}"),
true,
)
let imported = Arc::new(AtomicUsize::new(0));
let duplicates = Arc::new(AtomicUsize::new(0));
let errors = Arc::new(AtomicUsize::new(0));
let completed = Arc::new(AtomicUsize::new(0));
for chunk in paths.chunks(BATCH_SIZE) {
if let Some(first_path) = chunk.first() {
let file_name = first_path
.rsplit('/')
.next()
.unwrap_or(first_path);
import_current_file.set(Some(file_name.to_string()));
}
let futures: Vec<_> = chunk
.iter()
.map(|path| {
let client = Arc::clone(&client);
let tag_ids = Arc::clone(&tag_ids);
let new_tags = Arc::clone(&new_tags);
let col_id = Arc::clone(&col_id);
let imported = Arc::clone(&imported);
let duplicates = Arc::clone(&duplicates);
let errors = Arc::clone(&errors);
let completed = Arc::clone(&completed);
let path = path.clone();
async move {
let result = if tag_ids.is_empty() && new_tags.is_empty()
&& col_id.is_none()
{
client.import_file(&path).await
} else {
client
.import_with_options(
&path,
&tag_ids,
&new_tags,
col_id.as_deref(),
)
.await
};
match result {
Ok(resp) => {
if resp.was_duplicate {
duplicates.fetch_add(1, Ordering::Relaxed);
} else {
imported.fetch_add(1, Ordering::Relaxed);
}
}
Err(_) => {
errors.fetch_add(1, Ordering::Relaxed);
}
}
completed.fetch_add(1, Ordering::Relaxed);
}
})
.collect();
join_all(futures).await;
let done = completed.load(Ordering::Relaxed);
import_progress.set((done, file_count));
}
let imported = imported.load(Ordering::Relaxed);
let duplicates = duplicates.load(Ordering::Relaxed);
let errors = errors.load(Ordering::Relaxed);
show_toast(
format!(
"Done: {imported} imported, {duplicates} duplicates, {errors} errors",
),
errors > 0,
);
refresh_media();
if !new_tags.is_empty() {
refresh_tags();
}
preview_files.set(Vec::new());
preview_total_size.set(0);
import_progress.set((file_count, file_count));
import_current_file.set(None);
import_in_progress.set(false);
});
}
@ -1416,6 +1848,9 @@ pub fn App() -> Element {
},
preview_files: preview_files.read().clone(),
preview_total_size: *preview_total_size.read(),
current_file: import_current_file.read().clone(),
import_queue: import_queue.read().clone(),
import_progress: *import_progress.read(),
}
},
View::Database => {
@ -1620,6 +2055,7 @@ pub fn App() -> Element {
Ok(ui_cfg) => {
auto_play_media.set(ui_cfg.auto_play_media);
sidebar_collapsed.set(ui_cfg.sidebar_collapsed);
current_theme.set(ui_cfg.theme.clone());
if let Ok(cfg) = client.get_config().await {
config_data.set(Some(cfg));
}
@ -1654,6 +2090,7 @@ pub fn App() -> Element {
onclick: move |evt: MouseEvent| evt.stop_propagation(),
h3 { "Keyboard Shortcuts" }
div { class: "help-shortcuts",
h4 { "Navigation" }
div { class: "shortcut-row",
kbd { "Esc" }
span { "Go back / close overlay" }
@ -1664,12 +2101,42 @@ pub fn App() -> Element {
}
div { class: "shortcut-row",
kbd { "Ctrl+K" }
span { "Focus search" }
span { "Focus search (alternative)" }
}
div { class: "shortcut-row",
kbd { "Ctrl+," }
span { "Open settings" }
}
div { class: "shortcut-row",
kbd { "?" }
span { "Toggle this help" }
}
h4 { "Quick Views" }
div { class: "shortcut-row",
kbd { "1" }
span { "Library" }
}
div { class: "shortcut-row",
kbd { "2" }
span { "Search" }
}
div { class: "shortcut-row",
kbd { "3" }
span { "Import" }
}
div { class: "shortcut-row",
kbd { "4" }
span { "Tags" }
}
div { class: "shortcut-row",
kbd { "5" }
span { "Collections" }
}
div { class: "shortcut-row",
kbd { "6" }
span { "Audit Log" }
}
}
button {
class: "help-close",

View file

@ -277,6 +277,22 @@ pub struct DatabaseStatsResponse {
pub backend_name: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct SavedSearchResponse {
pub id: String,
pub name: String,
pub query: String,
pub sort_order: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CreateSavedSearchRequest {
pub name: String,
pub query: String,
pub sort_order: Option<String>,
}
#[allow(dead_code)]
impl ApiClient {
pub fn new(base_url: &str, api_key: Option<&str>) -> Self {
@ -1053,6 +1069,50 @@ impl ApiClient {
.await?)
}
// ── Saved Searches ──
pub async fn list_saved_searches(&self) -> Result<Vec<SavedSearchResponse>> {
Ok(self
.client
.get(self.url("/saved-searches"))
.send()
.await?
.error_for_status()?
.json()
.await?)
}
pub async fn create_saved_search(
&self,
name: &str,
query: &str,
sort_order: Option<&str>,
) -> Result<SavedSearchResponse> {
let req = CreateSavedSearchRequest {
name: name.to_string(),
query: query.to_string(),
sort_order: sort_order.map(|s| s.to_string()),
};
Ok(self
.client
.post(self.url("/saved-searches"))
.json(&req)
.send()
.await?
.error_for_status()?
.json()
.await?)
}
pub async fn delete_saved_search(&self, id: &str) -> Result<()> {
self.client
.delete(self.url(&format!("/saved-searches/{id}")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub fn set_token(&mut self, token: &str) {
let mut headers = header::HeaderMap::new();
if let Ok(val) = header::HeaderValue::from_str(&format!("Bearer {token}")) {

View file

@ -3,6 +3,7 @@ use dioxus::prelude::*;
use super::image_viewer::ImageViewer;
use super::markdown_viewer::MarkdownViewer;
use super::media_player::MediaPlayer;
use super::pdf_viewer::PdfViewer;
use super::utils::{format_duration, format_size, media_category, type_badge_class};
use crate::client::{MediaResponse, MediaUpdateEvent, TagResponse};
@ -262,15 +263,20 @@ pub fn Detail(
media_type: media.media_type.clone(),
}
} else if category == "document" {
div { class: "detail-no-preview",
p { class: "text-muted", "Preview not available for this document type." }
button {
class: "btn btn-primary",
onclick: {
let id_open = id.clone();
move |_| on_open.call(id_open.clone())
},
"Open Externally"
if media.media_type == "pdf" {
PdfViewer { src: stream_url.clone() }
} else {
// EPUB and other document types
div { class: "detail-no-preview",
p { class: "text-muted", "Preview not available for this document type." }
button {
class: "btn btn-primary",
onclick: {
let id_open = id.clone();
move |_| on_open.call(id_open.clone())
},
"Open Externally"
}
}
}
} else if has_thumbnail {

View file

@ -69,6 +69,8 @@ pub fn Duplicates(
rsx! {
div { class: "duplicate-group", key: "{hash}",
button {
class: "duplicate-group-header",
onclick: move |_| {
@ -109,8 +111,6 @@ pub fn Duplicates(
div { class: "dup-thumb",
if has_thumb {
img {

View file

@ -23,6 +23,10 @@ pub fn Import(
preview_total_size: u64,
scan_progress: Option<ScanStatusResponse>,
#[props(default = false)] is_importing: bool,
// Extended import state
#[props(default)] current_file: Option<String>,
#[props(default)] import_queue: Vec<String>,
#[props(default = (0, 0))] import_progress: (usize, usize),
) -> Element {
let mut import_mode = use_signal(|| 0usize);
let mut file_path = use_signal(String::new);
@ -47,13 +51,45 @@ pub fn Import(
rsx! {
// Import status panel (shown when import is in progress)
if is_importing {
div { class: "import-status-panel",
div { class: "import-status-header",
div { class: "status-dot checking" }
span { "Import in progress..." }
}
div { class: "progress-bar",
div { class: "progress-fill indeterminate" }
{
let (completed, total) = import_progress;
let has_progress = total > 0;
let pct = if total > 0 { (completed * 100) / total } else { 0 };
let queue_count = import_queue.len();
rsx! {
div { class: "import-status-panel",
div { class: "import-status-header",
div { class: "status-dot checking" }
span {
if has_progress {
"Importing {completed}/{total}..."
} else {
"Import in progress..."
}
}
}
// Show current file being imported
if let Some(ref file_name) = current_file {
div { class: "import-current-file",
span { class: "import-file-label", "Current: " }
span { class: "import-file-name", "{file_name}" }
}
}
// Show queue indicator
if queue_count > 0 {
div { class: "import-queue-indicator",
span { class: "import-queue-badge", "{queue_count}" }
span { class: "import-queue-text", " item(s) queued" }
}
}
div { class: "progress-bar",
if has_progress {
div { class: "progress-fill", style: "width: {pct}%;" }
} else {
div { class: "progress-fill indeterminate" }
}
}
}
}
}
}
@ -229,13 +265,13 @@ pub fn Import(
// Recursive toggle
div { class: "form-group",
label { class: "form-row",
label { class: "checkbox-label",
input {
r#type: "checkbox",
checked: *recursive.read(),
onchange: move |_| recursive.toggle(),
}
span { style: "margin-left: 6px;", "Recursive (include subdirectories)" }
span { "Recursive (include subdirectories)" }
}
}
}
@ -299,9 +335,12 @@ pub fn Import(
}
}
div { class: "filter-bar",
div { class: "flex-row mb-8",
label {
div { class: "filter-row",
span { class: "filter-label", "Types" }
label { class: if types_snapshot[0] { "filter-chip active" } else { "filter-chip" },
input {
r#type: "checkbox",
checked: types_snapshot[0],
@ -311,9 +350,9 @@ pub fn Import(
filter_types.set(types);
},
}
" Audio"
"Audio"
}
label {
label { class: if types_snapshot[1] { "filter-chip active" } else { "filter-chip" },
input {
r#type: "checkbox",
checked: types_snapshot[1],
@ -323,9 +362,9 @@ pub fn Import(
filter_types.set(types);
},
}
" Video"
"Video"
}
label {
label { class: if types_snapshot[2] { "filter-chip active" } else { "filter-chip" },
input {
r#type: "checkbox",
checked: types_snapshot[2],
@ -335,9 +374,9 @@ pub fn Import(
filter_types.set(types);
},
}
" Image"
"Image"
}
label {
label { class: if types_snapshot[3] { "filter-chip active" } else { "filter-chip" },
input {
r#type: "checkbox",
checked: types_snapshot[3],
@ -347,9 +386,9 @@ pub fn Import(
filter_types.set(types);
},
}
" Document"
"Document"
}
label {
label { class: if types_snapshot[4] { "filter-chip active" } else { "filter-chip" },
input {
r#type: "checkbox",
checked: types_snapshot[4],
@ -359,9 +398,9 @@ pub fn Import(
filter_types.set(types);
},
}
" Text"
"Text"
}
label {
label { class: if types_snapshot[5] { "filter-chip active" } else { "filter-chip" },
input {
r#type: "checkbox",
checked: types_snapshot[5],
@ -371,33 +410,41 @@ pub fn Import(
filter_types.set(types);
},
}
" Other"
"Other"
}
}
div { class: "flex-row",
label { class: "form-label", "Min size (MB): " }
input {
r#type: "number",
value: "{min / (1024 * 1024)}",
oninput: move |e| {
if let Ok(mb) = e.value().parse::<u64>() {
filter_min_size.set(mb * 1024 * 1024);
} else {
filter_min_size.set(0);
}
},
div { class: "size-filters",
div { class: "size-filter-group",
label { "Min size" }
input {
r#type: "number",
placeholder: "MB",
value: if min > 0 { format!("{}", min / (1024 * 1024)) } else { String::new() },
oninput: move |e| {
if let Ok(mb) = e.value().parse::<u64>() {
filter_min_size.set(mb * 1024 * 1024);
} else {
filter_min_size.set(0);
}
},
}
span { class: "text-muted text-sm", "MB" }
}
label { class: "form-label", "Max size (MB): " }
input {
r#type: "number",
value: "{max / (1024 * 1024)}",
oninput: move |e| {
if let Ok(mb) = e.value().parse::<u64>() {
filter_max_size.set(mb * 1024 * 1024);
} else {
filter_max_size.set(0);
}
},
div { class: "size-filter-group",
label { "Max size" }
input {
r#type: "number",
placeholder: "MB",
value: if max > 0 { format!("{}", max / (1024 * 1024)) } else { String::new() },
oninput: move |e| {
if let Ok(mb) = e.value().parse::<u64>() {
filter_max_size.set(mb * 1024 * 1024);
} else {
filter_max_size.set(0);
}
},
}
span { class: "text-muted text-sm", "MB" }
}
}
}
@ -565,34 +612,46 @@ pub fn Import(
}
// Import entire directory
button {
class: "btn btn-secondary",
disabled: is_importing,
onclick: {
let mut dir_path = dir_path;
let mut selected_tags = selected_tags;
let mut new_tags_input = new_tags_input;
let mut selected_collection = selected_collection;
let mut selected_file_paths = selected_file_paths;
move |_| {
let path = dir_path.read().clone();
if !path.is_empty() {
let tag_ids = selected_tags.read().clone();
let new_tags = parse_new_tags(&new_tags_input.read());
let col_id = selected_collection.read().clone();
on_import_directory.call((path, tag_ids, new_tags, col_id));
dir_path.set(String::new());
selected_tags.set(Vec::new());
new_tags_input.set(String::new());
selected_collection.set(None);
selected_file_paths.set(HashSet::new());
{
let has_dir = !dir_path.read().is_empty();
let has_preview = !preview_files.is_empty();
let file_count = preview_files.len();
rsx! {
button {
class: if has_dir { "btn btn-secondary" } else { "btn btn-secondary btn-disabled-hint" },
disabled: is_importing || !has_dir,
title: if !has_dir { "Select a directory first" } else { "" },
onclick: {
let mut dir_path = dir_path;
let mut selected_tags = selected_tags;
let mut new_tags_input = new_tags_input;
let mut selected_collection = selected_collection;
let mut selected_file_paths = selected_file_paths;
move |_| {
let path = dir_path.read().clone();
if !path.is_empty() {
let tag_ids = selected_tags.read().clone();
let new_tags = parse_new_tags(&new_tags_input.read());
let col_id = selected_collection.read().clone();
on_import_directory.call((path, tag_ids, new_tags, col_id));
dir_path.set(String::new());
selected_tags.set(Vec::new());
new_tags_input.set(String::new());
selected_collection.set(None);
selected_file_paths.set(HashSet::new());
}
}
},
if is_importing {
"Importing..."
} else if has_preview {
"Import All ({file_count} files)"
} else if has_dir {
"Import Entire Directory"
} else {
"Select Directory First"
}
}
},
if is_importing {
"Importing..."
} else {
"Import Entire Directory"
}
}
}

View file

@ -595,20 +595,23 @@ pub fn Library(
let badge_class = type_badge_class(&item.media_type);
let is_checked = current_selection.contains(&id);
// Build a list of all visible IDs for shift+click range selection.
// Shift+click: select range from last_click_index to current idx.
// No previous click, just toggle this one.
// Thumbnail with CSS fallback: both the icon and img
// are rendered. The img is absolutely positioned on
// top. If the image fails to load, the icon beneath
// shows through.
// Thumbnail with CSS fallback: icon always
// rendered, img overlays when available.
// Build a list of all visible IDs for shift+click range selection.
// Shift+click: select range from last_click_index to current idx.
// No previous click, just toggle this one.
// Thumbnail with CSS fallback: both the icon and img
// are rendered. The img is absolutely positioned on
// top. If the image fails to load, the icon beneath
// shows through.
// Thumbnail with CSS fallback: icon always
// rendered, img overlays when available.
let card_click = {
let id = item.id.clone();
move |_| on_select.call(id.clone())
@ -616,8 +619,6 @@ pub fn Library(
let visible_ids: Vec<String> = filtered_media
.iter()
.map(|m| m.id.clone())
.collect();
@ -665,6 +666,8 @@ pub fn Library(
rsx! {
div { key: "{item.id}", class: "{card_class}", onclick: card_click,
div { class: "card-checkbox",
input { r#type: "checkbox", checked: is_checked, onclick: toggle_id }
}

View file

@ -12,6 +12,7 @@ pub mod login;
pub mod markdown_viewer;
pub mod media_player;
pub mod pagination;
pub mod pdf_viewer;
pub mod search;
pub mod settings;
pub mod statistics;

View file

@ -0,0 +1,112 @@
use dioxus::prelude::*;
#[component]
pub fn PdfViewer(
src: String,
#[props(default = 1)] initial_page: usize,
#[props(default = 100)] initial_zoom: usize,
) -> Element {
let current_page = use_signal(|| initial_page);
let mut zoom_level = use_signal(|| initial_zoom);
let mut loading = use_signal(|| true);
let mut error = use_signal(|| Option::<String>::None);
// For navigation controls
let zoom = *zoom_level.read();
let page = *current_page.read();
rsx! {
div { class: "pdf-viewer",
// Toolbar
div { class: "pdf-toolbar",
div { class: "pdf-toolbar-group",
button {
class: "pdf-toolbar-btn",
title: "Zoom out",
disabled: zoom <= 50,
onclick: move |_| {
let new_zoom = (*zoom_level.read()).saturating_sub(25).max(50);
zoom_level.set(new_zoom);
},
"\u{2212}" // minus
}
span { class: "pdf-zoom-label", "{zoom}%" }
button {
class: "pdf-toolbar-btn",
title: "Zoom in",
disabled: zoom >= 200,
onclick: move |_| {
let new_zoom = (*zoom_level.read() + 25).min(200);
zoom_level.set(new_zoom);
},
"+" // plus
}
}
div { class: "pdf-toolbar-group",
button {
class: "pdf-toolbar-btn",
title: "Fit to width",
onclick: move |_| zoom_level.set(100),
"\u{2194}" // left-right arrow
}
}
}
// PDF embed container
div { class: "pdf-container",
if *loading.read() {
div { class: "pdf-loading",
div { class: "spinner" }
span { "Loading PDF..." }
}
}
if let Some(ref err) = *error.read() {
div { class: "pdf-error",
p { "{err}" }
a {
href: "{src}",
target: "_blank",
class: "btn btn-primary",
"Download PDF"
}
}
}
// Use object/embed for PDF rendering
// The webview should handle PDF rendering natively
object {
class: "pdf-object",
r#type: "application/pdf",
data: "{src}#zoom={zoom}&page={page}",
width: "100%",
height: "100%",
onload: move |_| {
loading.set(false);
error.set(None);
},
onerror: move |_| {
loading.set(false);
error
.set(
Some(
"Unable to display PDF. Your browser may not support embedded PDF viewing."
.to_string(),
),
);
},
// Fallback content
div { class: "pdf-fallback",
p { "PDF preview is not available in this browser." }
a {
href: "{src}",
target: "_blank",
class: "btn btn-primary",
"Download PDF"
}
}
}
}
}
}
}

View file

@ -2,7 +2,7 @@ use dioxus::prelude::*;
use super::pagination::Pagination as PaginationControls;
use super::utils::{format_size, type_badge_class, type_icon};
use crate::client::MediaResponse;
use crate::client::{MediaResponse, SavedSearchResponse};
#[component]
pub fn Search(
@ -14,10 +14,17 @@ pub fn Search(
on_select: EventHandler<String>,
on_page_change: EventHandler<u64>,
server_url: String,
#[props(default)] saved_searches: Vec<SavedSearchResponse>,
#[props(default)] on_save_search: Option<EventHandler<(String, String, Option<String>)>>,
#[props(default)] on_delete_saved_search: Option<EventHandler<String>>,
#[props(default)] on_load_saved_search: Option<EventHandler<SavedSearchResponse>>,
) -> Element {
let mut query = use_signal(String::new);
let mut sort_by = use_signal(|| String::from("relevance"));
let mut show_help = use_signal(|| false);
let mut show_save_dialog = use_signal(|| false);
let mut save_name = use_signal(String::new);
let mut show_saved_list = use_signal(|| false);
// 0 = table, 1 = grid
let mut view_mode = use_signal(|| 0u8);
@ -87,6 +94,23 @@ pub fn Search(
button { class: "btn btn-primary", onclick: do_search, "Search" }
button { class: "btn btn-ghost", onclick: toggle_help, "Syntax Help" }
// Save/Load search buttons
if on_save_search.is_some() {
button {
class: "btn btn-secondary",
disabled: query.read().is_empty(),
onclick: move |_| show_save_dialog.set(true),
"Save"
}
}
if !saved_searches.is_empty() {
button {
class: "btn btn-ghost",
onclick: move |_| show_saved_list.toggle(),
"Saved ({saved_searches.len()})"
}
}
// View mode toggle
div { class: "view-toggle",
button {
@ -148,6 +172,147 @@ pub fn Search(
}
}
// Save search dialog
if *show_save_dialog.read() {
div {
class: "modal-overlay",
onclick: move |_| show_save_dialog.set(false),
div {
class: "modal-content",
onclick: move |evt: MouseEvent| evt.stop_propagation(),
h3 { "Save Search" }
div { class: "form-field",
label { "Name" }
input {
r#type: "text",
placeholder: "Enter a name for this search...",
value: "{save_name}",
oninput: move |e| save_name.set(e.value()),
onkeypress: {
let query = query.read().clone();
let sort = sort_by.read().clone();
let handler = on_save_search;
move |e: KeyboardEvent| {
if e.key() == Key::Enter {
let name = save_name.read().clone();
if !name.is_empty() {
let sort_opt = if sort == "relevance" {
None
} else {
Some(sort.clone())
};
if let Some(ref h) = handler {
h.call((name, query.clone(), sort_opt));
}
show_save_dialog.set(false);
save_name.set(String::new());
}
}
}
},
}
}
p { class: "text-muted text-sm", "Query: {query}" }
div { class: "modal-actions",
button {
class: "btn btn-ghost",
onclick: move |_| {
show_save_dialog.set(false);
save_name.set(String::new());
},
"Cancel"
}
button {
class: "btn btn-primary",
disabled: save_name.read().is_empty(),
onclick: {
let query_val = query.read().clone();
let sort_val = sort_by.read().clone();
let handler = on_save_search;
move |_| {
let name = save_name.read().clone();
if !name.is_empty() {
let sort_opt = if sort_val == "relevance" {
None
} else {
Some(sort_val.clone())
};
if let Some(ref h) = handler {
h.call((name, query_val.clone(), sort_opt));
}
show_save_dialog.set(false);
save_name.set(String::new());
}
}
},
"Save"
}
}
}
}
}
// Saved searches list
if *show_saved_list.read() && !saved_searches.is_empty() {
div { class: "card mb-16",
div { class: "card-header",
h4 { "Saved Searches" }
button {
class: "btn btn-ghost btn-sm",
onclick: move |_| show_saved_list.set(false),
"Close"
}
}
div { class: "saved-searches-list",
for search in saved_searches.iter() {
{
let search_clone = search.clone();
let id_for_delete = search.id.clone();
let load_handler = on_load_saved_search;
let delete_handler = on_delete_saved_search;
rsx! {
div { class: "saved-search-item", key: "{search.id}",
div {
class: "saved-search-info",
onclick: {
let sc = search_clone.clone();
move |_| {
if let Some(ref h) = load_handler {
h.call(sc.clone());
}
query.set(sc.query.clone());
if let Some(ref s) = sc.sort_order {
sort_by.set(s.clone());
} else {
sort_by.set("relevance".to_string());
}
show_saved_list.set(false);
}
},
span { class: "saved-search-name", "{search.name}" }
span { class: "saved-search-query text-muted", "{search.query}" }
}
button {
class: "btn btn-danger btn-sm",
onclick: {
let id = id_for_delete.clone();
move |evt: MouseEvent| {
evt.stop_propagation();
if let Some(ref h) = delete_handler {
h.call(id.clone());
}
}
},
"Delete"
}
}
}
}
}
}
}
}
p { class: "text-muted text-sm mb-8", "Results: {total_count}" }
if results.is_empty() && query.read().is_empty() {
@ -190,6 +355,8 @@ pub fn Search(
rsx! {
div { key: "{item.id}", class: "media-card", onclick: card_click,
div { class: "card-thumbnail",

View file

@ -419,6 +419,7 @@ pub fn Settings(
},
option { value: "dark", "Dark" }
option { value: "light", "Light" }
option { value: "system", "System" }
}
}

View file

@ -66,14 +66,19 @@ pub fn Statistics(
// Media by Type
// Storage by Type
// Top Tags
// Top Collections
// Date Range
// Storage by Type
// Top Tags
// Top Collections
// Date Range
if !s.media_by_type.is_empty() {
div { class: "card mt-16",
h4 { class: "card-title", "Media by Type" }

View file

@ -137,6 +137,8 @@ pub fn Tags(
if !children.is_empty() {
div {
class: "tag-children",
style: "margin-left: 16px; margin-top: 4px;",
for child in children.iter() {

View file

@ -81,13 +81,25 @@ body {
.sidebar.collapsed .sidebar-header .logo,
.sidebar.collapsed .sidebar-header .version,
.sidebar.collapsed .nav-badge { display: none; }
.sidebar.collapsed .nav-item { justify-content: center; padding: 8px; border-left: none; }
.sidebar.collapsed .nav-item { justify-content: center; padding: 8px; border-left: none; border-radius: var(--radius-sm); }
.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 4px; }
/* Nav item text - hide when collapsed */
/* Nav item text - hide when collapsed, properly handle overflow when expanded */
.nav-item-text {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
/* When sidebar is expanded, allow text to show fully */
.sidebar:not(.collapsed) .nav-item-text {
overflow: visible;
}
.sidebar.collapsed .nav-item-text { display: none; }
@ -179,8 +191,14 @@ body {
.sidebar-footer {
padding: 12px;
border-top: 1px solid var(--border-subtle);
overflow: visible;
min-width: 0;
}
/* Hide footer content in collapsed sidebar */
.sidebar.collapsed .sidebar-footer .status-text { display: none; }
.sidebar.collapsed .sidebar-footer .user-info { justify-content: center; }
/* ── Main ── */
.main {
flex: 1;
@ -747,10 +765,86 @@ input[type="text"]:focus, textarea:focus, select:focus {
/* ── Checkbox ── */
input[type="checkbox"] {
accent-color: var(--accent);
width: 14px;
height: 14px;
appearance: none;
-webkit-appearance: none;
width: 16px;
height: 16px;
border: 1px solid var(--border-strong);
border-radius: 3px;
background: var(--bg-2);
cursor: pointer;
position: relative;
flex-shrink: 0;
transition: all 0.15s ease;
}
input[type="checkbox"]:hover {
border-color: var(--accent);
background: var(--bg-3);
}
input[type="checkbox"]:checked {
background: var(--accent);
border-color: var(--accent);
}
input[type="checkbox"]:checked::after {
content: "";
position: absolute;
left: 5px;
top: 2px;
width: 4px;
height: 8px;
border: solid var(--bg-0);
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
input[type="checkbox"]:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Checkbox with label */
.checkbox-label {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 13px;
color: var(--text-1);
user-select: none;
}
.checkbox-label:hover {
color: var(--text-0);
}
.checkbox-label input[type="checkbox"] {
margin: 0;
}
/* Number input */
input[type="number"] {
width: 80px;
padding: 6px 8px;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-0);
font-size: 12px;
-moz-appearance: textfield;
}
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"]:focus {
outline: none;
border-color: var(--accent);
}
/* ── Select ── */
@ -784,6 +878,8 @@ ul li { padding: 3px 0; font-size: 12px; color: var(--text-1); }
gap: 6px;
font-size: 11px;
font-weight: 500;
min-width: 0;
overflow: visible;
}
.status-dot {
@ -802,7 +898,18 @@ ul li { padding: 3px 0; font-size: 12px; color: var(--text-1); }
50% { opacity: 0.3; }
}
.status-text { color: var(--text-2); }
.status-text {
color: var(--text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
/* Ensure status text is visible in expanded sidebar */
.sidebar:not(.collapsed) .status-text {
overflow: visible;
}
/* ── Modal ── */
.modal-overlay {
@ -850,6 +957,61 @@ ul li { padding: 3px 0; font-size: 12px; color: var(--text-1); }
gap: 6px;
}
/* ── Saved Searches ── */
.saved-searches-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 300px;
overflow-y: auto;
}
.saved-search-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: var(--bg-1);
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.15s ease;
}
.saved-search-item:hover {
background: var(--bg-2);
}
.saved-search-info {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.saved-search-name {
font-weight: 500;
color: var(--text-0);
}
.saved-search-query {
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.card-header h4 {
margin: 0;
}
/* ── Offline banner ── */
.offline-banner {
background: rgba(228, 88, 88, 0.06);
@ -881,15 +1043,94 @@ ul li { padding: 3px 0; font-size: 12px; color: var(--text-1); }
/* ── Filter bar ── */
.filter-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
flex-direction: column;
gap: 12px;
padding: 8px 12px;
padding: 12px;
background: var(--bg-0);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
margin-bottom: 8px;
font-size: 12px;
border-radius: var(--radius);
margin-bottom: 12px;
}
.filter-bar .filter-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.filter-bar .filter-label {
font-size: 11px;
font-weight: 500;
color: var(--text-2);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-right: 4px;
}
/* Filter chip/toggle style */
.filter-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: 14px;
cursor: pointer;
font-size: 11px;
color: var(--text-1);
transition: all 0.15s ease;
user-select: none;
}
.filter-chip:hover {
background: var(--bg-3);
border-color: var(--border-strong);
color: var(--text-0);
}
.filter-chip.active {
background: var(--accent-dim);
border-color: var(--accent);
color: var(--accent-text);
}
.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;
}
/* Size filter inputs */
.filter-bar .size-filters {
display: flex;
align-items: center;
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--border-subtle);
}
.filter-bar .size-filter-group {
display: flex;
align-items: center;
gap: 6px;
}
.filter-bar .size-filter-group label {
font-size: 11px;
color: var(--text-2);
}
.filter-bar input[type="number"] {
width: 70px;
}
.filter-group {
@ -1071,6 +1312,14 @@ ul li { padding: 3px 0; font-size: 12px; color: var(--text-1); }
pointer-events: none;
}
/* Disabled with hint - shows what action is needed */
.btn.btn-disabled-hint:disabled {
opacity: 0.6;
border-style: dashed;
pointer-events: auto;
cursor: help;
}
/* ── Library Toolbar ── */
.library-toolbar {
display: flex;
@ -1589,6 +1838,93 @@ ul li { padding: 3px 0; font-size: 12px; color: var(--text-1); }
color: var(--text-0);
}
.import-current-file {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 6px;
font-size: 12px;
overflow: hidden;
}
.import-file-label {
color: var(--text-2);
flex-shrink: 0;
}
.import-file-name {
color: var(--text-0);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: monospace;
font-size: 11px;
}
.import-queue-indicator {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 8px;
font-size: 11px;
}
.import-queue-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 6px;
background: var(--accent-dim);
color: var(--accent-text);
border-radius: 9px;
font-weight: 600;
font-size: 10px;
}
.import-queue-text {
color: var(--text-2);
}
/* ── Sidebar import progress ── */
.sidebar-import-progress {
padding: 8px 12px;
background: var(--bg-2);
border-top: 1px solid var(--border-subtle);
font-size: 11px;
}
.sidebar-import-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
color: var(--text-1);
}
.sidebar-import-file {
color: var(--text-2);
font-size: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 4px;
}
.sidebar-import-progress .progress-bar {
height: 3px;
}
.sidebar.collapsed .sidebar-import-progress {
padding: 6px;
}
.sidebar.collapsed .sidebar-import-header span,
.sidebar.collapsed .sidebar-import-file {
display: none;
}
/* ── Tag confirmation ── */
.tag-confirm-delete {
display: inline-flex;
@ -2391,9 +2727,13 @@ ul li { padding: 3px 0; font-size: 12px; color: var(--text-1); }
/* Hide user details in collapsed sidebar, show only logout icon */
.sidebar.collapsed .user-info .user-name,
.sidebar.collapsed .user-info .role-badge { display: none; }
.sidebar.collapsed .user-info .role-badge,
.sidebar.collapsed .user-info .btn { display: none; }
.sidebar.collapsed .user-info .btn { padding: 6px; }
.sidebar.collapsed .user-info {
justify-content: center;
padding: 4px;
}
.role-badge {
display: inline-block;
@ -2676,4 +3016,117 @@ ul li { padding: 3px 0; font-size: 12px; color: var(--text-1); }
color: var(--text-2);
font-size: 0.85rem;
}
/* ── PDF Viewer ── */
.pdf-viewer {
display: flex;
flex-direction: column;
height: 100%;
min-height: 500px;
background: var(--bg-0);
border-radius: var(--radius);
overflow: hidden;
}
.pdf-toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: var(--bg-1);
border-bottom: 1px solid var(--border);
}
.pdf-toolbar-group {
display: flex;
align-items: center;
gap: 4px;
}
.pdf-toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-1);
font-size: 14px;
cursor: pointer;
transition: all 0.15s;
}
.pdf-toolbar-btn:hover:not(:disabled) {
background: var(--bg-3);
color: var(--text-0);
}
.pdf-toolbar-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.pdf-zoom-label {
min-width: 45px;
text-align: center;
font-size: 12px;
color: var(--text-1);
}
.pdf-container {
flex: 1;
position: relative;
overflow: hidden;
background: var(--bg-2);
}
.pdf-object {
width: 100%;
height: 100%;
border: none;
}
.pdf-loading {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
background: var(--bg-1);
color: var(--text-1);
}
.pdf-error {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
background: var(--bg-1);
color: var(--text-1);
padding: 24px;
text-align: center;
}
.pdf-fallback {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 48px 24px;
text-align: center;
color: var(--text-2);
}
/* Light theme adjustments */
.theme-light .pdf-container {
background: #e8e8e8;
}
"#;