pinakes-ui: streamline sidebar design

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I0176fa480e5ba40eea5a39685a4f97896a6a6964
This commit is contained in:
raf 2026-02-03 10:25:31 +03:00
commit 278bcaa4b0
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
25 changed files with 1805 additions and 1686 deletions

File diff suppressed because it is too large Load diff

View file

@ -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 }
}
}

View file

@ -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", " > " }
}

View file

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

View file

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

View file

@ -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}" }

View file

@ -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" }

View file

@ -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", "+" }
}

View file

@ -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",

View file

@ -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" }
}
}
}

View file

@ -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"
}
}
}
}

View file

@ -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! {

View file

@ -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 }
}
}

View file

@ -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" }
}
}

View file

@ -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..." }

View file

@ -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}"

View file

@ -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",

View file

@ -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; }