pinakes: import in parallel; various UI improvements

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I1eb47cd79cd4145c56af966f6756fe1d6a6a6964
This commit is contained in:
raf 2026-02-03 10:31:20 +03:00
commit 116fe7b059
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
42 changed files with 4316 additions and 316 deletions

View file

@ -1,3 +1,4 @@
use std::collections::HashSet;
use std::time::Duration;
use anyhow::Result;
@ -53,6 +54,9 @@ pub struct AppState {
pub page_size: u64,
pub total_media_count: u64,
pub server_url: String,
// Multi-select support
pub selected_items: HashSet<String>,
pub selection_mode: bool,
// Duplicates view
pub duplicate_groups: Vec<crate::client::DuplicateGroupResponse>,
pub duplicates_selected: Option<usize>,
@ -131,6 +135,9 @@ impl AppState {
page_size: 50,
total_media_count: 0,
server_url: server_url.to_string(),
// Multi-select
selected_items: HashSet::new(),
selection_mode: false,
}
}
}
@ -1156,6 +1163,154 @@ async fn handle_action(
state.current_view = View::Detail;
}
}
Action::ToggleSelection => {
// Toggle selection of current item
let item_id = match state.current_view {
View::Search => state
.search_selected
.and_then(|i| state.search_results.get(i))
.map(|m| m.id.clone()),
View::Library => state
.selected_index
.and_then(|i| state.media_list.get(i))
.map(|m| m.id.clone()),
_ => None,
};
if let Some(id) = item_id {
if state.selected_items.contains(&id) {
state.selected_items.remove(&id);
} else {
state.selected_items.insert(id);
}
let count = state.selected_items.len();
state.status_message = Some(format!("{} item(s) selected", count));
}
}
Action::SelectAll => {
// Select all items in current view
let items: Vec<String> = match state.current_view {
View::Search => state.search_results.iter().map(|m| m.id.clone()).collect(),
View::Library => state.media_list.iter().map(|m| m.id.clone()).collect(),
_ => Vec::new(),
};
for id in items {
state.selected_items.insert(id);
}
let count = state.selected_items.len();
state.status_message = Some(format!("{} item(s) selected", count));
}
Action::ClearSelection => {
state.selected_items.clear();
state.selection_mode = false;
state.status_message = Some("Selection cleared".into());
}
Action::ToggleSelectionMode => {
state.selection_mode = !state.selection_mode;
if state.selection_mode {
state.status_message =
Some("Selection mode: ON (Space to toggle, u to clear)".into());
} else {
state.status_message = Some("Selection mode: OFF".into());
}
}
Action::BatchDelete => {
if state.selected_items.is_empty() {
state.status_message = Some("No items selected".into());
} else {
let count = state.selected_items.len();
let ids: Vec<String> = state.selected_items.iter().cloned().collect();
state.status_message = Some(format!("Deleting {} item(s)...", count));
let client = client.clone();
let tx = event_sender.clone();
let page_offset = state.page_offset;
let page_size = state.page_size;
tokio::spawn(async move {
let mut deleted = 0;
let mut errors = Vec::new();
for id in &ids {
match client.delete_media(id).await {
Ok(_) => deleted += 1,
Err(e) => errors.push(format!("{}: {}", id, e)),
}
}
// Refresh the media list
if let Ok(items) = client.list_media(page_offset, page_size).await {
let _ = tx.send(AppEvent::ApiResult(ApiResult::MediaList(items)));
}
if errors.is_empty() {
let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!(
"Deleted {} item(s)",
deleted
))));
} else {
let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!(
"Deleted {} item(s), {} error(s)",
deleted,
errors.len()
))));
}
});
state.selected_items.clear();
}
}
Action::BatchTag => {
if state.selected_items.is_empty() {
state.status_message = Some("No items selected".into());
} else if state.all_tags.is_empty() {
// Load tags first
match client.list_tags().await {
Ok(tags) => {
state.all_tags = tags;
if state.all_tags.is_empty() {
state.status_message =
Some("No tags available. Create a tag first.".into());
} else {
state.tag_selected = Some(0);
state.status_message = Some(format!(
"{} item(s) selected. Press +/- to tag/untag with selected tag.",
state.selected_items.len()
));
}
}
Err(e) => state.status_message = Some(format!("Failed to load tags: {e}")),
}
} else if let Some(tag_idx) = state.tag_selected
&& let Some(tag) = state.all_tags.get(tag_idx)
{
let count = state.selected_items.len();
let ids: Vec<String> = state.selected_items.iter().cloned().collect();
let tag_id = tag.id.clone();
let tag_name = tag.name.clone();
state.status_message =
Some(format!("Tagging {} item(s) with '{}'...", count, tag_name));
let client = client.clone();
let tx = event_sender.clone();
tokio::spawn(async move {
let mut tagged = 0;
let mut errors = Vec::new();
for id in &ids {
match client.tag_media(id, &tag_id).await {
Ok(_) => tagged += 1,
Err(e) => errors.push(format!("{}: {}", id, e)),
}
}
if errors.is_empty() {
let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!(
"Tagged {} item(s) with '{}'",
tagged, tag_name
))));
} else {
let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!(
"Tagged {} item(s), {} error(s)",
tagged,
errors.len()
))));
}
});
} else {
state.status_message = Some("Select a tag first (use t to view tags)".into());
}
}
Action::NavigateLeft | Action::NavigateRight | Action::None => {}
}
}