pinakes-ui: streamline sidebar design
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I0176fa480e5ba40eea5a39685a4f97896a6a6964
This commit is contained in:
parent
3e14bbe607
commit
278bcaa4b0
25 changed files with 1805 additions and 1686 deletions
|
|
@ -165,7 +165,8 @@ impl PluginLoader {
|
|||
// Check content-length header before downloading
|
||||
const MAX_PLUGIN_SIZE: u64 = 100 * 1024 * 1024; // 100 MB
|
||||
if let Some(content_length) = response.content_length()
|
||||
&& content_length > MAX_PLUGIN_SIZE {
|
||||
&& content_length > MAX_PLUGIN_SIZE
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"Plugin archive too large: {} bytes (max {} bytes)",
|
||||
content_length,
|
||||
|
|
|
|||
|
|
@ -106,10 +106,10 @@ impl WasmPlugin {
|
|||
// If there are params and memory is available, write them
|
||||
let mut alloc_offset: i32 = 0;
|
||||
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
|
||||
let offset = if let Ok(alloc) =
|
||||
instance.get_typed_func::<i32, i32>(&mut store, "alloc")
|
||||
let offset = if let Ok(alloc) = instance.get_typed_func::<i32, i32>(&mut store, "alloc")
|
||||
{
|
||||
let result = alloc.call_async(&mut store, params.len() as i32).await?;
|
||||
if result < 0 {
|
||||
|
|
@ -209,7 +209,8 @@ impl HostFunctions {
|
|||
let start = ptr as usize;
|
||||
let end = start + len as usize;
|
||||
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 {
|
||||
0 => tracing::error!(plugin = true, "{}", msg),
|
||||
1 => tracing::warn!(plugin = true, "{}", msg),
|
||||
|
|
@ -258,11 +259,7 @@ impl HostFunctions {
|
|||
.filesystem
|
||||
.read
|
||||
.iter()
|
||||
.any(|allowed| {
|
||||
allowed
|
||||
.canonicalize()
|
||||
.is_ok_and(|a| path.starts_with(a))
|
||||
});
|
||||
.any(|allowed| allowed.canonicalize().is_ok_and(|a| path.starts_with(a)));
|
||||
|
||||
if !can_read {
|
||||
tracing::warn!(path = %path_str, "plugin read access denied");
|
||||
|
|
|
|||
|
|
@ -293,7 +293,8 @@ impl TranscodeService {
|
|||
|
||||
// Clean up cache directory
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -311,7 +312,8 @@ impl TranscodeService {
|
|||
.iter()
|
||||
.filter_map(|(id, sess)| {
|
||||
if let Some(expires) = sess.expires_at
|
||||
&& now > expires {
|
||||
&& now > expires
|
||||
{
|
||||
return Some((*id, sess.cache_path.clone()));
|
||||
}
|
||||
None
|
||||
|
|
@ -475,7 +477,8 @@ async fn run_ffmpeg(
|
|||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
// FFmpeg progress output: "out_time_us=12345678"
|
||||
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;
|
||||
// Calculate progress based on known duration
|
||||
let progress = match duration_secs {
|
||||
|
|
|
|||
|
|
@ -100,7 +100,8 @@ pub async fn update_playlist(
|
|||
Json(req): Json<UpdatePlaylistRequest>,
|
||||
) -> Result<Json<PlaylistResponse>, ApiError> {
|
||||
if let Some(ref name) = req.name
|
||||
&& (name.is_empty() || name.chars().count() > 255) {
|
||||
&& (name.is_empty() || name.chars().count() > 255)
|
||||
{
|
||||
return Err(ApiError(
|
||||
pinakes_core::error::PinakesError::InvalidOperation(
|
||||
"playlist name must be 1-255 characters".into(),
|
||||
|
|
|
|||
|
|
@ -137,7 +137,8 @@ pub async fn create_share_link(
|
|||
};
|
||||
const MAX_EXPIRY_HOURS: u64 = 8760; // 1 year
|
||||
if let Some(h) = req.expires_in_hours
|
||||
&& h > MAX_EXPIRY_HOURS {
|
||||
&& h > MAX_EXPIRY_HOURS
|
||||
{
|
||||
return Err(ApiError(
|
||||
pinakes_core::error::PinakesError::InvalidOperation(format!(
|
||||
"expires_in_hours cannot exceed {}",
|
||||
|
|
@ -169,11 +170,10 @@ pub async fn access_shared_media(
|
|||
let link = state.storage.get_share_link(&token).await?;
|
||||
// Check expiration
|
||||
if let Some(expires) = link.expires_at
|
||||
&& chrono::Utc::now() > expires {
|
||||
&& chrono::Utc::now() > expires
|
||||
{
|
||||
return Err(ApiError(
|
||||
pinakes_core::error::PinakesError::InvalidOperation(
|
||||
"share link has expired".into(),
|
||||
),
|
||||
pinakes_core::error::PinakesError::InvalidOperation("share link has expired".into()),
|
||||
));
|
||||
}
|
||||
// Verify password if set
|
||||
|
|
|
|||
|
|
@ -104,7 +104,8 @@ pub async fn hls_segment(
|
|||
|
||||
// Look for an active/completed transcode session
|
||||
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);
|
||||
|
||||
if segment_path.exists() {
|
||||
|
|
@ -206,7 +207,8 @@ pub async fn dash_segment(
|
|||
let media_id = MediaId(id);
|
||||
|
||||
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);
|
||||
|
||||
if segment_path.exists() {
|
||||
|
|
|
|||
|
|
@ -1028,7 +1028,8 @@ async fn handle_action(
|
|||
}
|
||||
Action::Edit => {
|
||||
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
|
||||
state.edit_title = media.title.clone().unwrap_or_default();
|
||||
state.edit_artist = media.artist.clone().unwrap_or_default();
|
||||
|
|
@ -1069,7 +1070,8 @@ async fn handle_action(
|
|||
Action::Toggle => {
|
||||
if state.current_view == View::Tasks
|
||||
&& 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 client = client.clone();
|
||||
let tx = event_sender.clone();
|
||||
|
|
@ -1078,16 +1080,15 @@ async fn handle_action(
|
|||
Ok(()) => {
|
||||
// Refresh tasks list
|
||||
if let Ok(tasks) = client.list_scheduled_tasks().await {
|
||||
let _ = tx.send(AppEvent::ApiResult(
|
||||
ApiResult::ScheduledTasks(tasks),
|
||||
));
|
||||
let _ =
|
||||
tx.send(AppEvent::ApiResult(ApiResult::ScheduledTasks(tasks)));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to toggle task: {}", e);
|
||||
let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(
|
||||
format!("Toggle task failed: {e}"),
|
||||
)));
|
||||
let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!(
|
||||
"Toggle task failed: {e}"
|
||||
))));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -1096,7 +1097,8 @@ async fn handle_action(
|
|||
Action::RunNow => {
|
||||
if state.current_view == View::Tasks
|
||||
&& 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_name = task.name.clone();
|
||||
state.status_message = Some(format!("Running task: {task_name}..."));
|
||||
|
|
@ -1107,16 +1109,15 @@ async fn handle_action(
|
|||
Ok(()) => {
|
||||
// Refresh tasks list
|
||||
if let Ok(tasks) = client.list_scheduled_tasks().await {
|
||||
let _ = tx.send(AppEvent::ApiResult(
|
||||
ApiResult::ScheduledTasks(tasks),
|
||||
));
|
||||
let _ =
|
||||
tx.send(AppEvent::ApiResult(ApiResult::ScheduledTasks(tasks)));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to run task: {}", e);
|
||||
let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(
|
||||
format!("Run task failed: {e}"),
|
||||
)));
|
||||
let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!(
|
||||
"Run task failed: {e}"
|
||||
))));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -1124,7 +1125,8 @@ async fn handle_action(
|
|||
}
|
||||
Action::Save => {
|
||||
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!({
|
||||
"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()) },
|
||||
|
|
|
|||
|
|
@ -309,7 +309,8 @@ pub fn App() -> Element {
|
|||
}
|
||||
} else {
|
||||
// Phase 7.1: Keyboard shortcuts
|
||||
div { class: "app",
|
||||
div {
|
||||
class: "app",
|
||||
tabindex: "0",
|
||||
onkeydown: {
|
||||
move |evt: KeyboardEvent| {
|
||||
|
|
@ -495,7 +496,11 @@ pub fn App() -> Element {
|
|||
button {
|
||||
class: "sidebar-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)
|
||||
|
|
@ -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! {
|
||||
div { class: "stats-grid",
|
||||
div { class: "stat-card",
|
||||
|
|
@ -683,7 +703,10 @@ pub fn App() -> Element {
|
|||
spawn(async move {
|
||||
match client.batch_add_to_collection(&ids, &col_id).await {
|
||||
Ok(resp) => {
|
||||
show_toast(format!("Added {} items to collection", resp.processed), false);
|
||||
show_toast(
|
||||
format!("Added {} items to collection", resp.processed),
|
||||
false,
|
||||
);
|
||||
refresh_media();
|
||||
}
|
||||
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: {
|
||||
let client = client.read().clone();
|
||||
move |sort: String| {
|
||||
|
|
@ -750,7 +772,10 @@ pub fn App() -> Element {
|
|||
let sort = media_sort.read().clone();
|
||||
match client.list_media(0, total, Some(&sort)).await {
|
||||
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);
|
||||
}
|
||||
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! {
|
||||
search::Search {
|
||||
results: search_results.read().clone(),
|
||||
|
|
@ -845,7 +869,6 @@ pub fn App() -> Element {
|
|||
},
|
||||
}
|
||||
},
|
||||
// Phase 3.1 + 3.2: Detail view enhancements
|
||||
View::Detail => {
|
||||
let media_ref = selected_media.read();
|
||||
match media_ref.as_ref() {
|
||||
|
|
@ -923,7 +946,10 @@ pub fn App() -> Element {
|
|||
move |(media_id, name, field_type, value): (String, String, String, String)| {
|
||||
let client = client.clone();
|
||||
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(_) => {
|
||||
if let Ok(updated) = client.get_media(&media_id).await {
|
||||
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: {
|
||||
let client = client.read().clone();
|
||||
let refresh_media = refresh_media.clone();
|
||||
|
|
@ -980,7 +1005,7 @@ pub fn App() -> Element {
|
|||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
View::Tags => rsx! {
|
||||
tags::Tags {
|
||||
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: {
|
||||
let client = client.read().clone();
|
||||
let refresh_tags = refresh_tags.clone();
|
||||
|
|
@ -1021,7 +1045,6 @@ pub fn App() -> Element {
|
|||
},
|
||||
}
|
||||
},
|
||||
// Phase 5.2: Collections enhancements
|
||||
View::Collections => rsx! {
|
||||
collections::Collections {
|
||||
collections: collections_list.read().clone(),
|
||||
|
|
@ -1031,11 +1054,16 @@ pub fn App() -> Element {
|
|||
on_create: {
|
||||
let client = client.read().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 refresh_collections = refresh_collections.clone();
|
||||
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(_) => {
|
||||
show_toast("Collection created".into(), false);
|
||||
refresh_collections();
|
||||
|
|
@ -1091,7 +1119,10 @@ pub fn App() -> Element {
|
|||
match client.remove_from_collection(&col_id, &media_id).await {
|
||||
Ok(_) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1100,7 +1131,6 @@ pub fn App() -> Element {
|
|||
});
|
||||
}
|
||||
},
|
||||
// Phase 5.2: Navigate to detail when clicking a collection member
|
||||
on_select: {
|
||||
let client = client.read().clone();
|
||||
move |id: String| {
|
||||
|
|
@ -1115,7 +1145,6 @@ pub fn App() -> Element {
|
|||
});
|
||||
}
|
||||
},
|
||||
// Phase 5.2: Add member to collection
|
||||
on_add_member: {
|
||||
let client = client.read().clone();
|
||||
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 {
|
||||
Ok(_) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1136,16 +1168,19 @@ pub fn App() -> Element {
|
|||
},
|
||||
}
|
||||
},
|
||||
// Phase 6.1: Audit improvements
|
||||
View::Audit => {
|
||||
let page_size = *audit_page_size.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! {
|
||||
audit::AuditLog {
|
||||
entries: audit_list.read().clone(),
|
||||
audit_page: *audit_page.read(),
|
||||
total_pages: total_pages,
|
||||
total_pages,
|
||||
audit_filter: audit_filter.read().clone(),
|
||||
on_select: {
|
||||
let client = client.read().clone();
|
||||
|
|
@ -1178,8 +1213,7 @@ pub fn App() -> Element {
|
|||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
// Phase 6.2: Scan progress
|
||||
}
|
||||
View::Import => rsx! {
|
||||
import::Import {
|
||||
tags: tags_list.read().clone(),
|
||||
|
|
@ -1200,7 +1234,10 @@ pub fn App() -> Element {
|
|||
match client.import_file(&path).await {
|
||||
Ok(resp) => {
|
||||
if resp.was_duplicate {
|
||||
show_toast("Duplicate file (already imported)".into(), false);
|
||||
show_toast(
|
||||
"Duplicate file (already imported)".into(),
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
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),
|
||||
}
|
||||
} 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) => {
|
||||
if resp.was_duplicate {
|
||||
show_toast("Duplicate file (already imported)".into(), false);
|
||||
show_toast(
|
||||
"Duplicate file (already imported)".into(),
|
||||
false,
|
||||
);
|
||||
} 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();
|
||||
if !new_tags.is_empty() {
|
||||
|
|
@ -1238,11 +1289,18 @@ pub fn App() -> Element {
|
|||
let refresh_tags = refresh_tags.clone();
|
||||
import_in_progress.set(true);
|
||||
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) => {
|
||||
show_toast(
|
||||
format!("Done: {} imported, {} duplicates, {} errors",
|
||||
resp.imported, resp.duplicates, resp.errors),
|
||||
format!(
|
||||
"Done: {} imported, {} duplicates, {} errors",
|
||||
resp.imported,
|
||||
resp.duplicates,
|
||||
resp.errors,
|
||||
),
|
||||
resp.errors > 0,
|
||||
);
|
||||
refresh_media();
|
||||
|
|
@ -1258,7 +1316,6 @@ pub fn App() -> Element {
|
|||
});
|
||||
}
|
||||
},
|
||||
// Phase 6.2: Scan with polling for progress
|
||||
on_scan: {
|
||||
let client = client.read().clone();
|
||||
let refresh_media = refresh_media.clone();
|
||||
|
|
@ -1269,7 +1326,6 @@ pub fn App() -> Element {
|
|||
spawn(async move {
|
||||
match client.trigger_scan().await {
|
||||
Ok(_results) => {
|
||||
// Poll scan status until done
|
||||
loop {
|
||||
match client.scan_status().await {
|
||||
Ok(status) => {
|
||||
|
|
@ -1277,7 +1333,10 @@ pub fn App() -> Element {
|
|||
scan_progress.set(Some(status.clone()));
|
||||
if done {
|
||||
let total = status.files_processed;
|
||||
show_toast(format!("Scan complete: {total} files processed"), false);
|
||||
show_toast(
|
||||
format!("Scan complete: {total} files processed"),
|
||||
false,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -1304,11 +1363,18 @@ pub fn App() -> Element {
|
|||
let file_count = paths.len();
|
||||
import_in_progress.set(true);
|
||||
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) => {
|
||||
show_toast(
|
||||
format!("Done: {} imported, {} duplicates, {} errors",
|
||||
resp.imported, resp.duplicates, resp.errors),
|
||||
format!(
|
||||
"Done: {} imported, {} duplicates, {} errors",
|
||||
resp.imported,
|
||||
resp.duplicates,
|
||||
resp.errors,
|
||||
),
|
||||
resp.errors > 0,
|
||||
);
|
||||
refresh_media();
|
||||
|
|
@ -1318,7 +1384,12 @@ pub fn App() -> Element {
|
|||
preview_files.set(Vec::new());
|
||||
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);
|
||||
});
|
||||
|
|
@ -1355,7 +1426,9 @@ pub fn App() -> Element {
|
|||
spawn(async move {
|
||||
match client.database_stats().await {
|
||||
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 => {
|
||||
rsx! {
|
||||
duplicates::Duplicates {
|
||||
|
|
@ -1432,7 +1505,6 @@ pub fn App() -> Element {
|
|||
match client.delete_media(&media_id).await {
|
||||
Ok(_) => {
|
||||
show_toast("Deleted duplicate".into(), false);
|
||||
// Refresh duplicates list
|
||||
if let Ok(groups) = client.list_duplicates().await {
|
||||
duplicate_groups.set(groups);
|
||||
}
|
||||
|
|
@ -1456,7 +1528,7 @@ pub fn App() -> Element {
|
|||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
View::Settings => {
|
||||
let cfg_ref = config_data.read();
|
||||
match cfg_ref.as_ref() {
|
||||
|
|
@ -1548,7 +1620,6 @@ pub fn App() -> Element {
|
|||
Ok(ui_cfg) => {
|
||||
auto_play_media.set(ui_cfg.auto_play_media);
|
||||
sidebar_collapsed.set(ui_cfg.sidebar_collapsed);
|
||||
// Reload full config
|
||||
if let Ok(cfg) = client.get_config().await {
|
||||
config_data.set(Some(cfg));
|
||||
}
|
||||
|
|
@ -1567,16 +1638,19 @@ pub fn App() -> Element {
|
|||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 7.1: Help overlay
|
||||
if *show_help.read() {
|
||||
div { class: "help-overlay",
|
||||
div {
|
||||
class: "help-overlay",
|
||||
onclick: move |_| show_help.set(false),
|
||||
div { class: "help-dialog",
|
||||
div {
|
||||
class: "help-dialog",
|
||||
onclick: move |evt: MouseEvent| evt.stop_propagation(),
|
||||
h3 { "Keyboard Shortcuts" }
|
||||
div { class: "help-shortcuts",
|
||||
|
|
@ -1614,12 +1688,8 @@ pub fn App() -> Element {
|
|||
let toasts = toast_queue.read().clone();
|
||||
let visible: Vec<_> = toasts.iter().rev().take(3).rev().cloned().collect();
|
||||
rsx! {
|
||||
for (msg, is_error, id) in visible {
|
||||
div {
|
||||
key: "{id}",
|
||||
class: if is_error { "toast error" } else { "toast success" },
|
||||
"{msg}"
|
||||
}
|
||||
for (msg , is_error , id) in visible {
|
||||
div { key: "{id}", class: if is_error { "toast error" } else { "toast success" }, "{msg}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,11 +105,7 @@ pub fn AuditLog(
|
|||
}
|
||||
}
|
||||
|
||||
PaginationControls {
|
||||
current_page: audit_page,
|
||||
total_pages: total_pages,
|
||||
on_page_change: on_page_change,
|
||||
}
|
||||
PaginationControls { current_page: audit_page, total_pages, on_page_change }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ pub fn Breadcrumb(
|
|||
) -> Element {
|
||||
rsx! {
|
||||
nav { class: "breadcrumb",
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
for (i , item) in items.iter().enumerate() {
|
||||
if i > 0 {
|
||||
span { class: "breadcrumb-sep", " > " }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,11 +44,7 @@ pub fn Collections(
|
|||
let modal_col_id = col_id.clone();
|
||||
|
||||
return rsx! {
|
||||
button {
|
||||
class: "btn btn-ghost mb-16",
|
||||
onclick: back_click,
|
||||
"\u{2190} Back to Collections"
|
||||
}
|
||||
button { class: "btn btn-ghost mb-16", onclick: back_click, "\u{2190} Back to Collections" }
|
||||
|
||||
h3 { class: "mb-16", "{col_name}" }
|
||||
|
||||
|
|
@ -88,10 +84,7 @@ pub fn Collections(
|
|||
move |_| on_select.call(mid.clone())
|
||||
};
|
||||
rsx! {
|
||||
tr {
|
||||
key: "{item.id}",
|
||||
class: "clickable-row",
|
||||
onclick: row_click,
|
||||
tr { key: "{item.id}", class: "clickable-row", onclick: row_click,
|
||||
td { "{item.file_name}" }
|
||||
td {
|
||||
span { class: "type-badge {badge_class}", "{item.media_type}" }
|
||||
|
|
@ -118,9 +111,11 @@ pub fn Collections(
|
|||
|
||||
// Add Media modal
|
||||
if *show_add_modal.read() {
|
||||
div { class: "modal-overlay",
|
||||
div {
|
||||
class: "modal-overlay",
|
||||
onclick: move |_| show_add_modal.set(false),
|
||||
div { class: "modal",
|
||||
div {
|
||||
class: "modal",
|
||||
onclick: move |e: Event<MouseData>| e.stop_propagation(),
|
||||
div { class: "modal-header",
|
||||
h3 { "Add Media to Collection" }
|
||||
|
|
@ -156,10 +151,7 @@ pub fn Collections(
|
|||
}
|
||||
};
|
||||
rsx! {
|
||||
tr {
|
||||
key: "{media.id}",
|
||||
class: "clickable-row",
|
||||
onclick: add_click,
|
||||
tr { key: "{media.id}", class: "clickable-row", onclick: add_click,
|
||||
td { "{media.file_name}" }
|
||||
td {
|
||||
span { class: "type-badge {badge_class}", "{media.media_type}" }
|
||||
|
|
@ -242,11 +234,7 @@ pub fn Collections(
|
|||
}
|
||||
|
||||
div { class: "form-row mb-16",
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
onclick: create_click,
|
||||
"Create"
|
||||
}
|
||||
button { class: "btn btn-primary", onclick: create_click, "Create" }
|
||||
}
|
||||
|
||||
if collections.is_empty() {
|
||||
|
|
@ -268,7 +256,11 @@ pub fn Collections(
|
|||
for col in collections.iter() {
|
||||
{
|
||||
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 id = col.id.clone();
|
||||
move |_| on_view_members.call(id.clone())
|
||||
|
|
@ -287,11 +279,7 @@ pub fn Collections(
|
|||
}
|
||||
td { "{desc}" }
|
||||
td {
|
||||
button {
|
||||
class: "btn btn-sm btn-secondary",
|
||||
onclick: view_click,
|
||||
"View"
|
||||
}
|
||||
button { class: "btn btn-sm btn-secondary", onclick: view_click, "View" }
|
||||
}
|
||||
td {
|
||||
if is_confirming {
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ pub fn Database(
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
None => rsx! {
|
||||
div { class: "empty-state",
|
||||
p { class: "text-muted", "Loading database stats..." }
|
||||
|
|
@ -162,7 +162,9 @@ pub fn Database(
|
|||
}
|
||||
if *confirm_clear.read() {
|
||||
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?"
|
||||
}
|
||||
button {
|
||||
|
|
|
|||
|
|
@ -231,14 +231,14 @@ pub fn Detail(
|
|||
media_type: "audio".to_string(),
|
||||
title: media.title.clone(),
|
||||
thumbnail_url: if has_thumbnail { Some(thumb_for_player.clone()) } else { None },
|
||||
autoplay: autoplay,
|
||||
autoplay,
|
||||
}
|
||||
} else if category == "video" {
|
||||
MediaPlayer {
|
||||
src: stream_url.clone(),
|
||||
media_type: "video".to_string(),
|
||||
title: media.title.clone(),
|
||||
autoplay: autoplay,
|
||||
autoplay,
|
||||
}
|
||||
} else if category == "image" {
|
||||
if has_thumbnail {
|
||||
|
|
@ -298,40 +298,16 @@ pub fn Detail(
|
|||
"Open"
|
||||
}
|
||||
if is_editing {
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
onclick: on_save_click,
|
||||
"Save"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
onclick: on_cancel_click,
|
||||
"Cancel"
|
||||
}
|
||||
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"
|
||||
}
|
||||
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"
|
||||
}
|
||||
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"
|
||||
}
|
||||
button { class: "btn btn-danger", onclick: on_delete_click, "Delete" }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -373,11 +349,13 @@ pub fn Detail(
|
|||
}
|
||||
div { class: "detail-field",
|
||||
label { class: "detail-label",
|
||||
{match category {
|
||||
{
|
||||
match category {
|
||||
"image" => "Photographer",
|
||||
"document" | "text" => "Author",
|
||||
_ => "Artist",
|
||||
}}
|
||||
}
|
||||
}
|
||||
}
|
||||
input {
|
||||
r#type: "text",
|
||||
|
|
@ -458,11 +436,13 @@ pub fn Detail(
|
|||
if !artist.is_empty() {
|
||||
div { class: "detail-field",
|
||||
span { class: "detail-label",
|
||||
{match category {
|
||||
{
|
||||
match category {
|
||||
"image" => "Photographer",
|
||||
"document" | "text" => "Author",
|
||||
_ => "Artist",
|
||||
}}
|
||||
}
|
||||
}
|
||||
}
|
||||
span { class: "detail-value", "{artist}" }
|
||||
}
|
||||
|
|
@ -482,7 +462,9 @@ pub fn Detail(
|
|||
}
|
||||
}
|
||||
// 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",
|
||||
span { class: "detail-label", "Year" }
|
||||
span { class: "detail-value", "{year_str}" }
|
||||
|
|
@ -524,9 +506,7 @@ pub fn Detail(
|
|||
let tag_id = tag.id.clone();
|
||||
let media_id_untag = id.clone();
|
||||
rsx! {
|
||||
span {
|
||||
class: "tag-badge",
|
||||
key: "{tag_id}",
|
||||
span { class: "tag-badge", key: "{tag_id}",
|
||||
"{tag.name}"
|
||||
span {
|
||||
class: "tag-remove",
|
||||
|
|
@ -552,11 +532,7 @@ pub fn Detail(
|
|||
let tid = tag.id.clone();
|
||||
let tname = tag.name.clone();
|
||||
rsx! {
|
||||
option {
|
||||
key: "{tid}",
|
||||
value: "{tid}",
|
||||
"{tname}"
|
||||
}
|
||||
option { key: "{tid}", value: "{tid}", "{tname}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -576,10 +552,8 @@ pub fn Detail(
|
|||
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}",
|
||||
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}" }
|
||||
}
|
||||
|
|
@ -595,14 +569,12 @@ pub fn Detail(
|
|||
}
|
||||
if has_user_fields {
|
||||
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 media_id_del = id.clone();
|
||||
rsx! {
|
||||
div {
|
||||
class: "detail-field",
|
||||
key: "{field_name}",
|
||||
div { class: "detail-field", key: "{field_name}",
|
||||
span { class: "detail-label", "{key} ({field_type})" }
|
||||
div { class: "flex-row",
|
||||
span { class: "detail-value", "{value}" }
|
||||
|
|
|
|||
|
|
@ -44,7 +44,20 @@ pub fn Duplicates(
|
|||
let is_expanded = expanded_group.read().as_ref() == Some(&hash);
|
||||
let hash_for_toggle = hash.clone();
|
||||
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())
|
||||
.unwrap_or_default();
|
||||
let total_size: u64 = group.items.iter().map(|i| i.file_size).sum();
|
||||
|
|
@ -53,13 +66,9 @@ pub fn Duplicates(
|
|||
} else {
|
||||
hash.clone()
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: "duplicate-group",
|
||||
key: "{hash}",
|
||||
div { class: "duplicate-group", key: "{hash}",
|
||||
|
||||
// Group header
|
||||
button {
|
||||
class: "duplicate-group-header",
|
||||
onclick: move |_| {
|
||||
|
|
@ -71,20 +80,21 @@ pub fn Duplicates(
|
|||
}
|
||||
},
|
||||
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-badge", "{item_count} files" }
|
||||
span { class: "group-size text-muted", "{format_size(total_size)}" }
|
||||
span { class: "group-hash mono text-muted",
|
||||
"{short_hash}"
|
||||
}
|
||||
span { class: "group-hash mono text-muted", "{short_hash}" }
|
||||
}
|
||||
|
||||
// Expanded: show items
|
||||
if is_expanded {
|
||||
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 is_first = idx == 0;
|
||||
|
|
@ -97,7 +107,10 @@ pub fn Duplicates(
|
|||
class: if is_first { "duplicate-item duplicate-item-keep" } else { "duplicate-item" },
|
||||
key: "{item_id}",
|
||||
|
||||
// Thumbnail
|
||||
|
||||
|
||||
|
||||
|
||||
div { class: "dup-thumb",
|
||||
if has_thumb {
|
||||
img {
|
||||
|
|
@ -110,7 +123,6 @@ pub fn Duplicates(
|
|||
}
|
||||
}
|
||||
|
||||
// Info
|
||||
div { class: "dup-info",
|
||||
div { class: "dup-filename", "{item.file_name}" }
|
||||
div { class: "dup-path mono text-muted", "{item.path}" }
|
||||
|
|
@ -121,7 +133,6 @@ pub fn Duplicates(
|
|||
}
|
||||
}
|
||||
|
||||
// Actions
|
||||
div { class: "dup-actions",
|
||||
if is_first {
|
||||
span { class: "keep-badge", "Keep" }
|
||||
|
|
|
|||
|
|
@ -194,10 +194,18 @@ pub fn ImageViewer(
|
|||
}
|
||||
}
|
||||
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()}"
|
||||
}
|
||||
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}%" }
|
||||
button { class: "iv-btn", onclick: zoom_in, title: "Zoom in", "+" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
tags: tags.clone(),
|
||||
collections: collections.clone(),
|
||||
selected_tags: selected_tags,
|
||||
new_tags_input: new_tags_input,
|
||||
selected_collection: selected_collection,
|
||||
selected_tags,
|
||||
new_tags_input,
|
||||
selected_collection,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -244,7 +248,18 @@ pub fn Import(
|
|||
let min = *filter_min_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) {
|
||||
"type-audio" => 0,
|
||||
"type-video" => 1,
|
||||
|
|
@ -253,23 +268,28 @@ pub fn Import(
|
|||
"type-text" => 4,
|
||||
_ => 5,
|
||||
};
|
||||
if !types_snapshot[type_idx] { return false; }
|
||||
if min > 0 && f.file_size < min { return false; }
|
||||
if max > 0 && f.file_size > max { return false; }
|
||||
if !types_snapshot[type_idx] {
|
||||
return false;
|
||||
}
|
||||
if min > 0 && f.file_size < min {
|
||||
return false;
|
||||
}
|
||||
if max > 0 && f.file_size > max {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}).collect();
|
||||
|
||||
})
|
||||
.collect();
|
||||
let filtered_count = filtered.len();
|
||||
let total_count = preview_files.len();
|
||||
|
||||
// Read selection once for display
|
||||
let selection = selected_file_paths.read().clone();
|
||||
let selected_count = selection.len();
|
||||
let all_filtered_selected = !filtered.is_empty()
|
||||
&& filtered.iter().all(|f| selection.contains(&f.path));
|
||||
|
||||
let filtered_paths: Vec<String> = filtered.iter().map(|f| f.path.clone()).collect();
|
||||
|
||||
let filtered_paths: Vec<String> = filtered
|
||||
.iter()
|
||||
.map(|f| f.path.clone())
|
||||
.collect();
|
||||
rsx! {
|
||||
div { class: "card mb-16",
|
||||
div { class: "card-header",
|
||||
|
|
@ -279,7 +299,6 @@ pub fn Import(
|
|||
}
|
||||
}
|
||||
|
||||
// Filter bar
|
||||
div { class: "filter-bar",
|
||||
div { class: "flex-row mb-8",
|
||||
label {
|
||||
|
|
@ -383,8 +402,9 @@ pub fn Import(
|
|||
}
|
||||
}
|
||||
|
||||
// Selection toolbar
|
||||
div { class: "flex-row mb-8", style: "gap: 8px; align-items: center; padding: 0 8px;",
|
||||
div {
|
||||
class: "flex-row mb-8",
|
||||
style: "gap: 8px; align-items: center; padding: 0 8px;",
|
||||
button {
|
||||
class: "btn btn-sm btn-secondary",
|
||||
onclick: {
|
||||
|
|
@ -407,9 +427,7 @@ pub fn Import(
|
|||
"Deselect All"
|
||||
}
|
||||
if selected_count > 0 {
|
||||
span { class: "text-muted text-sm",
|
||||
"{selected_count} files selected"
|
||||
}
|
||||
span { class: "text-muted text-sm", "{selected_count} files selected" }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -425,13 +443,17 @@ pub fn Import(
|
|||
let filtered_paths = filtered_paths.clone();
|
||||
move |_| {
|
||||
if all_filtered_selected {
|
||||
// Deselect all filtered
|
||||
let filtered_set: HashSet<String> = filtered_paths.iter().cloned().collect();
|
||||
let filtered_set: HashSet<String> = filtered_paths
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
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);
|
||||
} else {
|
||||
// Select all filtered
|
||||
let mut sel = selected_file_paths.read().clone();
|
||||
for p in &filtered_paths {
|
||||
sel.insert(p.clone());
|
||||
|
|
@ -455,9 +477,7 @@ pub fn Import(
|
|||
let is_selected = selection.contains(&file.path);
|
||||
let file_path_clone = file.path.clone();
|
||||
rsx! {
|
||||
tr {
|
||||
key: "{file.path}",
|
||||
class: if is_selected { "row-selected" } else { "" },
|
||||
tr { key: "{file.path}", class: if is_selected { "row-selected" } else { "" },
|
||||
td {
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
|
|
@ -496,9 +516,9 @@ pub fn Import(
|
|||
ImportOptions {
|
||||
tags: tags.clone(),
|
||||
collections: collections.clone(),
|
||||
selected_tags: selected_tags,
|
||||
new_tags_input: new_tags_input,
|
||||
selected_collection: selected_collection,
|
||||
selected_tags,
|
||||
new_tags_input,
|
||||
selected_collection,
|
||||
}
|
||||
|
||||
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 selected_collection = selected_collection;
|
||||
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() {
|
||||
let tag_ids = selected_tags.read().clone();
|
||||
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",
|
||||
disabled: is_importing,
|
||||
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 {
|
||||
{
|
||||
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! {
|
||||
div { class: "mb-16",
|
||||
div { class: "progress-bar",
|
||||
div {
|
||||
class: "progress-fill",
|
||||
style: "width: {pct}%;",
|
||||
}
|
||||
div { class: "progress-fill", style: "width: {pct}%;" }
|
||||
}
|
||||
p { class: "text-muted text-sm",
|
||||
"{progress.files_processed} / {progress.files_found} files processed"
|
||||
}
|
||||
if progress.error_count > 0 {
|
||||
p { class: "text-muted text-sm",
|
||||
"{progress.error_count} errors"
|
||||
}
|
||||
p { class: "text-muted text-sm", "{progress.error_count} errors" }
|
||||
}
|
||||
if progress.scanning {
|
||||
p { class: "text-muted text-sm", "Scanning..." }
|
||||
|
|
@ -647,7 +676,9 @@ fn ImportOptions(
|
|||
div { class: "form-group",
|
||||
label { class: "form-label", "Tags" }
|
||||
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 {
|
||||
div { class: "tag-list",
|
||||
for tag in tags.iter() {
|
||||
|
|
@ -655,11 +686,7 @@ fn ImportOptions(
|
|||
let tag_id = tag.id.clone();
|
||||
let tag_name = tag.name.clone();
|
||||
let is_selected = selected_tags.read().contains(&tag_id);
|
||||
let badge_class = if is_selected {
|
||||
"tag-badge selected"
|
||||
} else {
|
||||
"tag-badge"
|
||||
};
|
||||
let badge_class = if is_selected { "tag-badge selected" } else { "tag-badge" };
|
||||
rsx! {
|
||||
span {
|
||||
class: "{badge_class}",
|
||||
|
|
@ -693,7 +720,9 @@ fn ImportOptions(
|
|||
value: "{new_tags_input}",
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -107,7 +107,9 @@ pub fn Library(
|
|||
return rsx! {
|
||||
div { class: "empty-state",
|
||||
h3 { class: "empty-title", "No media found" }
|
||||
p { class: "empty-subtitle", "Import files or scan your root directories to get started." }
|
||||
p { class: "empty-subtitle",
|
||||
"Import files or scan your root directories to get started."
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -153,12 +155,16 @@ pub fn Library(
|
|||
rsx! {
|
||||
// Confirmation dialog for single delete
|
||||
if confirm_delete.read().is_some() {
|
||||
div { class: "modal-overlay",
|
||||
div {
|
||||
class: "modal-overlay",
|
||||
onclick: move |_| confirm_delete.set(None),
|
||||
div { class: "modal",
|
||||
div {
|
||||
class: "modal",
|
||||
onclick: move |e: Event<MouseData>| e.stop_propagation(),
|
||||
h3 { class: "modal-title", "Confirm Delete" }
|
||||
p { class: "modal-body", "Are you sure you want to delete this media item? This cannot be undone." }
|
||||
p { class: "modal-body",
|
||||
"Are you sure you want to delete this media item? This cannot be undone."
|
||||
}
|
||||
div { class: "modal-actions",
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
|
|
@ -182,9 +188,11 @@ pub fn Library(
|
|||
|
||||
// Confirmation dialog for batch delete
|
||||
if *confirm_batch_delete.read() {
|
||||
div { class: "modal-overlay",
|
||||
div {
|
||||
class: "modal-overlay",
|
||||
onclick: move |_| confirm_batch_delete.set(false),
|
||||
div { class: "modal",
|
||||
div {
|
||||
class: "modal",
|
||||
onclick: move |e: Event<MouseData>| e.stop_propagation(),
|
||||
h3 { class: "modal-title", "Confirm Batch Delete" }
|
||||
p { class: "modal-body",
|
||||
|
|
@ -214,9 +222,11 @@ pub fn Library(
|
|||
|
||||
// Confirmation dialog for delete all
|
||||
if *confirm_delete_all.read() {
|
||||
div { class: "modal-overlay",
|
||||
div {
|
||||
class: "modal-overlay",
|
||||
onclick: move |_| confirm_delete_all.set(false),
|
||||
div { class: "modal",
|
||||
div {
|
||||
class: "modal",
|
||||
onclick: move |e: Event<MouseData>| e.stop_propagation(),
|
||||
h3 { class: "modal-title", "Delete All Media" }
|
||||
p { class: "modal-body",
|
||||
|
|
@ -248,12 +258,14 @@ pub fn Library(
|
|||
|
||||
// Batch tag dialog
|
||||
if *show_batch_tag.read() {
|
||||
div { class: "modal-overlay",
|
||||
div {
|
||||
class: "modal-overlay",
|
||||
onclick: move |_| {
|
||||
show_batch_tag.set(false);
|
||||
batch_tag_selection.set(Vec::new());
|
||||
},
|
||||
div { class: "modal",
|
||||
div {
|
||||
class: "modal",
|
||||
onclick: move |e: Event<MouseData>| e.stop_propagation(),
|
||||
h3 { class: "modal-title", "Tag Selected Items" }
|
||||
p { class: "modal-body text-muted text-sm",
|
||||
|
|
@ -322,12 +334,14 @@ pub fn Library(
|
|||
|
||||
// Batch collection dialog
|
||||
if *show_batch_collection.read() {
|
||||
div { class: "modal-overlay",
|
||||
div {
|
||||
class: "modal-overlay",
|
||||
onclick: move |_| {
|
||||
show_batch_collection.set(false);
|
||||
batch_collection_id.set(String::new());
|
||||
},
|
||||
div { class: "modal",
|
||||
div {
|
||||
class: "modal",
|
||||
onclick: move |e: Event<MouseData>| e.stop_propagation(),
|
||||
h3 { class: "modal-title", "Add to Collection" }
|
||||
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()),
|
||||
option { value: "", "Select a collection..." }
|
||||
for col in collections.iter() {
|
||||
option {
|
||||
key: "{col.id}",
|
||||
value: "{col.id}",
|
||||
"{col.name}"
|
||||
}
|
||||
option { key: "{col.id}", value: "{col.id}", "{col.name}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -497,9 +507,7 @@ pub fn Library(
|
|||
"Delete All"
|
||||
}
|
||||
}
|
||||
span { class: "text-muted text-sm",
|
||||
"{total_count} items"
|
||||
}
|
||||
span { class: "text-muted text-sm", "{total_count} items" }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -537,9 +545,7 @@ pub fn Library(
|
|||
"Showing {filtered_count} items"
|
||||
}
|
||||
}
|
||||
span { class: "text-muted text-sm",
|
||||
"Page {current_page + 1} of {total_pages}"
|
||||
}
|
||||
span { class: "text-muted text-sm", "Page {current_page + 1} of {total_pages}" }
|
||||
}
|
||||
|
||||
// Select-all banner: when all items on this page are selected and there
|
||||
|
|
@ -551,10 +557,13 @@ pub fn Library(
|
|||
button {
|
||||
onclick: move |_| {
|
||||
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);
|
||||
global_all_selected.set(true);
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
"Select all {total_count} items"
|
||||
|
|
@ -580,29 +589,45 @@ pub fn Library(
|
|||
match current_mode {
|
||||
ViewMode::Grid => rsx! {
|
||||
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 badge_class = type_badge_class(&item.media_type);
|
||||
let is_checked = current_selection.contains(&id);
|
||||
|
||||
|
||||
|
||||
// Build a list of all visible IDs for shift+click range selection.
|
||||
|
||||
// Shift+click: select range from last_click_index to current idx.
|
||||
// No previous click, just toggle this one.
|
||||
|
||||
// Thumbnail with CSS fallback: both the icon and img
|
||||
// are rendered. The img is absolutely positioned on
|
||||
// top. If the image fails to load, the icon beneath
|
||||
// shows through.
|
||||
|
||||
// Thumbnail with CSS fallback: icon always
|
||||
// rendered, img overlays when available.
|
||||
let card_click = {
|
||||
let id = item.id.clone();
|
||||
move |_| on_select.call(id.clone())
|
||||
};
|
||||
|
||||
// Build a list of all visible IDs for shift+click range selection.
|
||||
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 id = id.clone();
|
||||
move |e: Event<MouseData>| {
|
||||
e.stop_propagation();
|
||||
let shift = e.modifiers().shift();
|
||||
let mut ids = selected_ids.read().clone();
|
||||
|
||||
if shift {
|
||||
// Shift+click: select range from last_click_index to current idx.
|
||||
if let Some(last) = *last_click_index.read() {
|
||||
let start = last.min(idx);
|
||||
let end = last.max(idx);
|
||||
|
|
@ -614,7 +639,6 @@ pub fn Library(
|
|||
}
|
||||
}
|
||||
} else {
|
||||
// No previous click, just toggle this one.
|
||||
if !ids.contains(&id) {
|
||||
ids.push(id.clone());
|
||||
}
|
||||
|
|
@ -624,12 +648,10 @@ pub fn Library(
|
|||
} else {
|
||||
ids.push(id.clone());
|
||||
}
|
||||
|
||||
last_click_index.set(Some(idx));
|
||||
selected_ids.set(ids);
|
||||
}
|
||||
};
|
||||
|
||||
let thumb_url = if item.has_thumbnail {
|
||||
format!("{}/api/v1/media/{}/thumbnail", server_url, item.id)
|
||||
} else {
|
||||
|
|
@ -640,29 +662,15 @@ pub fn Library(
|
|||
let card_class = if is_checked { "media-card selected" } else { "media-card" };
|
||||
let title_text = item.title.clone().unwrap_or_default();
|
||||
let artist_text = item.artist.clone().unwrap_or_default();
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
key: "{item.id}",
|
||||
class: "{card_class}",
|
||||
onclick: card_click,
|
||||
div { key: "{item.id}", class: "{card_class}", onclick: card_click,
|
||||
|
||||
div { class: "card-checkbox",
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
checked: is_checked,
|
||||
onclick: toggle_id,
|
||||
}
|
||||
input { 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-type-icon {badge_class}",
|
||||
"{type_icon(&media_type)}"
|
||||
}
|
||||
div { class: "card-type-icon {badge_class}", "{type_icon(&media_type)}" }
|
||||
if has_thumb {
|
||||
img {
|
||||
class: "card-thumb-img",
|
||||
|
|
@ -674,18 +682,12 @@ pub fn Library(
|
|||
}
|
||||
|
||||
div { class: "card-info",
|
||||
div { class: "card-name", title: "{item.file_name}",
|
||||
"{item.file_name}"
|
||||
}
|
||||
div { class: "card-name", title: "{item.file_name}", "{item.file_name}" }
|
||||
if !title_text.is_empty() {
|
||||
div { class: "card-title text-muted text-xs",
|
||||
"{title_text}"
|
||||
}
|
||||
div { class: "card-title text-muted text-xs", "{title_text}" }
|
||||
}
|
||||
if !artist_text.is_empty() {
|
||||
div { class: "card-artist text-muted text-xs",
|
||||
"{artist_text}"
|
||||
}
|
||||
div { class: "card-artist text-muted text-xs", "{artist_text}" }
|
||||
}
|
||||
div { class: "card-meta",
|
||||
span { class: "type-badge {badge_class}", "{item.media_type}" }
|
||||
|
|
@ -751,7 +753,7 @@ pub fn Library(
|
|||
}
|
||||
}
|
||||
tbody {
|
||||
for (idx, item) in filtered_media.iter().enumerate() {
|
||||
for (idx , item) in filtered_media.iter().enumerate() {
|
||||
{
|
||||
let id = item.id.clone();
|
||||
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 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 id = id.clone();
|
||||
move |e: Event<MouseData>| {
|
||||
e.stop_propagation();
|
||||
let shift = e.modifiers().shift();
|
||||
let mut ids = selected_ids.read().clone();
|
||||
|
||||
if shift {
|
||||
if let Some(last) = *last_click_index.read() {
|
||||
let start = last.min(idx);
|
||||
|
|
@ -789,17 +793,14 @@ pub fn Library(
|
|||
} else {
|
||||
ids.push(id.clone());
|
||||
}
|
||||
|
||||
last_click_index.set(Some(idx));
|
||||
selected_ids.set(ids);
|
||||
}
|
||||
};
|
||||
|
||||
let row_click = {
|
||||
let id = item.id.clone();
|
||||
move |_| on_select.call(id.clone())
|
||||
};
|
||||
|
||||
let delete_click = {
|
||||
let id = item.id.clone();
|
||||
move |e: Event<MouseData>| {
|
||||
|
|
@ -807,7 +808,6 @@ pub fn Library(
|
|||
confirm_delete.set(Some(id.clone()));
|
||||
}
|
||||
};
|
||||
|
||||
let thumb_url = if item.has_thumbnail {
|
||||
format!("{}/api/v1/media/{}/thumbnail", server_url, item.id)
|
||||
} else {
|
||||
|
|
@ -815,24 +815,13 @@ pub fn Library(
|
|||
};
|
||||
let has_thumb = item.has_thumbnail;
|
||||
let media_type_str = item.media_type.clone();
|
||||
|
||||
rsx! {
|
||||
tr {
|
||||
key: "{item.id}",
|
||||
onclick: row_click,
|
||||
tr { key: "{item.id}", onclick: row_click,
|
||||
td {
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
checked: is_checked,
|
||||
onclick: toggle_id,
|
||||
}
|
||||
input { r#type: "checkbox", checked: is_checked, onclick: toggle_id }
|
||||
}
|
||||
td { class: "table-thumb-cell",
|
||||
// Thumbnail with CSS fallback: icon always
|
||||
// rendered, img overlays when available.
|
||||
span { class: "table-type-icon {badge_class}",
|
||||
"{type_icon(&media_type_str)}"
|
||||
}
|
||||
span { class: "table-type-icon {badge_class}", "{type_icon(&media_type_str)}" }
|
||||
if has_thumb {
|
||||
img {
|
||||
class: "table-thumb table-thumb-overlay",
|
||||
|
|
@ -849,11 +838,7 @@ pub fn Library(
|
|||
td { "{artist}" }
|
||||
td { "{size}" }
|
||||
td {
|
||||
button {
|
||||
class: "btn btn-danger btn-sm",
|
||||
onclick: delete_click,
|
||||
"Delete"
|
||||
}
|
||||
button { class: "btn btn-danger btn-sm", onclick: delete_click, "Delete" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,11 @@ pub fn Login(
|
|||
class: "btn btn-primary login-btn",
|
||||
disabled: loading,
|
||||
onclick: on_submit,
|
||||
if loading { "Signing in..." } else { "Sign In" }
|
||||
if loading {
|
||||
"Signing in..."
|
||||
} else {
|
||||
"Sign In"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -498,10 +498,14 @@ pub fn QueuePanel(
|
|||
div { class: "queue-empty", "Queue is empty. Add items from the library." }
|
||||
} else {
|
||||
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 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 artist = item.artist.clone().unwrap_or_default();
|
||||
rsx! {
|
||||
|
|
|
|||
|
|
@ -75,9 +75,7 @@ pub fn Search(
|
|||
oninput: move |e| query.set(e.value()),
|
||||
onkeypress: on_key,
|
||||
}
|
||||
select {
|
||||
value: "{sort_by}",
|
||||
onchange: move |e| sort_by.set(e.value()),
|
||||
select { value: "{sort_by}", onchange: move |e| sort_by.set(e.value()),
|
||||
option { value: "relevance", "Relevance" }
|
||||
option { value: "date_desc", "Newest" }
|
||||
option { value: "date_asc", "Oldest" }
|
||||
|
|
@ -86,16 +84,8 @@ pub fn Search(
|
|||
option { value: "size_desc", "Size (largest)" }
|
||||
option { value: "size_asc", "Size (smallest)" }
|
||||
}
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
onclick: do_search,
|
||||
"Search"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
onclick: toggle_help,
|
||||
"Syntax Help"
|
||||
}
|
||||
button { class: "btn btn-primary", onclick: do_search, "Search" }
|
||||
button { class: "btn btn-ghost", onclick: toggle_help, "Syntax Help" }
|
||||
|
||||
// View mode toggle
|
||||
div { class: "view-toggle",
|
||||
|
|
@ -118,15 +108,42 @@ pub fn Search(
|
|||
div { class: "card mb-16",
|
||||
h4 { "Search Syntax" }
|
||||
ul {
|
||||
li { code { "hello world" } " -- full text search (implicit AND)" }
|
||||
li { code { "artist:Beatles" } " -- field 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" }
|
||||
li {
|
||||
code { "hello world" }
|
||||
" -- full text search (implicit AND)"
|
||||
}
|
||||
li {
|
||||
code { "artist:Beatles" }
|
||||
" -- field 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() {
|
||||
div { class: "empty-state",
|
||||
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())
|
||||
};
|
||||
|
||||
|
||||
|
||||
let thumb_url = if item.has_thumbnail {
|
||||
format!("{}/api/v1/media/{}/thumbnail", server_url, item.id)
|
||||
} else {
|
||||
|
|
@ -168,10 +189,8 @@ pub fn Search(
|
|||
let media_type = item.media_type.clone();
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
key: "{item.id}",
|
||||
class: "media-card",
|
||||
onclick: card_click,
|
||||
|
||||
div { key: "{item.id}", class: "media-card", onclick: card_click,
|
||||
|
||||
div { class: "card-thumbnail",
|
||||
if has_thumb {
|
||||
|
|
@ -181,16 +200,12 @@ pub fn Search(
|
|||
loading: "lazy",
|
||||
}
|
||||
} else {
|
||||
div { class: "card-type-icon {badge_class}",
|
||||
"{type_icon(&media_type)}"
|
||||
}
|
||||
div { class: "card-type-icon {badge_class}", "{type_icon(&media_type)}" }
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "card-info",
|
||||
div { class: "card-name", title: "{item.file_name}",
|
||||
"{item.file_name}"
|
||||
}
|
||||
div { class: "card-name", title: "{item.file_name}", "{item.file_name}" }
|
||||
div { class: "card-meta",
|
||||
span { class: "type-badge {badge_class}", "{item.media_type}" }
|
||||
span { class: "card-size", "{format_size(item.file_size)}" }
|
||||
|
|
@ -223,9 +238,7 @@ pub fn Search(
|
|||
move |_| on_select.call(id.clone())
|
||||
};
|
||||
rsx! {
|
||||
tr {
|
||||
key: "{item.id}",
|
||||
onclick: row_click,
|
||||
tr { key: "{item.id}", onclick: row_click,
|
||||
td { "{item.file_name}" }
|
||||
td {
|
||||
span { class: "type-badge {badge_class}", "{item.media_type}" }
|
||||
|
|
@ -242,10 +255,6 @@ pub fn Search(
|
|||
}
|
||||
|
||||
// Pagination controls
|
||||
PaginationControls {
|
||||
current_page: search_page,
|
||||
total_pages: total_pages,
|
||||
on_page_change: on_page_change,
|
||||
}
|
||||
PaginationControls { current_page: search_page, total_pages, on_page_change }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,9 @@ pub fn Settings(
|
|||
label { class: "form-label", "Backend" }
|
||||
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}" }
|
||||
|
|
@ -75,7 +77,9 @@ pub fn Settings(
|
|||
label { class: "form-label", "Server Address" }
|
||||
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}" }
|
||||
|
|
@ -85,7 +89,9 @@ pub fn Settings(
|
|||
label { class: "form-label", "Database Path" }
|
||||
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}" }
|
||||
|
|
@ -102,7 +108,9 @@ pub fn Settings(
|
|||
}
|
||||
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",
|
||||
|
|
@ -194,7 +202,9 @@ pub fn Settings(
|
|||
label { class: "form-label", "File Watching" }
|
||||
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 {
|
||||
|
|
@ -204,8 +214,7 @@ pub fn Settings(
|
|||
on_toggle_watch.call(!watch_enabled);
|
||||
}
|
||||
},
|
||||
div {
|
||||
class: if watch_enabled { "toggle-track active" } else { "toggle-track" },
|
||||
div { class: if watch_enabled { "toggle-track active" } else { "toggle-track" },
|
||||
div { class: "toggle-thumb" }
|
||||
}
|
||||
}
|
||||
|
|
@ -217,7 +226,9 @@ pub fn Settings(
|
|||
label { class: "form-label", "Poll Interval" }
|
||||
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() {
|
||||
|
|
@ -242,7 +253,8 @@ pub fn Settings(
|
|||
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" }
|
||||
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() {
|
||||
|
|
@ -359,7 +373,9 @@ pub fn Settings(
|
|||
class: "patterns-textarea",
|
||||
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 {
|
||||
if config.scanning.ignore_patterns.is_empty() {
|
||||
|
|
@ -397,7 +413,7 @@ pub fn Settings(
|
|||
let handler = on_update_ui_config;
|
||||
move |e: Event<FormData>| {
|
||||
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" }
|
||||
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 {
|
||||
|
|
@ -421,7 +439,7 @@ pub fn Settings(
|
|||
let handler = on_update_ui_config;
|
||||
move |e: Event<FormData>| {
|
||||
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" }
|
||||
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 {
|
||||
|
|
@ -444,9 +464,8 @@ pub fn Settings(
|
|||
onchange: {
|
||||
let handler = on_update_ui_config;
|
||||
move |e: Event<FormData>| {
|
||||
if let Some(ref h) = handler
|
||||
&& let Ok(size) = e.value().parse::<usize>() {
|
||||
h.call(serde_json::json!({"default_page_size": size}));
|
||||
if let Some(ref h) = handler && let Ok(size) = e.value().parse::<usize>() {
|
||||
h.call(serde_json::json!({ "default_page_size" : size }));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -463,7 +482,9 @@ pub fn Settings(
|
|||
label { class: "form-label", "Default View Mode" }
|
||||
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 {
|
||||
|
|
@ -472,7 +493,7 @@ pub fn Settings(
|
|||
let handler = on_update_ui_config;
|
||||
move |e: Event<FormData>| {
|
||||
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" }
|
||||
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",
|
||||
onclick: move |_| {
|
||||
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 {
|
||||
class: if autoplay { "toggle-track active" } else { "toggle-track" },
|
||||
div { class: if autoplay { "toggle-track active" } else { "toggle-track" },
|
||||
div { class: "toggle-thumb" }
|
||||
}
|
||||
}
|
||||
|
|
@ -516,7 +538,9 @@ pub fn Settings(
|
|||
label { class: "form-label", "Show Thumbnails" }
|
||||
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",
|
||||
onclick: move |_| {
|
||||
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 {
|
||||
class: if show_thumbs { "toggle-track active" } else { "toggle-track" },
|
||||
div { class: if show_thumbs { "toggle-track active" } else { "toggle-track" },
|
||||
div { class: "toggle-thumb" }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,15 @@ pub fn Statistics(
|
|||
}
|
||||
|
||||
// Media by Type
|
||||
|
||||
|
||||
// Storage by Type
|
||||
|
||||
// Top Tags
|
||||
|
||||
// Top Collections
|
||||
|
||||
// Date Range
|
||||
if !s.media_by_type.is_empty() {
|
||||
div { class: "card mt-16",
|
||||
h4 { class: "card-title", "Media by Type" }
|
||||
|
|
@ -87,7 +96,6 @@ pub fn Statistics(
|
|||
}
|
||||
}
|
||||
|
||||
// Storage by Type
|
||||
if !s.storage_by_type.is_empty() {
|
||||
div { class: "card mt-16",
|
||||
h4 { class: "card-title", "Storage by Type" }
|
||||
|
|
@ -110,7 +118,6 @@ pub fn Statistics(
|
|||
}
|
||||
}
|
||||
|
||||
// Top Tags
|
||||
if !s.top_tags.is_empty() {
|
||||
div { class: "card mt-16",
|
||||
h4 { class: "card-title", "Top Tags" }
|
||||
|
|
@ -133,7 +140,6 @@ pub fn Statistics(
|
|||
}
|
||||
}
|
||||
|
||||
// Top Collections
|
||||
if !s.top_collections.is_empty() {
|
||||
div { class: "card mt-16",
|
||||
h4 { class: "card-title", "Top Collections" }
|
||||
|
|
@ -156,7 +162,6 @@ pub fn Statistics(
|
|||
}
|
||||
}
|
||||
|
||||
// Date Range
|
||||
div { class: "card mt-16",
|
||||
h4 { class: "card-title", "Date Range" }
|
||||
div { class: "stats-grid",
|
||||
|
|
@ -171,7 +176,7 @@ pub fn Statistics(
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
None => rsx! {
|
||||
div { class: "empty-state",
|
||||
p { "Loading statistics..." }
|
||||
|
|
|
|||
|
|
@ -65,18 +65,10 @@ pub fn Tags(
|
|||
onchange: move |e| parent_tag.set(e.value()),
|
||||
option { value: "", "No Parent" }
|
||||
for tag in tags.iter() {
|
||||
option {
|
||||
key: "{tag.id}",
|
||||
value: "{tag.id}",
|
||||
"{tag.name}"
|
||||
option { 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() {
|
||||
|
|
@ -143,17 +135,19 @@ pub fn Tags(
|
|||
}
|
||||
}
|
||||
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() {
|
||||
{
|
||||
let child_id = child.id.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! {
|
||||
span {
|
||||
key: "{child_id}",
|
||||
class: "tag-badge",
|
||||
span { key: "{child_id}", class: "tag-badge",
|
||||
"{child_name}"
|
||||
if child_is_confirming {
|
||||
{
|
||||
|
|
@ -208,14 +202,21 @@ pub fn Tags(
|
|||
// Orphan child tags (parent not found in current list)
|
||||
for tag in child_tags.iter() {
|
||||
{
|
||||
let parent_exists = root_tags.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());
|
||||
let parent_exists = root_tags
|
||||
.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 {
|
||||
let orphan_id = tag.id.clone();
|
||||
let orphan_name = tag.name.clone();
|
||||
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! {
|
||||
span { key: "{orphan_id}", class: "tag-badge",
|
||||
"{orphan_name}"
|
||||
|
|
|
|||
|
|
@ -75,7 +75,11 @@ pub fn Tasks(
|
|||
button {
|
||||
class: "btn btn-sm btn-secondary mr-8",
|
||||
onclick: move |_| on_toggle.call(task_id_toggle.clone()),
|
||||
if task.enabled { "Disable" } else { "Enable" }
|
||||
if task.enabled {
|
||||
"Disable"
|
||||
} else {
|
||||
"Enable"
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn btn-sm btn-primary",
|
||||
|
|
|
|||
|
|
@ -63,17 +63,20 @@ body {
|
|||
.sidebar {
|
||||
width: 220px;
|
||||
min-width: 220px;
|
||||
max-width: 220px;
|
||||
background: var(--bg-1);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
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 .sidebar-header .logo,
|
||||
.sidebar.collapsed .sidebar-header .version,
|
||||
|
|
@ -83,9 +86,8 @@ body {
|
|||
|
||||
/* Nav item text - hide when collapsed */
|
||||
.nav-item-text {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-item-text { display: none; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue