From bb69f2fa370ffea5296840b3b42179120cddc62b Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Mar 2026 02:19:55 +0300 Subject: [PATCH] pinakes-tui: cover more API routes in the TUI crate Signed-off-by: NotAShelf Change-Id: Id14b6f82d3b9f3c27bee9c214a1bdedc6a6a6964 --- crates/pinakes-tui/src/app.rs | 1040 ++++++++++++++++++++++-- crates/pinakes-tui/src/client.rs | 363 ++++++++- crates/pinakes-tui/src/event.rs | 17 + crates/pinakes-tui/src/input.rs | 110 ++- crates/pinakes-tui/src/ui/admin.rs | 174 ++++ crates/pinakes-tui/src/ui/detail.rs | 107 +++ crates/pinakes-tui/src/ui/mod.rs | 19 +- crates/pinakes-tui/src/ui/playlists.rs | 117 +++ 8 files changed, 1873 insertions(+), 74 deletions(-) create mode 100644 crates/pinakes-tui/src/ui/admin.rs create mode 100644 crates/pinakes-tui/src/ui/playlists.rs diff --git a/crates/pinakes-tui/src/app.rs b/crates/pinakes-tui/src/app.rs index d1ee90c..45d0761 100644 --- a/crates/pinakes-tui/src/app.rs +++ b/crates/pinakes-tui/src/app.rs @@ -13,7 +13,14 @@ use crate::{ ApiClient, AuditEntryResponse, BookMetadataResponse, + CommentResponse, + DeviceResponse, + PlaylistResponse, ReadingProgressResponse, + SubtitleEntry, + TranscodeSessionResponse, + UserResponse, + WebhookInfo, }, event::{ApiResult, AppEvent, EventHandler}, input::{self, Action}, @@ -37,6 +44,8 @@ pub enum View { Statistics, Tasks, Books, + Playlists, + Admin, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -114,6 +123,37 @@ pub struct AppState { // Reading progress input (page number) pub page_input: String, pub entering_page: bool, + // Playlists view + pub playlists: Vec, + pub playlists_selected: usize, + pub playlist_items: Vec, + pub playlist_items_selected: usize, + pub viewing_playlist_items: bool, + pub playlist_name_input: String, + pub creating_playlist: bool, + // Social / detail extras + pub media_comments: Vec, + pub media_rating: Option, + pub is_favorite: bool, + pub comment_input: String, + pub entering_comment: bool, + pub entering_rating: bool, + pub rating_input: String, + pub subtitles: Vec, + pub showing_subtitles: bool, + pub showing_transcodes: bool, + pub transcode_profile_input: String, + pub entering_transcode: bool, + // Admin view + pub admin_tab: usize, + pub users_list: Vec, + pub users_selected: usize, + pub sync_devices: Vec, + pub sync_devices_selected: usize, + pub webhooks: Vec, + pub webhooks_selected: usize, + pub transcodes: Vec, + pub transcodes_selected: usize, } #[derive(Clone)] @@ -182,6 +222,34 @@ impl AppState { reading_progress: None, page_input: String::new(), entering_page: false, + playlists: Vec::new(), + playlists_selected: 0, + playlist_items: Vec::new(), + playlist_items_selected: 0, + viewing_playlist_items: false, + playlist_name_input: String::new(), + creating_playlist: false, + media_comments: Vec::new(), + media_rating: None, + is_favorite: false, + comment_input: String::new(), + entering_comment: false, + entering_rating: false, + rating_input: String::new(), + subtitles: Vec::new(), + showing_subtitles: false, + showing_transcodes: false, + transcode_profile_input: String::new(), + entering_transcode: false, + admin_tab: 0, + users_list: Vec::new(), + users_selected: 0, + sync_devices: Vec::new(), + sync_devices_selected: 0, + webhooks: Vec::new(), + webhooks_selected: 0, + transcodes: Vec::new(), + transcodes_selected: 0, } } } @@ -221,8 +289,215 @@ pub async fn run(server_url: &str, api_key: Option<&str>) -> Result<()> { if let Some(event) = events.next().await { match event { AppEvent::Key(key) => { + // Intercept input when entering a comment + if state.entering_comment { + use crossterm::event::KeyCode; + match key.code { + KeyCode::Char(c) => { + state.comment_input.push(c); + state.status_message = + Some(format!("Comment: {}", state.comment_input)); + }, + KeyCode::Backspace => { + state.comment_input.pop(); + state.status_message = if state.comment_input.is_empty() { + Some("Enter comment (Enter to submit, Esc to cancel)".into()) + } else { + Some(format!("Comment: {}", state.comment_input)) + }; + }, + KeyCode::Enter => { + state.entering_comment = false; + let text = state.comment_input.clone(); + state.comment_input.clear(); + if !text.is_empty() + && let Some(ref media) = state.selected_media + { + let media_id = media.id.clone(); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.add_comment(&media_id, &text).await { + Ok(_) => { + if let Ok(comments) = + client.list_comments(&media_id).await + { + let _ = tx.send(AppEvent::ApiResult( + ApiResult::CommentsLoaded(comments), + )); + } + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error( + format!("Comment: {e}"), + ))); + }, + } + }); + } + }, + KeyCode::Esc => { + state.entering_comment = false; + state.comment_input.clear(); + state.status_message = None; + }, + _ => {}, + } + // Intercept input when entering a rating + } else if state.entering_rating { + use crossterm::event::KeyCode; + match key.code { + KeyCode::Char(c) if c.is_ascii_digit() => { + state.rating_input.clear(); + state.rating_input.push(c); + state.status_message = + Some(format!("Rating: {c} stars (Enter to confirm)")); + }, + KeyCode::Enter => { + state.entering_rating = false; + if let Ok(stars) = state.rating_input.parse::() { + if (1u8..=5).contains(&stars) + && let Some(ref media) = state.selected_media + { + let media_id = media.id.clone(); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.rate_media(&media_id, stars).await { + Ok(()) => { + let _ = tx.send(AppEvent::ApiResult( + ApiResult::RatingSet(stars), + )); + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult( + ApiResult::Error(format!("Rate: {e}")), + )); + }, + } + }); + } else { + state.status_message = + Some("Rating must be between 1 and 5".into()); + } + } + state.rating_input.clear(); + }, + KeyCode::Esc => { + state.entering_rating = false; + state.rating_input.clear(); + state.status_message = None; + }, + _ => {}, + } + // Intercept input when entering transcode profile + } else if state.entering_transcode { + use crossterm::event::KeyCode; + match key.code { + KeyCode::Char(c) => { + state.transcode_profile_input.push(c); + state.status_message = + Some(format!("Profile: {}", state.transcode_profile_input)); + }, + KeyCode::Backspace => { + state.transcode_profile_input.pop(); + state.status_message = + if state.transcode_profile_input.is_empty() { + Some( + "Enter transcode profile (Enter to start, Esc to cancel)" + .into(), + ) + } else { + Some(format!("Profile: {}", state.transcode_profile_input)) + }; + }, + KeyCode::Enter => { + state.entering_transcode = false; + let profile = state.transcode_profile_input.clone(); + state.transcode_profile_input.clear(); + if !profile.is_empty() + && let Some(ref media) = state.selected_media + { + let media_id = media.id.clone(); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.start_transcode(&media_id, &profile).await { + Ok(_) => { + let _ = tx.send(AppEvent::ApiResult( + ApiResult::TranscodeStarted, + )); + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error( + format!("Transcode: {e}"), + ))); + }, + } + }); + } + }, + KeyCode::Esc => { + state.entering_transcode = false; + state.transcode_profile_input.clear(); + state.status_message = None; + }, + _ => {}, + } + // Intercept input when creating a playlist + } else if state.creating_playlist { + use crossterm::event::KeyCode; + match key.code { + KeyCode::Char(c) => { + state.playlist_name_input.push(c); + state.status_message = + Some(format!("Playlist name: {}", state.playlist_name_input)); + }, + KeyCode::Backspace => { + state.playlist_name_input.pop(); + state.status_message = if state.playlist_name_input.is_empty() { + Some( + "Enter playlist name (Enter to create, Esc to cancel)" + .into(), + ) + } else { + Some(format!("Playlist name: {}", state.playlist_name_input)) + }; + }, + KeyCode::Enter => { + state.creating_playlist = false; + let name = state.playlist_name_input.clone(); + state.playlist_name_input.clear(); + if !name.is_empty() { + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.create_playlist(&name, None).await { + Ok(_) => { + if let Ok(playlists) = client.list_playlists().await { + let _ = tx.send(AppEvent::ApiResult( + ApiResult::PlaylistsLoaded(playlists), + )); + } + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error( + format!("Create playlist: {e}"), + ))); + }, + } + }); + } + }, + KeyCode::Esc => { + state.creating_playlist = false; + state.playlist_name_input.clear(); + state.status_message = None; + }, + _ => {}, + } // Intercept input when entering reading progress page number - if state.entering_page { + } else if state.entering_page { use crossterm::event::KeyCode; match key.code { KeyCode::Char(c) if c.is_ascii_digit() => { @@ -425,6 +700,69 @@ fn handle_api_result(state: &mut AppState, result: ApiResult) { ApiResult::ReadingProgressUpdated => { state.status_message = Some("Reading progress updated".into()); }, + ApiResult::PlaylistsLoaded(playlists) => { + state.playlists = playlists; + if !state.playlists.is_empty() + && state.playlists_selected >= state.playlists.len() + { + state.playlists_selected = 0; + } + state.status_message = None; + }, + ApiResult::PlaylistItemsLoaded(items) => { + state.playlist_items = items; + state.playlist_items_selected = 0; + state.viewing_playlist_items = true; + state.status_message = None; + }, + ApiResult::CommentsLoaded(comments) => { + state.media_comments = comments; + state.status_message = None; + }, + ApiResult::RatingSet(stars) => { + state.media_rating = Some(stars); + state.status_message = Some("Rating saved".into()); + }, + ApiResult::FavoriteToggled => { + state.is_favorite = !state.is_favorite; + if state.is_favorite { + state.status_message = Some("Added to favorites".into()); + } else { + state.status_message = Some("Removed from favorites".into()); + } + }, + ApiResult::SubtitlesLoaded(resp) => { + state.subtitles = resp.subtitles; + state.showing_subtitles = true; + state.status_message = None; + }, + ApiResult::EnrichmentTriggered => { + state.status_message = Some("Enrichment started".into()); + }, + ApiResult::TranscodeStarted => { + state.status_message = Some("Transcode started".into()); + }, + ApiResult::UsersLoaded(users) => { + state.users_list = users; + state.users_selected = 0; + state.status_message = None; + }, + ApiResult::SyncDevicesLoaded(devices) => { + state.sync_devices = devices; + state.sync_devices_selected = 0; + state.status_message = None; + }, + ApiResult::WebhooksLoaded(webhooks) => { + state.webhooks = webhooks; + state.webhooks_selected = 0; + state.status_message = None; + }, + ApiResult::TranscodesLoaded(transcodes) => { + state.transcodes = transcodes; + state.transcodes_selected = 0; + state.showing_transcodes = true; + state.status_message = None; + }, ApiResult::Error(msg) => { state.status_message = Some(format!("Error: {msg}")); }, @@ -440,70 +778,93 @@ async fn handle_action( match action { Action::Quit => state.should_quit = true, Action::NavigateDown => { - let len = match state.current_view { - View::Search => state.search_results.len(), - View::Tags => state.tags.len(), - View::Collections => state.collections.len(), - View::Audit => state.audit_log.len(), - View::Books => { - match state.books_sub_view { - BooksSubView::List => state.books_list.len(), - BooksSubView::Series => state.books_series.len(), - BooksSubView::Authors => state.books_authors.len(), + if state.current_view == View::Playlists { + if state.viewing_playlist_items { + let len = state.playlist_items.len(); + if len > 0 { + state.playlist_items_selected = + (state.playlist_items_selected + 1).min(len - 1); } - }, - _ => state.media_list.len(), - }; - if len > 0 { - let idx = match state.current_view { - View::Search => &mut state.search_selected, - View::Tags => &mut state.tag_selected, - View::Collections => &mut state.collection_selected, - View::Audit => &mut state.audit_selected, - View::Books => &mut state.books_selected, - _ => &mut state.selected_index, + } else { + let len = state.playlists.len(); + if len > 0 { + state.playlists_selected = + (state.playlists_selected + 1).min(len - 1); + } + } + } else if state.current_view == View::Admin { + match state.admin_tab { + 0 => { + let len = state.users_list.len(); + if len > 0 { + state.users_selected = (state.users_selected + 1).min(len - 1); + } + }, + 1 => { + let len = state.sync_devices.len(); + if len > 0 { + state.sync_devices_selected = + (state.sync_devices_selected + 1).min(len - 1); + } + }, + _ => { + let len = state.webhooks.len(); + if len > 0 { + state.webhooks_selected = + (state.webhooks_selected + 1).min(len - 1); + } + }, + } + } else { + let len = match state.current_view { + View::Search => state.search_results.len(), + View::Tags => state.tags.len(), + View::Collections => state.collections.len(), + View::Audit => state.audit_log.len(), + View::Books => { + match state.books_sub_view { + BooksSubView::List => state.books_list.len(), + BooksSubView::Series => state.books_series.len(), + BooksSubView::Authors => state.books_authors.len(), + } + }, + _ => state.media_list.len(), }; - *idx = Some(idx.map_or(0, |i| (i + 1).min(len - 1))); + if len > 0 { + let idx = match state.current_view { + View::Search => &mut state.search_selected, + View::Tags => &mut state.tag_selected, + View::Collections => &mut state.collection_selected, + View::Audit => &mut state.audit_selected, + View::Books => &mut state.books_selected, + _ => &mut state.selected_index, + }; + *idx = Some(idx.map_or(0, |i| (i + 1).min(len - 1))); + } } }, Action::NavigateUp => { - let idx = match state.current_view { - View::Search => &mut state.search_selected, - View::Tags => &mut state.tag_selected, - View::Collections => &mut state.collection_selected, - View::Audit => &mut state.audit_selected, - View::Books => &mut state.books_selected, - _ => &mut state.selected_index, - }; - *idx = Some(idx.map_or(0, |i| i.saturating_sub(1))); - }, - Action::GoTop => { - let idx = match state.current_view { - View::Search => &mut state.search_selected, - View::Tags => &mut state.tag_selected, - View::Collections => &mut state.collection_selected, - View::Audit => &mut state.audit_selected, - View::Books => &mut state.books_selected, - _ => &mut state.selected_index, - }; - *idx = Some(0); - }, - Action::GoBottom => { - let len = match state.current_view { - View::Search => state.search_results.len(), - View::Tags => state.tags.len(), - View::Collections => state.collections.len(), - View::Audit => state.audit_log.len(), - View::Books => { - match state.books_sub_view { - BooksSubView::List => state.books_list.len(), - BooksSubView::Series => state.books_series.len(), - BooksSubView::Authors => state.books_authors.len(), - } - }, - _ => state.media_list.len(), - }; - if len > 0 { + if state.current_view == View::Playlists { + if state.viewing_playlist_items { + state.playlist_items_selected = + state.playlist_items_selected.saturating_sub(1); + } else { + state.playlists_selected = state.playlists_selected.saturating_sub(1); + } + } else if state.current_view == View::Admin { + match state.admin_tab { + 0 => { + state.users_selected = state.users_selected.saturating_sub(1); + }, + 1 => { + state.sync_devices_selected = + state.sync_devices_selected.saturating_sub(1); + }, + _ => { + state.webhooks_selected = state.webhooks_selected.saturating_sub(1); + }, + } + } else { let idx = match state.current_view { View::Search => &mut state.search_selected, View::Tags => &mut state.tag_selected, @@ -512,10 +873,121 @@ async fn handle_action( View::Books => &mut state.books_selected, _ => &mut state.selected_index, }; - *idx = Some(len - 1); + *idx = Some(idx.map_or(0, |i| i.saturating_sub(1))); + } + }, + Action::GoTop => { + if state.current_view == View::Playlists { + if state.viewing_playlist_items { + state.playlist_items_selected = 0; + } else { + state.playlists_selected = 0; + } + } else if state.current_view == View::Admin { + match state.admin_tab { + 0 => state.users_selected = 0, + 1 => state.sync_devices_selected = 0, + _ => state.webhooks_selected = 0, + } + } else { + let idx = match state.current_view { + View::Search => &mut state.search_selected, + View::Tags => &mut state.tag_selected, + View::Collections => &mut state.collection_selected, + View::Audit => &mut state.audit_selected, + View::Books => &mut state.books_selected, + _ => &mut state.selected_index, + }; + *idx = Some(0); + } + }, + Action::GoBottom => { + if state.current_view == View::Playlists { + if state.viewing_playlist_items { + let len = state.playlist_items.len(); + if len > 0 { + state.playlist_items_selected = len - 1; + } + } else { + let len = state.playlists.len(); + if len > 0 { + state.playlists_selected = len - 1; + } + } + } else if state.current_view == View::Admin { + match state.admin_tab { + 0 => { + let len = state.users_list.len(); + if len > 0 { + state.users_selected = len - 1; + } + }, + 1 => { + let len = state.sync_devices.len(); + if len > 0 { + state.sync_devices_selected = len - 1; + } + }, + _ => { + let len = state.webhooks.len(); + if len > 0 { + state.webhooks_selected = len - 1; + } + }, + } + } else { + let len = match state.current_view { + View::Search => state.search_results.len(), + View::Tags => state.tags.len(), + View::Collections => state.collections.len(), + View::Audit => state.audit_log.len(), + View::Books => { + match state.books_sub_view { + BooksSubView::List => state.books_list.len(), + BooksSubView::Series => state.books_series.len(), + BooksSubView::Authors => state.books_authors.len(), + } + }, + _ => state.media_list.len(), + }; + if len > 0 { + let idx = match state.current_view { + View::Search => &mut state.search_selected, + View::Tags => &mut state.tag_selected, + View::Collections => &mut state.collection_selected, + View::Audit => &mut state.audit_selected, + View::Books => &mut state.books_selected, + _ => &mut state.selected_index, + }; + *idx = Some(len - 1); + } } }, Action::Select => { + if state.current_view == View::Playlists && !state.input_mode { + // Open items for the selected playlist inline (no recursion) + if let Some(pl) = state.playlists.get(state.playlists_selected).cloned() + { + state.status_message = Some("Loading playlist items...".into()); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.get_playlist_items(&pl.id).await { + Ok(items) => { + let _ = tx.send(AppEvent::ApiResult( + ApiResult::PlaylistItemsLoaded(items), + )); + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error( + format!("Playlist items: {e}"), + ))); + }, + } + }); + } + return; + } if state.input_mode { state.input_mode = false; match state.current_view { @@ -625,6 +1097,24 @@ async fn handle_action( client.get_book_metadata(&full_media.id).await.ok(); state.reading_progress = client.get_reading_progress(&full_media.id).await.ok(); + // Load comments and subtitles asynchronously + let media_id = full_media.id.clone(); + let client_clone = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + if let Ok(comments) = client_clone.list_comments(&media_id).await + { + let _ = tx.send(AppEvent::ApiResult( + ApiResult::CommentsLoaded(comments), + )); + } + }); + state.media_comments.clear(); + state.media_rating = None; + state.is_favorite = false; + state.showing_subtitles = false; + state.showing_transcodes = false; + state.subtitles.clear(); state.selected_media = Some(full_media); if let Some(tags) = media_tags { state.tags = tags; @@ -635,6 +1125,12 @@ async fn handle_action( } else { state.book_metadata = None; state.reading_progress = None; + state.media_comments.clear(); + state.media_rating = None; + state.is_favorite = false; + state.showing_subtitles = false; + state.showing_transcodes = false; + state.subtitles.clear(); state.selected_media = Some(media); } state.current_view = View::Detail; @@ -644,6 +1140,11 @@ async fn handle_action( Action::Back => { if state.input_mode { state.input_mode = false; + } else if state.current_view == View::Playlists + && state.viewing_playlist_items + { + state.viewing_playlist_items = false; + state.playlist_items.clear(); } else { state.current_view = View::Library; state.status_message = None; @@ -1079,6 +1580,28 @@ async fn handle_action( tx.send(AppEvent::ApiResult(ApiResult::BookAuthors(authors))); } }, + View::Playlists => { + if let Ok(playlists) = client.list_playlists().await { + let _ = tx.send(AppEvent::ApiResult(ApiResult::PlaylistsLoaded( + playlists, + ))); + } + }, + View::Admin => { + if let Ok(users) = client.list_users().await { + let _ = + tx.send(AppEvent::ApiResult(ApiResult::UsersLoaded(users))); + } + if let Ok(devices) = client.list_sync_devices().await { + let _ = tx.send(AppEvent::ApiResult( + ApiResult::SyncDevicesLoaded(devices), + )); + } + if let Ok(webhooks) = client.list_webhooks().await { + let _ = tx + .send(AppEvent::ApiResult(ApiResult::WebhooksLoaded(webhooks))); + } + }, View::Search | View::MetadataEdit | View::Queue => { // No generic refresh for these views }, @@ -1113,7 +1636,9 @@ async fn handle_action( View::Books => View::Queue, View::Queue => View::Statistics, View::Statistics => View::Tasks, - View::Tasks + View::Tasks => View::Playlists, + View::Playlists => View::Admin, + View::Admin | View::Detail | View::Import | View::Settings @@ -1143,7 +1668,7 @@ async fn handle_action( } } else { state.current_view = match state.current_view { - View::Library => View::Tasks, + View::Library => View::Admin, View::Search | View::Detail | View::Import @@ -1158,6 +1683,8 @@ async fn handle_action( View::Queue => View::Books, View::Statistics => View::Queue, View::Tasks => View::Statistics, + View::Playlists => View::Tasks, + View::Admin => View::Playlists, }; } }, @@ -1629,6 +2156,393 @@ async fn handle_action( Some("Select a tag first (use t to view tags)".into()); } }, + Action::PlaylistsView => { + state.current_view = View::Playlists; + state.viewing_playlist_items = false; + state.status_message = Some("Loading playlists...".into()); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.list_playlists().await { + Ok(playlists) => { + let _ = tx + .send(AppEvent::ApiResult(ApiResult::PlaylistsLoaded(playlists))); + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Playlists: {e}" + )))); + }, + } + }); + }, + Action::CreatePlaylist => { + if state.current_view == View::Playlists { + state.creating_playlist = true; + state.playlist_name_input.clear(); + state.status_message = + Some("Enter playlist name (Enter to create, Esc to cancel)".into()); + } + }, + Action::DeletePlaylist => { + if state.current_view == View::Playlists + && !state.viewing_playlist_items + && let Some(pl) = state.playlists.get(state.playlists_selected).cloned() + { + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.delete_playlist(&pl.id).await { + Ok(()) => { + if let Ok(playlists) = client.list_playlists().await { + let _ = tx.send(AppEvent::ApiResult( + ApiResult::PlaylistsLoaded(playlists), + )); + } + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Delete playlist: {e}" + )))); + }, + } + }); + } + }, + Action::RemoveFromPlaylist => { + if state.current_view == View::Playlists + && state.viewing_playlist_items + && let Some(pl) = state.playlists.get(state.playlists_selected).cloned() + && let Some(item) = state + .playlist_items + .get(state.playlist_items_selected) + .cloned() + { + let client = client.clone(); + let tx = event_sender.clone(); + let pl_id = pl.id; + tokio::spawn(async move { + match client.remove_from_playlist(&pl_id, &item.id).await { + Ok(()) => { + if let Ok(items) = client.get_playlist_items(&pl_id).await { + let _ = tx.send(AppEvent::ApiResult( + ApiResult::PlaylistItemsLoaded(items), + )); + } + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Remove from playlist: {e}" + )))); + }, + } + }); + } + }, + Action::ShufflePlaylist => { + if state.current_view == View::Playlists + && let Some(pl) = state.playlists.get(state.playlists_selected).cloned() + { + let client = client.clone(); + let tx = event_sender.clone(); + let pl_id = pl.id; + tokio::spawn(async move { + match client.shuffle_playlist(&pl_id).await { + Ok(()) => { + if let Ok(items) = client.get_playlist_items(&pl_id).await { + let _ = tx.send(AppEvent::ApiResult( + ApiResult::PlaylistItemsLoaded(items), + )); + } + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Shuffle playlist: {e}" + )))); + }, + } + }); + } + }, + Action::ToggleFavorite => { + if state.current_view == View::Detail + && let Some(ref media) = state.selected_media + { + let media_id = media.id.clone(); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.toggle_favorite(&media_id).await { + Ok(()) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::FavoriteToggled)); + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Favorite: {e}" + )))); + }, + } + }); + } + }, + Action::RateMedia => { + if state.current_view == View::Detail && state.selected_media.is_some() { + state.entering_rating = true; + state.rating_input.clear(); + state.status_message = + Some("Enter rating 1-5 (Enter to confirm, Esc to cancel)".into()); + } + }, + Action::AddComment => { + if state.current_view == View::Detail && state.selected_media.is_some() { + state.entering_comment = true; + state.comment_input.clear(); + state.status_message = + Some("Enter comment (Enter to submit, Esc to cancel)".into()); + } + }, + Action::EnrichMedia => { + if state.current_view == View::Detail + && let Some(ref media) = state.selected_media + { + let media_id = media.id.clone(); + state.status_message = Some("Enriching...".into()); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.enrich_media(&media_id).await { + Ok(()) => { + let _ = + tx.send(AppEvent::ApiResult(ApiResult::EnrichmentTriggered)); + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Enrich: {e}" + )))); + }, + } + }); + } + }, + Action::ToggleSubtitles => { + if state.current_view == View::Detail { + if state.showing_subtitles { + state.showing_subtitles = false; + } else if let Some(ref media) = state.selected_media { + let media_id = media.id.clone(); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.list_subtitles(&media_id).await { + Ok(resp) => { + let _ = tx + .send(AppEvent::ApiResult(ApiResult::SubtitlesLoaded(resp))); + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error( + format!("Subtitles: {e}"), + ))); + }, + } + }); + } + } + }, + Action::ToggleTranscodes => { + if state.current_view == View::Detail { + if state.showing_transcodes { + state.showing_transcodes = false; + } else { + state.entering_transcode = true; + state.transcode_profile_input.clear(); + state.status_message = Some( + "Enter transcode profile (Enter to start, Esc to cancel)".into(), + ); + } + } + }, + Action::CancelTranscode => { + if state.current_view == View::Detail + && state.showing_transcodes + && let Some(tc) = + state.transcodes.get(state.transcodes_selected).cloned() + { + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.cancel_transcode(&tc.id).await { + Ok(()) => { + if let Ok(transcodes) = client.list_transcodes().await { + let _ = tx.send(AppEvent::ApiResult( + ApiResult::TranscodesLoaded(transcodes), + )); + } + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Cancel transcode: {e}" + )))); + }, + } + }); + } + }, + Action::AdminView => { + state.current_view = View::Admin; + state.admin_tab = 0; + state.status_message = Some("Loading admin data...".into()); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + if let Ok(users) = client.list_users().await { + let _ = tx.send(AppEvent::ApiResult(ApiResult::UsersLoaded(users))); + } + if let Ok(devices) = client.list_sync_devices().await { + let _ = + tx.send(AppEvent::ApiResult(ApiResult::SyncDevicesLoaded(devices))); + } + if let Ok(webhooks) = client.list_webhooks().await { + let _ = + tx.send(AppEvent::ApiResult(ApiResult::WebhooksLoaded(webhooks))); + } + }); + }, + Action::AdminTabNext => { + if state.current_view == View::Admin { + state.admin_tab = (state.admin_tab + 1) % 3; + } + }, + Action::AdminTabPrev => { + if state.current_view == View::Admin { + state.admin_tab = if state.admin_tab == 0 { + 2 + } else { + state.admin_tab - 1 + }; + } + }, + Action::DeleteUser => { + if state.current_view == View::Admin + && state.admin_tab == 0 + && let Some(user) = state.users_list.get(state.users_selected).cloned() + { + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.delete_user(&user.id).await { + Ok(()) => { + if let Ok(users) = client.list_users().await { + let _ = + tx.send(AppEvent::ApiResult(ApiResult::UsersLoaded(users))); + } + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Delete user: {e}" + )))); + }, + } + }); + } + }, + Action::DeleteDevice => { + if state.current_view == View::Admin + && state.admin_tab == 1 + && let Some(device) = + state.sync_devices.get(state.sync_devices_selected).cloned() + { + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.delete_sync_device(&device.id).await { + Ok(()) => { + if let Ok(devices) = client.list_sync_devices().await { + let _ = tx.send(AppEvent::ApiResult( + ApiResult::SyncDevicesLoaded(devices), + )); + } + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Delete device: {e}" + )))); + }, + } + }); + } + }, + Action::TestWebhook => { + if state.current_view == View::Admin + && state.admin_tab == 2 + && let Some(wh) = state.webhooks.get(state.webhooks_selected).cloned() + { + let client = client.clone(); + let tx = event_sender.clone(); + state.status_message = Some(format!("Testing webhook: {}", wh.url)); + tokio::spawn(async move { + match client.test_webhook(&wh.id).await { + Ok(()) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error( + "Webhook test sent".into(), + ))); + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Webhook test: {e}" + )))); + }, + } + }); + } + }, + Action::AddToPlaylist => { + if (state.current_view == View::Library + || state.current_view == View::Search) + && !state.playlists.is_empty() + { + let media = if state.current_view == View::Library { + state + .selected_index + .and_then(|i| state.media_list.get(i)) + .cloned() + } else { + state + .search_selected + .and_then(|i| state.search_results.get(i)) + .cloned() + }; + if let Some(item) = media { + if let Some(pl) = + state.playlists.get(state.playlists_selected).cloned() + { + let client = client.clone(); + let tx = event_sender.clone(); + let pl_id = pl.id.clone(); + let media_id = item.id; + state.status_message = + Some(format!("Adding to playlist \"{}\"...", pl.name)); + tokio::spawn(async move { + match client.add_to_playlist(&pl_id, &media_id).await { + Ok(()) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error( + format!("Added to playlist \"{name}\"", name = pl.name), + ))); + }, + Err(e) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error( + format!("Add to playlist: {e}"), + ))); + }, + } + }); + } else { + state.status_message = Some( + "No playlist selected; open Playlists view (p) first".into(), + ); + } + } + } + }, Action::NavigateLeft | Action::NavigateRight | Action::None => {}, } } diff --git a/crates/pinakes-tui/src/client.rs b/crates/pinakes-tui/src/client.rs index 3a1de56..59cd8cd 100644 --- a/crates/pinakes-tui/src/client.rs +++ b/crates/pinakes-tui/src/client.rs @@ -186,6 +186,62 @@ pub struct AuthorSummary { pub count: u64, } +#[derive(Debug, Clone, Deserialize)] +pub struct PlaylistResponse { + pub id: String, + pub name: String, + pub description: Option, + pub created_at: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CommentResponse { + pub text: String, + pub created_at: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct TranscodeSessionResponse { + pub id: String, + pub profile: String, + pub status: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SubtitleEntry { + pub language: Option, + pub format: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SubtitleListResponse { + pub subtitles: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DeviceResponse { + pub id: String, + pub name: String, + pub device_type: Option, + pub last_seen: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct WebhookInfo { + #[serde(default)] + pub id: String, + pub url: String, + pub events: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct UserResponse { + pub id: String, + pub username: String, + pub role: String, + pub created_at: String, +} + impl ApiClient { pub fn new(base_url: &str, api_key: Option<&str>) -> Self { let client = api_key.map_or_else(Client::new, |key| { @@ -198,7 +254,13 @@ impl ApiClient { Client::builder() .default_headers(headers) .build() - .unwrap_or_default() + .unwrap_or_else(|e| { + tracing::warn!( + "failed to build authenticated HTTP client: {e}; falling back to \ + unauthenticated client" + ); + Client::new() + }) }); Self { client, @@ -627,4 +689,303 @@ impl ApiClient { .error_for_status()?; Ok(()) } + + pub async fn list_playlists(&self) -> Result> { + let resp = self + .client + .get(self.url("/playlists")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn create_playlist( + &self, + name: &str, + description: Option<&str>, + ) -> Result { + let mut body = serde_json::json!({"name": name}); + if let Some(desc) = description { + body["description"] = serde_json::Value::String(desc.to_string()); + } + let resp = self + .client + .post(self.url("/playlists")) + .json(&body) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn delete_playlist(&self, id: &str) -> Result<()> { + self + .client + .delete(self.url(&format!("/playlists/{id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn get_playlist_items( + &self, + id: &str, + ) -> Result> { + let resp = self + .client + .get(self.url(&format!("/playlists/{id}/items"))) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn remove_from_playlist( + &self, + playlist_id: &str, + media_id: &str, + ) -> Result<()> { + self + .client + .delete(self.url(&format!("/playlists/{playlist_id}/items/{media_id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn shuffle_playlist(&self, id: &str) -> Result<()> { + self + .client + .post(self.url(&format!("/playlists/{id}/shuffle"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn rate_media(&self, media_id: &str, stars: u8) -> Result<()> { + self + .client + .post(self.url(&format!("/media/{media_id}/ratings"))) + .json(&serde_json::json!({"stars": stars})) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn add_comment( + &self, + media_id: &str, + text: &str, + ) -> Result { + let resp = self + .client + .post(self.url(&format!("/media/{media_id}/comments"))) + .json(&serde_json::json!({"text": text})) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn list_comments( + &self, + media_id: &str, + ) -> Result> { + let resp = self + .client + .get(self.url(&format!("/media/{media_id}/comments"))) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn toggle_favorite(&self, media_id: &str) -> Result<()> { + // Try POST to add; if it fails with conflict, DELETE to remove + let post_resp = self + .client + .post(self.url("/favorites")) + .json(&serde_json::json!({"media_id": media_id})) + .send() + .await?; + if post_resp.status() == reqwest::StatusCode::CONFLICT + || post_resp.status() == reqwest::StatusCode::UNPROCESSABLE_ENTITY + { + // Already a favorite: remove it + self + .client + .delete(self.url(&format!("/favorites/{media_id}"))) + .send() + .await? + .error_for_status()?; + } else { + post_resp.error_for_status()?; + } + Ok(()) + } + + pub async fn enrich_media(&self, media_id: &str) -> Result<()> { + self + .client + .post(self.url(&format!("/media/{media_id}/enrich"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn start_transcode( + &self, + media_id: &str, + profile: &str, + ) -> Result { + let resp = self + .client + .post(self.url(&format!("/media/{media_id}/transcode"))) + .json(&serde_json::json!({"profile": profile})) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn list_transcodes(&self) -> Result> { + let resp = self + .client + .get(self.url("/transcode")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn cancel_transcode(&self, id: &str) -> Result<()> { + self + .client + .delete(self.url(&format!("/transcode/{id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn list_subtitles( + &self, + media_id: &str, + ) -> Result { + let resp = self + .client + .get(self.url(&format!("/media/{media_id}/subtitles"))) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn list_sync_devices(&self) -> Result> { + let resp = self + .client + .get(self.url("/sync/devices")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn delete_sync_device(&self, id: &str) -> Result<()> { + self + .client + .delete(self.url(&format!("/sync/devices/{id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn list_webhooks(&self) -> Result> { + let resp = self + .client + .get(self.url("/webhooks")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn test_webhook(&self, id: &str) -> Result<()> { + self + .client + .post(self.url("/webhooks/test")) + .json(&serde_json::json!({"id": id})) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn list_users(&self) -> Result> { + let resp = self + .client + .get(self.url("/users")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn delete_user(&self, id: &str) -> Result<()> { + self + .client + .delete(self.url(&format!("/users/{id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn add_to_playlist( + &self, + playlist_id: &str, + media_id: &str, + ) -> Result<()> { + let body = serde_json::json!({"media_id": media_id}); + let resp = self + .client + .post(self.url(&format!("/playlists/{playlist_id}/items"))) + .json(&body) + .send() + .await?; + if resp.status().is_success() { + Ok(()) + } else { + anyhow::bail!("add to playlist failed: {}", resp.status()) + } + } } diff --git a/crates/pinakes-tui/src/event.rs b/crates/pinakes-tui/src/event.rs index 394bc51..2af2483 100644 --- a/crates/pinakes-tui/src/event.rs +++ b/crates/pinakes-tui/src/event.rs @@ -28,6 +28,23 @@ pub enum ApiResult { BookAuthors(Vec), MediaUpdated, ReadingProgressUpdated, + // Playlists + PlaylistsLoaded(Vec), + PlaylistItemsLoaded(Vec), + // Social + CommentsLoaded(Vec), + RatingSet(u8), + FavoriteToggled, + // Subtitles + SubtitlesLoaded(crate::client::SubtitleListResponse), + // Enrichment / transcode + EnrichmentTriggered, + TranscodeStarted, + // Admin + UsersLoaded(Vec), + SyncDevicesLoaded(Vec), + WebhooksLoaded(Vec), + TranscodesLoaded(Vec), Error(String), } diff --git a/crates/pinakes-tui/src/input.rs b/crates/pinakes-tui/src/input.rs index d7b9d7c..a70c08a 100644 --- a/crates/pinakes-tui/src/input.rs +++ b/crates/pinakes-tui/src/input.rs @@ -53,6 +53,29 @@ pub enum Action { ConfirmBatchDelete, BatchTag, UpdateReadingProgress, + // Playlists + PlaylistsView, + CreatePlaylist, + DeletePlaylist, + RemoveFromPlaylist, + ShufflePlaylist, + AddToPlaylist, + // Social / detail + ToggleFavorite, + RateMedia, + AddComment, + EnrichMedia, + ToggleSubtitles, + // Transcode + ToggleTranscodes, + CancelTranscode, + // Admin + AdminView, + AdminTabNext, + AdminTabPrev, + DeleteUser, + DeleteDevice, + TestWebhook, None, } @@ -98,6 +121,8 @@ pub fn handle_key( (KeyCode::Char('d'), _) => { match current_view { View::Tags | View::Collections => Action::DeleteSelected, + View::Playlists => Action::DeletePlaylist, + View::Admin => Action::DeleteUser, _ => Action::Delete, } }, @@ -111,16 +136,22 @@ pub fn handle_key( (KeyCode::Char('p'), _) => { match current_view { View::Detail => Action::UpdateReadingProgress, - _ => Action::None, + _ => Action::PlaylistsView, } }, (KeyCode::Char('t'), _) => { match current_view { View::Tasks => Action::Toggle, + View::Detail => Action::ToggleTranscodes, _ => Action::TagView, } }, - (KeyCode::Char('c'), _) => Action::CollectionView, + (KeyCode::Char('c'), _) => { + match current_view { + View::Detail => Action::AddComment, + _ => Action::CollectionView, + } + }, // Multi-select: Ctrl+A for SelectAll (must come before plain 'a') (KeyCode::Char('a'), KeyModifiers::CONTROL) => { match current_view { @@ -130,15 +161,22 @@ pub fn handle_key( }, (KeyCode::Char('a'), _) => Action::AuditView, (KeyCode::Char('b'), _) => Action::BooksView, - (KeyCode::Char('S'), _) => Action::SettingsView, + (KeyCode::Char('S'), _) => { + match current_view { + View::Playlists => Action::ShufflePlaylist, + _ => Action::SettingsView, + } + }, (KeyCode::Char('B'), _) => Action::DatabaseView, (KeyCode::Char('Q'), _) => Action::QueueView, (KeyCode::Char('X'), _) => Action::StatisticsView, + (KeyCode::Char('A'), _) => Action::AdminView, // 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, + View::Admin => Action::DeleteDevice, _ => Action::DuplicatesView, } }, @@ -157,9 +195,24 @@ pub fn handle_key( }, (KeyCode::Char('s'), _) => Action::ScanTrigger, (KeyCode::Char('r'), _) => Action::Refresh, - (KeyCode::Char('n'), _) => Action::CreateTag, - (KeyCode::Char('+'), _) => Action::TagMedia, - (KeyCode::Char('-'), _) => Action::UntagMedia, + (KeyCode::Char('n'), _) => { + match current_view { + View::Playlists => Action::CreatePlaylist, + _ => Action::CreateTag, + } + }, + (KeyCode::Char('+'), _) => { + match current_view { + View::Library | View::Search => Action::AddToPlaylist, + _ => Action::TagMedia, + } + }, + (KeyCode::Char('-'), _) => { + match current_view { + View::Playlists => Action::RemoveFromPlaylist, + _ => Action::UntagMedia, + } + }, (KeyCode::Char('v'), _) => { match current_view { View::Database => Action::Vacuum, @@ -169,11 +222,52 @@ pub fn handle_key( (KeyCode::Char('x'), _) => { match current_view { View::Tasks => Action::RunNow, + View::Detail => Action::CancelTranscode, _ => Action::None, } }, - (KeyCode::Tab, _) => Action::NextTab, - (KeyCode::BackTab, _) => Action::PrevTab, + (KeyCode::Char('f'), _) => { + match current_view { + View::Detail => Action::ToggleFavorite, + _ => Action::None, + } + }, + (KeyCode::Char('R'), _) => { + match current_view { + View::Detail => Action::RateMedia, + _ => Action::None, + } + }, + (KeyCode::Char('E'), _) => { + match current_view { + View::Detail => Action::EnrichMedia, + _ => Action::None, + } + }, + (KeyCode::Char('U'), _) => { + match current_view { + View::Detail => Action::ToggleSubtitles, + _ => Action::None, + } + }, + (KeyCode::Char('w'), _) => { + match current_view { + View::Admin => Action::TestWebhook, + _ => Action::None, + } + }, + (KeyCode::Tab, _) => { + match current_view { + View::Admin => Action::AdminTabNext, + _ => Action::NextTab, + } + }, + (KeyCode::BackTab, _) => { + match current_view { + View::Admin => Action::AdminTabPrev, + _ => Action::PrevTab, + } + }, (KeyCode::PageUp, _) => Action::PageUp, (KeyCode::PageDown, _) => Action::PageDown, // Multi-select keys diff --git a/crates/pinakes-tui/src/ui/admin.rs b/crates/pinakes-tui/src/ui/admin.rs new file mode 100644 index 0000000..ef07c18 --- /dev/null +++ b/crates/pinakes-tui/src/ui/admin.rs @@ -0,0 +1,174 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Row, Table, Tabs}, +}; + +use super::format_date; +use crate::app::AppState; + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0)]) + .split(area); + + render_tab_bar(f, state, chunks[0]); + + match state.admin_tab { + 0 => render_users(f, state, chunks[1]), + 1 => render_devices(f, state, chunks[1]), + _ => render_webhooks(f, state, chunks[1]), + } +} + +fn render_tab_bar(f: &mut Frame, state: &AppState, area: Rect) { + let titles: Vec = vec!["Users", "Sync Devices", "Webhooks"] + .into_iter() + .map(|t| Line::from(Span::styled(t, Style::default().fg(Color::White)))) + .collect(); + + let tabs = Tabs::new(titles) + .block(Block::default().borders(Borders::ALL).title(" Admin ")) + .select(state.admin_tab) + .style(Style::default().fg(Color::Gray)) + .highlight_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ); + + f.render_widget(tabs, area); +} + +fn render_users(f: &mut Frame, state: &AppState, area: Rect) { + let header = Row::new(vec!["Username", "Role", "Created"]).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + + let rows: Vec = state + .users_list + .iter() + .enumerate() + .map(|(i, user)| { + let style = if i == state.users_selected { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + let role_color = match user.role.as_str() { + "admin" => Color::Red, + "editor" => Color::Yellow, + _ => Color::White, + }; + Style::default().fg(role_color) + }; + Row::new(vec![ + user.username.clone(), + user.role.clone(), + format_date(&user.created_at).to_string(), + ]) + .style(style) + }) + .collect(); + + let title = format!(" Users ({}) ", state.users_list.len()); + + let table = Table::new(rows, [ + Constraint::Percentage(40), + Constraint::Percentage(20), + Constraint::Percentage(40), + ]) + .header(header) + .block(Block::default().borders(Borders::ALL).title(title)); + + f.render_widget(table, area); +} + +fn render_devices(f: &mut Frame, state: &AppState, area: Rect) { + let header = Row::new(vec!["Name", "Type", "Last Seen"]).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + + let rows: Vec = state + .sync_devices + .iter() + .enumerate() + .map(|(i, dev)| { + let style = if i == state.sync_devices_selected { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default() + }; + Row::new(vec![ + dev.name.clone(), + dev.device_type.clone().unwrap_or_else(|| "-".into()), + dev + .last_seen + .as_deref() + .map_or("-", format_date) + .to_string(), + ]) + .style(style) + }) + .collect(); + + let title = format!(" Sync Devices ({}) ", state.sync_devices.len()); + + let table = Table::new(rows, [ + Constraint::Percentage(40), + Constraint::Percentage(20), + Constraint::Percentage(40), + ]) + .header(header) + .block(Block::default().borders(Borders::ALL).title(title)); + + f.render_widget(table, area); +} + +fn render_webhooks(f: &mut Frame, state: &AppState, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0)]) + .split(area); + + let header = Row::new(vec!["URL", "Events"]).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + + let rows: Vec = state + .webhooks + .iter() + .enumerate() + .map(|(i, wh)| { + let style = if i == state.webhooks_selected { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default() + }; + let events = if wh.events.is_empty() { + "-".to_string() + } else { + wh.events.join(", ") + }; + Row::new(vec![wh.url.clone(), events]).style(style) + }) + .collect(); + + let title = format!(" Webhooks ({}) ", state.webhooks.len()); + + let table = Table::new(rows, [ + Constraint::Percentage(50), + Constraint::Percentage(50), + ]) + .header(header) + .block(Block::default().borders(Borders::ALL).title(title)); + + f.render_widget(table, chunks[0]); +} diff --git a/crates/pinakes-tui/src/ui/detail.rs b/crates/pinakes-tui/src/ui/detail.rs index 9788ca8..400bdbd 100644 --- a/crates/pinakes-tui/src/ui/detail.rs +++ b/crates/pinakes-tui/src/ui/detail.rs @@ -252,6 +252,113 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) { } } + // Social section: rating, favorite, comments + { + let has_social = state.media_rating.is_some() + || state.is_favorite + || !state.media_comments.is_empty(); + if has_social { + lines.push(Line::default()); + lines.push(Line::from(Span::styled( + "--- Social ---", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))); + } + if state.is_favorite { + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Favorite"), label_style), + Span::styled("Yes", Style::default().fg(Color::Yellow)), + ])); + } + if let Some(stars) = state.media_rating { + let stars_str = "*".repeat(stars as usize); + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Rating"), label_style), + Span::styled(format!("{stars_str} ({stars}/5)"), value_style), + ])); + } + if !state.media_comments.is_empty() { + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled( + format!("Comments ({})", state.media_comments.len()), + label_style, + ), + ])); + for comment in state.media_comments.iter().take(5) { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + format!("[{}] {}", format_date(&comment.created_at), comment.text), + dim_style, + ), + ])); + } + if state.media_comments.len() > 5 { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + format!("... and {} more", state.media_comments.len() - 5), + dim_style, + ), + ])); + } + } + } + + // Subtitles section + if state.showing_subtitles { + lines.push(Line::default()); + lines.push(Line::from(Span::styled( + "--- Subtitles ---", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))); + if state.subtitles.is_empty() { + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled("No subtitles found", dim_style), + ])); + } else { + for sub in &state.subtitles { + let lang = sub.language.as_deref().unwrap_or("?"); + let fmt = sub.format.as_deref().unwrap_or("?"); + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(format!("[{lang}] {fmt}"), value_style), + ])); + } + } + } + + // Transcodes section + if state.showing_transcodes && !state.transcodes.is_empty() { + lines.push(Line::default()); + lines.push(Line::from(Span::styled( + "--- Transcodes ---", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))); + for tc in &state.transcodes { + let status_color = match tc.status.as_str() { + "done" | "completed" => Color::Green, + "failed" | "error" => Color::Red, + _ => Color::Yellow, + }; + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(format!("[{}] ", tc.profile), label_style), + Span::styled(&tc.status, Style::default().fg(status_color)), + ])); + } + } + // Reading progress section if let Some(ref progress) = state.reading_progress { lines.push(Line::default()); diff --git a/crates/pinakes-tui/src/ui/mod.rs b/crates/pinakes-tui/src/ui/mod.rs index 01b3f18..b3cf733 100644 --- a/crates/pinakes-tui/src/ui/mod.rs +++ b/crates/pinakes-tui/src/ui/mod.rs @@ -1,3 +1,4 @@ +pub mod admin; pub mod audit; pub mod books; pub mod collections; @@ -7,6 +8,7 @@ pub mod duplicates; pub mod import; pub mod library; pub mod metadata_edit; +pub mod playlists; pub mod queue; pub mod search; pub mod settings; @@ -109,6 +111,8 @@ pub fn render(f: &mut Frame, state: &AppState) { View::Statistics => statistics::render(f, state, chunks[1]), View::Tasks => tasks::render(f, state, chunks[1]), View::Books => books::render(f, state, chunks[1]), + View::Playlists => playlists::render(f, state, chunks[1]), + View::Admin => admin::render(f, state, chunks[1]), } render_status_bar(f, state, chunks[2]); @@ -125,6 +129,8 @@ fn render_tabs(f: &mut Frame, state: &AppState, area: Rect) { "Queue", "Stats", "Tasks", + "Playlists", + "Admin", ] .into_iter() .map(|t| Line::from(Span::styled(t, Style::default().fg(Color::White)))) @@ -144,6 +150,8 @@ fn render_tabs(f: &mut Frame, state: &AppState, area: Rect) { View::Queue => 6, View::Statistics => 7, View::Tasks => 8, + View::Playlists => 9, + View::Admin => 10, }; let tabs = Tabs::new(titles) @@ -177,10 +185,17 @@ fn render_status_bar(f: &mut Frame, state: &AppState, area: Rect) { .to_string() }, View::Detail => { - " q:Quit Esc:Back o:Open e:Edit p:Page +:Tag -:Untag \ - r:Refresh ?:Help" + " q:Quit Esc:Back o:Open e:Edit p:Page +:Tag -:Untag f:Fav \ + R:Rate c:Comment E:Enrich U:Subtitles t:Transcode r:Refresh" .to_string() }, + View::Playlists => { + " q:Quit j/k:Nav n:New d:Delete Enter:Items S:Shuffle Esc:Back" + .to_string() + }, + View::Admin => " q:Quit j/k:Nav Tab:Switch tab d:Del user/device \ + w:Test webhook r:Refresh Esc:Back" + .to_string(), View::Import => { " Enter:Import Esc:Cancel s:Scan libraries ?:Help".to_string() }, diff --git a/crates/pinakes-tui/src/ui/playlists.rs b/crates/pinakes-tui/src/ui/playlists.rs new file mode 100644 index 0000000..105d5f8 --- /dev/null +++ b/crates/pinakes-tui/src/ui/playlists.rs @@ -0,0 +1,117 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Row, Table}, +}; + +use super::format_date; +use crate::app::AppState; + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + if state.viewing_playlist_items { + render_items(f, state, area); + } else { + render_list(f, state, area); + } +} + +fn render_list(f: &mut Frame, state: &AppState, area: Rect) { + let header = Row::new(vec!["Name", "Description", "Created"]).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + + let rows: Vec = state + .playlists + .iter() + .enumerate() + .map(|(i, pl)| { + let style = if i == state.playlists_selected { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default() + }; + Row::new(vec![ + pl.name.clone(), + pl.description.clone().unwrap_or_else(|| "-".into()), + format_date(&pl.created_at).to_string(), + ]) + .style(style) + }) + .collect(); + + let title = format!(" Playlists ({}) ", state.playlists.len()); + + let table = Table::new(rows, [ + Constraint::Percentage(40), + Constraint::Percentage(40), + Constraint::Percentage(20), + ]) + .header(header) + .block(Block::default().borders(Borders::ALL).title(title)); + + f.render_widget(table, area); +} + +fn render_items(f: &mut Frame, state: &AppState, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(2), Constraint::Min(0)]) + .split(area); + + let pl_name = state + .playlists + .get(state.playlists_selected) + .map_or("Playlist", |p| p.name.as_str()); + + let hint = Paragraph::new(Line::from(vec![ + Span::styled( + format!(" {pl_name} "), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::styled("Esc:Back d:Remove", Style::default().fg(Color::DarkGray)), + ])); + f.render_widget(hint, chunks[0]); + + let header = Row::new(vec!["File", "Type", "Title"]).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + + let rows: Vec = state + .playlist_items + .iter() + .enumerate() + .map(|(i, item)| { + let style = if i == state.playlist_items_selected { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default() + }; + Row::new(vec![ + item.file_name.clone(), + item.media_type.clone(), + item.title.clone().unwrap_or_else(|| "-".into()), + ]) + .style(style) + }) + .collect(); + + let title = format!(" Items ({}) ", state.playlist_items.len()); + + let table = Table::new(rows, [ + Constraint::Percentage(40), + Constraint::Percentage(20), + Constraint::Percentage(40), + ]) + .header(header) + .block(Block::default().borders(Borders::ALL).title(title)); + + f.render_widget(table, chunks[1]); +}