pinakes/crates/pinakes-ui/src/components/detail.rs
NotAShelf cfdc3d0622
various: remove dead code; fix skipped tests
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I9100489be899f9e9fbd32f6aca3080196a6a6964
2026-02-05 06:34:20 +03:00

643 lines
25 KiB
Rust

use dioxus::prelude::*;
use super::image_viewer::ImageViewer;
use super::markdown_viewer::MarkdownViewer;
use super::media_player::MediaPlayer;
use super::pdf_viewer::PdfViewer;
use super::utils::{format_duration, format_size, media_category, type_badge_class};
use crate::client::{ApiClient, MediaResponse, MediaUpdateEvent, TagResponse};
#[component]
pub fn Detail(
media: MediaResponse,
media_tags: Vec<TagResponse>,
all_tags: Vec<TagResponse>,
server_url: String,
#[props(default = false)] autoplay: bool,
on_back: EventHandler<()>,
on_open: EventHandler<String>,
on_update: EventHandler<MediaUpdateEvent>,
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<String>,
) -> 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<String> = media_tags.iter().map(|t| t.id.clone()).collect();
let available_tags: Vec<TagResponse> = 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::<i32>().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();
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,
}
} else if category == "video" {
MediaPlayer {
src: stream_url.clone(),
media_type: "video".to_string(),
title: media.title.clone(),
autoplay,
}
} 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",
}
}
}
// 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"
}
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<FormData>| 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<FormData>| 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<FormData>| 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<FormData>| 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<FormData>| edit_year.set(e.value()),
}
}
}
div { class: "detail-field full-width",
label { class: "detail-label", "Description" }
textarea {
value: "{edit_description}",
oninput: move |e: Event<FormData>| 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<FormData>| 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<FormData>| new_field_name.set(e.value()),
}
select {
value: "{new_field_type}",
onchange: move |e: Event<FormData>| 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<FormData>| new_field_value.set(e.value()),
}
button {
class: "btn btn-sm btn-primary",
onclick: on_add_field_click,
"Add"
}
}
}
// 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),
}
}
}
}