Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I81fda8247814da19eed1e76dbe97bd5b6a6a6964
802 lines
32 KiB
Rust
802 lines
32 KiB
Rust
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<TagResponse>,
|
|
all_tags: Vec<TagResponse>,
|
|
server_url: String,
|
|
#[props(default = false)] autoplay: bool,
|
|
#[props(default)] play_queue: Option<PlayQueue>,
|
|
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>,
|
|
#[props(default)] on_navigate_to_media: Option<EventHandler<String>>,
|
|
#[props(default)] on_queue_select: Option<EventHandler<usize>>,
|
|
#[props(default)] on_queue_remove: Option<EventHandler<usize>>,
|
|
#[props(default)] on_queue_clear: Option<EventHandler<()>>,
|
|
#[props(default)] on_queue_toggle_repeat: Option<EventHandler<()>>,
|
|
#[props(default)] on_queue_toggle_shuffle: Option<EventHandler<()>>,
|
|
#[props(default)] on_queue_next: Option<EventHandler<()>>,
|
|
#[props(default)] on_queue_previous: Option<EventHandler<()>>,
|
|
#[props(default)] on_track_ended: Option<EventHandler<()>>,
|
|
#[props(default)] on_add_to_queue: Option<EventHandler<QueueItem>>,
|
|
) -> 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();
|
|
|
|
// 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<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"
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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),
|
|
}
|
|
}
|
|
}
|
|
}
|