pinakes: import in parallel; various UI improvements
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I1eb47cd79cd4145c56af966f6756fe1d6a6a6964
This commit is contained in:
parent
278bcaa4b0
commit
116fe7b059
42 changed files with 4316 additions and 316 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}")) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
112
crates/pinakes-ui/src/components/pdf_viewer.rs
Normal file
112
crates/pinakes-ui/src/components/pdf_viewer.rs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn PdfViewer(
|
||||
src: String,
|
||||
#[props(default = 1)] initial_page: usize,
|
||||
#[props(default = 100)] initial_zoom: usize,
|
||||
) -> Element {
|
||||
let current_page = use_signal(|| initial_page);
|
||||
let mut zoom_level = use_signal(|| initial_zoom);
|
||||
let mut loading = use_signal(|| true);
|
||||
let mut error = use_signal(|| Option::<String>::None);
|
||||
|
||||
// For navigation controls
|
||||
let zoom = *zoom_level.read();
|
||||
let page = *current_page.read();
|
||||
|
||||
rsx! {
|
||||
div { class: "pdf-viewer",
|
||||
// Toolbar
|
||||
div { class: "pdf-toolbar",
|
||||
div { class: "pdf-toolbar-group",
|
||||
button {
|
||||
class: "pdf-toolbar-btn",
|
||||
title: "Zoom out",
|
||||
disabled: zoom <= 50,
|
||||
onclick: move |_| {
|
||||
let new_zoom = (*zoom_level.read()).saturating_sub(25).max(50);
|
||||
zoom_level.set(new_zoom);
|
||||
},
|
||||
"\u{2212}" // minus
|
||||
}
|
||||
span { class: "pdf-zoom-label", "{zoom}%" }
|
||||
button {
|
||||
class: "pdf-toolbar-btn",
|
||||
title: "Zoom in",
|
||||
disabled: zoom >= 200,
|
||||
onclick: move |_| {
|
||||
let new_zoom = (*zoom_level.read() + 25).min(200);
|
||||
zoom_level.set(new_zoom);
|
||||
},
|
||||
"+" // plus
|
||||
}
|
||||
}
|
||||
div { class: "pdf-toolbar-group",
|
||||
button {
|
||||
class: "pdf-toolbar-btn",
|
||||
title: "Fit to width",
|
||||
onclick: move |_| zoom_level.set(100),
|
||||
"\u{2194}" // left-right arrow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PDF embed container
|
||||
div { class: "pdf-container",
|
||||
if *loading.read() {
|
||||
div { class: "pdf-loading",
|
||||
div { class: "spinner" }
|
||||
span { "Loading PDF..." }
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref err) = *error.read() {
|
||||
div { class: "pdf-error",
|
||||
p { "{err}" }
|
||||
a {
|
||||
href: "{src}",
|
||||
target: "_blank",
|
||||
class: "btn btn-primary",
|
||||
"Download PDF"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use object/embed for PDF rendering
|
||||
// The webview should handle PDF rendering natively
|
||||
object {
|
||||
class: "pdf-object",
|
||||
r#type: "application/pdf",
|
||||
data: "{src}#zoom={zoom}&page={page}",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
onload: move |_| {
|
||||
loading.set(false);
|
||||
error.set(None);
|
||||
},
|
||||
onerror: move |_| {
|
||||
loading.set(false);
|
||||
error
|
||||
.set(
|
||||
Some(
|
||||
"Unable to display PDF. Your browser may not support embedded PDF viewing."
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
},
|
||||
// Fallback content
|
||||
div { class: "pdf-fallback",
|
||||
p { "PDF preview is not available in this browser." }
|
||||
a {
|
||||
href: "{src}",
|
||||
target: "_blank",
|
||||
class: "btn btn-primary",
|
||||
"Download PDF"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -419,6 +419,7 @@ pub fn Settings(
|
|||
},
|
||||
option { value: "dark", "Dark" }
|
||||
option { value: "light", "Light" }
|
||||
option { value: "system", "System" }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
"#;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue