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,13 +165,14 @@ 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!(
|
{
|
||||||
"Plugin archive too large: {} bytes (max {} bytes)",
|
return Err(anyhow!(
|
||||||
content_length,
|
"Plugin archive too large: {} bytes (max {} bytes)",
|
||||||
MAX_PLUGIN_SIZE
|
content_length,
|
||||||
));
|
MAX_PLUGIN_SIZE
|
||||||
}
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let bytes = response
|
let bytes = response
|
||||||
.bytes()
|
.bytes()
|
||||||
|
|
|
||||||
|
|
@ -106,26 +106,26 @@ 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
|
{
|
||||||
let offset = if let Ok(alloc) =
|
// Call the plugin's alloc function if available, otherwise write at offset 0
|
||||||
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?;
|
let result = alloc.call_async(&mut store, params.len() as i32).await?;
|
||||||
if result < 0 {
|
if result < 0 {
|
||||||
return Err(anyhow!("plugin alloc returned negative offset: {}", result));
|
return Err(anyhow!("plugin alloc returned negative offset: {}", result));
|
||||||
}
|
|
||||||
result as usize
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
|
|
||||||
alloc_offset = offset as i32;
|
|
||||||
let mem_data = mem.data_mut(&mut store);
|
|
||||||
if offset + params.len() <= mem_data.len() {
|
|
||||||
mem_data[offset..offset + params.len()].copy_from_slice(params);
|
|
||||||
}
|
}
|
||||||
|
result as usize
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
alloc_offset = offset as i32;
|
||||||
|
let mem_data = mem.data_mut(&mut store);
|
||||||
|
if offset + params.len() <= mem_data.len() {
|
||||||
|
mem_data[offset..offset + params.len()].copy_from_slice(params);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Look up the exported function and call it
|
// Look up the exported function and call it
|
||||||
let func = instance
|
let func = instance
|
||||||
|
|
@ -209,14 +209,15 @@ 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 {
|
{
|
||||||
0 => tracing::error!(plugin = true, "{}", msg),
|
match level {
|
||||||
1 => tracing::warn!(plugin = true, "{}", msg),
|
0 => tracing::error!(plugin = true, "{}", msg),
|
||||||
2 => tracing::info!(plugin = true, "{}", msg),
|
1 => tracing::warn!(plugin = true, "{}", msg),
|
||||||
_ => tracing::debug!(plugin = true, "{}", msg),
|
2 => tracing::info!(plugin = true, "{}", msg),
|
||||||
}
|
_ => tracing::debug!(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");
|
||||||
|
|
|
||||||
|
|
@ -293,9 +293,10 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -311,9 +312,10 @@ 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
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -475,21 +477,22 @@ 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;
|
{
|
||||||
// Calculate progress based on known duration
|
let secs = us / 1_000_000.0;
|
||||||
let progress = match duration_secs {
|
// Calculate progress based on known duration
|
||||||
Some(dur) if dur > 0.0 => (secs / dur).min(0.99) as f32,
|
let progress = match duration_secs {
|
||||||
_ => {
|
Some(dur) if dur > 0.0 => (secs / dur).min(0.99) as f32,
|
||||||
// Duration unknown; don't update progress
|
_ => {
|
||||||
continue;
|
// Duration unknown; don't update progress
|
||||||
}
|
continue;
|
||||||
};
|
|
||||||
let mut s = sessions.write().await;
|
|
||||||
if let Some(sess) = s.get_mut(&session_id) {
|
|
||||||
sess.progress = progress;
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
let mut s = sessions.write().await;
|
||||||
|
if let Some(sess) = s.get_mut(&session_id) {
|
||||||
|
sess.progress = progress;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -100,13 +100,14 @@ 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(
|
{
|
||||||
pinakes_core::error::PinakesError::InvalidOperation(
|
return Err(ApiError(
|
||||||
"playlist name must be 1-255 characters".into(),
|
pinakes_core::error::PinakesError::InvalidOperation(
|
||||||
),
|
"playlist name must be 1-255 characters".into(),
|
||||||
));
|
),
|
||||||
}
|
));
|
||||||
|
}
|
||||||
let user_id = resolve_user_id(&state.storage, &username).await?;
|
let user_id = resolve_user_id(&state.storage, &username).await?;
|
||||||
check_playlist_access(&state.storage, id, user_id, true).await?;
|
check_playlist_access(&state.storage, id, user_id, true).await?;
|
||||||
let playlist = state
|
let playlist = state
|
||||||
|
|
|
||||||
|
|
@ -137,14 +137,15 @@ 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(
|
{
|
||||||
pinakes_core::error::PinakesError::InvalidOperation(format!(
|
return Err(ApiError(
|
||||||
"expires_in_hours cannot exceed {}",
|
pinakes_core::error::PinakesError::InvalidOperation(format!(
|
||||||
MAX_EXPIRY_HOURS
|
"expires_in_hours cannot exceed {}",
|
||||||
)),
|
MAX_EXPIRY_HOURS
|
||||||
));
|
)),
|
||||||
}
|
));
|
||||||
|
}
|
||||||
let expires_at = req
|
let expires_at = req
|
||||||
.expires_in_hours
|
.expires_in_hours
|
||||||
.map(|h| chrono::Utc::now() + chrono::Duration::hours(h as i64));
|
.map(|h| chrono::Utc::now() + chrono::Duration::hours(h as i64));
|
||||||
|
|
@ -169,13 +170,12 @@ 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(
|
{
|
||||||
pinakes_core::error::PinakesError::InvalidOperation(
|
return Err(ApiError(
|
||||||
"share link has expired".into(),
|
pinakes_core::error::PinakesError::InvalidOperation("share link has expired".into()),
|
||||||
),
|
));
|
||||||
));
|
}
|
||||||
}
|
|
||||||
// Verify password if set
|
// Verify password if set
|
||||||
if let Some(ref hash) = link.password_hash {
|
if let Some(ref hash) = link.password_hash {
|
||||||
let password = match query.password.as_deref() {
|
let password = match query.password.as_deref() {
|
||||||
|
|
|
||||||
|
|
@ -104,30 +104,31 @@ 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() {
|
||||||
let data = tokio::fs::read(&segment_path).await.map_err(|e| {
|
let data = tokio::fs::read(&segment_path).await.map_err(|e| {
|
||||||
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
|
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
|
||||||
format!("failed to read segment: {}", e),
|
format!("failed to read segment: {}", e),
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
return Ok(axum::response::Response::builder()
|
|
||||||
.header("Content-Type", "video/MP2T")
|
|
||||||
.body(axum::body::Body::from(data))
|
|
||||||
.unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Session exists but segment not ready yet
|
|
||||||
return Ok(axum::response::Response::builder()
|
return Ok(axum::response::Response::builder()
|
||||||
.status(StatusCode::ACCEPTED)
|
.header("Content-Type", "video/MP2T")
|
||||||
.header("Retry-After", "2")
|
.body(axum::body::Body::from(data))
|
||||||
.body(axum::body::Body::from("segment not yet available"))
|
|
||||||
.unwrap());
|
.unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Session exists but segment not ready yet
|
||||||
|
return Ok(axum::response::Response::builder()
|
||||||
|
.status(StatusCode::ACCEPTED)
|
||||||
|
.header("Retry-After", "2")
|
||||||
|
.body(axum::body::Body::from("segment not yet available"))
|
||||||
|
.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
Err(ApiError(
|
Err(ApiError(
|
||||||
pinakes_core::error::PinakesError::InvalidOperation(
|
pinakes_core::error::PinakesError::InvalidOperation(
|
||||||
"no transcode session found; start a transcode first via POST /media/{id}/transcode"
|
"no transcode session found; start a transcode first via POST /media/{id}/transcode"
|
||||||
|
|
@ -206,29 +207,30 @@ 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() {
|
||||||
let data = tokio::fs::read(&segment_path).await.map_err(|e| {
|
let data = tokio::fs::read(&segment_path).await.map_err(|e| {
|
||||||
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
|
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
|
||||||
format!("failed to read segment: {}", e),
|
format!("failed to read segment: {}", e),
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
return Ok(axum::response::Response::builder()
|
|
||||||
.header("Content-Type", "video/mp4")
|
|
||||||
.body(axum::body::Body::from(data))
|
|
||||||
.unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(axum::response::Response::builder()
|
return Ok(axum::response::Response::builder()
|
||||||
.status(StatusCode::ACCEPTED)
|
.header("Content-Type", "video/mp4")
|
||||||
.header("Retry-After", "2")
|
.body(axum::body::Body::from(data))
|
||||||
.body(axum::body::Body::from("segment not yet available"))
|
|
||||||
.unwrap());
|
.unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Ok(axum::response::Response::builder()
|
||||||
|
.status(StatusCode::ACCEPTED)
|
||||||
|
.header("Retry-After", "2")
|
||||||
|
.body(axum::body::Body::from("segment not yet available"))
|
||||||
|
.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
Err(ApiError(
|
Err(ApiError(
|
||||||
pinakes_core::error::PinakesError::InvalidOperation(
|
pinakes_core::error::PinakesError::InvalidOperation(
|
||||||
"no transcode session found; start a transcode first via POST /media/{id}/transcode"
|
"no transcode session found; start a transcode first via POST /media/{id}/transcode"
|
||||||
|
|
|
||||||
|
|
@ -1028,18 +1028,19 @@ 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
|
{
|
||||||
state.edit_title = media.title.clone().unwrap_or_default();
|
// Populate edit fields from selected media
|
||||||
state.edit_artist = media.artist.clone().unwrap_or_default();
|
state.edit_title = media.title.clone().unwrap_or_default();
|
||||||
state.edit_album = media.album.clone().unwrap_or_default();
|
state.edit_artist = media.artist.clone().unwrap_or_default();
|
||||||
state.edit_genre = media.genre.clone().unwrap_or_default();
|
state.edit_album = media.album.clone().unwrap_or_default();
|
||||||
state.edit_year = media.year.map(|y| y.to_string()).unwrap_or_default();
|
state.edit_genre = media.genre.clone().unwrap_or_default();
|
||||||
state.edit_description = media.description.clone().unwrap_or_default();
|
state.edit_year = media.year.map(|y| y.to_string()).unwrap_or_default();
|
||||||
state.edit_field_index = Some(0);
|
state.edit_description = media.description.clone().unwrap_or_default();
|
||||||
state.input_mode = true;
|
state.edit_field_index = Some(0);
|
||||||
state.current_view = View::MetadataEdit;
|
state.input_mode = true;
|
||||||
}
|
state.current_view = View::MetadataEdit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Action::Vacuum => {
|
Action::Vacuum => {
|
||||||
if state.current_view == View::Database {
|
if state.current_view == View::Database {
|
||||||
|
|
@ -1069,90 +1070,91 @@ 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 client = client.clone();
|
let task_id = task.id.clone();
|
||||||
let tx = event_sender.clone();
|
let client = client.clone();
|
||||||
tokio::spawn(async move {
|
let tx = event_sender.clone();
|
||||||
match client.toggle_scheduled_task(&task_id).await {
|
tokio::spawn(async move {
|
||||||
Ok(()) => {
|
match client.toggle_scheduled_task(&task_id).await {
|
||||||
// Refresh tasks list
|
Ok(()) => {
|
||||||
if let Ok(tasks) = client.list_scheduled_tasks().await {
|
// Refresh tasks list
|
||||||
let _ = tx.send(AppEvent::ApiResult(
|
if let Ok(tasks) = client.list_scheduled_tasks().await {
|
||||||
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}"),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to toggle task: {}", e);
|
||||||
|
let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!(
|
||||||
|
"Toggle task failed: {e}"
|
||||||
|
))));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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_name = task.name.clone();
|
let task_id = task.id.clone();
|
||||||
state.status_message = Some(format!("Running task: {task_name}..."));
|
let task_name = task.name.clone();
|
||||||
let client = client.clone();
|
state.status_message = Some(format!("Running task: {task_name}..."));
|
||||||
let tx = event_sender.clone();
|
let client = client.clone();
|
||||||
tokio::spawn(async move {
|
let tx = event_sender.clone();
|
||||||
match client.run_task_now(&task_id).await {
|
tokio::spawn(async move {
|
||||||
Ok(()) => {
|
match client.run_task_now(&task_id).await {
|
||||||
// Refresh tasks list
|
Ok(()) => {
|
||||||
if let Ok(tasks) = client.list_scheduled_tasks().await {
|
// Refresh tasks list
|
||||||
let _ = tx.send(AppEvent::ApiResult(
|
if let Ok(tasks) = client.list_scheduled_tasks().await {
|
||||||
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}"),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to run task: {}", e);
|
||||||
|
let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!(
|
||||||
|
"Run task failed: {e}"
|
||||||
|
))));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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!({
|
{
|
||||||
"title": if state.edit_title.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_title.clone()) },
|
let updates = serde_json::json!({
|
||||||
"artist": if state.edit_artist.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_artist.clone()) },
|
"title": if state.edit_title.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_title.clone()) },
|
||||||
"album": if state.edit_album.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_album.clone()) },
|
"artist": if state.edit_artist.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_artist.clone()) },
|
||||||
"genre": if state.edit_genre.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_genre.clone()) },
|
"album": if state.edit_album.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_album.clone()) },
|
||||||
"year": state.edit_year.parse::<i32>().ok(),
|
"genre": if state.edit_genre.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_genre.clone()) },
|
||||||
"description": if state.edit_description.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_description.clone()) },
|
"year": state.edit_year.parse::<i32>().ok(),
|
||||||
});
|
"description": if state.edit_description.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_description.clone()) },
|
||||||
let media_id = media.id.clone();
|
});
|
||||||
let client = client.clone();
|
let media_id = media.id.clone();
|
||||||
let tx = event_sender.clone();
|
let client = client.clone();
|
||||||
state.status_message = Some("Saving...".to_string());
|
let tx = event_sender.clone();
|
||||||
tokio::spawn(async move {
|
state.status_message = Some("Saving...".to_string());
|
||||||
match client.update_media(&media_id, updates).await {
|
tokio::spawn(async move {
|
||||||
Ok(_) => {
|
match client.update_media(&media_id, updates).await {
|
||||||
let _ = tx.send(AppEvent::ApiResult(ApiResult::MediaUpdated));
|
Ok(_) => {
|
||||||
}
|
let _ = tx.send(AppEvent::ApiResult(ApiResult::MediaUpdated));
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to update media: {}", e);
|
|
||||||
let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!(
|
|
||||||
"Update failed: {e}"
|
|
||||||
))));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
Err(e) => {
|
||||||
state.input_mode = false;
|
tracing::error!("Failed to update media: {}", e);
|
||||||
state.current_view = View::Detail;
|
let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!(
|
||||||
}
|
"Update failed: {e}"
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
state.input_mode = false;
|
||||||
|
state.current_view = View::Detail;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Action::NavigateLeft | Action::NavigateRight | Action::None => {}
|
Action::NavigateLeft | Action::NavigateRight | Action::None => {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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", " > " }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
{
|
||||||
"image" => "Photographer",
|
match category {
|
||||||
"document" | "text" => "Author",
|
"image" => "Photographer",
|
||||||
_ => "Artist",
|
"document" | "text" => "Author",
|
||||||
}}
|
_ => "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 {
|
{
|
||||||
"image" => "Photographer",
|
match category {
|
||||||
"document" | "text" => "Author",
|
"image" => "Photographer",
|
||||||
_ => "Artist",
|
"document" | "text" => "Author",
|
||||||
}}
|
_ => "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}" }
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
|
|
@ -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", "+" }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,32 +248,48 @@ 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
|
||||||
let type_idx = match type_badge_class(&f.media_type) {
|
|
||||||
"type-audio" => 0,
|
|
||||||
"type-video" => 1,
|
|
||||||
"type-image" => 2,
|
|
||||||
"type-document" => 3,
|
|
||||||
"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; }
|
|
||||||
true
|
|
||||||
}).collect();
|
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
"type-image" => 2,
|
||||||
|
"type-document" => 3,
|
||||||
|
"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;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
})
|
||||||
|
.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,36 +617,37 @@ 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",
|
||||||
|
"{progress.files_processed} / {progress.files_found} files processed"
|
||||||
|
}
|
||||||
|
if progress.error_count > 0 {
|
||||||
|
p { class: "text-muted text-sm", "{progress.error_count} errors" }
|
||||||
|
}
|
||||||
|
if progress.scanning {
|
||||||
|
p { class: "text-muted text-sm", "Scanning..." }
|
||||||
|
} else {
|
||||||
|
p { class: "text-muted text-sm", "Scan complete" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if progress.scanning {
|
|
||||||
p { class: "text-muted text-sm", "Scanning..." }
|
|
||||||
} else {
|
|
||||||
p { class: "text-muted text-sm", "Scan complete" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
selected_ids.set(all_ids);
|
.call(
|
||||||
global_all_selected.set(true);
|
EventHandler::new(move |all_ids: Vec<String>| {
|
||||||
}));
|
selected_ids.set(all_ids);
|
||||||
|
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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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! {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,10 +464,9 @@ 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}));
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
option { value: "24", "24" }
|
option { value: "24", "24" }
|
||||||
|
|
@ -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" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,16 @@ 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..." }
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
button { class: "btn btn-primary", onclick: create_click, "Create" }
|
||||||
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}"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue