pinakes: import in parallel; various UI improvements
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I1eb47cd79cd4145c56af966f6756fe1d6a6a6964
This commit is contained in:
parent
278bcaa4b0
commit
116fe7b059
42 changed files with 4316 additions and 316 deletions
|
|
@ -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 => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,13 @@ pub enum Action {
|
|||
Save,
|
||||
Char(char),
|
||||
Backspace,
|
||||
// Multi-select actions
|
||||
ToggleSelection,
|
||||
SelectAll,
|
||||
ClearSelection,
|
||||
ToggleSelectionMode,
|
||||
BatchDelete,
|
||||
BatchTag,
|
||||
None,
|
||||
}
|
||||
|
||||
|
|
@ -87,13 +94,25 @@ pub fn handle_key(key: KeyEvent, in_input_mode: bool, current_view: &View) -> Ac
|
|||
_ => Action::TagView,
|
||||
},
|
||||
(KeyCode::Char('c'), _) => Action::CollectionView,
|
||||
// Multi-select: Ctrl+A for SelectAll (must come before plain 'a')
|
||||
(KeyCode::Char('a'), KeyModifiers::CONTROL) => match current_view {
|
||||
View::Library | View::Search => Action::SelectAll,
|
||||
_ => Action::None,
|
||||
},
|
||||
(KeyCode::Char('a'), _) => Action::AuditView,
|
||||
(KeyCode::Char('S'), _) => Action::SettingsView,
|
||||
(KeyCode::Char('D'), _) => Action::DuplicatesView,
|
||||
(KeyCode::Char('B'), _) => Action::DatabaseView,
|
||||
(KeyCode::Char('Q'), _) => Action::QueueView,
|
||||
(KeyCode::Char('X'), _) => Action::StatisticsView,
|
||||
(KeyCode::Char('T'), _) => Action::TasksView,
|
||||
// Use plain D/T for views in non-library contexts, keep for batch ops in library/search
|
||||
(KeyCode::Char('D'), _) => match current_view {
|
||||
View::Library | View::Search => Action::BatchDelete,
|
||||
_ => Action::DuplicatesView,
|
||||
},
|
||||
(KeyCode::Char('T'), _) => match current_view {
|
||||
View::Library | View::Search => Action::BatchTag,
|
||||
_ => Action::TasksView,
|
||||
},
|
||||
// Ctrl+S must come before plain 's' to ensure proper precedence
|
||||
(KeyCode::Char('s'), KeyModifiers::CONTROL) => match current_view {
|
||||
View::MetadataEdit => Action::Save,
|
||||
|
|
@ -106,7 +125,7 @@ pub fn handle_key(key: KeyEvent, in_input_mode: bool, current_view: &View) -> Ac
|
|||
(KeyCode::Char('-'), _) => Action::UntagMedia,
|
||||
(KeyCode::Char('v'), _) => match current_view {
|
||||
View::Database => Action::Vacuum,
|
||||
_ => Action::None,
|
||||
_ => Action::ToggleSelectionMode,
|
||||
},
|
||||
(KeyCode::Char('x'), _) => match current_view {
|
||||
View::Tasks => Action::RunNow,
|
||||
|
|
@ -116,6 +135,15 @@ pub fn handle_key(key: KeyEvent, in_input_mode: bool, current_view: &View) -> Ac
|
|||
(KeyCode::BackTab, _) => Action::PrevTab,
|
||||
(KeyCode::PageUp, _) => Action::PageUp,
|
||||
(KeyCode::PageDown, _) => Action::PageDown,
|
||||
// Multi-select keys
|
||||
(KeyCode::Char(' '), _) => match current_view {
|
||||
View::Library | View::Search => Action::ToggleSelection,
|
||||
_ => Action::None,
|
||||
},
|
||||
(KeyCode::Char('u'), _) => match current_view {
|
||||
View::Library | View::Search => Action::ClearSelection,
|
||||
_ => Action::None,
|
||||
},
|
||||
_ => Action::None,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use super::{format_duration, format_size, media_type_color};
|
|||
use crate::app::AppState;
|
||||
|
||||
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||
let header = Row::new(vec!["Title / Name", "Type", "Duration", "Year", "Size"]).style(
|
||||
let header = Row::new(vec!["", "Title / Name", "Type", "Duration", "Year", "Size"]).style(
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
|
|
@ -19,12 +19,27 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
|||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, item)| {
|
||||
let style = if Some(i) == state.selected_index {
|
||||
let is_cursor = Some(i) == state.selected_index;
|
||||
let is_selected = state.selected_items.contains(&item.id);
|
||||
|
||||
let style = if is_cursor {
|
||||
Style::default().fg(Color::Black).bg(Color::Cyan)
|
||||
} else if is_selected {
|
||||
Style::default().fg(Color::Black).bg(Color::Green)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
// Selection marker
|
||||
let marker = if is_selected { "[*]" } else { "[ ]" };
|
||||
let marker_style = if is_selected {
|
||||
Style::default()
|
||||
.fg(Color::Green)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
|
||||
let display_name = item.title.as_deref().unwrap_or(&item.file_name).to_string();
|
||||
|
||||
let type_color = media_type_color(&item.media_type);
|
||||
|
|
@ -44,6 +59,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
|||
.unwrap_or_else(|| "-".to_string());
|
||||
|
||||
Row::new(vec![
|
||||
Cell::from(Span::styled(marker, marker_style)),
|
||||
Cell::from(display_name),
|
||||
type_cell,
|
||||
Cell::from(duration),
|
||||
|
|
@ -56,16 +72,22 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
|||
|
||||
let page = (state.page_offset / state.page_size) + 1;
|
||||
let item_count = state.media_list.len();
|
||||
let title = format!(" Library (page {page}, {item_count} items) ");
|
||||
let selected_count = state.selected_items.len();
|
||||
let title = if selected_count > 0 {
|
||||
format!(" Library (page {page}, {item_count} items, {selected_count} selected) ")
|
||||
} else {
|
||||
format!(" Library (page {page}, {item_count} items) ")
|
||||
};
|
||||
|
||||
let table = Table::new(
|
||||
rows,
|
||||
[
|
||||
Constraint::Percentage(35),
|
||||
Constraint::Percentage(20),
|
||||
Constraint::Percentage(15),
|
||||
Constraint::Percentage(10),
|
||||
Constraint::Percentage(20),
|
||||
Constraint::Length(3), // Selection marker
|
||||
Constraint::Percentage(33), // Title
|
||||
Constraint::Percentage(18), // Type
|
||||
Constraint::Percentage(13), // Duration
|
||||
Constraint::Percentage(8), // Year
|
||||
Constraint::Percentage(18), // Size
|
||||
],
|
||||
)
|
||||
.header(header)
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
|||
f.render_widget(input, chunks[0]);
|
||||
|
||||
// Results
|
||||
let header = Row::new(vec!["Name", "Type", "Artist", "Size"]).style(
|
||||
let header = Row::new(vec!["", "Name", "Type", "Artist", "Size"]).style(
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
|
|
@ -39,12 +39,27 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
|||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, item)| {
|
||||
let style = if Some(i) == state.search_selected {
|
||||
let is_cursor = Some(i) == state.search_selected;
|
||||
let is_selected = state.selected_items.contains(&item.id);
|
||||
|
||||
let style = if is_cursor {
|
||||
Style::default().fg(Color::Black).bg(Color::Cyan)
|
||||
} else if is_selected {
|
||||
Style::default().fg(Color::Black).bg(Color::Green)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
// Selection marker
|
||||
let marker = if is_selected { "[*]" } else { "[ ]" };
|
||||
let marker_style = if is_selected {
|
||||
Style::default()
|
||||
.fg(Color::Green)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
|
||||
let type_color = media_type_color(&item.media_type);
|
||||
let type_cell = Cell::from(Span::styled(
|
||||
item.media_type.clone(),
|
||||
|
|
@ -52,6 +67,7 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
|||
));
|
||||
|
||||
Row::new(vec![
|
||||
Cell::from(Span::styled(marker, marker_style)),
|
||||
Cell::from(item.file_name.clone()),
|
||||
type_cell,
|
||||
Cell::from(item.artist.clone().unwrap_or_default()),
|
||||
|
|
@ -63,15 +79,21 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
|||
|
||||
let shown = state.search_results.len();
|
||||
let total = state.search_total_count;
|
||||
let results_title = format!(" Results: {shown} shown, {total} total ");
|
||||
let selected_count = state.selected_items.len();
|
||||
let results_title = if selected_count > 0 {
|
||||
format!(" Results: {shown} shown, {total} total, {selected_count} selected ")
|
||||
} else {
|
||||
format!(" Results: {shown} shown, {total} total ")
|
||||
};
|
||||
|
||||
let table = Table::new(
|
||||
rows,
|
||||
[
|
||||
Constraint::Percentage(35),
|
||||
Constraint::Percentage(20),
|
||||
Constraint::Percentage(25),
|
||||
Constraint::Percentage(20),
|
||||
Constraint::Length(3), // Selection marker
|
||||
Constraint::Percentage(33), // Name
|
||||
Constraint::Percentage(18), // Type
|
||||
Constraint::Percentage(23), // Artist
|
||||
Constraint::Percentage(18), // Size
|
||||
],
|
||||
)
|
||||
.header(header)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue