use dioxus::prelude::*; use super::pagination::Pagination as PaginationControls; use super::utils::{format_size, media_category, type_badge_class, type_icon}; use crate::client::{CollectionResponse, MediaResponse, TagResponse}; #[derive(Debug, Clone, Copy, PartialEq)] pub enum ViewMode { Grid, Table, } /// The set of type filter categories available to the user. const TYPE_FILTERS: &[&str] = &["all", "audio", "video", "image", "document", "text"]; /// Human-readable label for a type filter value. fn filter_label(f: &str) -> &str { match f { "all" => "All", "audio" => "Audio", "video" => "Video", "image" => "Image", "document" => "Document", "text" => "Text", _ => f, } } /// Parse the current sort field string into (column, direction) so table /// headers can show the correct arrow indicator. fn parse_sort(sort: &str) -> (&str, &str) { if let Some(col) = sort.strip_suffix("_asc") { (col, "asc") } else if let Some(col) = sort.strip_suffix("_desc") { (col, "desc") } else { (sort, "asc") } } /// Return the sort arrow indicator for a table column header. Returns an empty /// string when the column is not the active sort column. fn sort_arrow(current_sort: &str, column: &str) -> &'static str { let (col, dir) = parse_sort(current_sort); if col == column { if dir == "asc" { " \u{25b2}" } else { " \u{25bc}" } } else { "" } } /// Compute the next sort value when a table column header is clicked. If the /// column is already sorted ascending, flip to descending and vice-versa. /// Otherwise default to ascending. fn next_sort(current_sort: &str, column: &str) -> String { let (col, dir) = parse_sort(current_sort); if col == column { let new_dir = if dir == "asc" { "desc" } else { "asc" }; format!("{column}_{new_dir}") } else { format!("{column}_asc") } } #[component] pub fn Library( media: Vec, tags: Vec, collections: Vec, total_count: u64, current_page: u64, page_size: u64, server_url: String, on_select: EventHandler, on_delete: EventHandler, on_batch_delete: EventHandler>, on_batch_tag: EventHandler<(Vec, Vec)>, on_batch_collection: EventHandler<(Vec, String)>, on_page_change: EventHandler, on_page_size_change: EventHandler, on_sort_change: EventHandler, #[props(default)] on_select_all_global: Option>>>, #[props(default)] on_delete_all: Option>, ) -> Element { let mut selected_ids = use_signal(Vec::::new); let mut select_all = use_signal(|| false); let mut confirm_delete = use_signal(|| Option::::None); let mut confirm_batch_delete = use_signal(|| false); let mut confirm_delete_all = use_signal(|| false); let mut show_batch_tag = use_signal(|| false); let mut batch_tag_selection = use_signal(Vec::::new); let mut show_batch_collection = use_signal(|| false); let mut batch_collection_id = use_signal(String::new); let mut view_mode = use_signal(|| ViewMode::Grid); let mut sort_field = use_signal(|| "created_at_desc".to_string()); let mut type_filter = use_signal(|| "all".to_string()); // Track the last-clicked index for shift+click range selection. let mut last_click_index = use_signal(|| Option::::None); // True when all items across all pages have been selected. let mut global_all_selected = use_signal(|| false); if media.is_empty() && total_count == 0 { 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." } } }; } // Apply client-side type filter. let active_filter = type_filter.read().clone(); let filtered_media: Vec = if active_filter == "all" { media.clone() } else { media .iter() .filter(|m| media_category(&m.media_type) == active_filter.as_str()) .cloned() .collect() }; let filtered_count = filtered_media.len(); let all_ids: Vec = filtered_media.iter().map(|m| m.id.clone()).collect(); // Read selection once to avoid repeated signal reads in loops let current_selection: Vec = selected_ids.read().clone(); let selection_count = current_selection.len(); let has_selection = selection_count > 0; let total_pages = total_count.div_ceil(page_size); let toggle_select_all = { let all_ids = all_ids.clone(); move |_| { let new_val = !*select_all.read(); select_all.set(new_val); global_all_selected.set(false); if new_val { selected_ids.set(all_ids.clone()); } else { selected_ids.set(Vec::new()); } } }; let is_all_selected = *select_all.read(); let current_mode = *view_mode.read(); let current_sort = sort_field.read().clone(); rsx! { // Confirmation dialog for single delete if confirm_delete.read().is_some() { div { class: "modal-overlay", onclick: move |_| confirm_delete.set(None), div { class: "modal", onclick: move |e: Event| 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." } div { class: "modal-actions", button { class: "btn btn-ghost", onclick: move |_| confirm_delete.set(None), "Cancel" } button { class: "btn btn-danger", onclick: move |_| { if let Some(id) = confirm_delete.read().clone() { on_delete.call(id); } confirm_delete.set(None); }, "Delete" } } } } } // Confirmation dialog for batch delete if *confirm_batch_delete.read() { div { class: "modal-overlay", onclick: move |_| confirm_batch_delete.set(false), div { class: "modal", onclick: move |e: Event| e.stop_propagation(), h3 { class: "modal-title", "Confirm Batch Delete" } p { class: "modal-body", "Are you sure you want to delete {selection_count} selected items? This cannot be undone." } div { class: "modal-actions", button { class: "btn btn-ghost", onclick: move |_| confirm_batch_delete.set(false), "Cancel" } button { class: "btn btn-danger", onclick: move |_| { let ids = selected_ids.read().clone(); on_batch_delete.call(ids); selected_ids.set(Vec::new()); select_all.set(false); confirm_batch_delete.set(false); }, "Delete All" } } } } } // Confirmation dialog for delete all if *confirm_delete_all.read() { div { class: "modal-overlay", onclick: move |_| confirm_delete_all.set(false), div { class: "modal", onclick: move |e: Event| e.stop_propagation(), h3 { class: "modal-title", "Delete All Media" } p { class: "modal-body", "Are you sure you want to delete ALL {total_count} items? This cannot be undone." } div { class: "modal-actions", button { class: "btn btn-ghost", onclick: move |_| confirm_delete_all.set(false), "Cancel" } button { class: "btn btn-danger", onclick: move |_| { if let Some(handler) = on_delete_all { handler.call(()); } selected_ids.set(Vec::new()); select_all.set(false); global_all_selected.set(false); confirm_delete_all.set(false); }, "Delete Everything" } } } } } // Batch tag dialog if *show_batch_tag.read() { div { class: "modal-overlay", onclick: move |_| { show_batch_tag.set(false); batch_tag_selection.set(Vec::new()); }, div { class: "modal", onclick: move |e: Event| e.stop_propagation(), h3 { class: "modal-title", "Tag Selected Items" } p { class: "modal-body text-muted text-sm", "Select tags to apply to {selection_count} items:" } if tags.is_empty() { p { class: "text-muted", "No tags available. Create tags first." } } else { div { class: "tag-list", style: "margin: 12px 0;", for tag in tags.iter() { { let tag_id = tag.id.clone(); let tag_name = tag.name.clone(); let is_selected = batch_tag_selection.read().contains(&tag_id); let badge_class = if is_selected { "tag-badge selected" } else { "tag-badge" }; rsx! { span { class: "{badge_class}", onclick: { let tag_id = tag_id.clone(); move |_| { let mut current = batch_tag_selection.read().clone(); if let Some(pos) = current.iter().position(|t| t == &tag_id) { current.remove(pos); } else { current.push(tag_id.clone()); } batch_tag_selection.set(current); } }, "{tag_name}" } } } } } } div { class: "modal-actions", button { class: "btn btn-ghost", onclick: move |_| { show_batch_tag.set(false); batch_tag_selection.set(Vec::new()); }, "Cancel" } button { class: "btn btn-primary", onclick: move |_| { let ids = selected_ids.read().clone(); let tag_ids = batch_tag_selection.read().clone(); if !tag_ids.is_empty() { on_batch_tag.call((ids, tag_ids)); selected_ids.set(Vec::new()); select_all.set(false); } show_batch_tag.set(false); batch_tag_selection.set(Vec::new()); }, "Apply Tags" } } } } } // Batch collection dialog if *show_batch_collection.read() { div { class: "modal-overlay", onclick: move |_| { show_batch_collection.set(false); batch_collection_id.set(String::new()); }, div { class: "modal", onclick: move |e: Event| e.stop_propagation(), h3 { class: "modal-title", "Add to Collection" } p { class: "modal-body text-muted text-sm", "Choose a collection for {selection_count} items:" } if collections.is_empty() { p { class: "text-muted", "No collections available. Create one first." } } else { select { style: "width: 100%; margin: 12px 0;", value: "{batch_collection_id}", onchange: move |e: Event| 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}" } } } } div { class: "modal-actions", button { class: "btn btn-ghost", onclick: move |_| { show_batch_collection.set(false); batch_collection_id.set(String::new()); }, "Cancel" } button { class: "btn btn-primary", onclick: move |_| { let ids = selected_ids.read().clone(); let col_id = batch_collection_id.read().clone(); if !col_id.is_empty() { on_batch_collection.call((ids, col_id)); selected_ids.set(Vec::new()); select_all.set(false); } show_batch_collection.set(false); batch_collection_id.set(String::new()); }, "Add to Collection" } } } } } // Toolbar: view toggle, sort, batch actions div { class: "library-toolbar", div { class: "toolbar-left", // View mode toggle div { class: "view-toggle", button { class: if current_mode == ViewMode::Grid { "view-btn active" } else { "view-btn" }, onclick: move |_| view_mode.set(ViewMode::Grid), title: "Grid view", "\u{25a6}" } button { class: if current_mode == ViewMode::Table { "view-btn active" } else { "view-btn" }, onclick: move |_| view_mode.set(ViewMode::Table), title: "Table view", "\u{2630}" } } // Sort selector div { class: "sort-control", select { value: "{sort_field}", onchange: move |e: Event| { let val = e.value(); sort_field.set(val.clone()); on_sort_change.call(val); }, option { value: "created_at_desc", "Newest first" } option { value: "created_at_asc", "Oldest first" } option { value: "file_name_asc", "Name A-Z" } option { value: "file_name_desc", "Name Z-A" } option { value: "file_size_desc", "Largest first" } option { value: "file_size_asc", "Smallest first" } option { value: "media_type_asc", "Type" } } } // Page size div { class: "page-size-control", span { class: "text-muted text-sm", "Show:" } select { value: "{page_size}", onchange: move |e: Event| { if let Ok(size) = e.value().parse::() { on_page_size_change.call(size); } }, option { value: "24", "24" } option { value: "48", "48" } option { value: "96", "96" } option { value: "200", "200" } } } } div { class: "toolbar-right", // Select All / Deselect All toggle (works in both grid and table) { let all_ids2 = all_ids.clone(); rsx! { button { class: "btn btn-sm btn-ghost", onclick: move |_| { if is_all_selected { selected_ids.set(Vec::new()); select_all.set(false); global_all_selected.set(false); } else { selected_ids.set(all_ids2.clone()); select_all.set(true); } }, if is_all_selected { "Deselect All" } else { "Select All" } } } } if has_selection { div { class: "batch-actions", span { "{selection_count} selected" } button { class: "btn btn-sm btn-secondary", onclick: move |_| show_batch_tag.set(true), "Tag" } button { class: "btn btn-sm btn-secondary", onclick: move |_| show_batch_collection.set(true), "Collection" } button { class: "btn btn-sm btn-danger", onclick: move |_| confirm_batch_delete.set(true), "Delete" } button { class: "btn btn-sm btn-ghost", onclick: move |_| { selected_ids.set(Vec::new()); select_all.set(false); global_all_selected.set(false); }, "Clear" } } } if on_delete_all.is_some() && total_count > 0 { button { class: "btn btn-sm btn-danger", onclick: move |_| confirm_delete_all.set(true), "Delete All" } } span { class: "text-muted text-sm", "{total_count} items" } } } // Type filter chips div { class: "type-filter-row", for filter in TYPE_FILTERS.iter() { { let f = (*filter).to_string(); let is_active = active_filter == f; let chip_class = if is_active { "filter-chip active" } else { "filter-chip" }; let label = filter_label(filter); rsx! { button { key: "{f}", class: "{chip_class}", onclick: { let f = f.clone(); move |_| { type_filter.set(f.clone()); } }, "{label}" } } } } } // Stats summary row div { class: "library-stats", span { class: "text-muted text-sm", if active_filter != "all" { "Showing {filtered_count} of {total_count} items (filtered: {active_filter})" } else { "Showing {filtered_count} items" } } 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 // are more pages, offer to select everything across all pages. if is_all_selected && total_count > page_size && !*global_all_selected.read() { div { class: "select-all-banner", "All {filtered_count} items on this page are selected." if on_select_all_global.is_some() { button { onclick: move |_| { if let Some(handler) = on_select_all_global { handler .call( EventHandler::new(move |all_ids: Vec| { selected_ids.set(all_ids); global_all_selected.set(true); }), ); } }, "Select all {total_count} items" } } } } if *global_all_selected.read() { div { class: "select-all-banner", "All {selection_count} items across all pages are selected." button { onclick: move |_| { selected_ids.set(Vec::new()); select_all.set(false); global_all_selected.set(false); }, "Clear selection" } } } // Content: grid or table match current_mode { ViewMode::Grid => rsx! { div { class: "media-grid", 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()) }; let visible_ids: Vec = filtered_media .iter() .map(|m| m.id.clone()) .collect(); let toggle_id = { let id = id.clone(); move |e: Event| { 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); let end = last.max(idx); for i in start..=end { if let Some(range_id) = visible_ids.get(i) && !ids.contains(range_id) { ids.push(range_id.clone()); } } } else { if !ids.contains(&id) { ids.push(id.clone()); } } } else if ids.contains(&id) { ids.retain(|x| x != &id); } 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 { String::new() }; let has_thumb = item.has_thumbnail; let media_type = item.media_type.clone(); 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 { class: "card-checkbox", input { r#type: "checkbox", checked: is_checked, onclick: toggle_id } } div { class: "card-thumbnail", div { class: "card-type-icon {badge_class}", "{type_icon(&media_type)}" } if has_thumb { img { class: "card-thumb-img", src: "{thumb_url}", alt: "{item.file_name}", loading: "lazy", } } } div { class: "card-info", 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}" } } if !artist_text.is_empty() { div { class: "card-artist text-muted text-xs", "{artist_text}" } } div { class: "card-meta", span { class: "type-badge {badge_class}", "{item.media_type}" } span { class: "card-size", "{format_size(item.file_size)}" } } } } } } } } }, ViewMode::Table => rsx! { table { class: "data-table", thead { tr { th { input { r#type: "checkbox", checked: is_all_selected, onclick: toggle_select_all, } } th { "" } th { class: "sortable-header", onclick: { let cs = current_sort.clone(); move |_| { let val = next_sort(&cs, "file_name"); sort_field.set(val.clone()); on_sort_change.call(val); } }, "Name{sort_arrow(¤t_sort, \"file_name\")}" } th { class: "sortable-header", onclick: { let cs = current_sort.clone(); move |_| { let val = next_sort(&cs, "media_type"); sort_field.set(val.clone()); on_sort_change.call(val); } }, "Type{sort_arrow(¤t_sort, \"media_type\")}" } th { "Artist" } th { class: "sortable-header", onclick: { let cs = current_sort.clone(); move |_| { let val = next_sort(&cs, "file_size"); sort_field.set(val.clone()); on_sort_change.call(val); } }, "Size{sort_arrow(¤t_sort, \"file_size\")}" } th { "" } } } tbody { for (idx , item) in filtered_media.iter().enumerate() { { let id = item.id.clone(); let artist = item.artist.clone().unwrap_or_default(); let size = format_size(item.file_size); let badge_class = type_badge_class(&item.media_type); let is_checked = current_selection.contains(&id); let visible_ids: Vec = filtered_media .iter() .map(|m| m.id.clone()) .collect(); let toggle_id = { let id = id.clone(); move |e: Event| { 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); let end = last.max(idx); for i in start..=end { if let Some(range_id) = visible_ids.get(i) && !ids.contains(range_id) { ids.push(range_id.clone()); } } } else { if !ids.contains(&id) { ids.push(id.clone()); } } } else if ids.contains(&id) { ids.retain(|x| x != &id); } 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| { e.stop_propagation(); confirm_delete.set(Some(id.clone())); } }; let thumb_url = if item.has_thumbnail { format!("{}/api/v1/media/{}/thumbnail", server_url, item.id) } else { String::new() }; let has_thumb = item.has_thumbnail; let media_type_str = item.media_type.clone(); rsx! { tr { key: "{item.id}", onclick: row_click, td { input { r#type: "checkbox", checked: is_checked, onclick: toggle_id } } td { class: "table-thumb-cell", span { class: "table-type-icon {badge_class}", "{type_icon(&media_type_str)}" } if has_thumb { img { class: "table-thumb table-thumb-overlay", src: "{thumb_url}", alt: "", loading: "lazy", } } } td { "{item.file_name}" } td { span { class: "type-badge {badge_class}", "{item.media_type}" } } td { "{artist}" } td { "{size}" } td { button { class: "btn btn-danger btn-sm", onclick: delete_click, "Delete" } } } } } } } } }, } // Pagination controls PaginationControls { current_page, total_pages, on_page_change: move |page: u64| on_page_change.call(page), } } }