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
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue