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

@ -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() {