use dioxus::prelude::*; use super::backlinks_panel::{BacklinksPanel, OutgoingLinksPanel}; use super::image_viewer::ImageViewer; use super::markdown_viewer::MarkdownViewer; use super::media_player::{MediaPlayer, PlayQueue, QueueItem, QueuePanel}; use super::pdf_viewer::PdfViewer; use super::utils::{format_duration, format_size, media_category, type_badge_class}; use crate::client::{ApiClient, MediaResponse, MediaUpdateEvent, TagResponse}; #[component] pub fn Detail( media: MediaResponse, media_tags: Vec, all_tags: Vec, server_url: String, #[props(default = false)] autoplay: bool, #[props(default)] play_queue: Option, on_back: EventHandler<()>, on_open: EventHandler, on_update: EventHandler, on_tag: EventHandler<(String, String)>, on_untag: EventHandler<(String, String)>, on_set_custom_field: EventHandler<(String, String, String, String)>, on_delete_custom_field: EventHandler<(String, String)>, on_delete: EventHandler, #[props(default)] on_navigate_to_media: Option>, #[props(default)] on_queue_select: Option>, #[props(default)] on_queue_remove: Option>, #[props(default)] on_queue_clear: Option>, #[props(default)] on_queue_toggle_repeat: Option>, #[props(default)] on_queue_toggle_shuffle: Option>, #[props(default)] on_queue_next: Option>, #[props(default)] on_queue_previous: Option>, #[props(default)] on_track_ended: Option>, #[props(default)] on_add_to_queue: Option>, ) -> Element { let mut editing = use_signal(|| false); let mut show_image_viewer = use_signal(|| false); let mut edit_title = use_signal(String::new); let mut edit_artist = use_signal(String::new); let mut edit_album = use_signal(String::new); let mut edit_genre = use_signal(String::new); let mut edit_year = use_signal(String::new); let mut edit_description = use_signal(String::new); let mut add_tag_id = use_signal(String::new); let mut new_field_name = use_signal(String::new); let mut new_field_type = use_signal(|| "text".to_string()); let mut new_field_value = use_signal(String::new); let mut confirm_delete = use_signal(|| false); let id = media.id.clone(); let title = media.title.clone().unwrap_or_default(); let artist = media.artist.clone().unwrap_or_default(); let album = media.album.clone().unwrap_or_default(); let genre = media.genre.clone().unwrap_or_default(); let year_str = media.year.map(|y| y.to_string()).unwrap_or_default(); let duration_str = media.duration_secs.map(format_duration).unwrap_or_default(); let description = media.description.clone().unwrap_or_default(); let size = format_size(media.file_size); let badge_class = type_badge_class(&media.media_type); let custom_fields: Vec<(String, String, String)> = media .custom_fields .iter() .map(|(k, v)| (k.clone(), v.field_type.clone(), v.value.clone())) .collect(); let is_editing = editing(); // Separate system-extracted metadata from user-defined custom fields. // System fields are those set by extractors (camera info, dimensions, etc.) let system_field_names: &[&str] = &[ "width", "height", "camera_make", "camera_model", "date_taken", "gps_latitude", "gps_longitude", "iso", "exposure_time", "f_number", "focal_length", "software", "lens_model", "flash", "orientation", "track_number", "disc_number", "comment", "bitrate", "sample_rate", "channels", "resolution", "video_codec", "audio_codec", "audio_bitrate", ]; let system_fields: Vec<(String, String, String)> = custom_fields .iter() .filter(|(k, _, _)| system_field_names.contains(&k.as_str())) .cloned() .collect(); let user_fields: Vec<(String, String, String)> = custom_fields .iter() .filter(|(k, _, _)| !system_field_names.contains(&k.as_str())) .cloned() .collect(); let has_system_fields = !system_fields.is_empty(); let has_user_fields = !user_fields.is_empty(); // Media preview URLs - use ApiClient methods for consistent URL building let client = ApiClient::new(&server_url, None); tracing::trace!("Using API base URL: {}", client.base_url()); let stream_url = client.stream_url(&media.id); let thumbnail_url = client.thumbnail_url(&media.id); let category = media_category(&media.media_type); let has_thumbnail = media.has_thumbnail; // Compute available tags (all_tags minus media_tags) let media_tag_ids: Vec = media_tags.iter().map(|t| t.id.clone()).collect(); let available_tags: Vec = all_tags .iter() .filter(|t| !media_tag_ids.contains(&t.id)) .cloned() .collect(); // Clone values needed for closures let id_for_open = id.clone(); let id_for_save = id.clone(); let id_for_tag = id.clone(); let id_for_field = id.clone(); let id_for_delete = id.clone(); // Clone media field values for the edit button let title_for_edit = media.title.clone().unwrap_or_default(); let artist_for_edit = media.artist.clone().unwrap_or_default(); let album_for_edit = media.album.clone().unwrap_or_default(); let genre_for_edit = media.genre.clone().unwrap_or_default(); let year_for_edit = media.year.map(|y| y.to_string()).unwrap_or_default(); let description_for_edit = media.description.clone().unwrap_or_default(); let on_edit_click = move |_| { edit_title.set(title_for_edit.clone()); edit_artist.set(artist_for_edit.clone()); edit_album.set(album_for_edit.clone()); edit_genre.set(genre_for_edit.clone()); edit_year.set(year_for_edit.clone()); edit_description.set(description_for_edit.clone()); editing.set(true); }; let on_save_click = { let id_save = id_for_save.clone(); move |_| { let t = edit_title(); let ar = edit_artist(); let al = edit_album(); let g = edit_genre(); let y_str = edit_year(); let d = edit_description(); let title_opt = if t.is_empty() { None } else { Some(t) }; let artist_opt = if ar.is_empty() { None } else { Some(ar) }; let album_opt = if al.is_empty() { None } else { Some(al) }; let genre_opt = if g.is_empty() { None } else { Some(g) }; let year_opt = if y_str.is_empty() { None } else { y_str.parse::().ok() }; let desc_opt = if d.is_empty() { None } else { Some(d) }; on_update.call(MediaUpdateEvent { id: id_save.clone(), title: title_opt, artist: artist_opt, album: album_opt, genre: genre_opt, year: year_opt, description: desc_opt, }); editing.set(false); } }; let on_cancel_click = move |_| { editing.set(false); }; let on_tag_add_click = { let id_tag = id_for_tag.clone(); move |_| { let tag_id = add_tag_id(); if !tag_id.is_empty() { on_tag.call((id_tag.clone(), tag_id)); add_tag_id.set(String::new()); } } }; let on_add_field_click = { let id_field = id_for_field.clone(); move |_| { let name = new_field_name(); let ft = new_field_type(); let val = new_field_value(); if !name.is_empty() && !val.is_empty() { on_set_custom_field.call((id_field.clone(), name, ft, val)); new_field_name.set(String::new()); new_field_type.set("text".to_string()); new_field_value.set(String::new()); } } }; let on_delete_click = move |_| { confirm_delete.set(true); }; let on_confirm_delete = { let id_del = id_for_delete.clone(); move |_| { on_delete.call(id_del.clone()); confirm_delete.set(false); } }; let on_cancel_delete = move |_| { confirm_delete.set(false); }; let stream_url_for_viewer = stream_url.clone(); let thumb_for_player = thumbnail_url.clone(); let file_name_for_viewer = media.file_name.clone(); // Clone queue handlers for use in the component let has_queue = play_queue.is_some(); rsx! { // Media preview div { class: "detail-preview", if category == "audio" { MediaPlayer { src: stream_url.clone(), media_type: "audio".to_string(), title: media.title.clone(), thumbnail_url: if has_thumbnail { Some(thumb_for_player.clone()) } else { None }, autoplay, on_track_ended: on_track_ended, } } else if category == "video" { MediaPlayer { src: stream_url.clone(), media_type: "video".to_string(), title: media.title.clone(), autoplay, on_track_ended: on_track_ended, } } else if category == "image" { if has_thumbnail { img { src: "{thumbnail_url}", alt: "{media.file_name}", class: "detail-preview-image clickable", onclick: move |_| show_image_viewer.set(true), } } else { img { src: "{stream_url}", alt: "{media.file_name}", class: "detail-preview-image clickable", onclick: move |_| show_image_viewer.set(true), } } } else if category == "text" { MarkdownViewer { content_url: stream_url.clone(), media_type: media.media_type.clone(), } } else if category == "document" { if media.media_type == "pdf" { PdfViewer { src: stream_url.clone() } } else { // EPUB and other document types div { class: "detail-no-preview", p { class: "text-muted", "Preview not available for this document type." } button { class: "btn btn-primary", onclick: { let id_open = id.clone(); move |_| on_open.call(id_open.clone()) }, "Open Externally" } } } } else if has_thumbnail { img { src: "{thumbnail_url}", alt: "Thumbnail", class: "detail-thumbnail", } } } // Play queue panel (only for audio/video with a queue) if has_queue && (category == "audio" || category == "video") { if let Some(ref queue) = play_queue { QueuePanel { queue: queue.clone(), on_select: { let handler = on_queue_select; move |idx| { if let Some(ref h) = handler { h.call(idx); } } }, on_remove: { let handler = on_queue_remove; move |idx| { if let Some(ref h) = handler { h.call(idx); } } }, on_clear: { let handler = on_queue_clear; move |_| { if let Some(ref h) = handler { h.call(()); } } }, on_toggle_repeat: { let handler = on_queue_toggle_repeat; move |_| { if let Some(ref h) = handler { h.call(()); } } }, on_toggle_shuffle: { let handler = on_queue_toggle_shuffle; move |_| { if let Some(ref h) = handler { h.call(()); } } }, on_next: { let handler = on_queue_next; move |_| { if let Some(ref h) = handler { h.call(()); } } }, on_previous: { let handler = on_queue_previous; move |_| { if let Some(ref h) = handler { h.call(()); } } }, } } } // Action bar div { class: "detail-actions", button { class: "btn btn-secondary", onclick: move |_| on_back.call(()), "Back" } button { class: "btn btn-primary", onclick: { let id_open = id_for_open.clone(); move |_| on_open.call(id_open.clone()) }, "Open" } // Add to Queue button for audio/video content if (category == "audio" || category == "video") && on_add_to_queue.is_some() { { // Check if this item is currently playing let is_current = play_queue.as_ref() .and_then(|q| q.current()) .map(|item| item.media_id == id) .unwrap_or(false); let media_id_for_queue = id.clone(); let title_for_queue = media.title.clone().unwrap_or_else(|| media.file_name.clone()); let artist_for_queue = media.artist.clone(); let duration_for_queue = media.duration_secs; let media_type_for_queue = category.to_string(); let stream_url_for_queue = stream_url.clone(); let thumbnail_for_queue = if has_thumbnail { Some(thumbnail_url.clone()) } else { None }; let on_add = on_add_to_queue; rsx! { button { class: if is_current { "btn btn-secondary disabled" } else { "btn btn-secondary" }, disabled: is_current, title: if is_current { "Currently playing" } else { "Add to play queue" }, onclick: move |_| { if let Some(ref handler) = on_add { let item = QueueItem { media_id: media_id_for_queue.clone(), title: title_for_queue.clone(), artist: artist_for_queue.clone(), duration_secs: duration_for_queue, media_type: media_type_for_queue.clone(), stream_url: stream_url_for_queue.clone(), thumbnail_url: thumbnail_for_queue.clone(), }; handler.call(item); } }, if is_current { "\u{266b} Playing" } else { "\u{2795} Queue" } } } } } if is_editing { 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" } } if confirm_delete() { 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" } } } // Info / Edit section if is_editing { div { class: "detail-grid", // Read-only file info div { class: "detail-field", span { class: "detail-label", "File Name" } span { class: "detail-value", "{media.file_name}" } } div { class: "detail-field", span { class: "detail-label", "Path" } span { class: "detail-value mono", "{media.path}" } } div { class: "detail-field", span { class: "detail-label", "Type" } span { class: "detail-value", span { class: "type-badge {badge_class}", "{media.media_type}" } } } div { class: "detail-field", span { class: "detail-label", "Size" } span { class: "detail-value", "{size}" } } div { class: "detail-field", span { class: "detail-label", "Hash" } span { class: "detail-value mono", "{media.content_hash}" } } // Editable fields — conditional by media category div { class: "detail-field", label { class: "detail-label", "Title" } input { r#type: "text", value: "{edit_title}", oninput: move |e: Event| edit_title.set(e.value()), } } div { class: "detail-field", label { class: "detail-label", { match category { "image" => "Photographer", "document" | "text" => "Author", _ => "Artist", } } } input { r#type: "text", value: "{edit_artist}", oninput: move |e: Event| edit_artist.set(e.value()), } } if category == "audio" { div { class: "detail-field", label { class: "detail-label", "Album" } input { r#type: "text", value: "{edit_album}", oninput: move |e: Event| edit_album.set(e.value()), } } } if category == "audio" || category == "video" { div { class: "detail-field", label { class: "detail-label", "Genre" } input { r#type: "text", value: "{edit_genre}", oninput: move |e: Event| edit_genre.set(e.value()), } } } if category == "audio" || category == "video" || category == "document" { div { class: "detail-field", label { class: "detail-label", "Year" } input { r#type: "text", value: "{edit_year}", oninput: move |e: Event| edit_year.set(e.value()), } } } div { class: "detail-field full-width", label { class: "detail-label", "Description" } textarea { value: "{edit_description}", oninput: move |e: Event| edit_description.set(e.value()), } } } } else { div { class: "detail-grid", div { class: "detail-field", span { class: "detail-label", "File Name" } span { class: "detail-value", "{media.file_name}" } } div { class: "detail-field", span { class: "detail-label", "Path" } span { class: "detail-value mono", "{media.path}" } } div { class: "detail-field", span { class: "detail-label", "Type" } span { class: "detail-value", span { class: "type-badge {badge_class}", "{media.media_type}" } } } div { class: "detail-field", span { class: "detail-label", "Size" } span { class: "detail-value", "{size}" } } div { class: "detail-field", span { class: "detail-label", "Hash" } span { class: "detail-value mono", "{media.content_hash}" } } // Title: only shown when non-empty if !title.is_empty() { div { class: "detail-field", span { class: "detail-label", "Title" } span { class: "detail-value", "{title}" } } } // Artist/Author/Photographer: only shown when non-empty if !artist.is_empty() { div { class: "detail-field", span { class: "detail-label", { match category { "image" => "Photographer", "document" | "text" => "Author", _ => "Artist", } } } span { class: "detail-value", "{artist}" } } } // Album: audio only, when non-empty if category == "audio" && !album.is_empty() { div { class: "detail-field", span { class: "detail-label", "Album" } span { class: "detail-value", "{album}" } } } // Genre: audio and video, when non-empty if (category == "audio" || category == "video") && !genre.is_empty() { div { class: "detail-field", span { class: "detail-label", "Genre" } span { class: "detail-value", "{genre}" } } } // Year: audio, video, document, when non-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}" } } } // Duration: audio and video if (category == "audio" || category == "video") && media.duration_secs.is_some() { div { class: "detail-field", span { class: "detail-label", "Duration" } span { class: "detail-value", "{duration_str}" } } } // Description: only shown when non-empty if !description.is_empty() { div { class: "detail-field full-width", span { class: "detail-label", "Description" } span { class: "detail-value", "{description}" } } } div { class: "detail-field", span { class: "detail-label", "Created" } span { class: "detail-value", "{media.created_at}" } } div { class: "detail-field", span { class: "detail-label", "Updated" } span { class: "detail-value", "{media.updated_at}" } } } } // Tags section div { class: "card mb-16", div { class: "card-header", h4 { class: "card-title", "Tags" } } div { class: "tag-list mb-8", for tag in media_tags.iter() { { let tag_id = tag.id.clone(); let media_id_untag = id.clone(); rsx! { span { class: "tag-badge", key: "{tag_id}", "{tag.name}" span { class: "tag-remove", onclick: { let tid = tag_id.clone(); let mid = media_id_untag.clone(); move |_| on_untag.call((mid.clone(), tid.clone())) }, "x" } } } } } } div { class: "form-row", select { value: "{add_tag_id}", onchange: move |e: Event| add_tag_id.set(e.value()), option { value: "", "Add tag..." } for tag in available_tags.iter() { { let tid = tag.id.clone(); let tname = tag.name.clone(); rsx! { option { key: "{tid}", value: "{tid}", "{tname}" } } } } } button { class: "btn btn-sm btn-primary", onclick: on_tag_add_click, "Add" } } } // Technical Metadata section (system-extracted fields) if has_system_fields { div { class: "card mb-16", div { class: "card-header", 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}", span { class: "detail-label", "{key}" } span { class: "detail-value", "{value}" } } } } } } // Custom Fields section (user-defined) div { class: "card", div { class: "card-header", h4 { class: "card-title", "Custom Fields" } } if has_user_fields { div { class: "detail-grid", 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}", span { class: "detail-label", "{key} ({field_type})" } div { class: "flex-row", span { class: "detail-value", "{value}" } button { class: "btn-icon", onclick: { let fname = field_name.clone(); let mid = media_id_del.clone(); move |_| on_delete_custom_field.call((mid.clone(), fname.clone())) }, "x" } } } } } } } } div { class: "form-row", input { r#type: "text", placeholder: "Field name", value: "{new_field_name}", oninput: move |e: Event| new_field_name.set(e.value()), } select { value: "{new_field_type}", onchange: move |e: Event| new_field_type.set(e.value()), option { value: "text", "text" } option { value: "number", "number" } option { value: "date", "date" } option { value: "boolean", "boolean" } } input { r#type: "text", placeholder: "Value", value: "{new_field_value}", oninput: move |e: Event| new_field_value.set(e.value()), } button { class: "btn btn-sm btn-primary", onclick: on_add_field_click, "Add" } } } // Backlinks and outgoing links panels for markdown/text files if category == "text" { { let client_for_backlinks = client.clone(); let client_for_outgoing = client.clone(); let media_id_for_backlinks = id.clone(); let media_id_for_outgoing = id.clone(); let nav_handler = on_navigate_to_media; rsx! { BacklinksPanel { media_id: media_id_for_backlinks, client: client_for_backlinks, on_navigate: { let handler = nav_handler; move |target_id: String| { if let Some(ref h) = handler { h.call(target_id); } } }, } OutgoingLinksPanel { media_id: media_id_for_outgoing, client: client_for_outgoing, on_navigate: { let handler = nav_handler; move |target_id: String| { if let Some(ref h) = handler { h.call(target_id); } } }, } } } } // Image viewer overlay if *show_image_viewer.read() { ImageViewer { src: stream_url_for_viewer.clone(), alt: file_name_for_viewer.clone(), on_close: move |_| show_image_viewer.set(false), } } } }