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

View file

@ -165,7 +165,8 @@ impl PluginLoader {
// Check content-length header before downloading // Check content-length header before downloading
const MAX_PLUGIN_SIZE: u64 = 100 * 1024 * 1024; // 100 MB const MAX_PLUGIN_SIZE: u64 = 100 * 1024 * 1024; // 100 MB
if let Some(content_length) = response.content_length() if let Some(content_length) = response.content_length()
&& content_length > MAX_PLUGIN_SIZE { && content_length > MAX_PLUGIN_SIZE
{
return Err(anyhow!( return Err(anyhow!(
"Plugin archive too large: {} bytes (max {} bytes)", "Plugin archive too large: {} bytes (max {} bytes)",
content_length, content_length,

View file

@ -106,10 +106,10 @@ impl WasmPlugin {
// If there are params and memory is available, write them // If there are params and memory is available, write them
let mut alloc_offset: i32 = 0; let mut alloc_offset: i32 = 0;
if !params.is_empty() if !params.is_empty()
&& let Some(mem) = &memory { && let Some(mem) = &memory
{
// Call the plugin's alloc function if available, otherwise write at offset 0 // Call the plugin's alloc function if available, otherwise write at offset 0
let offset = if let Ok(alloc) = let offset = if let Ok(alloc) = instance.get_typed_func::<i32, i32>(&mut store, "alloc")
instance.get_typed_func::<i32, i32>(&mut store, "alloc")
{ {
let result = alloc.call_async(&mut store, params.len() as i32).await?; let result = alloc.call_async(&mut store, params.len() as i32).await?;
if result < 0 { if result < 0 {
@ -209,7 +209,8 @@ impl HostFunctions {
let start = ptr as usize; let start = ptr as usize;
let end = start + len as usize; let end = start + len as usize;
if end <= data.len() if end <= data.len()
&& let Ok(msg) = std::str::from_utf8(&data[start..end]) { && let Ok(msg) = std::str::from_utf8(&data[start..end])
{
match level { match level {
0 => tracing::error!(plugin = true, "{}", msg), 0 => tracing::error!(plugin = true, "{}", msg),
1 => tracing::warn!(plugin = true, "{}", msg), 1 => tracing::warn!(plugin = true, "{}", msg),
@ -258,11 +259,7 @@ impl HostFunctions {
.filesystem .filesystem
.read .read
.iter() .iter()
.any(|allowed| { .any(|allowed| allowed.canonicalize().is_ok_and(|a| path.starts_with(a)));
allowed
.canonicalize()
.is_ok_and(|a| path.starts_with(a))
});
if !can_read { if !can_read {
tracing::warn!(path = %path_str, "plugin read access denied"); tracing::warn!(path = %path_str, "plugin read access denied");

View file

@ -293,7 +293,8 @@ impl TranscodeService {
// Clean up cache directory // Clean up cache directory
if let Some(path) = cache_path if let Some(path) = cache_path
&& let Err(e) = tokio::fs::remove_dir_all(&path).await { && let Err(e) = tokio::fs::remove_dir_all(&path).await
{
tracing::error!("failed to remove transcode cache directory: {}", e); tracing::error!("failed to remove transcode cache directory: {}", e);
} }
@ -311,7 +312,8 @@ impl TranscodeService {
.iter() .iter()
.filter_map(|(id, sess)| { .filter_map(|(id, sess)| {
if let Some(expires) = sess.expires_at if let Some(expires) = sess.expires_at
&& now > expires { && now > expires
{
return Some((*id, sess.cache_path.clone())); return Some((*id, sess.cache_path.clone()));
} }
None None
@ -475,7 +477,8 @@ async fn run_ffmpeg(
while let Ok(Some(line)) = lines.next_line().await { while let Ok(Some(line)) = lines.next_line().await {
// FFmpeg progress output: "out_time_us=12345678" // FFmpeg progress output: "out_time_us=12345678"
if let Some(time_str) = line.strip_prefix("out_time_us=") if let Some(time_str) = line.strip_prefix("out_time_us=")
&& let Ok(us) = time_str.trim().parse::<f64>() { && let Ok(us) = time_str.trim().parse::<f64>()
{
let secs = us / 1_000_000.0; let secs = us / 1_000_000.0;
// Calculate progress based on known duration // Calculate progress based on known duration
let progress = match duration_secs { let progress = match duration_secs {

View file

@ -100,7 +100,8 @@ pub async fn update_playlist(
Json(req): Json<UpdatePlaylistRequest>, Json(req): Json<UpdatePlaylistRequest>,
) -> Result<Json<PlaylistResponse>, ApiError> { ) -> Result<Json<PlaylistResponse>, ApiError> {
if let Some(ref name) = req.name if let Some(ref name) = req.name
&& (name.is_empty() || name.chars().count() > 255) { && (name.is_empty() || name.chars().count() > 255)
{
return Err(ApiError( return Err(ApiError(
pinakes_core::error::PinakesError::InvalidOperation( pinakes_core::error::PinakesError::InvalidOperation(
"playlist name must be 1-255 characters".into(), "playlist name must be 1-255 characters".into(),

View file

@ -137,7 +137,8 @@ pub async fn create_share_link(
}; };
const MAX_EXPIRY_HOURS: u64 = 8760; // 1 year const MAX_EXPIRY_HOURS: u64 = 8760; // 1 year
if let Some(h) = req.expires_in_hours if let Some(h) = req.expires_in_hours
&& h > MAX_EXPIRY_HOURS { && h > MAX_EXPIRY_HOURS
{
return Err(ApiError( return Err(ApiError(
pinakes_core::error::PinakesError::InvalidOperation(format!( pinakes_core::error::PinakesError::InvalidOperation(format!(
"expires_in_hours cannot exceed {}", "expires_in_hours cannot exceed {}",
@ -169,11 +170,10 @@ pub async fn access_shared_media(
let link = state.storage.get_share_link(&token).await?; let link = state.storage.get_share_link(&token).await?;
// Check expiration // Check expiration
if let Some(expires) = link.expires_at if let Some(expires) = link.expires_at
&& chrono::Utc::now() > expires { && chrono::Utc::now() > expires
{
return Err(ApiError( return Err(ApiError(
pinakes_core::error::PinakesError::InvalidOperation( pinakes_core::error::PinakesError::InvalidOperation("share link has expired".into()),
"share link has expired".into(),
),
)); ));
} }
// Verify password if set // Verify password if set

View file

@ -104,7 +104,8 @@ pub async fn hls_segment(
// Look for an active/completed transcode session // Look for an active/completed transcode session
if let Some(transcode_service) = &state.transcode_service if let Some(transcode_service) = &state.transcode_service
&& let Some(session) = transcode_service.find_session(media_id, &profile).await { && let Some(session) = transcode_service.find_session(media_id, &profile).await
{
let segment_path = session.cache_path.join(&segment); let segment_path = session.cache_path.join(&segment);
if segment_path.exists() { if segment_path.exists() {
@ -206,7 +207,8 @@ pub async fn dash_segment(
let media_id = MediaId(id); let media_id = MediaId(id);
if let Some(transcode_service) = &state.transcode_service if let Some(transcode_service) = &state.transcode_service
&& let Some(session) = transcode_service.find_session(media_id, &profile).await { && let Some(session) = transcode_service.find_session(media_id, &profile).await
{
let segment_path = session.cache_path.join(&segment); let segment_path = session.cache_path.join(&segment);
if segment_path.exists() { if segment_path.exists() {

View file

@ -1028,7 +1028,8 @@ async fn handle_action(
} }
Action::Edit => { Action::Edit => {
if state.current_view == View::Detail if state.current_view == View::Detail
&& let Some(ref media) = state.selected_media { && let Some(ref media) = state.selected_media
{
// Populate edit fields from selected media // Populate edit fields from selected media
state.edit_title = media.title.clone().unwrap_or_default(); state.edit_title = media.title.clone().unwrap_or_default();
state.edit_artist = media.artist.clone().unwrap_or_default(); state.edit_artist = media.artist.clone().unwrap_or_default();
@ -1069,7 +1070,8 @@ async fn handle_action(
Action::Toggle => { Action::Toggle => {
if state.current_view == View::Tasks if state.current_view == View::Tasks
&& let Some(idx) = state.scheduled_tasks_selected && let Some(idx) = state.scheduled_tasks_selected
&& let Some(task) = state.scheduled_tasks.get(idx) { && let Some(task) = state.scheduled_tasks.get(idx)
{
let task_id = task.id.clone(); let task_id = task.id.clone();
let client = client.clone(); let client = client.clone();
let tx = event_sender.clone(); let tx = event_sender.clone();
@ -1078,16 +1080,15 @@ async fn handle_action(
Ok(()) => { Ok(()) => {
// Refresh tasks list // Refresh tasks list
if let Ok(tasks) = client.list_scheduled_tasks().await { if let Ok(tasks) = client.list_scheduled_tasks().await {
let _ = tx.send(AppEvent::ApiResult( let _ =
ApiResult::ScheduledTasks(tasks), tx.send(AppEvent::ApiResult(ApiResult::ScheduledTasks(tasks)));
));
} }
} }
Err(e) => { Err(e) => {
tracing::error!("Failed to toggle task: {}", e); tracing::error!("Failed to toggle task: {}", e);
let _ = tx.send(AppEvent::ApiResult(ApiResult::Error( let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!(
format!("Toggle task failed: {e}"), "Toggle task failed: {e}"
))); ))));
} }
} }
}); });
@ -1096,7 +1097,8 @@ async fn handle_action(
Action::RunNow => { Action::RunNow => {
if state.current_view == View::Tasks if state.current_view == View::Tasks
&& let Some(idx) = state.scheduled_tasks_selected && let Some(idx) = state.scheduled_tasks_selected
&& let Some(task) = state.scheduled_tasks.get(idx) { && let Some(task) = state.scheduled_tasks.get(idx)
{
let task_id = task.id.clone(); let task_id = task.id.clone();
let task_name = task.name.clone(); let task_name = task.name.clone();
state.status_message = Some(format!("Running task: {task_name}...")); state.status_message = Some(format!("Running task: {task_name}..."));
@ -1107,16 +1109,15 @@ async fn handle_action(
Ok(()) => { Ok(()) => {
// Refresh tasks list // Refresh tasks list
if let Ok(tasks) = client.list_scheduled_tasks().await { if let Ok(tasks) = client.list_scheduled_tasks().await {
let _ = tx.send(AppEvent::ApiResult( let _ =
ApiResult::ScheduledTasks(tasks), tx.send(AppEvent::ApiResult(ApiResult::ScheduledTasks(tasks)));
));
} }
} }
Err(e) => { Err(e) => {
tracing::error!("Failed to run task: {}", e); tracing::error!("Failed to run task: {}", e);
let _ = tx.send(AppEvent::ApiResult(ApiResult::Error( let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!(
format!("Run task failed: {e}"), "Run task failed: {e}"
))); ))));
} }
} }
}); });
@ -1124,7 +1125,8 @@ async fn handle_action(
} }
Action::Save => { Action::Save => {
if state.current_view == View::MetadataEdit if state.current_view == View::MetadataEdit
&& let Some(ref media) = state.selected_media { && let Some(ref media) = state.selected_media
{
let updates = serde_json::json!({ let updates = serde_json::json!({
"title": if state.edit_title.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_title.clone()) }, "title": if state.edit_title.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_title.clone()) },
"artist": if state.edit_artist.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_artist.clone()) }, "artist": if state.edit_artist.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_artist.clone()) },

View file

@ -309,7 +309,8 @@ pub fn App() -> Element {
} }
} else { } else {
// Phase 7.1: Keyboard shortcuts // Phase 7.1: Keyboard shortcuts
div { class: "app", div {
class: "app",
tabindex: "0", tabindex: "0",
onkeydown: { onkeydown: {
move |evt: KeyboardEvent| { move |evt: KeyboardEvent| {
@ -495,7 +496,11 @@ pub fn App() -> Element {
button { button {
class: "sidebar-toggle", class: "sidebar-toggle",
onclick: move |_| sidebar_collapsed.toggle(), onclick: move |_| sidebar_collapsed.toggle(),
if *sidebar_collapsed.read() { "\u{25b6}" } else { "\u{25c0}" } if *sidebar_collapsed.read() {
"\u{25b6}"
} else {
"\u{25c0}"
}
} }
// User info (when logged in) // User info (when logged in)
@ -582,7 +587,22 @@ pub fn App() -> Element {
} }
} }
{match *current_view.read() { {
// Phase 2.2: Sort wiring - actually refetch with sort
// Phase 4.1 + 4.2: Search improvements
// Phase 3.1 + 3.2: Detail view enhancements
// Phase 3.2: Delete from detail navigates back and refreshes
// Phase 5.1: Tags on_delete - confirmation handled inside Tags component
// Phase 5.2: Collections enhancements
// Phase 5.2: Navigate to detail when clicking a collection member
// Phase 5.2: Add member to collection
// Phase 6.1: Audit improvements
// Phase 6.2: Scan progress
// Phase 6.2: Scan with polling for progress
// Poll scan status until done
// Refresh duplicates list
// Reload full config
match *current_view.read() {
View::Library => rsx! { View::Library => rsx! {
div { class: "stats-grid", div { class: "stats-grid",
div { class: "stat-card", div { class: "stat-card",
@ -683,7 +703,10 @@ pub fn App() -> Element {
spawn(async move { spawn(async move {
match client.batch_add_to_collection(&ids, &col_id).await { match client.batch_add_to_collection(&ids, &col_id).await {
Ok(resp) => { Ok(resp) => {
show_toast(format!("Added {} items to collection", resp.processed), false); show_toast(
format!("Added {} items to collection", resp.processed),
false,
);
refresh_media(); refresh_media();
} }
Err(e) => show_toast(format!("Failed: {e}"), true), Err(e) => show_toast(format!("Failed: {e}"), true),
@ -724,7 +747,6 @@ pub fn App() -> Element {
}); });
} }
}, },
// Phase 2.2: Sort wiring - actually refetch with sort
on_sort_change: { on_sort_change: {
let client = client.read().clone(); let client = client.read().clone();
move |sort: String| { move |sort: String| {
@ -750,7 +772,10 @@ pub fn App() -> Element {
let sort = media_sort.read().clone(); let sort = media_sort.read().clone();
match client.list_media(0, total, Some(&sort)).await { match client.list_media(0, total, Some(&sort)).await {
Ok(items) => { Ok(items) => {
let all_ids: Vec<String> = items.iter().map(|m| m.id.clone()).collect(); let all_ids: Vec<String> = items
.iter()
.map(|m| m.id.clone())
.collect();
callback.call(all_ids); callback.call(all_ids);
} }
Err(e) => show_toast(format!("Failed to select all: {e}"), true), Err(e) => show_toast(format!("Failed to select all: {e}"), true),
@ -777,7 +802,6 @@ pub fn App() -> Element {
}, },
} }
}, },
// Phase 4.1 + 4.2: Search improvements
View::Search => rsx! { View::Search => rsx! {
search::Search { search::Search {
results: search_results.read().clone(), results: search_results.read().clone(),
@ -845,7 +869,6 @@ pub fn App() -> Element {
}, },
} }
}, },
// Phase 3.1 + 3.2: Detail view enhancements
View::Detail => { View::Detail => {
let media_ref = selected_media.read(); let media_ref = selected_media.read();
match media_ref.as_ref() { match media_ref.as_ref() {
@ -923,7 +946,10 @@ pub fn App() -> Element {
move |(media_id, name, field_type, value): (String, String, String, String)| { move |(media_id, name, field_type, value): (String, String, String, String)| {
let client = client.clone(); let client = client.clone();
spawn(async move { spawn(async move {
match client.set_custom_field(&media_id, &name, &field_type, &value).await { match client
.set_custom_field(&media_id, &name, &field_type, &value)
.await
{
Ok(_) => { Ok(_) => {
if let Ok(updated) = client.get_media(&media_id).await { if let Ok(updated) = client.get_media(&media_id).await {
selected_media.set(Some(updated)); selected_media.set(Some(updated));
@ -952,7 +978,6 @@ pub fn App() -> Element {
}); });
} }
}, },
// Phase 3.2: Delete from detail navigates back and refreshes
on_delete: { on_delete: {
let client = client.read().clone(); let client = client.read().clone();
let refresh_media = refresh_media.clone(); let refresh_media = refresh_media.clone();
@ -980,7 +1005,7 @@ pub fn App() -> Element {
} }
}, },
} }
}, }
View::Tags => rsx! { View::Tags => rsx! {
tags::Tags { tags::Tags {
tags: tags_list.read().clone(), tags: tags_list.read().clone(),
@ -1001,7 +1026,6 @@ pub fn App() -> Element {
}); });
} }
}, },
// Phase 5.1: Tags on_delete - confirmation handled inside Tags component
on_delete: { on_delete: {
let client = client.read().clone(); let client = client.read().clone();
let refresh_tags = refresh_tags.clone(); let refresh_tags = refresh_tags.clone();
@ -1021,7 +1045,6 @@ pub fn App() -> Element {
}, },
} }
}, },
// Phase 5.2: Collections enhancements
View::Collections => rsx! { View::Collections => rsx! {
collections::Collections { collections::Collections {
collections: collections_list.read().clone(), collections: collections_list.read().clone(),
@ -1031,11 +1054,16 @@ pub fn App() -> Element {
on_create: { on_create: {
let client = client.read().clone(); let client = client.read().clone();
let refresh_collections = refresh_collections.clone(); let refresh_collections = refresh_collections.clone();
move |(name, kind, desc, filter): (String, String, Option<String>, Option<String>)| { move |
(name, kind, desc, filter): (String, String, Option<String>, Option<String>)|
{
let client = client.clone(); let client = client.clone();
let refresh_collections = refresh_collections.clone(); let refresh_collections = refresh_collections.clone();
spawn(async move { spawn(async move {
match client.create_collection(&name, &kind, desc.as_deref(), filter.as_deref()).await { match client
.create_collection(&name, &kind, desc.as_deref(), filter.as_deref())
.await
{
Ok(_) => { Ok(_) => {
show_toast("Collection created".into(), false); show_toast("Collection created".into(), false);
refresh_collections(); refresh_collections();
@ -1091,7 +1119,10 @@ pub fn App() -> Element {
match client.remove_from_collection(&col_id, &media_id).await { match client.remove_from_collection(&col_id, &media_id).await {
Ok(_) => { Ok(_) => {
show_toast("Removed from collection".into(), false); show_toast("Removed from collection".into(), false);
if let Ok(members) = client.get_collection_members(&col_id2).await { if let Ok(members) = client
.get_collection_members(&col_id2)
.await
{
collection_members.set(members); collection_members.set(members);
} }
} }
@ -1100,7 +1131,6 @@ pub fn App() -> Element {
}); });
} }
}, },
// Phase 5.2: Navigate to detail when clicking a collection member
on_select: { on_select: {
let client = client.read().clone(); let client = client.read().clone();
move |id: String| { move |id: String| {
@ -1115,7 +1145,6 @@ pub fn App() -> Element {
}); });
} }
}, },
// Phase 5.2: Add member to collection
on_add_member: { on_add_member: {
let client = client.read().clone(); let client = client.read().clone();
move |(col_id, media_id): (String, String)| { move |(col_id, media_id): (String, String)| {
@ -1125,7 +1154,10 @@ pub fn App() -> Element {
match client.add_to_collection(&col_id, &media_id, 0).await { match client.add_to_collection(&col_id, &media_id, 0).await {
Ok(_) => { Ok(_) => {
show_toast("Added to collection".into(), false); show_toast("Added to collection".into(), false);
if let Ok(members) = client.get_collection_members(&col_id2).await { if let Ok(members) = client
.get_collection_members(&col_id2)
.await
{
collection_members.set(members); collection_members.set(members);
} }
} }
@ -1136,16 +1168,19 @@ pub fn App() -> Element {
}, },
} }
}, },
// Phase 6.1: Audit improvements
View::Audit => { View::Audit => {
let page_size = *audit_page_size.read(); let page_size = *audit_page_size.read();
let total = *audit_total_count.read(); let total = *audit_total_count.read();
let total_pages = if page_size > 0 { total.div_ceil(page_size) } else { 1 }; let total_pages = if page_size > 0 {
total.div_ceil(page_size)
} else {
1
};
rsx! { rsx! {
audit::AuditLog { audit::AuditLog {
entries: audit_list.read().clone(), entries: audit_list.read().clone(),
audit_page: *audit_page.read(), audit_page: *audit_page.read(),
total_pages: total_pages, total_pages,
audit_filter: audit_filter.read().clone(), audit_filter: audit_filter.read().clone(),
on_select: { on_select: {
let client = client.read().clone(); let client = client.read().clone();
@ -1178,8 +1213,7 @@ pub fn App() -> Element {
}, },
} }
} }
}, }
// Phase 6.2: Scan progress
View::Import => rsx! { View::Import => rsx! {
import::Import { import::Import {
tags: tags_list.read().clone(), tags: tags_list.read().clone(),
@ -1200,7 +1234,10 @@ pub fn App() -> Element {
match client.import_file(&path).await { match client.import_file(&path).await {
Ok(resp) => { Ok(resp) => {
if resp.was_duplicate { if resp.was_duplicate {
show_toast("Duplicate file (already imported)".into(), false); show_toast(
"Duplicate file (already imported)".into(),
false,
);
} else { } else {
show_toast(format!("Imported: {}", resp.media_id), false); show_toast(format!("Imported: {}", resp.media_id), false);
} }
@ -1209,12 +1246,26 @@ pub fn App() -> Element {
Err(e) => show_toast(format!("Import failed: {e}"), true), Err(e) => show_toast(format!("Import failed: {e}"), true),
} }
} else { } else {
match client.import_with_options(&path, &tag_ids, &new_tags, col_id.as_deref()).await { match client
.import_with_options(
&path,
&tag_ids,
&new_tags,
col_id.as_deref(),
)
.await
{
Ok(resp) => { Ok(resp) => {
if resp.was_duplicate { if resp.was_duplicate {
show_toast("Duplicate file (already imported)".into(), false); show_toast(
"Duplicate file (already imported)".into(),
false,
);
} else { } else {
show_toast(format!("Imported with tags/collection: {}", resp.media_id), false); show_toast(
format!("Imported with tags/collection: {}", resp.media_id),
false,
);
} }
refresh_media(); refresh_media();
if !new_tags.is_empty() { if !new_tags.is_empty() {
@ -1238,11 +1289,18 @@ pub fn App() -> Element {
let refresh_tags = refresh_tags.clone(); let refresh_tags = refresh_tags.clone();
import_in_progress.set(true); import_in_progress.set(true);
spawn(async move { spawn(async move {
match client.import_directory(&path, &tag_ids, &new_tags, col_id.as_deref()).await { match client
.import_directory(&path, &tag_ids, &new_tags, col_id.as_deref())
.await
{
Ok(resp) => { Ok(resp) => {
show_toast( show_toast(
format!("Done: {} imported, {} duplicates, {} errors", format!(
resp.imported, resp.duplicates, resp.errors), "Done: {} imported, {} duplicates, {} errors",
resp.imported,
resp.duplicates,
resp.errors,
),
resp.errors > 0, resp.errors > 0,
); );
refresh_media(); refresh_media();
@ -1258,7 +1316,6 @@ pub fn App() -> Element {
}); });
} }
}, },
// Phase 6.2: Scan with polling for progress
on_scan: { on_scan: {
let client = client.read().clone(); let client = client.read().clone();
let refresh_media = refresh_media.clone(); let refresh_media = refresh_media.clone();
@ -1269,7 +1326,6 @@ pub fn App() -> Element {
spawn(async move { spawn(async move {
match client.trigger_scan().await { match client.trigger_scan().await {
Ok(_results) => { Ok(_results) => {
// Poll scan status until done
loop { loop {
match client.scan_status().await { match client.scan_status().await {
Ok(status) => { Ok(status) => {
@ -1277,7 +1333,10 @@ pub fn App() -> Element {
scan_progress.set(Some(status.clone())); scan_progress.set(Some(status.clone()));
if done { if done {
let total = status.files_processed; let total = status.files_processed;
show_toast(format!("Scan complete: {total} files processed"), false); show_toast(
format!("Scan complete: {total} files processed"),
false,
);
break; break;
} }
} }
@ -1304,11 +1363,18 @@ pub fn App() -> Element {
let file_count = paths.len(); let file_count = paths.len();
import_in_progress.set(true); import_in_progress.set(true);
spawn(async move { spawn(async move {
match client.batch_import(&paths, &tag_ids, &new_tags, col_id.as_deref()).await { match client
.batch_import(&paths, &tag_ids, &new_tags, col_id.as_deref())
.await
{
Ok(resp) => { Ok(resp) => {
show_toast( show_toast(
format!("Done: {} imported, {} duplicates, {} errors", format!(
resp.imported, resp.duplicates, resp.errors), "Done: {} imported, {} duplicates, {} errors",
resp.imported,
resp.duplicates,
resp.errors,
),
resp.errors > 0, resp.errors > 0,
); );
refresh_media(); refresh_media();
@ -1318,7 +1384,12 @@ pub fn App() -> Element {
preview_files.set(Vec::new()); preview_files.set(Vec::new());
preview_total_size.set(0); preview_total_size.set(0);
} }
Err(e) => show_toast(format!("Batch import failed ({file_count} files): {e}"), true), Err(e) => {
show_toast(
format!("Batch import failed ({file_count} files): {e}"),
true,
)
}
} }
import_in_progress.set(false); import_in_progress.set(false);
}); });
@ -1355,7 +1426,9 @@ pub fn App() -> Element {
spawn(async move { spawn(async move {
match client.database_stats().await { match client.database_stats().await {
Ok(stats) => db_stats.set(Some(stats)), Ok(stats) => db_stats.set(Some(stats)),
Err(e) => show_toast(format!("Failed to load stats: {e}"), true), Err(e) => {
show_toast(format!("Failed to load stats: {e}"), true)
}
} }
}); });
} }
@ -1418,7 +1491,7 @@ pub fn App() -> Element {
}, },
} }
} }
}, }
View::Duplicates => { View::Duplicates => {
rsx! { rsx! {
duplicates::Duplicates { duplicates::Duplicates {
@ -1432,7 +1505,6 @@ pub fn App() -> Element {
match client.delete_media(&media_id).await { match client.delete_media(&media_id).await {
Ok(_) => { Ok(_) => {
show_toast("Deleted duplicate".into(), false); show_toast("Deleted duplicate".into(), false);
// Refresh duplicates list
if let Ok(groups) = client.list_duplicates().await { if let Ok(groups) = client.list_duplicates().await {
duplicate_groups.set(groups); duplicate_groups.set(groups);
} }
@ -1456,7 +1528,7 @@ pub fn App() -> Element {
}, },
} }
} }
}, }
View::Settings => { View::Settings => {
let cfg_ref = config_data.read(); let cfg_ref = config_data.read();
match cfg_ref.as_ref() { match cfg_ref.as_ref() {
@ -1548,7 +1620,6 @@ pub fn App() -> Element {
Ok(ui_cfg) => { Ok(ui_cfg) => {
auto_play_media.set(ui_cfg.auto_play_media); auto_play_media.set(ui_cfg.auto_play_media);
sidebar_collapsed.set(ui_cfg.sidebar_collapsed); sidebar_collapsed.set(ui_cfg.sidebar_collapsed);
// Reload full config
if let Ok(cfg) = client.get_config().await { if let Ok(cfg) = client.get_config().await {
config_data.set(Some(cfg)); config_data.set(Some(cfg));
} }
@ -1567,16 +1638,19 @@ pub fn App() -> Element {
} }
}, },
} }
}, }
}} }
}
} }
} }
// Phase 7.1: Help overlay // Phase 7.1: Help overlay
if *show_help.read() { if *show_help.read() {
div { class: "help-overlay", div {
class: "help-overlay",
onclick: move |_| show_help.set(false), onclick: move |_| show_help.set(false),
div { class: "help-dialog", div {
class: "help-dialog",
onclick: move |evt: MouseEvent| evt.stop_propagation(), onclick: move |evt: MouseEvent| evt.stop_propagation(),
h3 { "Keyboard Shortcuts" } h3 { "Keyboard Shortcuts" }
div { class: "help-shortcuts", div { class: "help-shortcuts",
@ -1614,12 +1688,8 @@ pub fn App() -> Element {
let toasts = toast_queue.read().clone(); let toasts = toast_queue.read().clone();
let visible: Vec<_> = toasts.iter().rev().take(3).rev().cloned().collect(); let visible: Vec<_> = toasts.iter().rev().take(3).rev().cloned().collect();
rsx! { rsx! {
for (msg, is_error, id) in visible { for (msg , is_error , id) in visible {
div { div { key: "{id}", class: if is_error { "toast error" } else { "toast success" }, "{msg}" }
key: "{id}",
class: if is_error { "toast error" } else { "toast success" },
"{msg}"
}
} }
} }
} }

View file

@ -105,11 +105,7 @@ pub fn AuditLog(
} }
} }
PaginationControls { PaginationControls { current_page: audit_page, total_pages, on_page_change }
current_page: audit_page,
total_pages: total_pages,
on_page_change: on_page_change,
}
} }
} }

View file

@ -13,7 +13,7 @@ pub fn Breadcrumb(
) -> Element { ) -> Element {
rsx! { rsx! {
nav { class: "breadcrumb", nav { class: "breadcrumb",
for (i, item) in items.iter().enumerate() { for (i , item) in items.iter().enumerate() {
if i > 0 { if i > 0 {
span { class: "breadcrumb-sep", " > " } span { class: "breadcrumb-sep", " > " }
} }

View file

@ -44,11 +44,7 @@ pub fn Collections(
let modal_col_id = col_id.clone(); let modal_col_id = col_id.clone();
return rsx! { return rsx! {
button { button { class: "btn btn-ghost mb-16", onclick: back_click, "\u{2190} Back to Collections" }
class: "btn btn-ghost mb-16",
onclick: back_click,
"\u{2190} Back to Collections"
}
h3 { class: "mb-16", "{col_name}" } h3 { class: "mb-16", "{col_name}" }
@ -88,10 +84,7 @@ pub fn Collections(
move |_| on_select.call(mid.clone()) move |_| on_select.call(mid.clone())
}; };
rsx! { rsx! {
tr { tr { key: "{item.id}", class: "clickable-row", onclick: row_click,
key: "{item.id}",
class: "clickable-row",
onclick: row_click,
td { "{item.file_name}" } td { "{item.file_name}" }
td { td {
span { class: "type-badge {badge_class}", "{item.media_type}" } span { class: "type-badge {badge_class}", "{item.media_type}" }
@ -118,9 +111,11 @@ pub fn Collections(
// Add Media modal // Add Media modal
if *show_add_modal.read() { if *show_add_modal.read() {
div { class: "modal-overlay", div {
class: "modal-overlay",
onclick: move |_| show_add_modal.set(false), onclick: move |_| show_add_modal.set(false),
div { class: "modal", div {
class: "modal",
onclick: move |e: Event<MouseData>| e.stop_propagation(), onclick: move |e: Event<MouseData>| e.stop_propagation(),
div { class: "modal-header", div { class: "modal-header",
h3 { "Add Media to Collection" } h3 { "Add Media to Collection" }
@ -156,10 +151,7 @@ pub fn Collections(
} }
}; };
rsx! { rsx! {
tr { tr { key: "{media.id}", class: "clickable-row", onclick: add_click,
key: "{media.id}",
class: "clickable-row",
onclick: add_click,
td { "{media.file_name}" } td { "{media.file_name}" }
td { td {
span { class: "type-badge {badge_class}", "{media.media_type}" } span { class: "type-badge {badge_class}", "{media.media_type}" }
@ -242,11 +234,7 @@ pub fn Collections(
} }
div { class: "form-row mb-16", div { class: "form-row mb-16",
button { button { class: "btn btn-primary", onclick: create_click, "Create" }
class: "btn btn-primary",
onclick: create_click,
"Create"
}
} }
if collections.is_empty() { if collections.is_empty() {
@ -268,7 +256,11 @@ pub fn Collections(
for col in collections.iter() { for col in collections.iter() {
{ {
let desc = col.description.clone().unwrap_or_default(); 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 view_click = {
let id = col.id.clone(); let id = col.id.clone();
move |_| on_view_members.call(id.clone()) move |_| on_view_members.call(id.clone())
@ -287,11 +279,7 @@ pub fn Collections(
} }
td { "{desc}" } td { "{desc}" }
td { td {
button { button { class: "btn btn-sm btn-secondary", onclick: view_click, "View" }
class: "btn btn-sm btn-secondary",
onclick: view_click,
"View"
}
} }
td { td {
if is_confirming { if is_confirming {

View file

@ -57,7 +57,7 @@ pub fn Database(
} }
} }
} }
}, }
None => rsx! { None => rsx! {
div { class: "empty-state", div { class: "empty-state",
p { class: "text-muted", "Loading database stats..." } p { class: "text-muted", "Loading database stats..." }
@ -162,7 +162,9 @@ pub fn Database(
} }
if *confirm_clear.read() { if *confirm_clear.read() {
div { class: "db-action-confirm", 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?" "This will delete everything. Are you sure?"
} }
button { button {

View file

@ -231,14 +231,14 @@ pub fn Detail(
media_type: "audio".to_string(), media_type: "audio".to_string(),
title: media.title.clone(), title: media.title.clone(),
thumbnail_url: if has_thumbnail { Some(thumb_for_player.clone()) } else { None }, thumbnail_url: if has_thumbnail { Some(thumb_for_player.clone()) } else { None },
autoplay: autoplay, autoplay,
} }
} else if category == "video" { } else if category == "video" {
MediaPlayer { MediaPlayer {
src: stream_url.clone(), src: stream_url.clone(),
media_type: "video".to_string(), media_type: "video".to_string(),
title: media.title.clone(), title: media.title.clone(),
autoplay: autoplay, autoplay,
} }
} else if category == "image" { } else if category == "image" {
if has_thumbnail { if has_thumbnail {
@ -298,40 +298,16 @@ pub fn Detail(
"Open" "Open"
} }
if is_editing { if is_editing {
button { button { class: "btn btn-primary", onclick: on_save_click, "Save" }
class: "btn btn-primary", button { class: "btn btn-ghost", onclick: on_cancel_click, "Cancel" }
onclick: on_save_click,
"Save"
}
button {
class: "btn btn-ghost",
onclick: on_cancel_click,
"Cancel"
}
} else { } else {
button { button { class: "btn btn-secondary", onclick: on_edit_click, "Edit" }
class: "btn btn-secondary",
onclick: on_edit_click,
"Edit"
}
} }
if confirm_delete() { if confirm_delete() {
button { button { class: "btn btn-danger", onclick: on_confirm_delete, "Confirm Delete" }
class: "btn btn-danger", button { class: "btn btn-ghost", onclick: on_cancel_delete, "Cancel" }
onclick: on_confirm_delete,
"Confirm Delete"
}
button {
class: "btn btn-ghost",
onclick: on_cancel_delete,
"Cancel"
}
} else { } else {
button { button { class: "btn btn-danger", onclick: on_delete_click, "Delete" }
class: "btn btn-danger",
onclick: on_delete_click,
"Delete"
}
} }
} }
@ -373,11 +349,13 @@ pub fn Detail(
} }
div { class: "detail-field", div { class: "detail-field",
label { class: "detail-label", label { class: "detail-label",
{match category { {
match category {
"image" => "Photographer", "image" => "Photographer",
"document" | "text" => "Author", "document" | "text" => "Author",
_ => "Artist", _ => "Artist",
}} }
}
} }
input { input {
r#type: "text", r#type: "text",
@ -458,11 +436,13 @@ pub fn Detail(
if !artist.is_empty() { if !artist.is_empty() {
div { class: "detail-field", div { class: "detail-field",
span { class: "detail-label", span { class: "detail-label",
{match category { {
match category {
"image" => "Photographer", "image" => "Photographer",
"document" | "text" => "Author", "document" | "text" => "Author",
_ => "Artist", _ => "Artist",
}} }
}
} }
span { class: "detail-value", "{artist}" } span { class: "detail-value", "{artist}" }
} }
@ -482,7 +462,9 @@ pub fn Detail(
} }
} }
// Year: audio, video, document, when non-empty // 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", div { class: "detail-field",
span { class: "detail-label", "Year" } span { class: "detail-label", "Year" }
span { class: "detail-value", "{year_str}" } span { class: "detail-value", "{year_str}" }
@ -524,9 +506,7 @@ pub fn Detail(
let tag_id = tag.id.clone(); let tag_id = tag.id.clone();
let media_id_untag = id.clone(); let media_id_untag = id.clone();
rsx! { rsx! {
span { span { class: "tag-badge", key: "{tag_id}",
class: "tag-badge",
key: "{tag_id}",
"{tag.name}" "{tag.name}"
span { span {
class: "tag-remove", class: "tag-remove",
@ -552,11 +532,7 @@ pub fn Detail(
let tid = tag.id.clone(); let tid = tag.id.clone();
let tname = tag.name.clone(); let tname = tag.name.clone();
rsx! { rsx! {
option { option { key: "{tid}", value: "{tid}", "{tname}" }
key: "{tid}",
value: "{tid}",
"{tname}"
}
} }
} }
} }
@ -576,10 +552,8 @@ pub fn Detail(
h4 { class: "card-title", "Technical Metadata" } h4 { class: "card-title", "Technical Metadata" }
} }
div { class: "detail-grid", div { class: "detail-grid",
for (key, _field_type, value) in system_fields.iter() { for (key , _field_type , value) in system_fields.iter() {
div { div { class: "detail-field", key: "{key}",
class: "detail-field",
key: "{key}",
span { class: "detail-label", "{key}" } span { class: "detail-label", "{key}" }
span { class: "detail-value", "{value}" } span { class: "detail-value", "{value}" }
} }
@ -595,14 +569,12 @@ pub fn Detail(
} }
if has_user_fields { if has_user_fields {
div { class: "detail-grid", 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 field_name = key.clone();
let media_id_del = id.clone(); let media_id_del = id.clone();
rsx! { rsx! {
div { div { class: "detail-field", key: "{field_name}",
class: "detail-field",
key: "{field_name}",
span { class: "detail-label", "{key} ({field_type})" } span { class: "detail-label", "{key} ({field_type})" }
div { class: "flex-row", div { class: "flex-row",
span { class: "detail-value", "{value}" } 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 is_expanded = expanded_group.read().as_ref() == Some(&hash);
let hash_for_toggle = hash.clone(); let hash_for_toggle = hash.clone();
let item_count = group.items.len(); 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()) .map(|i| i.file_name.clone())
.unwrap_or_default(); .unwrap_or_default();
let total_size: u64 = group.items.iter().map(|i| i.file_size).sum(); let total_size: u64 = group.items.iter().map(|i| i.file_size).sum();
@ -53,13 +66,9 @@ pub fn Duplicates(
} else { } else {
hash.clone() hash.clone()
}; };
rsx! { rsx! {
div { div { class: "duplicate-group", key: "{hash}",
class: "duplicate-group",
key: "{hash}",
// Group header
button { button {
class: "duplicate-group-header", class: "duplicate-group-header",
onclick: move |_| { onclick: move |_| {
@ -71,20 +80,21 @@ pub fn Duplicates(
} }
}, },
span { class: "expand-icon", 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-name", "{first_name}" }
span { class: "group-badge", "{item_count} files" } span { class: "group-badge", "{item_count} files" }
span { class: "group-size text-muted", "{format_size(total_size)}" } span { class: "group-size text-muted", "{format_size(total_size)}" }
span { class: "group-hash mono text-muted", span { class: "group-hash mono text-muted", "{short_hash}" }
"{short_hash}"
}
} }
// Expanded: show items
if is_expanded { if is_expanded {
div { class: "duplicate-items", 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 item_id = item.id.clone();
let is_first = idx == 0; let is_first = idx == 0;
@ -97,7 +107,10 @@ pub fn Duplicates(
class: if is_first { "duplicate-item duplicate-item-keep" } else { "duplicate-item" }, class: if is_first { "duplicate-item duplicate-item-keep" } else { "duplicate-item" },
key: "{item_id}", key: "{item_id}",
// Thumbnail
div { class: "dup-thumb", div { class: "dup-thumb",
if has_thumb { if has_thumb {
img { img {
@ -110,7 +123,6 @@ pub fn Duplicates(
} }
} }
// Info
div { class: "dup-info", div { class: "dup-info",
div { class: "dup-filename", "{item.file_name}" } div { class: "dup-filename", "{item.file_name}" }
div { class: "dup-path mono text-muted", "{item.path}" } div { class: "dup-path mono text-muted", "{item.path}" }
@ -121,7 +133,6 @@ pub fn Duplicates(
} }
} }
// Actions
div { class: "dup-actions", div { class: "dup-actions",
if is_first { if is_first {
span { class: "keep-badge", "Keep" } span { class: "keep-badge", "Keep" }

View file

@ -194,10 +194,18 @@ pub fn ImageViewer(
} }
} }
div { class: "image-viewer-toolbar-center", 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()}" "{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}%" } span { class: "iv-zoom-label", "{zoom_pct}%" }
button { class: "iv-btn", onclick: zoom_in, title: "Zoom in", "+" } 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 { ImportOptions {
tags: tags.clone(), tags: tags.clone(),
collections: collections.clone(), collections: collections.clone(),
selected_tags: selected_tags, selected_tags,
new_tags_input: new_tags_input, new_tags_input,
selected_collection: selected_collection, selected_collection,
} }
} }
@ -244,7 +248,18 @@ pub fn Import(
let min = *filter_min_size.read(); let min = *filter_min_size.read();
let max = *filter_max_size.read(); let max = *filter_max_size.read();
let filtered: Vec<&DirectoryPreviewFile> = preview_files.iter().filter(|f| { 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) { let type_idx = match type_badge_class(&f.media_type) {
"type-audio" => 0, "type-audio" => 0,
"type-video" => 1, "type-video" => 1,
@ -253,23 +268,28 @@ pub fn Import(
"type-text" => 4, "type-text" => 4,
_ => 5, _ => 5,
}; };
if !types_snapshot[type_idx] { return false; } if !types_snapshot[type_idx] {
if min > 0 && f.file_size < min { return false; } return false;
if max > 0 && f.file_size > max { return false; } }
if min > 0 && f.file_size < min {
return false;
}
if max > 0 && f.file_size > max {
return false;
}
true true
}).collect(); })
.collect();
let filtered_count = filtered.len(); let filtered_count = filtered.len();
let total_count = preview_files.len(); let total_count = preview_files.len();
// Read selection once for display
let selection = selected_file_paths.read().clone(); let selection = selected_file_paths.read().clone();
let selected_count = selection.len(); let selected_count = selection.len();
let all_filtered_selected = !filtered.is_empty() let all_filtered_selected = !filtered.is_empty()
&& filtered.iter().all(|f| selection.contains(&f.path)); && filtered.iter().all(|f| selection.contains(&f.path));
let filtered_paths: Vec<String> = filtered
let filtered_paths: Vec<String> = filtered.iter().map(|f| f.path.clone()).collect(); .iter()
.map(|f| f.path.clone())
.collect();
rsx! { rsx! {
div { class: "card mb-16", div { class: "card mb-16",
div { class: "card-header", div { class: "card-header",
@ -279,7 +299,6 @@ pub fn Import(
} }
} }
// Filter bar
div { class: "filter-bar", div { class: "filter-bar",
div { class: "flex-row mb-8", div { class: "flex-row mb-8",
label { label {
@ -383,8 +402,9 @@ pub fn Import(
} }
} }
// Selection toolbar div {
div { class: "flex-row mb-8", style: "gap: 8px; align-items: center; padding: 0 8px;", class: "flex-row mb-8",
style: "gap: 8px; align-items: center; padding: 0 8px;",
button { button {
class: "btn btn-sm btn-secondary", class: "btn btn-sm btn-secondary",
onclick: { onclick: {
@ -407,9 +427,7 @@ pub fn Import(
"Deselect All" "Deselect All"
} }
if selected_count > 0 { if selected_count > 0 {
span { class: "text-muted text-sm", span { class: "text-muted text-sm", "{selected_count} files selected" }
"{selected_count} files selected"
}
} }
} }
@ -425,13 +443,17 @@ pub fn Import(
let filtered_paths = filtered_paths.clone(); let filtered_paths = filtered_paths.clone();
move |_| { move |_| {
if all_filtered_selected { if all_filtered_selected {
// Deselect all filtered let filtered_set: HashSet<String> = filtered_paths
let filtered_set: HashSet<String> = filtered_paths.iter().cloned().collect(); .iter()
.cloned()
.collect();
let sel = selected_file_paths.read().clone(); 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); selected_file_paths.set(new_sel);
} else { } else {
// Select all filtered
let mut sel = selected_file_paths.read().clone(); let mut sel = selected_file_paths.read().clone();
for p in &filtered_paths { for p in &filtered_paths {
sel.insert(p.clone()); sel.insert(p.clone());
@ -455,9 +477,7 @@ pub fn Import(
let is_selected = selection.contains(&file.path); let is_selected = selection.contains(&file.path);
let file_path_clone = file.path.clone(); let file_path_clone = file.path.clone();
rsx! { rsx! {
tr { tr { key: "{file.path}", class: if is_selected { "row-selected" } else { "" },
key: "{file.path}",
class: if is_selected { "row-selected" } else { "" },
td { td {
input { input {
r#type: "checkbox", r#type: "checkbox",
@ -496,9 +516,9 @@ pub fn Import(
ImportOptions { ImportOptions {
tags: tags.clone(), tags: tags.clone(),
collections: collections.clone(), collections: collections.clone(),
selected_tags: selected_tags, selected_tags,
new_tags_input: new_tags_input, new_tags_input,
selected_collection: selected_collection, selected_collection,
} }
div { class: "flex-row mb-16", style: "gap: 8px;", 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 new_tags_input = new_tags_input;
let mut selected_collection = selected_collection; let mut selected_collection = selected_collection;
move |_| { 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() { if !paths.is_empty() {
let tag_ids = selected_tags.read().clone(); let tag_ids = selected_tags.read().clone();
let new_tags = parse_new_tags(&new_tags_input.read()); 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,28 +617,29 @@ pub fn Import(
class: "btn btn-primary", class: "btn btn-primary",
disabled: is_importing, disabled: is_importing,
onclick: move |_| on_scan.call(()), 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 { 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! { rsx! {
div { class: "mb-16", div { class: "mb-16",
div { class: "progress-bar", div { class: "progress-bar",
div { div { class: "progress-fill", style: "width: {pct}%;" }
class: "progress-fill",
style: "width: {pct}%;",
}
} }
p { class: "text-muted text-sm", p { class: "text-muted text-sm",
"{progress.files_processed} / {progress.files_found} files processed" "{progress.files_processed} / {progress.files_found} files processed"
} }
if progress.error_count > 0 { if progress.error_count > 0 {
p { class: "text-muted text-sm", p { class: "text-muted text-sm", "{progress.error_count} errors" }
"{progress.error_count} errors"
}
} }
if progress.scanning { if progress.scanning {
p { class: "text-muted text-sm", "Scanning..." } p { class: "text-muted text-sm", "Scanning..." }
@ -647,7 +676,9 @@ fn ImportOptions(
div { class: "form-group", div { class: "form-group",
label { class: "form-label", "Tags" } label { class: "form-label", "Tags" }
if tags.is_empty() { 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 { } else {
div { class: "tag-list", div { class: "tag-list",
for tag in tags.iter() { for tag in tags.iter() {
@ -655,11 +686,7 @@ fn ImportOptions(
let tag_id = tag.id.clone(); let tag_id = tag.id.clone();
let tag_name = tag.name.clone(); let tag_name = tag.name.clone();
let is_selected = selected_tags.read().contains(&tag_id); let is_selected = selected_tags.read().contains(&tag_id);
let badge_class = if is_selected { let badge_class = if is_selected { "tag-badge selected" } else { "tag-badge" };
"tag-badge selected"
} else {
"tag-badge"
};
rsx! { rsx! {
span { span {
class: "{badge_class}", class: "{badge_class}",
@ -693,7 +720,9 @@ fn ImportOptions(
value: "{new_tags_input}", value: "{new_tags_input}",
oninput: move |e| new_tags_input.set(e.value()), 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", div { class: "form-group",

View file

@ -107,7 +107,9 @@ pub fn Library(
return rsx! { return rsx! {
div { class: "empty-state", div { class: "empty-state",
h3 { class: "empty-title", "No media found" } 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! { rsx! {
// Confirmation dialog for single delete // Confirmation dialog for single delete
if confirm_delete.read().is_some() { if confirm_delete.read().is_some() {
div { class: "modal-overlay", div {
class: "modal-overlay",
onclick: move |_| confirm_delete.set(None), onclick: move |_| confirm_delete.set(None),
div { class: "modal", div {
class: "modal",
onclick: move |e: Event<MouseData>| e.stop_propagation(), onclick: move |e: Event<MouseData>| e.stop_propagation(),
h3 { class: "modal-title", "Confirm Delete" } 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", div { class: "modal-actions",
button { button {
class: "btn btn-ghost", class: "btn btn-ghost",
@ -182,9 +188,11 @@ pub fn Library(
// Confirmation dialog for batch delete // Confirmation dialog for batch delete
if *confirm_batch_delete.read() { if *confirm_batch_delete.read() {
div { class: "modal-overlay", div {
class: "modal-overlay",
onclick: move |_| confirm_batch_delete.set(false), onclick: move |_| confirm_batch_delete.set(false),
div { class: "modal", div {
class: "modal",
onclick: move |e: Event<MouseData>| e.stop_propagation(), onclick: move |e: Event<MouseData>| e.stop_propagation(),
h3 { class: "modal-title", "Confirm Batch Delete" } h3 { class: "modal-title", "Confirm Batch Delete" }
p { class: "modal-body", p { class: "modal-body",
@ -214,9 +222,11 @@ pub fn Library(
// Confirmation dialog for delete all // Confirmation dialog for delete all
if *confirm_delete_all.read() { if *confirm_delete_all.read() {
div { class: "modal-overlay", div {
class: "modal-overlay",
onclick: move |_| confirm_delete_all.set(false), onclick: move |_| confirm_delete_all.set(false),
div { class: "modal", div {
class: "modal",
onclick: move |e: Event<MouseData>| e.stop_propagation(), onclick: move |e: Event<MouseData>| e.stop_propagation(),
h3 { class: "modal-title", "Delete All Media" } h3 { class: "modal-title", "Delete All Media" }
p { class: "modal-body", p { class: "modal-body",
@ -248,12 +258,14 @@ pub fn Library(
// Batch tag dialog // Batch tag dialog
if *show_batch_tag.read() { if *show_batch_tag.read() {
div { class: "modal-overlay", div {
class: "modal-overlay",
onclick: move |_| { onclick: move |_| {
show_batch_tag.set(false); show_batch_tag.set(false);
batch_tag_selection.set(Vec::new()); batch_tag_selection.set(Vec::new());
}, },
div { class: "modal", div {
class: "modal",
onclick: move |e: Event<MouseData>| e.stop_propagation(), onclick: move |e: Event<MouseData>| e.stop_propagation(),
h3 { class: "modal-title", "Tag Selected Items" } h3 { class: "modal-title", "Tag Selected Items" }
p { class: "modal-body text-muted text-sm", p { class: "modal-body text-muted text-sm",
@ -322,12 +334,14 @@ pub fn Library(
// Batch collection dialog // Batch collection dialog
if *show_batch_collection.read() { if *show_batch_collection.read() {
div { class: "modal-overlay", div {
class: "modal-overlay",
onclick: move |_| { onclick: move |_| {
show_batch_collection.set(false); show_batch_collection.set(false);
batch_collection_id.set(String::new()); batch_collection_id.set(String::new());
}, },
div { class: "modal", div {
class: "modal",
onclick: move |e: Event<MouseData>| e.stop_propagation(), onclick: move |e: Event<MouseData>| e.stop_propagation(),
h3 { class: "modal-title", "Add to Collection" } h3 { class: "modal-title", "Add to Collection" }
p { class: "modal-body text-muted text-sm", 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()), onchange: move |e: Event<FormData>| batch_collection_id.set(e.value()),
option { value: "", "Select a collection..." } option { value: "", "Select a collection..." }
for col in collections.iter() { for col in collections.iter() {
option { option { key: "{col.id}", value: "{col.id}", "{col.name}" }
key: "{col.id}",
value: "{col.id}",
"{col.name}"
}
} }
} }
} }
@ -497,9 +507,7 @@ pub fn Library(
"Delete All" "Delete All"
} }
} }
span { class: "text-muted text-sm", span { class: "text-muted text-sm", "{total_count} items" }
"{total_count} items"
}
} }
} }
@ -537,9 +545,7 @@ pub fn Library(
"Showing {filtered_count} items" "Showing {filtered_count} items"
} }
} }
span { class: "text-muted text-sm", span { class: "text-muted text-sm", "Page {current_page + 1} of {total_pages}" }
"Page {current_page + 1} of {total_pages}"
}
} }
// Select-all banner: when all items on this page are selected and there // Select-all banner: when all items on this page are selected and there
@ -551,10 +557,13 @@ pub fn Library(
button { button {
onclick: move |_| { onclick: move |_| {
if let Some(handler) = on_select_all_global { if let Some(handler) = on_select_all_global {
handler.call(EventHandler::new(move |all_ids: Vec<String>| { handler
.call(
EventHandler::new(move |all_ids: Vec<String>| {
selected_ids.set(all_ids); selected_ids.set(all_ids);
global_all_selected.set(true); global_all_selected.set(true);
})); }),
);
} }
}, },
"Select all {total_count} items" "Select all {total_count} items"
@ -580,29 +589,45 @@ pub fn Library(
match current_mode { match current_mode {
ViewMode::Grid => rsx! { ViewMode::Grid => rsx! {
div { class: "media-grid", 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 id = item.id.clone();
let badge_class = type_badge_class(&item.media_type); let badge_class = type_badge_class(&item.media_type);
let is_checked = current_selection.contains(&id); 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 card_click = {
let id = item.id.clone(); let id = item.id.clone();
move |_| on_select.call(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
let visible_ids: Vec<String> = filtered_media.iter().map(|m| m.id.clone()).collect();
.iter()
.map(|m| m.id.clone())
.collect();
let toggle_id = { let toggle_id = {
let id = id.clone(); let id = id.clone();
move |e: Event<MouseData>| { move |e: Event<MouseData>| {
e.stop_propagation(); e.stop_propagation();
let shift = e.modifiers().shift(); let shift = e.modifiers().shift();
let mut ids = selected_ids.read().clone(); let mut ids = selected_ids.read().clone();
if shift { if shift {
// Shift+click: select range from last_click_index to current idx.
if let Some(last) = *last_click_index.read() { if let Some(last) = *last_click_index.read() {
let start = last.min(idx); let start = last.min(idx);
let end = last.max(idx); let end = last.max(idx);
@ -614,7 +639,6 @@ pub fn Library(
} }
} }
} else { } else {
// No previous click, just toggle this one.
if !ids.contains(&id) { if !ids.contains(&id) {
ids.push(id.clone()); ids.push(id.clone());
} }
@ -624,12 +648,10 @@ pub fn Library(
} else { } else {
ids.push(id.clone()); ids.push(id.clone());
} }
last_click_index.set(Some(idx)); last_click_index.set(Some(idx));
selected_ids.set(ids); selected_ids.set(ids);
} }
}; };
let thumb_url = if item.has_thumbnail { let thumb_url = if item.has_thumbnail {
format!("{}/api/v1/media/{}/thumbnail", server_url, item.id) format!("{}/api/v1/media/{}/thumbnail", server_url, item.id)
} else { } else {
@ -640,29 +662,15 @@ pub fn Library(
let card_class = if is_checked { "media-card selected" } else { "media-card" }; let card_class = if is_checked { "media-card selected" } else { "media-card" };
let title_text = item.title.clone().unwrap_or_default(); let title_text = item.title.clone().unwrap_or_default();
let artist_text = item.artist.clone().unwrap_or_default(); let artist_text = item.artist.clone().unwrap_or_default();
rsx! { rsx! {
div { div { key: "{item.id}", class: "{card_class}", onclick: card_click,
key: "{item.id}",
class: "{card_class}",
onclick: card_click,
div { class: "card-checkbox", div { class: "card-checkbox",
input { input { r#type: "checkbox", checked: is_checked, onclick: toggle_id }
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-thumbnail",
div { class: "card-type-icon {badge_class}", div { class: "card-type-icon {badge_class}", "{type_icon(&media_type)}" }
"{type_icon(&media_type)}"
}
if has_thumb { if has_thumb {
img { img {
class: "card-thumb-img", class: "card-thumb-img",
@ -674,18 +682,12 @@ pub fn Library(
} }
div { class: "card-info", div { class: "card-info",
div { class: "card-name", title: "{item.file_name}", div { class: "card-name", title: "{item.file_name}", "{item.file_name}" }
"{item.file_name}"
}
if !title_text.is_empty() { if !title_text.is_empty() {
div { class: "card-title text-muted text-xs", div { class: "card-title text-muted text-xs", "{title_text}" }
"{title_text}"
}
} }
if !artist_text.is_empty() { if !artist_text.is_empty() {
div { class: "card-artist text-muted text-xs", div { class: "card-artist text-muted text-xs", "{artist_text}" }
"{artist_text}"
}
} }
div { class: "card-meta", div { class: "card-meta",
span { class: "type-badge {badge_class}", "{item.media_type}" } span { class: "type-badge {badge_class}", "{item.media_type}" }
@ -751,7 +753,7 @@ pub fn Library(
} }
} }
tbody { tbody {
for (idx, item) in filtered_media.iter().enumerate() { for (idx , item) in filtered_media.iter().enumerate() {
{ {
let id = item.id.clone(); let id = item.id.clone();
let artist = item.artist.clone().unwrap_or_default(); 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 badge_class = type_badge_class(&item.media_type);
let is_checked = current_selection.contains(&id); 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 toggle_id = {
let id = id.clone(); let id = id.clone();
move |e: Event<MouseData>| { move |e: Event<MouseData>| {
e.stop_propagation(); e.stop_propagation();
let shift = e.modifiers().shift(); let shift = e.modifiers().shift();
let mut ids = selected_ids.read().clone(); let mut ids = selected_ids.read().clone();
if shift { if shift {
if let Some(last) = *last_click_index.read() { if let Some(last) = *last_click_index.read() {
let start = last.min(idx); let start = last.min(idx);
@ -789,17 +793,14 @@ pub fn Library(
} else { } else {
ids.push(id.clone()); ids.push(id.clone());
} }
last_click_index.set(Some(idx)); last_click_index.set(Some(idx));
selected_ids.set(ids); selected_ids.set(ids);
} }
}; };
let row_click = { let row_click = {
let id = item.id.clone(); let id = item.id.clone();
move |_| on_select.call(id.clone()) move |_| on_select.call(id.clone())
}; };
let delete_click = { let delete_click = {
let id = item.id.clone(); let id = item.id.clone();
move |e: Event<MouseData>| { move |e: Event<MouseData>| {
@ -807,7 +808,6 @@ pub fn Library(
confirm_delete.set(Some(id.clone())); confirm_delete.set(Some(id.clone()));
} }
}; };
let thumb_url = if item.has_thumbnail { let thumb_url = if item.has_thumbnail {
format!("{}/api/v1/media/{}/thumbnail", server_url, item.id) format!("{}/api/v1/media/{}/thumbnail", server_url, item.id)
} else { } else {
@ -815,24 +815,13 @@ pub fn Library(
}; };
let has_thumb = item.has_thumbnail; let has_thumb = item.has_thumbnail;
let media_type_str = item.media_type.clone(); let media_type_str = item.media_type.clone();
rsx! { rsx! {
tr { tr { key: "{item.id}", onclick: row_click,
key: "{item.id}",
onclick: row_click,
td { td {
input { input { r#type: "checkbox", checked: is_checked, onclick: toggle_id }
r#type: "checkbox",
checked: is_checked,
onclick: toggle_id,
}
} }
td { class: "table-thumb-cell", td { class: "table-thumb-cell",
// Thumbnail with CSS fallback: icon always span { class: "table-type-icon {badge_class}", "{type_icon(&media_type_str)}" }
// rendered, img overlays when available.
span { class: "table-type-icon {badge_class}",
"{type_icon(&media_type_str)}"
}
if has_thumb { if has_thumb {
img { img {
class: "table-thumb table-thumb-overlay", class: "table-thumb table-thumb-overlay",
@ -849,11 +838,7 @@ pub fn Library(
td { "{artist}" } td { "{artist}" }
td { "{size}" } td { "{size}" }
td { td {
button { button { class: "btn btn-danger btn-sm", onclick: delete_click, "Delete" }
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", class: "btn btn-primary login-btn",
disabled: loading, disabled: loading,
onclick: on_submit, 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." } div { class: "queue-empty", "Queue is empty. Add items from the library." }
} else { } else {
div { class: "queue-list", 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 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 title = item.title.clone();
let artist = item.artist.clone().unwrap_or_default(); let artist = item.artist.clone().unwrap_or_default();
rsx! { rsx! {

View file

@ -75,9 +75,7 @@ pub fn Search(
oninput: move |e| query.set(e.value()), oninput: move |e| query.set(e.value()),
onkeypress: on_key, onkeypress: on_key,
} }
select { select { value: "{sort_by}", onchange: move |e| sort_by.set(e.value()),
value: "{sort_by}",
onchange: move |e| sort_by.set(e.value()),
option { value: "relevance", "Relevance" } option { value: "relevance", "Relevance" }
option { value: "date_desc", "Newest" } option { value: "date_desc", "Newest" }
option { value: "date_asc", "Oldest" } option { value: "date_asc", "Oldest" }
@ -86,16 +84,8 @@ pub fn Search(
option { value: "size_desc", "Size (largest)" } option { value: "size_desc", "Size (largest)" }
option { value: "size_asc", "Size (smallest)" } option { value: "size_asc", "Size (smallest)" }
} }
button { button { class: "btn btn-primary", onclick: do_search, "Search" }
class: "btn btn-primary", button { class: "btn btn-ghost", onclick: toggle_help, "Syntax Help" }
onclick: do_search,
"Search"
}
button {
class: "btn btn-ghost",
onclick: toggle_help,
"Syntax Help"
}
// View mode toggle // View mode toggle
div { class: "view-toggle", div { class: "view-toggle",
@ -118,15 +108,42 @@ pub fn Search(
div { class: "card mb-16", div { class: "card mb-16",
h4 { "Search Syntax" } h4 { "Search Syntax" }
ul { ul {
li { code { "hello world" } " -- full text search (implicit AND)" } li {
li { code { "artist:Beatles" } " -- field match" } code { "hello world" }
li { code { "type:pdf" } " -- filter by media type" } " -- full text search (implicit AND)"
li { code { "tag:music" } " -- filter by tag" } }
li { code { "hello OR world" } " -- OR operator" } li {
li { code { "-excluded" } " -- NOT (exclude term)" } code { "artist:Beatles" }
li { code { "hel*" } " -- prefix search" } " -- field match"
li { code { "hello~" } " -- fuzzy search" } }
li { code { "\"exact phrase\"" } " -- quoted exact 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() { if results.is_empty() && query.read().is_empty() {
div { class: "empty-state", div { class: "empty-state",
h3 { class: "empty-title", "Search your media" } 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()) move |_| on_select.call(id.clone())
}; };
let thumb_url = if item.has_thumbnail { let thumb_url = if item.has_thumbnail {
format!("{}/api/v1/media/{}/thumbnail", server_url, item.id) format!("{}/api/v1/media/{}/thumbnail", server_url, item.id)
} else { } else {
@ -168,10 +189,8 @@ pub fn Search(
let media_type = item.media_type.clone(); let media_type = item.media_type.clone();
rsx! { rsx! {
div {
key: "{item.id}", div { key: "{item.id}", class: "media-card", onclick: card_click,
class: "media-card",
onclick: card_click,
div { class: "card-thumbnail", div { class: "card-thumbnail",
if has_thumb { if has_thumb {
@ -181,16 +200,12 @@ pub fn Search(
loading: "lazy", loading: "lazy",
} }
} else { } else {
div { class: "card-type-icon {badge_class}", div { class: "card-type-icon {badge_class}", "{type_icon(&media_type)}" }
"{type_icon(&media_type)}"
}
} }
} }
div { class: "card-info", div { class: "card-info",
div { class: "card-name", title: "{item.file_name}", div { class: "card-name", title: "{item.file_name}", "{item.file_name}" }
"{item.file_name}"
}
div { class: "card-meta", div { class: "card-meta",
span { class: "type-badge {badge_class}", "{item.media_type}" } span { class: "type-badge {badge_class}", "{item.media_type}" }
span { class: "card-size", "{format_size(item.file_size)}" } span { class: "card-size", "{format_size(item.file_size)}" }
@ -223,9 +238,7 @@ pub fn Search(
move |_| on_select.call(id.clone()) move |_| on_select.call(id.clone())
}; };
rsx! { rsx! {
tr { tr { key: "{item.id}", onclick: row_click,
key: "{item.id}",
onclick: row_click,
td { "{item.file_name}" } td { "{item.file_name}" }
td { td {
span { class: "type-badge {badge_class}", "{item.media_type}" } span { class: "type-badge {badge_class}", "{item.media_type}" }
@ -242,10 +255,6 @@ pub fn Search(
} }
// Pagination controls // Pagination controls
PaginationControls { PaginationControls { current_page: search_page, total_pages, on_page_change }
current_page: search_page,
total_pages: total_pages,
on_page_change: on_page_change,
}
} }
} }

View file

@ -65,7 +65,9 @@ pub fn Settings(
label { class: "form-label", "Backend" } label { class: "form-label", "Backend" }
span { class: "tooltip-trigger", 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}" } span { class: "info-value badge badge-neutral", "{config.backend}" }
@ -75,7 +77,9 @@ pub fn Settings(
label { class: "form-label", "Server Address" } label { class: "form-label", "Server Address" }
span { class: "tooltip-trigger", 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}" } span { class: "info-value mono", "{host_port}" }
@ -85,7 +89,9 @@ pub fn Settings(
label { class: "form-label", "Database Path" } label { class: "form-label", "Database Path" }
span { class: "tooltip-trigger", 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}" } span { class: "info-value mono", "{db_path}" }
@ -102,7 +108,9 @@ pub fn Settings(
} }
span { class: "tooltip-trigger", 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", div { class: "settings-card-body",
@ -194,7 +202,9 @@ pub fn Settings(
label { class: "form-label", "File Watching" } label { class: "form-label", "File Watching" }
span { class: "tooltip-trigger", 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 { div {
@ -204,8 +214,7 @@ pub fn Settings(
on_toggle_watch.call(!watch_enabled); on_toggle_watch.call(!watch_enabled);
} }
}, },
div { div { class: if watch_enabled { "toggle-track active" } else { "toggle-track" },
class: if watch_enabled { "toggle-track active" } else { "toggle-track" },
div { class: "toggle-thumb" } div { class: "toggle-thumb" }
} }
} }
@ -217,7 +226,9 @@ pub fn Settings(
label { class: "form-label", "Poll Interval" } label { class: "form-label", "Poll Interval" }
span { class: "tooltip-trigger", 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() { if *editing_poll.read() {
@ -242,7 +253,8 @@ pub fn Settings(
poll_error.set(None); 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" } label { class: "form-label", "Ignore Patterns" }
span { class: "tooltip-trigger", 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() { if *editing_patterns.read() {
@ -359,7 +373,9 @@ pub fn Settings(
class: "patterns-textarea", class: "patterns-textarea",
placeholder: "One pattern per line, e.g.:\n*.tmp\n.git/**\nnode_modules/**", 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 { } else {
if config.scanning.ignore_patterns.is_empty() { if config.scanning.ignore_patterns.is_empty() {
@ -397,7 +413,7 @@ pub fn Settings(
let handler = on_update_ui_config; let handler = on_update_ui_config;
move |e: Event<FormData>| { move |e: Event<FormData>| {
if let Some(ref h) = handler { 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" } label { class: "form-label", "Default View" }
span { class: "tooltip-trigger", 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 { select {
@ -421,7 +439,7 @@ pub fn Settings(
let handler = on_update_ui_config; let handler = on_update_ui_config;
move |e: Event<FormData>| { move |e: Event<FormData>| {
if let Some(ref h) = handler { 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" } label { class: "form-label", "Default Page Size" }
span { class: "tooltip-trigger", 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 { select {
@ -444,9 +464,8 @@ pub fn Settings(
onchange: { onchange: {
let handler = on_update_ui_config; let handler = on_update_ui_config;
move |e: Event<FormData>| { move |e: Event<FormData>| {
if let Some(ref h) = handler if let Some(ref h) = handler && let Ok(size) = e.value().parse::<usize>() {
&& let Ok(size) = e.value().parse::<usize>() { h.call(serde_json::json!({ "default_page_size" : size }));
h.call(serde_json::json!({"default_page_size": size}));
} }
} }
}, },
@ -463,7 +482,9 @@ pub fn Settings(
label { class: "form-label", "Default View Mode" } label { class: "form-label", "Default View Mode" }
span { class: "tooltip-trigger", 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 { select {
@ -472,7 +493,7 @@ pub fn Settings(
let handler = on_update_ui_config; let handler = on_update_ui_config;
move |e: Event<FormData>| { move |e: Event<FormData>| {
if let Some(ref h) = handler { 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" } label { class: "form-label", "Auto-play Media" }
span { class: "tooltip-trigger", 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", class: "toggle",
onclick: move |_| { onclick: move |_| {
if let Some(ref h) = handler { 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 { div { class: if autoplay { "toggle-track active" } else { "toggle-track" },
class: if autoplay { "toggle-track active" } else { "toggle-track" },
div { class: "toggle-thumb" } div { class: "toggle-thumb" }
} }
} }
@ -516,7 +538,9 @@ pub fn Settings(
label { class: "form-label", "Show Thumbnails" } label { class: "form-label", "Show Thumbnails" }
span { class: "tooltip-trigger", 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", class: "toggle",
onclick: move |_| { onclick: move |_| {
if let Some(ref h) = handler { 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 { div { class: if show_thumbs { "toggle-track active" } else { "toggle-track" },
class: if show_thumbs { "toggle-track active" } else { "toggle-track" },
div { class: "toggle-thumb" } div { class: "toggle-thumb" }
} }
} }

View file

@ -65,6 +65,15 @@ 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() { if !s.media_by_type.is_empty() {
div { class: "card mt-16", div { class: "card mt-16",
h4 { class: "card-title", "Media by Type" } h4 { class: "card-title", "Media by Type" }
@ -87,7 +96,6 @@ pub fn Statistics(
} }
} }
// Storage by Type
if !s.storage_by_type.is_empty() { if !s.storage_by_type.is_empty() {
div { class: "card mt-16", div { class: "card mt-16",
h4 { class: "card-title", "Storage by Type" } h4 { class: "card-title", "Storage by Type" }
@ -110,7 +118,6 @@ pub fn Statistics(
} }
} }
// Top Tags
if !s.top_tags.is_empty() { if !s.top_tags.is_empty() {
div { class: "card mt-16", div { class: "card mt-16",
h4 { class: "card-title", "Top Tags" } h4 { class: "card-title", "Top Tags" }
@ -133,7 +140,6 @@ pub fn Statistics(
} }
} }
// Top Collections
if !s.top_collections.is_empty() { if !s.top_collections.is_empty() {
div { class: "card mt-16", div { class: "card mt-16",
h4 { class: "card-title", "Top Collections" } h4 { class: "card-title", "Top Collections" }
@ -156,7 +162,6 @@ pub fn Statistics(
} }
} }
// Date Range
div { class: "card mt-16", div { class: "card mt-16",
h4 { class: "card-title", "Date Range" } h4 { class: "card-title", "Date Range" }
div { class: "stats-grid", div { class: "stats-grid",
@ -171,7 +176,7 @@ pub fn Statistics(
} }
} }
} }
}, }
None => rsx! { None => rsx! {
div { class: "empty-state", div { class: "empty-state",
p { "Loading statistics..." } p { "Loading statistics..." }

View file

@ -65,18 +65,10 @@ pub fn Tags(
onchange: move |e| parent_tag.set(e.value()), onchange: move |e| parent_tag.set(e.value()),
option { value: "", "No Parent" } option { value: "", "No Parent" }
for tag in tags.iter() { for tag in tags.iter() {
option { option { key: "{tag.id}", value: "{tag.id}", "{tag.name}" }
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() { if tags.is_empty() {
@ -143,17 +135,19 @@ pub fn Tags(
} }
} }
if !children.is_empty() { 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() { for child in children.iter() {
{ {
let child_id = child.id.clone(); let child_id = child.id.clone();
let child_name = child.name.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! { rsx! {
span { span { key: "{child_id}", class: "tag-badge",
key: "{child_id}",
class: "tag-badge",
"{child_name}" "{child_name}"
if child_is_confirming { if child_is_confirming {
{ {
@ -208,14 +202,21 @@ pub fn Tags(
// Orphan child tags (parent not found in current list) // Orphan child tags (parent not found in current list)
for tag in child_tags.iter() { for tag in child_tags.iter() {
{ {
let parent_exists = root_tags.iter().any(|r| Some(r.id.as_str()) == tag.parent_id.as_deref()) let parent_exists = root_tags
|| child_tags.iter().any(|c| c.id != tag.id && Some(c.id.as_str()) == tag.parent_id.as_deref()); .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 { if !parent_exists {
let orphan_id = tag.id.clone(); let orphan_id = tag.id.clone();
let orphan_name = tag.name.clone(); let orphan_name = tag.name.clone();
let parent_label = tag.parent_id.clone().unwrap_or_default(); 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! { rsx! {
span { key: "{orphan_id}", class: "tag-badge", span { key: "{orphan_id}", class: "tag-badge",
"{orphan_name}" "{orphan_name}"

View file

@ -75,7 +75,11 @@ pub fn Tasks(
button { button {
class: "btn btn-sm btn-secondary mr-8", class: "btn btn-sm btn-secondary mr-8",
onclick: move |_| on_toggle.call(task_id_toggle.clone()), onclick: move |_| on_toggle.call(task_id_toggle.clone()),
if task.enabled { "Disable" } else { "Enable" } if task.enabled {
"Disable"
} else {
"Enable"
}
} }
button { button {
class: "btn btn-sm btn-primary", class: "btn btn-sm btn-primary",

View file

@ -63,17 +63,20 @@ body {
.sidebar { .sidebar {
width: 220px; width: 220px;
min-width: 220px; min-width: 220px;
max-width: 220px;
background: var(--bg-1); background: var(--bg-1);
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-shrink: 0;
user-select: none; user-select: none;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
z-index: 10; 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 .nav-label,
.sidebar.collapsed .sidebar-header .logo, .sidebar.collapsed .sidebar-header .logo,
.sidebar.collapsed .sidebar-header .version, .sidebar.collapsed .sidebar-header .version,
@ -83,9 +86,8 @@ body {
/* Nav item text - hide when collapsed */ /* Nav item text - hide when collapsed */
.nav-item-text { .nav-item-text {
flex: 1;
white-space: nowrap; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.sidebar.collapsed .nav-item-text { display: none; } .sidebar.collapsed .nav-item-text { display: none; }