pinakes-ui: streamline sidebar design
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I0176fa480e5ba40eea5a39685a4f97896a6a6964
This commit is contained in:
parent
3e14bbe607
commit
278bcaa4b0
25 changed files with 1805 additions and 1686 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -105,11 +105,7 @@ pub fn AuditLog(
|
|||
}
|
||||
}
|
||||
|
||||
PaginationControls {
|
||||
current_page: audit_page,
|
||||
total_pages: total_pages,
|
||||
on_page_change: on_page_change,
|
||||
}
|
||||
PaginationControls { current_page: audit_page, total_pages, on_page_change }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ pub fn Breadcrumb(
|
|||
) -> Element {
|
||||
rsx! {
|
||||
nav { class: "breadcrumb",
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
for (i , item) in items.iter().enumerate() {
|
||||
if i > 0 {
|
||||
span { class: "breadcrumb-sep", " > " }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,11 +44,7 @@ pub fn Collections(
|
|||
let modal_col_id = col_id.clone();
|
||||
|
||||
return rsx! {
|
||||
button {
|
||||
class: "btn btn-ghost mb-16",
|
||||
onclick: back_click,
|
||||
"\u{2190} Back to Collections"
|
||||
}
|
||||
button { class: "btn btn-ghost mb-16", onclick: back_click, "\u{2190} Back to Collections" }
|
||||
|
||||
h3 { class: "mb-16", "{col_name}" }
|
||||
|
||||
|
|
@ -88,10 +84,7 @@ pub fn Collections(
|
|||
move |_| on_select.call(mid.clone())
|
||||
};
|
||||
rsx! {
|
||||
tr {
|
||||
key: "{item.id}",
|
||||
class: "clickable-row",
|
||||
onclick: row_click,
|
||||
tr { key: "{item.id}", class: "clickable-row", onclick: row_click,
|
||||
td { "{item.file_name}" }
|
||||
td {
|
||||
span { class: "type-badge {badge_class}", "{item.media_type}" }
|
||||
|
|
@ -118,9 +111,11 @@ pub fn Collections(
|
|||
|
||||
// Add Media modal
|
||||
if *show_add_modal.read() {
|
||||
div { class: "modal-overlay",
|
||||
div {
|
||||
class: "modal-overlay",
|
||||
onclick: move |_| show_add_modal.set(false),
|
||||
div { class: "modal",
|
||||
div {
|
||||
class: "modal",
|
||||
onclick: move |e: Event<MouseData>| e.stop_propagation(),
|
||||
div { class: "modal-header",
|
||||
h3 { "Add Media to Collection" }
|
||||
|
|
@ -156,10 +151,7 @@ pub fn Collections(
|
|||
}
|
||||
};
|
||||
rsx! {
|
||||
tr {
|
||||
key: "{media.id}",
|
||||
class: "clickable-row",
|
||||
onclick: add_click,
|
||||
tr { key: "{media.id}", class: "clickable-row", onclick: add_click,
|
||||
td { "{media.file_name}" }
|
||||
td {
|
||||
span { class: "type-badge {badge_class}", "{media.media_type}" }
|
||||
|
|
@ -242,11 +234,7 @@ pub fn Collections(
|
|||
}
|
||||
|
||||
div { class: "form-row mb-16",
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
onclick: create_click,
|
||||
"Create"
|
||||
}
|
||||
button { class: "btn btn-primary", onclick: create_click, "Create" }
|
||||
}
|
||||
|
||||
if collections.is_empty() {
|
||||
|
|
@ -268,7 +256,11 @@ pub fn Collections(
|
|||
for col in collections.iter() {
|
||||
{
|
||||
let desc = col.description.clone().unwrap_or_default();
|
||||
let kind_class = if col.kind == "virtual" { "type-document" } else { "type-other" };
|
||||
let kind_class = if col.kind == "virtual" {
|
||||
"type-document"
|
||||
} else {
|
||||
"type-other"
|
||||
};
|
||||
let view_click = {
|
||||
let id = col.id.clone();
|
||||
move |_| on_view_members.call(id.clone())
|
||||
|
|
@ -287,11 +279,7 @@ pub fn Collections(
|
|||
}
|
||||
td { "{desc}" }
|
||||
td {
|
||||
button {
|
||||
class: "btn btn-sm btn-secondary",
|
||||
onclick: view_click,
|
||||
"View"
|
||||
}
|
||||
button { class: "btn btn-sm btn-secondary", onclick: view_click, "View" }
|
||||
}
|
||||
td {
|
||||
if is_confirming {
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ pub fn Database(
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
None => rsx! {
|
||||
div { class: "empty-state",
|
||||
p { class: "text-muted", "Loading database stats..." }
|
||||
|
|
@ -162,7 +162,9 @@ pub fn Database(
|
|||
}
|
||||
if *confirm_clear.read() {
|
||||
div { class: "db-action-confirm",
|
||||
span { class: "text-sm", style: "color: var(--danger);",
|
||||
span {
|
||||
class: "text-sm",
|
||||
style: "color: var(--danger);",
|
||||
"This will delete everything. Are you sure?"
|
||||
}
|
||||
button {
|
||||
|
|
|
|||
|
|
@ -231,14 +231,14 @@ pub fn Detail(
|
|||
media_type: "audio".to_string(),
|
||||
title: media.title.clone(),
|
||||
thumbnail_url: if has_thumbnail { Some(thumb_for_player.clone()) } else { None },
|
||||
autoplay: autoplay,
|
||||
autoplay,
|
||||
}
|
||||
} else if category == "video" {
|
||||
MediaPlayer {
|
||||
src: stream_url.clone(),
|
||||
media_type: "video".to_string(),
|
||||
title: media.title.clone(),
|
||||
autoplay: autoplay,
|
||||
autoplay,
|
||||
}
|
||||
} else if category == "image" {
|
||||
if has_thumbnail {
|
||||
|
|
@ -298,40 +298,16 @@ pub fn Detail(
|
|||
"Open"
|
||||
}
|
||||
if is_editing {
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
onclick: on_save_click,
|
||||
"Save"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
onclick: on_cancel_click,
|
||||
"Cancel"
|
||||
}
|
||||
button { class: "btn btn-primary", onclick: on_save_click, "Save" }
|
||||
button { class: "btn btn-ghost", onclick: on_cancel_click, "Cancel" }
|
||||
} else {
|
||||
button {
|
||||
class: "btn btn-secondary",
|
||||
onclick: on_edit_click,
|
||||
"Edit"
|
||||
}
|
||||
button { class: "btn btn-secondary", onclick: on_edit_click, "Edit" }
|
||||
}
|
||||
if confirm_delete() {
|
||||
button {
|
||||
class: "btn btn-danger",
|
||||
onclick: on_confirm_delete,
|
||||
"Confirm Delete"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
onclick: on_cancel_delete,
|
||||
"Cancel"
|
||||
}
|
||||
button { class: "btn btn-danger", onclick: on_confirm_delete, "Confirm Delete" }
|
||||
button { class: "btn btn-ghost", onclick: on_cancel_delete, "Cancel" }
|
||||
} else {
|
||||
button {
|
||||
class: "btn btn-danger",
|
||||
onclick: on_delete_click,
|
||||
"Delete"
|
||||
}
|
||||
button { class: "btn btn-danger", onclick: on_delete_click, "Delete" }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -373,11 +349,13 @@ pub fn Detail(
|
|||
}
|
||||
div { class: "detail-field",
|
||||
label { class: "detail-label",
|
||||
{match category {
|
||||
"image" => "Photographer",
|
||||
"document" | "text" => "Author",
|
||||
_ => "Artist",
|
||||
}}
|
||||
{
|
||||
match category {
|
||||
"image" => "Photographer",
|
||||
"document" | "text" => "Author",
|
||||
_ => "Artist",
|
||||
}
|
||||
}
|
||||
}
|
||||
input {
|
||||
r#type: "text",
|
||||
|
|
@ -458,11 +436,13 @@ pub fn Detail(
|
|||
if !artist.is_empty() {
|
||||
div { class: "detail-field",
|
||||
span { class: "detail-label",
|
||||
{match category {
|
||||
"image" => "Photographer",
|
||||
"document" | "text" => "Author",
|
||||
_ => "Artist",
|
||||
}}
|
||||
{
|
||||
match category {
|
||||
"image" => "Photographer",
|
||||
"document" | "text" => "Author",
|
||||
_ => "Artist",
|
||||
}
|
||||
}
|
||||
}
|
||||
span { class: "detail-value", "{artist}" }
|
||||
}
|
||||
|
|
@ -482,7 +462,9 @@ pub fn Detail(
|
|||
}
|
||||
}
|
||||
// Year: audio, video, document, when non-empty
|
||||
if (category == "audio" || category == "video" || category == "document") && !year_str.is_empty() {
|
||||
if (category == "audio" || category == "video" || category == "document")
|
||||
&& !year_str.is_empty()
|
||||
{
|
||||
div { class: "detail-field",
|
||||
span { class: "detail-label", "Year" }
|
||||
span { class: "detail-value", "{year_str}" }
|
||||
|
|
@ -524,9 +506,7 @@ pub fn Detail(
|
|||
let tag_id = tag.id.clone();
|
||||
let media_id_untag = id.clone();
|
||||
rsx! {
|
||||
span {
|
||||
class: "tag-badge",
|
||||
key: "{tag_id}",
|
||||
span { class: "tag-badge", key: "{tag_id}",
|
||||
"{tag.name}"
|
||||
span {
|
||||
class: "tag-remove",
|
||||
|
|
@ -552,11 +532,7 @@ pub fn Detail(
|
|||
let tid = tag.id.clone();
|
||||
let tname = tag.name.clone();
|
||||
rsx! {
|
||||
option {
|
||||
key: "{tid}",
|
||||
value: "{tid}",
|
||||
"{tname}"
|
||||
}
|
||||
option { key: "{tid}", value: "{tid}", "{tname}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -576,10 +552,8 @@ pub fn Detail(
|
|||
h4 { class: "card-title", "Technical Metadata" }
|
||||
}
|
||||
div { class: "detail-grid",
|
||||
for (key, _field_type, value) in system_fields.iter() {
|
||||
div {
|
||||
class: "detail-field",
|
||||
key: "{key}",
|
||||
for (key , _field_type , value) in system_fields.iter() {
|
||||
div { class: "detail-field", key: "{key}",
|
||||
span { class: "detail-label", "{key}" }
|
||||
span { class: "detail-value", "{value}" }
|
||||
}
|
||||
|
|
@ -595,14 +569,12 @@ pub fn Detail(
|
|||
}
|
||||
if has_user_fields {
|
||||
div { class: "detail-grid",
|
||||
for (key, field_type, value) in user_fields.iter() {
|
||||
for (key , field_type , value) in user_fields.iter() {
|
||||
{
|
||||
let field_name = key.clone();
|
||||
let media_id_del = id.clone();
|
||||
rsx! {
|
||||
div {
|
||||
class: "detail-field",
|
||||
key: "{field_name}",
|
||||
div { class: "detail-field", key: "{field_name}",
|
||||
span { class: "detail-label", "{key} ({field_type})" }
|
||||
div { class: "flex-row",
|
||||
span { class: "detail-value", "{value}" }
|
||||
|
|
|
|||
|
|
@ -44,7 +44,20 @@ pub fn Duplicates(
|
|||
let is_expanded = expanded_group.read().as_ref() == Some(&hash);
|
||||
let hash_for_toggle = hash.clone();
|
||||
let item_count = group.items.len();
|
||||
let first_name = group.items.first()
|
||||
let first_name = group
|
||||
.items
|
||||
|
||||
// Group header
|
||||
|
||||
// Expanded: show items
|
||||
|
||||
// Thumbnail
|
||||
|
||||
// Info
|
||||
|
||||
// Actions
|
||||
|
||||
.first()
|
||||
.map(|i| i.file_name.clone())
|
||||
.unwrap_or_default();
|
||||
let total_size: u64 = group.items.iter().map(|i| i.file_size).sum();
|
||||
|
|
@ -53,13 +66,9 @@ pub fn Duplicates(
|
|||
} else {
|
||||
hash.clone()
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: "duplicate-group",
|
||||
key: "{hash}",
|
||||
div { class: "duplicate-group", key: "{hash}",
|
||||
|
||||
// Group header
|
||||
button {
|
||||
class: "duplicate-group-header",
|
||||
onclick: move |_| {
|
||||
|
|
@ -71,20 +80,21 @@ pub fn Duplicates(
|
|||
}
|
||||
},
|
||||
span { class: "expand-icon",
|
||||
if is_expanded { "\u{25bc}" } else { "\u{25b6}" }
|
||||
if is_expanded {
|
||||
"\u{25bc}"
|
||||
} else {
|
||||
"\u{25b6}"
|
||||
}
|
||||
}
|
||||
span { class: "group-name", "{first_name}" }
|
||||
span { class: "group-badge", "{item_count} files" }
|
||||
span { class: "group-size text-muted", "{format_size(total_size)}" }
|
||||
span { class: "group-hash mono text-muted",
|
||||
"{short_hash}"
|
||||
}
|
||||
span { class: "group-hash mono text-muted", "{short_hash}" }
|
||||
}
|
||||
|
||||
// Expanded: show items
|
||||
if is_expanded {
|
||||
div { class: "duplicate-items",
|
||||
for (idx, item) in group.items.iter().enumerate() {
|
||||
for (idx , item) in group.items.iter().enumerate() {
|
||||
{
|
||||
let item_id = item.id.clone();
|
||||
let is_first = idx == 0;
|
||||
|
|
@ -97,7 +107,10 @@ pub fn Duplicates(
|
|||
class: if is_first { "duplicate-item duplicate-item-keep" } else { "duplicate-item" },
|
||||
key: "{item_id}",
|
||||
|
||||
// Thumbnail
|
||||
|
||||
|
||||
|
||||
|
||||
div { class: "dup-thumb",
|
||||
if has_thumb {
|
||||
img {
|
||||
|
|
@ -110,7 +123,6 @@ pub fn Duplicates(
|
|||
}
|
||||
}
|
||||
|
||||
// Info
|
||||
div { class: "dup-info",
|
||||
div { class: "dup-filename", "{item.file_name}" }
|
||||
div { class: "dup-path mono text-muted", "{item.path}" }
|
||||
|
|
@ -121,7 +133,6 @@ pub fn Duplicates(
|
|||
}
|
||||
}
|
||||
|
||||
// Actions
|
||||
div { class: "dup-actions",
|
||||
if is_first {
|
||||
span { class: "keep-badge", "Keep" }
|
||||
|
|
|
|||
|
|
@ -194,10 +194,18 @@ pub fn ImageViewer(
|
|||
}
|
||||
}
|
||||
div { class: "image-viewer-toolbar-center",
|
||||
button { class: "iv-btn", onclick: cycle_fit, title: "Cycle fit mode",
|
||||
button {
|
||||
class: "iv-btn",
|
||||
onclick: cycle_fit,
|
||||
title: "Cycle fit mode",
|
||||
"{current_fit.label()}"
|
||||
}
|
||||
button { class: "iv-btn", onclick: zoom_out, title: "Zoom out", "\u{2212}" }
|
||||
button {
|
||||
class: "iv-btn",
|
||||
onclick: zoom_out,
|
||||
title: "Zoom out",
|
||||
"\u{2212}"
|
||||
}
|
||||
span { class: "iv-zoom-label", "{zoom_pct}%" }
|
||||
button { class: "iv-btn", onclick: zoom_in, title: "Zoom in", "+" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,7 +148,11 @@ pub fn Import(
|
|||
}
|
||||
}
|
||||
},
|
||||
if is_importing { "Importing..." } else { "Import" }
|
||||
if is_importing {
|
||||
"Importing..."
|
||||
} else {
|
||||
"Import"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -157,9 +161,9 @@ pub fn Import(
|
|||
ImportOptions {
|
||||
tags: tags.clone(),
|
||||
collections: collections.clone(),
|
||||
selected_tags: selected_tags,
|
||||
new_tags_input: new_tags_input,
|
||||
selected_collection: selected_collection,
|
||||
selected_tags,
|
||||
new_tags_input,
|
||||
selected_collection,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -244,32 +248,48 @@ pub fn Import(
|
|||
let min = *filter_min_size.read();
|
||||
let max = *filter_max_size.read();
|
||||
|
||||
let filtered: Vec<&DirectoryPreviewFile> = preview_files.iter().filter(|f| {
|
||||
let type_idx = match type_badge_class(&f.media_type) {
|
||||
"type-audio" => 0,
|
||||
"type-video" => 1,
|
||||
"type-image" => 2,
|
||||
"type-document" => 3,
|
||||
"type-text" => 4,
|
||||
_ => 5,
|
||||
};
|
||||
if !types_snapshot[type_idx] { return false; }
|
||||
if min > 0 && f.file_size < min { return false; }
|
||||
if max > 0 && f.file_size > max { return false; }
|
||||
true
|
||||
}).collect();
|
||||
let filtered: Vec<&DirectoryPreviewFile> = preview_files
|
||||
|
||||
// Read selection once for display
|
||||
.iter()
|
||||
|
||||
// Filter bar
|
||||
|
||||
// Selection toolbar
|
||||
|
||||
// Deselect all filtered
|
||||
// Select all filtered
|
||||
.filter(|f| {
|
||||
let type_idx = match type_badge_class(&f.media_type) {
|
||||
"type-audio" => 0,
|
||||
"type-video" => 1,
|
||||
"type-image" => 2,
|
||||
"type-document" => 3,
|
||||
"type-text" => 4,
|
||||
_ => 5,
|
||||
};
|
||||
if !types_snapshot[type_idx] {
|
||||
return false;
|
||||
}
|
||||
if min > 0 && f.file_size < min {
|
||||
return false;
|
||||
}
|
||||
if max > 0 && f.file_size > max {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
})
|
||||
.collect();
|
||||
let filtered_count = filtered.len();
|
||||
let total_count = preview_files.len();
|
||||
|
||||
// Read selection once for display
|
||||
let selection = selected_file_paths.read().clone();
|
||||
let selected_count = selection.len();
|
||||
let all_filtered_selected = !filtered.is_empty()
|
||||
&& filtered.iter().all(|f| selection.contains(&f.path));
|
||||
|
||||
let filtered_paths: Vec<String> = filtered.iter().map(|f| f.path.clone()).collect();
|
||||
|
||||
let filtered_paths: Vec<String> = filtered
|
||||
.iter()
|
||||
.map(|f| f.path.clone())
|
||||
.collect();
|
||||
rsx! {
|
||||
div { class: "card mb-16",
|
||||
div { class: "card-header",
|
||||
|
|
@ -279,7 +299,6 @@ pub fn Import(
|
|||
}
|
||||
}
|
||||
|
||||
// Filter bar
|
||||
div { class: "filter-bar",
|
||||
div { class: "flex-row mb-8",
|
||||
label {
|
||||
|
|
@ -383,8 +402,9 @@ pub fn Import(
|
|||
}
|
||||
}
|
||||
|
||||
// Selection toolbar
|
||||
div { class: "flex-row mb-8", style: "gap: 8px; align-items: center; padding: 0 8px;",
|
||||
div {
|
||||
class: "flex-row mb-8",
|
||||
style: "gap: 8px; align-items: center; padding: 0 8px;",
|
||||
button {
|
||||
class: "btn btn-sm btn-secondary",
|
||||
onclick: {
|
||||
|
|
@ -407,9 +427,7 @@ pub fn Import(
|
|||
"Deselect All"
|
||||
}
|
||||
if selected_count > 0 {
|
||||
span { class: "text-muted text-sm",
|
||||
"{selected_count} files selected"
|
||||
}
|
||||
span { class: "text-muted text-sm", "{selected_count} files selected" }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -425,13 +443,17 @@ pub fn Import(
|
|||
let filtered_paths = filtered_paths.clone();
|
||||
move |_| {
|
||||
if all_filtered_selected {
|
||||
// Deselect all filtered
|
||||
let filtered_set: HashSet<String> = filtered_paths.iter().cloned().collect();
|
||||
let filtered_set: HashSet<String> = filtered_paths
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
let sel = selected_file_paths.read().clone();
|
||||
let new_sel: HashSet<String> = sel.difference(&filtered_set).cloned().collect();
|
||||
let new_sel: HashSet<String> = sel
|
||||
.difference(&filtered_set)
|
||||
.cloned()
|
||||
.collect();
|
||||
selected_file_paths.set(new_sel);
|
||||
} else {
|
||||
// Select all filtered
|
||||
let mut sel = selected_file_paths.read().clone();
|
||||
for p in &filtered_paths {
|
||||
sel.insert(p.clone());
|
||||
|
|
@ -455,9 +477,7 @@ pub fn Import(
|
|||
let is_selected = selection.contains(&file.path);
|
||||
let file_path_clone = file.path.clone();
|
||||
rsx! {
|
||||
tr {
|
||||
key: "{file.path}",
|
||||
class: if is_selected { "row-selected" } else { "" },
|
||||
tr { key: "{file.path}", class: if is_selected { "row-selected" } else { "" },
|
||||
td {
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
|
|
@ -496,9 +516,9 @@ pub fn Import(
|
|||
ImportOptions {
|
||||
tags: tags.clone(),
|
||||
collections: collections.clone(),
|
||||
selected_tags: selected_tags,
|
||||
new_tags_input: new_tags_input,
|
||||
selected_collection: selected_collection,
|
||||
selected_tags,
|
||||
new_tags_input,
|
||||
selected_collection,
|
||||
}
|
||||
|
||||
div { class: "flex-row mb-16", style: "gap: 8px;",
|
||||
|
|
@ -516,7 +536,11 @@ pub fn Import(
|
|||
let mut new_tags_input = new_tags_input;
|
||||
let mut selected_collection = selected_collection;
|
||||
move |_| {
|
||||
let paths: Vec<String> = selected_file_paths.read().iter().cloned().collect();
|
||||
let paths: Vec<String> = selected_file_paths
|
||||
.read()
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
if !paths.is_empty() {
|
||||
let tag_ids = selected_tags.read().clone();
|
||||
let new_tags = parse_new_tags(&new_tags_input.read());
|
||||
|
|
@ -565,7 +589,11 @@ pub fn Import(
|
|||
}
|
||||
}
|
||||
},
|
||||
if is_importing { "Importing..." } else { "Import Entire Directory" }
|
||||
if is_importing {
|
||||
"Importing..."
|
||||
} else {
|
||||
"Import Entire Directory"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -589,36 +617,37 @@ pub fn Import(
|
|||
class: "btn btn-primary",
|
||||
disabled: is_importing,
|
||||
onclick: move |_| on_scan.call(()),
|
||||
if is_importing { "Scanning..." } else { "Scan All Roots" }
|
||||
if is_importing {
|
||||
"Scanning..."
|
||||
} else {
|
||||
"Scan All Roots"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref progress) = scan_progress {
|
||||
{
|
||||
let pct = (progress.files_processed * 100).checked_div(progress.files_found).unwrap_or(0);
|
||||
let pct = (progress.files_processed * 100)
|
||||
.checked_div(progress.files_found)
|
||||
.unwrap_or(0);
|
||||
rsx! {
|
||||
div { class: "mb-16",
|
||||
div { class: "progress-bar",
|
||||
div {
|
||||
class: "progress-fill",
|
||||
style: "width: {pct}%;",
|
||||
div { class: "mb-16",
|
||||
div { class: "progress-bar",
|
||||
div { class: "progress-fill", style: "width: {pct}%;" }
|
||||
}
|
||||
p { class: "text-muted text-sm",
|
||||
"{progress.files_processed} / {progress.files_found} files processed"
|
||||
}
|
||||
if progress.error_count > 0 {
|
||||
p { class: "text-muted text-sm", "{progress.error_count} errors" }
|
||||
}
|
||||
if progress.scanning {
|
||||
p { class: "text-muted text-sm", "Scanning..." }
|
||||
} else {
|
||||
p { class: "text-muted text-sm", "Scan complete" }
|
||||
}
|
||||
}
|
||||
}
|
||||
p { class: "text-muted text-sm",
|
||||
"{progress.files_processed} / {progress.files_found} files processed"
|
||||
}
|
||||
if progress.error_count > 0 {
|
||||
p { class: "text-muted text-sm",
|
||||
"{progress.error_count} errors"
|
||||
}
|
||||
}
|
||||
if progress.scanning {
|
||||
p { class: "text-muted text-sm", "Scanning..." }
|
||||
} else {
|
||||
p { class: "text-muted text-sm", "Scan complete" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -647,7 +676,9 @@ fn ImportOptions(
|
|||
div { class: "form-group",
|
||||
label { class: "form-label", "Tags" }
|
||||
if tags.is_empty() {
|
||||
p { class: "text-muted text-sm", "No tags available. Create tags from the Tags page." }
|
||||
p { class: "text-muted text-sm",
|
||||
"No tags available. Create tags from the Tags page."
|
||||
}
|
||||
} else {
|
||||
div { class: "tag-list",
|
||||
for tag in tags.iter() {
|
||||
|
|
@ -655,11 +686,7 @@ fn ImportOptions(
|
|||
let tag_id = tag.id.clone();
|
||||
let tag_name = tag.name.clone();
|
||||
let is_selected = selected_tags.read().contains(&tag_id);
|
||||
let badge_class = if is_selected {
|
||||
"tag-badge selected"
|
||||
} else {
|
||||
"tag-badge"
|
||||
};
|
||||
let badge_class = if is_selected { "tag-badge selected" } else { "tag-badge" };
|
||||
rsx! {
|
||||
span {
|
||||
class: "{badge_class}",
|
||||
|
|
@ -693,7 +720,9 @@ fn ImportOptions(
|
|||
value: "{new_tags_input}",
|
||||
oninput: move |e| new_tags_input.set(e.value()),
|
||||
}
|
||||
p { class: "text-muted text-sm", "Comma-separated. Will be created if they don't exist." }
|
||||
p { class: "text-muted text-sm",
|
||||
"Comma-separated. Will be created if they don't exist."
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "form-group",
|
||||
|
|
|
|||
|
|
@ -107,7 +107,9 @@ pub fn Library(
|
|||
return rsx! {
|
||||
div { class: "empty-state",
|
||||
h3 { class: "empty-title", "No media found" }
|
||||
p { class: "empty-subtitle", "Import files or scan your root directories to get started." }
|
||||
p { class: "empty-subtitle",
|
||||
"Import files or scan your root directories to get started."
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -153,12 +155,16 @@ pub fn Library(
|
|||
rsx! {
|
||||
// Confirmation dialog for single delete
|
||||
if confirm_delete.read().is_some() {
|
||||
div { class: "modal-overlay",
|
||||
div {
|
||||
class: "modal-overlay",
|
||||
onclick: move |_| confirm_delete.set(None),
|
||||
div { class: "modal",
|
||||
div {
|
||||
class: "modal",
|
||||
onclick: move |e: Event<MouseData>| e.stop_propagation(),
|
||||
h3 { class: "modal-title", "Confirm Delete" }
|
||||
p { class: "modal-body", "Are you sure you want to delete this media item? This cannot be undone." }
|
||||
p { class: "modal-body",
|
||||
"Are you sure you want to delete this media item? This cannot be undone."
|
||||
}
|
||||
div { class: "modal-actions",
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
|
|
@ -182,9 +188,11 @@ pub fn Library(
|
|||
|
||||
// Confirmation dialog for batch delete
|
||||
if *confirm_batch_delete.read() {
|
||||
div { class: "modal-overlay",
|
||||
div {
|
||||
class: "modal-overlay",
|
||||
onclick: move |_| confirm_batch_delete.set(false),
|
||||
div { class: "modal",
|
||||
div {
|
||||
class: "modal",
|
||||
onclick: move |e: Event<MouseData>| e.stop_propagation(),
|
||||
h3 { class: "modal-title", "Confirm Batch Delete" }
|
||||
p { class: "modal-body",
|
||||
|
|
@ -214,9 +222,11 @@ pub fn Library(
|
|||
|
||||
// Confirmation dialog for delete all
|
||||
if *confirm_delete_all.read() {
|
||||
div { class: "modal-overlay",
|
||||
div {
|
||||
class: "modal-overlay",
|
||||
onclick: move |_| confirm_delete_all.set(false),
|
||||
div { class: "modal",
|
||||
div {
|
||||
class: "modal",
|
||||
onclick: move |e: Event<MouseData>| e.stop_propagation(),
|
||||
h3 { class: "modal-title", "Delete All Media" }
|
||||
p { class: "modal-body",
|
||||
|
|
@ -248,12 +258,14 @@ pub fn Library(
|
|||
|
||||
// Batch tag dialog
|
||||
if *show_batch_tag.read() {
|
||||
div { class: "modal-overlay",
|
||||
div {
|
||||
class: "modal-overlay",
|
||||
onclick: move |_| {
|
||||
show_batch_tag.set(false);
|
||||
batch_tag_selection.set(Vec::new());
|
||||
},
|
||||
div { class: "modal",
|
||||
div {
|
||||
class: "modal",
|
||||
onclick: move |e: Event<MouseData>| e.stop_propagation(),
|
||||
h3 { class: "modal-title", "Tag Selected Items" }
|
||||
p { class: "modal-body text-muted text-sm",
|
||||
|
|
@ -322,12 +334,14 @@ pub fn Library(
|
|||
|
||||
// Batch collection dialog
|
||||
if *show_batch_collection.read() {
|
||||
div { class: "modal-overlay",
|
||||
div {
|
||||
class: "modal-overlay",
|
||||
onclick: move |_| {
|
||||
show_batch_collection.set(false);
|
||||
batch_collection_id.set(String::new());
|
||||
},
|
||||
div { class: "modal",
|
||||
div {
|
||||
class: "modal",
|
||||
onclick: move |e: Event<MouseData>| e.stop_propagation(),
|
||||
h3 { class: "modal-title", "Add to Collection" }
|
||||
p { class: "modal-body text-muted text-sm",
|
||||
|
|
@ -342,11 +356,7 @@ pub fn Library(
|
|||
onchange: move |e: Event<FormData>| batch_collection_id.set(e.value()),
|
||||
option { value: "", "Select a collection..." }
|
||||
for col in collections.iter() {
|
||||
option {
|
||||
key: "{col.id}",
|
||||
value: "{col.id}",
|
||||
"{col.name}"
|
||||
}
|
||||
option { key: "{col.id}", value: "{col.id}", "{col.name}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -497,9 +507,7 @@ pub fn Library(
|
|||
"Delete All"
|
||||
}
|
||||
}
|
||||
span { class: "text-muted text-sm",
|
||||
"{total_count} items"
|
||||
}
|
||||
span { class: "text-muted text-sm", "{total_count} items" }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -537,9 +545,7 @@ pub fn Library(
|
|||
"Showing {filtered_count} items"
|
||||
}
|
||||
}
|
||||
span { class: "text-muted text-sm",
|
||||
"Page {current_page + 1} of {total_pages}"
|
||||
}
|
||||
span { class: "text-muted text-sm", "Page {current_page + 1} of {total_pages}" }
|
||||
}
|
||||
|
||||
// Select-all banner: when all items on this page are selected and there
|
||||
|
|
@ -551,10 +557,13 @@ pub fn Library(
|
|||
button {
|
||||
onclick: move |_| {
|
||||
if let Some(handler) = on_select_all_global {
|
||||
handler.call(EventHandler::new(move |all_ids: Vec<String>| {
|
||||
selected_ids.set(all_ids);
|
||||
global_all_selected.set(true);
|
||||
}));
|
||||
handler
|
||||
.call(
|
||||
EventHandler::new(move |all_ids: Vec<String>| {
|
||||
selected_ids.set(all_ids);
|
||||
global_all_selected.set(true);
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
"Select all {total_count} items"
|
||||
|
|
@ -580,29 +589,45 @@ pub fn Library(
|
|||
match current_mode {
|
||||
ViewMode::Grid => rsx! {
|
||||
div { class: "media-grid",
|
||||
for (idx, item) in filtered_media.iter().enumerate() {
|
||||
for (idx , item) in filtered_media.iter().enumerate() {
|
||||
{
|
||||
let id = item.id.clone();
|
||||
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.
|
||||
let card_click = {
|
||||
let id = item.id.clone();
|
||||
move |_| on_select.call(id.clone())
|
||||
};
|
||||
|
||||
// Build a list of all visible IDs for shift+click range selection.
|
||||
let visible_ids: Vec<String> = filtered_media.iter().map(|m| m.id.clone()).collect();
|
||||
let visible_ids: Vec<String> = filtered_media
|
||||
|
||||
|
||||
|
||||
.iter()
|
||||
.map(|m| m.id.clone())
|
||||
.collect();
|
||||
let toggle_id = {
|
||||
let id = id.clone();
|
||||
move |e: Event<MouseData>| {
|
||||
e.stop_propagation();
|
||||
let shift = e.modifiers().shift();
|
||||
let mut ids = selected_ids.read().clone();
|
||||
|
||||
if shift {
|
||||
// Shift+click: select range from last_click_index to current idx.
|
||||
if let Some(last) = *last_click_index.read() {
|
||||
let start = last.min(idx);
|
||||
let end = last.max(idx);
|
||||
|
|
@ -614,7 +639,6 @@ pub fn Library(
|
|||
}
|
||||
}
|
||||
} else {
|
||||
// No previous click, just toggle this one.
|
||||
if !ids.contains(&id) {
|
||||
ids.push(id.clone());
|
||||
}
|
||||
|
|
@ -624,12 +648,10 @@ pub fn Library(
|
|||
} else {
|
||||
ids.push(id.clone());
|
||||
}
|
||||
|
||||
last_click_index.set(Some(idx));
|
||||
selected_ids.set(ids);
|
||||
}
|
||||
};
|
||||
|
||||
let thumb_url = if item.has_thumbnail {
|
||||
format!("{}/api/v1/media/{}/thumbnail", server_url, item.id)
|
||||
} else {
|
||||
|
|
@ -640,29 +662,15 @@ pub fn Library(
|
|||
let card_class = if is_checked { "media-card selected" } else { "media-card" };
|
||||
let title_text = item.title.clone().unwrap_or_default();
|
||||
let artist_text = item.artist.clone().unwrap_or_default();
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
key: "{item.id}",
|
||||
class: "{card_class}",
|
||||
onclick: card_click,
|
||||
div { key: "{item.id}", class: "{card_class}", onclick: card_click,
|
||||
|
||||
div { class: "card-checkbox",
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
checked: is_checked,
|
||||
onclick: toggle_id,
|
||||
}
|
||||
input { r#type: "checkbox", checked: is_checked, onclick: toggle_id }
|
||||
}
|
||||
|
||||
// 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.
|
||||
div { class: "card-thumbnail",
|
||||
div { class: "card-type-icon {badge_class}",
|
||||
"{type_icon(&media_type)}"
|
||||
}
|
||||
div { class: "card-type-icon {badge_class}", "{type_icon(&media_type)}" }
|
||||
if has_thumb {
|
||||
img {
|
||||
class: "card-thumb-img",
|
||||
|
|
@ -674,18 +682,12 @@ pub fn Library(
|
|||
}
|
||||
|
||||
div { class: "card-info",
|
||||
div { class: "card-name", title: "{item.file_name}",
|
||||
"{item.file_name}"
|
||||
}
|
||||
div { class: "card-name", title: "{item.file_name}", "{item.file_name}" }
|
||||
if !title_text.is_empty() {
|
||||
div { class: "card-title text-muted text-xs",
|
||||
"{title_text}"
|
||||
}
|
||||
div { class: "card-title text-muted text-xs", "{title_text}" }
|
||||
}
|
||||
if !artist_text.is_empty() {
|
||||
div { class: "card-artist text-muted text-xs",
|
||||
"{artist_text}"
|
||||
}
|
||||
div { class: "card-artist text-muted text-xs", "{artist_text}" }
|
||||
}
|
||||
div { class: "card-meta",
|
||||
span { class: "type-badge {badge_class}", "{item.media_type}" }
|
||||
|
|
@ -751,7 +753,7 @@ pub fn Library(
|
|||
}
|
||||
}
|
||||
tbody {
|
||||
for (idx, item) in filtered_media.iter().enumerate() {
|
||||
for (idx , item) in filtered_media.iter().enumerate() {
|
||||
{
|
||||
let id = item.id.clone();
|
||||
let artist = item.artist.clone().unwrap_or_default();
|
||||
|
|
@ -759,15 +761,17 @@ pub fn Library(
|
|||
let badge_class = type_badge_class(&item.media_type);
|
||||
let is_checked = current_selection.contains(&id);
|
||||
|
||||
let visible_ids: Vec<String> = filtered_media.iter().map(|m| m.id.clone()).collect();
|
||||
let visible_ids: Vec<String> = filtered_media
|
||||
|
||||
.iter()
|
||||
.map(|m| m.id.clone())
|
||||
.collect();
|
||||
let toggle_id = {
|
||||
let id = id.clone();
|
||||
move |e: Event<MouseData>| {
|
||||
e.stop_propagation();
|
||||
let shift = e.modifiers().shift();
|
||||
let mut ids = selected_ids.read().clone();
|
||||
|
||||
if shift {
|
||||
if let Some(last) = *last_click_index.read() {
|
||||
let start = last.min(idx);
|
||||
|
|
@ -789,17 +793,14 @@ pub fn Library(
|
|||
} else {
|
||||
ids.push(id.clone());
|
||||
}
|
||||
|
||||
last_click_index.set(Some(idx));
|
||||
selected_ids.set(ids);
|
||||
}
|
||||
};
|
||||
|
||||
let row_click = {
|
||||
let id = item.id.clone();
|
||||
move |_| on_select.call(id.clone())
|
||||
};
|
||||
|
||||
let delete_click = {
|
||||
let id = item.id.clone();
|
||||
move |e: Event<MouseData>| {
|
||||
|
|
@ -807,7 +808,6 @@ pub fn Library(
|
|||
confirm_delete.set(Some(id.clone()));
|
||||
}
|
||||
};
|
||||
|
||||
let thumb_url = if item.has_thumbnail {
|
||||
format!("{}/api/v1/media/{}/thumbnail", server_url, item.id)
|
||||
} else {
|
||||
|
|
@ -815,24 +815,13 @@ pub fn Library(
|
|||
};
|
||||
let has_thumb = item.has_thumbnail;
|
||||
let media_type_str = item.media_type.clone();
|
||||
|
||||
rsx! {
|
||||
tr {
|
||||
key: "{item.id}",
|
||||
onclick: row_click,
|
||||
tr { key: "{item.id}", onclick: row_click,
|
||||
td {
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
checked: is_checked,
|
||||
onclick: toggle_id,
|
||||
}
|
||||
input { r#type: "checkbox", checked: is_checked, onclick: toggle_id }
|
||||
}
|
||||
td { class: "table-thumb-cell",
|
||||
// Thumbnail with CSS fallback: icon always
|
||||
// rendered, img overlays when available.
|
||||
span { class: "table-type-icon {badge_class}",
|
||||
"{type_icon(&media_type_str)}"
|
||||
}
|
||||
span { class: "table-type-icon {badge_class}", "{type_icon(&media_type_str)}" }
|
||||
if has_thumb {
|
||||
img {
|
||||
class: "table-thumb table-thumb-overlay",
|
||||
|
|
@ -849,11 +838,7 @@ pub fn Library(
|
|||
td { "{artist}" }
|
||||
td { "{size}" }
|
||||
td {
|
||||
button {
|
||||
class: "btn btn-danger btn-sm",
|
||||
onclick: delete_click,
|
||||
"Delete"
|
||||
}
|
||||
button { class: "btn btn-danger btn-sm", onclick: delete_click, "Delete" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,11 @@ pub fn Login(
|
|||
class: "btn btn-primary login-btn",
|
||||
disabled: loading,
|
||||
onclick: on_submit,
|
||||
if loading { "Signing in..." } else { "Sign In" }
|
||||
if loading {
|
||||
"Signing in..."
|
||||
} else {
|
||||
"Sign In"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -498,10 +498,14 @@ pub fn QueuePanel(
|
|||
div { class: "queue-empty", "Queue is empty. Add items from the library." }
|
||||
} else {
|
||||
div { class: "queue-list",
|
||||
for (i, item) in queue.items.iter().enumerate() {
|
||||
for (i , item) in queue.items.iter().enumerate() {
|
||||
{
|
||||
let is_current = i == current_idx;
|
||||
let item_class = if is_current { "queue-item queue-item-active" } else { "queue-item" };
|
||||
let item_class = if is_current {
|
||||
"queue-item queue-item-active"
|
||||
} else {
|
||||
"queue-item"
|
||||
};
|
||||
let title = item.title.clone();
|
||||
let artist = item.artist.clone().unwrap_or_default();
|
||||
rsx! {
|
||||
|
|
|
|||
|
|
@ -75,9 +75,7 @@ pub fn Search(
|
|||
oninput: move |e| query.set(e.value()),
|
||||
onkeypress: on_key,
|
||||
}
|
||||
select {
|
||||
value: "{sort_by}",
|
||||
onchange: move |e| sort_by.set(e.value()),
|
||||
select { value: "{sort_by}", onchange: move |e| sort_by.set(e.value()),
|
||||
option { value: "relevance", "Relevance" }
|
||||
option { value: "date_desc", "Newest" }
|
||||
option { value: "date_asc", "Oldest" }
|
||||
|
|
@ -86,16 +84,8 @@ pub fn Search(
|
|||
option { value: "size_desc", "Size (largest)" }
|
||||
option { value: "size_asc", "Size (smallest)" }
|
||||
}
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
onclick: do_search,
|
||||
"Search"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
onclick: toggle_help,
|
||||
"Syntax Help"
|
||||
}
|
||||
button { class: "btn btn-primary", onclick: do_search, "Search" }
|
||||
button { class: "btn btn-ghost", onclick: toggle_help, "Syntax Help" }
|
||||
|
||||
// View mode toggle
|
||||
div { class: "view-toggle",
|
||||
|
|
@ -118,15 +108,42 @@ pub fn Search(
|
|||
div { class: "card mb-16",
|
||||
h4 { "Search Syntax" }
|
||||
ul {
|
||||
li { code { "hello world" } " -- full text search (implicit AND)" }
|
||||
li { code { "artist:Beatles" } " -- field match" }
|
||||
li { code { "type:pdf" } " -- filter by media type" }
|
||||
li { code { "tag:music" } " -- filter by tag" }
|
||||
li { code { "hello OR world" } " -- OR operator" }
|
||||
li { code { "-excluded" } " -- NOT (exclude term)" }
|
||||
li { code { "hel*" } " -- prefix search" }
|
||||
li { code { "hello~" } " -- fuzzy search" }
|
||||
li { code { "\"exact phrase\"" } " -- quoted exact match" }
|
||||
li {
|
||||
code { "hello world" }
|
||||
" -- full text search (implicit AND)"
|
||||
}
|
||||
li {
|
||||
code { "artist:Beatles" }
|
||||
" -- field match"
|
||||
}
|
||||
li {
|
||||
code { "type:pdf" }
|
||||
" -- filter by media type"
|
||||
}
|
||||
li {
|
||||
code { "tag:music" }
|
||||
" -- filter by tag"
|
||||
}
|
||||
li {
|
||||
code { "hello OR world" }
|
||||
" -- OR operator"
|
||||
}
|
||||
li {
|
||||
code { "-excluded" }
|
||||
" -- NOT (exclude term)"
|
||||
}
|
||||
li {
|
||||
code { "hel*" }
|
||||
" -- prefix search"
|
||||
}
|
||||
li {
|
||||
code { "hello~" }
|
||||
" -- fuzzy search"
|
||||
}
|
||||
li {
|
||||
code { "\"exact phrase\"" }
|
||||
" -- quoted exact match"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -136,7 +153,9 @@ pub fn Search(
|
|||
if results.is_empty() && query.read().is_empty() {
|
||||
div { class: "empty-state",
|
||||
h3 { class: "empty-title", "Search your media" }
|
||||
p { class: "empty-subtitle", "Enter a query above to find files by name, metadata, tags, or type." }
|
||||
p { class: "empty-subtitle",
|
||||
"Enter a query above to find files by name, metadata, tags, or type."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -159,6 +178,8 @@ pub fn Search(
|
|||
move |_| on_select.call(id.clone())
|
||||
};
|
||||
|
||||
|
||||
|
||||
let thumb_url = if item.has_thumbnail {
|
||||
format!("{}/api/v1/media/{}/thumbnail", server_url, item.id)
|
||||
} else {
|
||||
|
|
@ -168,10 +189,8 @@ pub fn Search(
|
|||
let media_type = item.media_type.clone();
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
key: "{item.id}",
|
||||
class: "media-card",
|
||||
onclick: card_click,
|
||||
|
||||
div { key: "{item.id}", class: "media-card", onclick: card_click,
|
||||
|
||||
div { class: "card-thumbnail",
|
||||
if has_thumb {
|
||||
|
|
@ -181,16 +200,12 @@ pub fn Search(
|
|||
loading: "lazy",
|
||||
}
|
||||
} else {
|
||||
div { class: "card-type-icon {badge_class}",
|
||||
"{type_icon(&media_type)}"
|
||||
}
|
||||
div { class: "card-type-icon {badge_class}", "{type_icon(&media_type)}" }
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "card-info",
|
||||
div { class: "card-name", title: "{item.file_name}",
|
||||
"{item.file_name}"
|
||||
}
|
||||
div { class: "card-name", title: "{item.file_name}", "{item.file_name}" }
|
||||
div { class: "card-meta",
|
||||
span { class: "type-badge {badge_class}", "{item.media_type}" }
|
||||
span { class: "card-size", "{format_size(item.file_size)}" }
|
||||
|
|
@ -223,9 +238,7 @@ pub fn Search(
|
|||
move |_| on_select.call(id.clone())
|
||||
};
|
||||
rsx! {
|
||||
tr {
|
||||
key: "{item.id}",
|
||||
onclick: row_click,
|
||||
tr { key: "{item.id}", onclick: row_click,
|
||||
td { "{item.file_name}" }
|
||||
td {
|
||||
span { class: "type-badge {badge_class}", "{item.media_type}" }
|
||||
|
|
@ -242,10 +255,6 @@ pub fn Search(
|
|||
}
|
||||
|
||||
// Pagination controls
|
||||
PaginationControls {
|
||||
current_page: search_page,
|
||||
total_pages: total_pages,
|
||||
on_page_change: on_page_change,
|
||||
}
|
||||
PaginationControls { current_page: search_page, total_pages, on_page_change }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,9 @@ pub fn Settings(
|
|||
label { class: "form-label", "Backend" }
|
||||
span { class: "tooltip-trigger",
|
||||
"?"
|
||||
span { class: "tooltip-text", "The storage backend used by the server (SQLite or PostgreSQL)." }
|
||||
span { class: "tooltip-text",
|
||||
"The storage backend used by the server (SQLite or PostgreSQL)."
|
||||
}
|
||||
}
|
||||
}
|
||||
span { class: "info-value badge badge-neutral", "{config.backend}" }
|
||||
|
|
@ -75,7 +77,9 @@ pub fn Settings(
|
|||
label { class: "form-label", "Server Address" }
|
||||
span { class: "tooltip-trigger",
|
||||
"?"
|
||||
span { class: "tooltip-text", "The address and port the server is listening on." }
|
||||
span { class: "tooltip-text",
|
||||
"The address and port the server is listening on."
|
||||
}
|
||||
}
|
||||
}
|
||||
span { class: "info-value mono", "{host_port}" }
|
||||
|
|
@ -85,7 +89,9 @@ pub fn Settings(
|
|||
label { class: "form-label", "Database Path" }
|
||||
span { class: "tooltip-trigger",
|
||||
"?"
|
||||
span { class: "tooltip-text", "File path to the SQLite database, or connection info for PostgreSQL." }
|
||||
span { class: "tooltip-text",
|
||||
"File path to the SQLite database, or connection info for PostgreSQL."
|
||||
}
|
||||
}
|
||||
}
|
||||
span { class: "info-value mono", "{db_path}" }
|
||||
|
|
@ -102,7 +108,9 @@ pub fn Settings(
|
|||
}
|
||||
span { class: "tooltip-trigger",
|
||||
"?"
|
||||
span { class: "tooltip-text", "Directories that Pinakes scans for media files. Only existing directories can be added." }
|
||||
span { class: "tooltip-text",
|
||||
"Directories that Pinakes scans for media files. Only existing directories can be added."
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "settings-card-body",
|
||||
|
|
@ -194,7 +202,9 @@ pub fn Settings(
|
|||
label { class: "form-label", "File Watching" }
|
||||
span { class: "tooltip-trigger",
|
||||
"?"
|
||||
span { class: "tooltip-text", "When enabled, Pinakes monitors root directories for new, modified, or deleted files in real time using filesystem events." }
|
||||
span { class: "tooltip-text",
|
||||
"When enabled, Pinakes monitors root directories for new, modified, or deleted files in real time using filesystem events."
|
||||
}
|
||||
}
|
||||
}
|
||||
div {
|
||||
|
|
@ -204,8 +214,7 @@ pub fn Settings(
|
|||
on_toggle_watch.call(!watch_enabled);
|
||||
}
|
||||
},
|
||||
div {
|
||||
class: if watch_enabled { "toggle-track active" } else { "toggle-track" },
|
||||
div { class: if watch_enabled { "toggle-track active" } else { "toggle-track" },
|
||||
div { class: "toggle-thumb" }
|
||||
}
|
||||
}
|
||||
|
|
@ -217,7 +226,9 @@ pub fn Settings(
|
|||
label { class: "form-label", "Poll Interval" }
|
||||
span { class: "tooltip-trigger",
|
||||
"?"
|
||||
span { class: "tooltip-text", "How often (in seconds) Pinakes polls for file changes when watch mode is not available or as a fallback." }
|
||||
span { class: "tooltip-text",
|
||||
"How often (in seconds) Pinakes polls for file changes when watch mode is not available or as a fallback."
|
||||
}
|
||||
}
|
||||
}
|
||||
if *editing_poll.read() {
|
||||
|
|
@ -242,7 +253,8 @@ pub fn Settings(
|
|||
poll_error.set(None);
|
||||
}
|
||||
_ => {
|
||||
poll_error.set(Some("Enter a positive integer (seconds).".to_string()));
|
||||
poll_error
|
||||
.set(Some("Enter a positive integer (seconds).".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -307,7 +319,9 @@ pub fn Settings(
|
|||
label { class: "form-label", "Ignore Patterns" }
|
||||
span { class: "tooltip-trigger",
|
||||
"?"
|
||||
span { class: "tooltip-text", "Glob patterns for files and directories to skip during scanning. One pattern per line." }
|
||||
span { class: "tooltip-text",
|
||||
"Glob patterns for files and directories to skip during scanning. One pattern per line."
|
||||
}
|
||||
}
|
||||
}
|
||||
if *editing_patterns.read() {
|
||||
|
|
@ -359,7 +373,9 @@ pub fn Settings(
|
|||
class: "patterns-textarea",
|
||||
placeholder: "One pattern per line, e.g.:\n*.tmp\n.git/**\nnode_modules/**",
|
||||
}
|
||||
p { class: "text-muted text-sm", "Enter one glob pattern per line. Empty lines are ignored." }
|
||||
p { class: "text-muted text-sm",
|
||||
"Enter one glob pattern per line. Empty lines are ignored."
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if config.scanning.ignore_patterns.is_empty() {
|
||||
|
|
@ -397,7 +413,7 @@ pub fn Settings(
|
|||
let handler = on_update_ui_config;
|
||||
move |e: Event<FormData>| {
|
||||
if let Some(ref h) = handler {
|
||||
h.call(serde_json::json!({"theme": e.value()}));
|
||||
h.call(serde_json::json!({ "theme" : e.value() }));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -412,7 +428,9 @@ pub fn Settings(
|
|||
label { class: "form-label", "Default View" }
|
||||
span { class: "tooltip-trigger",
|
||||
"?"
|
||||
span { class: "tooltip-text", "The view shown when the application starts." }
|
||||
span { class: "tooltip-text",
|
||||
"The view shown when the application starts."
|
||||
}
|
||||
}
|
||||
}
|
||||
select {
|
||||
|
|
@ -421,7 +439,7 @@ pub fn Settings(
|
|||
let handler = on_update_ui_config;
|
||||
move |e: Event<FormData>| {
|
||||
if let Some(ref h) = handler {
|
||||
h.call(serde_json::json!({"default_view": e.value()}));
|
||||
h.call(serde_json::json!({ "default_view" : e.value() }));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -436,7 +454,9 @@ pub fn Settings(
|
|||
label { class: "form-label", "Default Page Size" }
|
||||
span { class: "tooltip-trigger",
|
||||
"?"
|
||||
span { class: "tooltip-text", "Number of items shown per page by default." }
|
||||
span { class: "tooltip-text",
|
||||
"Number of items shown per page by default."
|
||||
}
|
||||
}
|
||||
}
|
||||
select {
|
||||
|
|
@ -444,10 +464,9 @@ pub fn Settings(
|
|||
onchange: {
|
||||
let handler = on_update_ui_config;
|
||||
move |e: Event<FormData>| {
|
||||
if let Some(ref h) = handler
|
||||
&& let Ok(size) = e.value().parse::<usize>() {
|
||||
h.call(serde_json::json!({"default_page_size": size}));
|
||||
}
|
||||
if let Some(ref h) = handler && let Ok(size) = e.value().parse::<usize>() {
|
||||
h.call(serde_json::json!({ "default_page_size" : size }));
|
||||
}
|
||||
}
|
||||
},
|
||||
option { value: "24", "24" }
|
||||
|
|
@ -463,7 +482,9 @@ pub fn Settings(
|
|||
label { class: "form-label", "Default View Mode" }
|
||||
span { class: "tooltip-trigger",
|
||||
"?"
|
||||
span { class: "tooltip-text", "Whether to show items in a grid or table layout." }
|
||||
span { class: "tooltip-text",
|
||||
"Whether to show items in a grid or table layout."
|
||||
}
|
||||
}
|
||||
}
|
||||
select {
|
||||
|
|
@ -472,7 +493,7 @@ pub fn Settings(
|
|||
let handler = on_update_ui_config;
|
||||
move |e: Event<FormData>| {
|
||||
if let Some(ref h) = handler {
|
||||
h.call(serde_json::json!({"default_view_mode": e.value()}));
|
||||
h.call(serde_json::json!({ "default_view_mode" : e.value() }));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -487,7 +508,9 @@ pub fn Settings(
|
|||
label { class: "form-label", "Auto-play Media" }
|
||||
span { class: "tooltip-trigger",
|
||||
"?"
|
||||
span { class: "tooltip-text", "Automatically start playback when opening audio or video." }
|
||||
span { class: "tooltip-text",
|
||||
"Automatically start playback when opening audio or video."
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
|
|
@ -498,11 +521,10 @@ pub fn Settings(
|
|||
class: "toggle",
|
||||
onclick: move |_| {
|
||||
if let Some(ref h) = handler {
|
||||
h.call(serde_json::json!({"auto_play_media": !autoplay}));
|
||||
h.call(serde_json::json!({ "auto_play_media" : ! autoplay }));
|
||||
}
|
||||
},
|
||||
div {
|
||||
class: if autoplay { "toggle-track active" } else { "toggle-track" },
|
||||
div { class: if autoplay { "toggle-track active" } else { "toggle-track" },
|
||||
div { class: "toggle-thumb" }
|
||||
}
|
||||
}
|
||||
|
|
@ -516,7 +538,9 @@ pub fn Settings(
|
|||
label { class: "form-label", "Show Thumbnails" }
|
||||
span { class: "tooltip-trigger",
|
||||
"?"
|
||||
span { class: "tooltip-text", "Display thumbnail previews in library and search views." }
|
||||
span { class: "tooltip-text",
|
||||
"Display thumbnail previews in library and search views."
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
|
|
@ -527,11 +551,10 @@ pub fn Settings(
|
|||
class: "toggle",
|
||||
onclick: move |_| {
|
||||
if let Some(ref h) = handler {
|
||||
h.call(serde_json::json!({"show_thumbnails": !show_thumbs}));
|
||||
h.call(serde_json::json!({ "show_thumbnails" : ! show_thumbs }));
|
||||
}
|
||||
},
|
||||
div {
|
||||
class: if show_thumbs { "toggle-track active" } else { "toggle-track" },
|
||||
div { class: if show_thumbs { "toggle-track active" } else { "toggle-track" },
|
||||
div { class: "toggle-thumb" }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,16 @@ pub fn Statistics(
|
|||
}
|
||||
}
|
||||
|
||||
// Media by Type
|
||||
// Media by Type
|
||||
|
||||
|
||||
// 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" }
|
||||
|
|
@ -87,7 +96,6 @@ pub fn Statistics(
|
|||
}
|
||||
}
|
||||
|
||||
// Storage by Type
|
||||
if !s.storage_by_type.is_empty() {
|
||||
div { class: "card mt-16",
|
||||
h4 { class: "card-title", "Storage by Type" }
|
||||
|
|
@ -110,7 +118,6 @@ pub fn Statistics(
|
|||
}
|
||||
}
|
||||
|
||||
// Top Tags
|
||||
if !s.top_tags.is_empty() {
|
||||
div { class: "card mt-16",
|
||||
h4 { class: "card-title", "Top Tags" }
|
||||
|
|
@ -133,7 +140,6 @@ pub fn Statistics(
|
|||
}
|
||||
}
|
||||
|
||||
// Top Collections
|
||||
if !s.top_collections.is_empty() {
|
||||
div { class: "card mt-16",
|
||||
h4 { class: "card-title", "Top Collections" }
|
||||
|
|
@ -156,7 +162,6 @@ pub fn Statistics(
|
|||
}
|
||||
}
|
||||
|
||||
// Date Range
|
||||
div { class: "card mt-16",
|
||||
h4 { class: "card-title", "Date Range" }
|
||||
div { class: "stats-grid",
|
||||
|
|
@ -171,7 +176,7 @@ pub fn Statistics(
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
None => rsx! {
|
||||
div { class: "empty-state",
|
||||
p { "Loading statistics..." }
|
||||
|
|
|
|||
|
|
@ -65,18 +65,10 @@ pub fn Tags(
|
|||
onchange: move |e| parent_tag.set(e.value()),
|
||||
option { value: "", "No Parent" }
|
||||
for tag in tags.iter() {
|
||||
option {
|
||||
key: "{tag.id}",
|
||||
value: "{tag.id}",
|
||||
"{tag.name}"
|
||||
}
|
||||
option { key: "{tag.id}", value: "{tag.id}", "{tag.name}" }
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
onclick: create_click,
|
||||
"Create"
|
||||
}
|
||||
button { class: "btn btn-primary", onclick: create_click, "Create" }
|
||||
}
|
||||
|
||||
if tags.is_empty() {
|
||||
|
|
@ -143,17 +135,19 @@ pub fn Tags(
|
|||
}
|
||||
}
|
||||
if !children.is_empty() {
|
||||
div { class: "tag-children", style: "margin-left: 16px; margin-top: 4px;",
|
||||
div {
|
||||
|
||||
class: "tag-children",
|
||||
style: "margin-left: 16px; margin-top: 4px;",
|
||||
for child in children.iter() {
|
||||
{
|
||||
let child_id = child.id.clone();
|
||||
let child_name = child.name.clone();
|
||||
let child_is_confirming = confirm_delete.read().as_deref() == Some(child_id.as_str());
|
||||
let child_is_confirming = confirm_delete.read().as_deref()
|
||||
|
||||
== Some(child_id.as_str());
|
||||
rsx! {
|
||||
span {
|
||||
key: "{child_id}",
|
||||
class: "tag-badge",
|
||||
span { key: "{child_id}", class: "tag-badge",
|
||||
"{child_name}"
|
||||
if child_is_confirming {
|
||||
{
|
||||
|
|
@ -208,14 +202,21 @@ pub fn Tags(
|
|||
// Orphan child tags (parent not found in current list)
|
||||
for tag in child_tags.iter() {
|
||||
{
|
||||
let parent_exists = root_tags.iter().any(|r| Some(r.id.as_str()) == tag.parent_id.as_deref())
|
||||
|| child_tags.iter().any(|c| c.id != tag.id && Some(c.id.as_str()) == tag.parent_id.as_deref());
|
||||
let parent_exists = root_tags
|
||||
.iter()
|
||||
|
||||
.any(|r| Some(r.id.as_str()) == tag.parent_id.as_deref())
|
||||
|| child_tags
|
||||
.iter()
|
||||
.any(|c| {
|
||||
c.id != tag.id && Some(c.id.as_str()) == tag.parent_id.as_deref()
|
||||
});
|
||||
if !parent_exists {
|
||||
let orphan_id = tag.id.clone();
|
||||
let orphan_name = tag.name.clone();
|
||||
let parent_label = tag.parent_id.clone().unwrap_or_default();
|
||||
let is_confirming = confirm_delete.read().as_deref() == Some(orphan_id.as_str());
|
||||
|
||||
let is_confirming = confirm_delete.read().as_deref()
|
||||
== Some(orphan_id.as_str());
|
||||
rsx! {
|
||||
span { key: "{orphan_id}", class: "tag-badge",
|
||||
"{orphan_name}"
|
||||
|
|
|
|||
|
|
@ -75,7 +75,11 @@ pub fn Tasks(
|
|||
button {
|
||||
class: "btn btn-sm btn-secondary mr-8",
|
||||
onclick: move |_| on_toggle.call(task_id_toggle.clone()),
|
||||
if task.enabled { "Disable" } else { "Enable" }
|
||||
if task.enabled {
|
||||
"Disable"
|
||||
} else {
|
||||
"Enable"
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn btn-sm btn-primary",
|
||||
|
|
|
|||
|
|
@ -63,17 +63,20 @@ body {
|
|||
.sidebar {
|
||||
width: 220px;
|
||||
min-width: 220px;
|
||||
max-width: 220px;
|
||||
background: var(--bg-1);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
z-index: 10;
|
||||
transition: width 0.15s, min-width 0.15s;
|
||||
transition: width 0.15s, min-width 0.15s, max-width 0.15s;
|
||||
}
|
||||
|
||||
.sidebar.collapsed { width: 48px; min-width: 48px; }
|
||||
.sidebar.collapsed { width: 48px; min-width: 48px; max-width: 48px; }
|
||||
.sidebar.collapsed .nav-label,
|
||||
.sidebar.collapsed .sidebar-header .logo,
|
||||
.sidebar.collapsed .sidebar-header .version,
|
||||
|
|
@ -83,9 +86,8 @@ body {
|
|||
|
||||
/* Nav item text - hide when collapsed */
|
||||
.nav-item-text {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-item-text { display: none; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue