diff --git a/crates/pinakes-ui/Cargo.toml b/crates/pinakes-ui/Cargo.toml index e516438..a8dcc21 100644 --- a/crates/pinakes-ui/Cargo.toml +++ b/crates/pinakes-ui/Cargo.toml @@ -25,6 +25,7 @@ ammonia = { workspace = true } dioxus-free-icons = { workspace = true } gloo-timers = { workspace = true } rand = { workspace = true } +urlencoding = { workspace = true } [lints] workspace = true diff --git a/crates/pinakes-ui/build.rs b/crates/pinakes-ui/build.rs index 058a7d7..22fa64d 100644 --- a/crates/pinakes-ui/build.rs +++ b/crates/pinakes-ui/build.rs @@ -1,3 +1,9 @@ +#![expect( + clippy::expect_used, + reason = "build scripts conventionally panic on failure; there is no caller \ + to propagate errors to" +)] + use std::{fs, path::Path}; fn main() { diff --git a/crates/pinakes-ui/src/app.rs b/crates/pinakes-ui/src/app.rs index a36deed..4ce21e1 100644 --- a/crates/pinakes-ui/src/app.rs +++ b/crates/pinakes-ui/src/app.rs @@ -10,6 +10,7 @@ use dioxus_free_icons::{ icons::fa_solid_icons::{ FaArrowRightFromBracket, FaBook, + FaBookOpen, FaChartBar, FaChevronLeft, FaChevronRight, @@ -43,6 +44,7 @@ use crate::{ client::*, components::{ audit, + books, collections, database, detail, @@ -69,6 +71,7 @@ enum View { Detail, Tags, Collections, + Books, Audit, Import, Duplicates, @@ -87,6 +90,7 @@ impl View { Self::Detail => "Detail", Self::Tags => "Tags", Self::Collections => "Collections", + Self::Books => "Books", Self::Audit => "Audit Log", Self::Import => "Import", Self::Duplicates => "Duplicates", @@ -112,7 +116,7 @@ pub fn App() -> Element { let mut media_list = use_signal(Vec::::new); let mut media_total_count = use_signal(|| 0u64); let mut media_page = use_signal(|| 0u64); - let mut media_page_size = use_signal(|| 48u64); + let mut media_page_size = use_signal(|| 50u64); let mut media_sort = use_signal(|| "created_at_desc".to_string()); let mut search_results = use_signal(Vec::::new); let mut search_total = use_signal(|| 0u64); @@ -134,6 +138,23 @@ pub fn App() -> Element { let mut preview_total_size = use_signal(|| 0u64); let mut viewing_collection = use_signal(|| Option::::None); let mut collection_members = use_signal(Vec::::new); + + // Phase 4A: Book management + let mut books_list = use_signal(Vec::::new); + let mut books_series_list = + use_signal(Vec::::new); + let mut books_authors_list = + use_signal(Vec::::new); + let mut books_series_detail = use_signal(Vec::::new); + let mut books_author_detail = use_signal(Vec::::new); + let mut viewing_series = use_signal(|| Option::::None); + let mut viewing_author = use_signal(|| Option::::None); + let mut books_reading_list = use_signal(Vec::::new); + let mut detail_book_metadata = + use_signal(|| Option::::None); + let mut detail_reading_progress = + use_signal(|| Option::::None); + let mut server_connected = use_signal(|| false); let mut server_checking = use_signal(|| true); let mut loading = use_signal(|| true); @@ -587,6 +608,27 @@ pub fn App() -> Element { span { class: "nav-item-text", "Collections" } span { class: "nav-badge", "{collections_list.read().len()}" } } + button { + class: if *current_view.read() == View::Books { "nav-item active" } else { "nav-item" }, + onclick: { + let client = client.read().clone(); + move |_| { + current_view.set(View::Books); + viewing_series.set(None); + viewing_author.set(None); + let client = client.clone(); + spawn(async move { + if let Ok(items) = client.list_books(0, 50, None, None).await { + books_list.set(items); + } + }); + } + }, + span { class: "nav-icon", + NavIcon { icon: FaBookOpen } + } + span { class: "nav-item-text", "Books" } + } } div { class: "nav-section", @@ -886,6 +928,17 @@ pub fn App() -> Element { Ok(item) => { let mtags = client.get_media_tags(&id).await.unwrap_or_default(); media_tags.set(mtags); + // Fetch book metadata for document types + let is_document = item.media_type == "document"; + if is_document { + let bm = client.get_book_metadata(&id).await.ok(); + let rp = client.get_reading_progress(&id).await.ok(); + detail_book_metadata.set(bm); + detail_reading_progress.set(rp); + } else { + detail_book_metadata.set(None); + detail_reading_progress.set(None); + } selected_media.set(Some(item)); current_view.set(View::Detail); } @@ -1090,6 +1143,13 @@ pub fn App() -> Element { if let Ok(item) = client.get_media(&id).await { let mtags = client.get_media_tags(&id).await.unwrap_or_default(); media_tags.set(mtags); + if item.media_type == "document" { + detail_book_metadata.set(client.get_book_metadata(&id).await.ok()); + detail_reading_progress.set(client.get_reading_progress(&id).await.ok()); + } else { + detail_book_metadata.set(None); + detail_reading_progress.set(None); + } selected_media.set(Some(item)); current_view.set(View::Detail); } @@ -1476,6 +1536,25 @@ pub fn App() -> Element { }); } }, + book_metadata: detail_book_metadata.read().clone(), + reading_progress: detail_reading_progress.read().clone(), + on_update_reading_progress: { + let client = client.read().clone(); + move |(media_id, page): (String, i32)| { + let client = client.clone(); + spawn(async move { + match client.update_reading_progress(&media_id, page).await { + Ok(()) => { + if let Ok(rp) = client.get_reading_progress(&media_id).await { + detail_reading_progress.set(Some(rp)); + } + show_toast("Reading progress updated".into(), false); + } + Err(e) => show_toast(format!("Failed to update progress: {e}"), true), + } + }); + } + }, } }, None => rsx! { @@ -1618,6 +1697,13 @@ pub fn App() -> Element { if let Ok(item) = client.get_media(&id).await { let mtags = client.get_media_tags(&id).await.unwrap_or_default(); media_tags.set(mtags); + if item.media_type == "document" { + detail_book_metadata.set(client.get_book_metadata(&id).await.ok()); + detail_reading_progress.set(client.get_reading_progress(&id).await.ok()); + } else { + detail_book_metadata.set(None); + detail_reading_progress.set(None); + } selected_media.set(Some(item)); current_view.set(View::Detail); } @@ -1669,6 +1755,13 @@ pub fn App() -> Element { if let Ok(item) = client.get_media(&id).await { let mtags = client.get_media_tags(&id).await.unwrap_or_default(); media_tags.set(mtags); + if item.media_type == "document" { + detail_book_metadata.set(client.get_book_metadata(&id).await.ok()); + detail_reading_progress.set(client.get_reading_progress(&id).await.ok()); + } else { + detail_book_metadata.set(None); + detail_reading_progress.set(None); + } selected_media.set(Some(item)); current_view.set(View::Detail); } @@ -2181,13 +2274,140 @@ pub fn App() -> Element { } }, on_backup: { - move |_path: String| { - show_toast("Backup not yet implemented on server".into(), false); + let client = client.read().clone(); + move |path: String| { + let client = client.clone(); + spawn(async move { + match client.backup_database(&path).await { + Ok(()) => { + show_toast(format!("Backup saved to: {path}"), false); + }, + Err(e) => show_toast(format!("Backup failed: {e}"), true), + } + }); } }, } } } + View::Books => { + rsx! { + books::Books { + books: books_list.read().clone(), + series_list: books_series_list.read().clone(), + authors_list: books_authors_list.read().clone(), + series_books: books_series_detail.read().clone(), + author_books: books_author_detail.read().clone(), + reading_list: books_reading_list.read().clone(), + viewing_series: viewing_series.read().clone(), + viewing_author: viewing_author.read().clone(), + on_select: { + let client = client.read().clone(); + move |id: String| { + let client = client.clone(); + spawn(async move { + match client.get_media(&id).await { + Ok(item) => { + let mtags = client.get_media_tags(&id).await.unwrap_or_default(); + media_tags.set(mtags); + if item.media_type == "document" { + detail_book_metadata.set(client.get_book_metadata(&id).await.ok()); + detail_reading_progress.set(client.get_reading_progress(&id).await.ok()); + } else { + detail_book_metadata.set(None); + detail_reading_progress.set(None); + } + selected_media.set(Some(item)); + current_view.set(View::Detail); + } + Err(e) => show_toast(format!("Failed to load: {e}"), true), + } + }); + } + }, + on_load_books: { + let client = client.read().clone(); + move |(offset, limit, author, series): (u64, u64, Option, Option)| { + let client = client.clone(); + spawn(async move { + match client.list_books(offset, limit, author.as_deref(), series.as_deref()).await { + Ok(items) => books_list.set(items), + Err(e) => show_toast(format!("Failed to load books: {e}"), true), + } + }); + } + }, + on_load_series: { + let client = client.read().clone(); + move |_| { + let client = client.clone(); + spawn(async move { + match client.list_series().await { + Ok(series) => books_series_list.set(series), + Err(e) => show_toast(format!("Failed to load series: {e}"), true), + } + }); + } + }, + on_load_authors: { + let client = client.read().clone(); + move |_| { + let client = client.clone(); + spawn(async move { + match client.list_authors().await { + Ok(authors) => books_authors_list.set(authors), + Err(e) => show_toast(format!("Failed to load authors: {e}"), true), + } + }); + } + }, + on_load_reading_list: { + let client = client.read().clone(); + move |status: Option| { + let client = client.clone(); + spawn(async move { + match client.get_reading_list(status.as_deref()).await { + Ok(items) => books_reading_list.set(items), + Err(e) => show_toast(format!("Failed to load reading list: {e}"), true), + } + }); + } + }, + on_view_series: { + let client = client.read().clone(); + move |name: String| { + viewing_series.set(Some(name.clone())); + let client = client.clone(); + spawn(async move { + match client.get_series_books(&name).await { + Ok(items) => books_series_detail.set(items), + Err(e) => show_toast(format!("Failed to load series books: {e}"), true), + } + }); + } + }, + on_view_author: { + let client = client.read().clone(); + move |name: String| { + viewing_author.set(Some(name.clone())); + let client = client.clone(); + spawn(async move { + match client.get_author_books(&name).await { + Ok(items) => books_author_detail.set(items), + Err(e) => show_toast(format!("Failed to load author books: {e}"), true), + } + }); + } + }, + on_back_to_list: move |_| { + viewing_series.set(None); + viewing_author.set(None); + books_series_detail.set(Vec::new()); + books_author_detail.set(Vec::new()); + }, + } + } + } View::Duplicates => { rsx! { duplicates::Duplicates { @@ -2435,6 +2655,13 @@ pub fn App() -> Element { if let Ok(mtags) = client.get_media_tags(&media_id).await { media_tags.set(mtags); } + if media.media_type == "document" { + detail_book_metadata.set(client.get_book_metadata(&media_id).await.ok()); + detail_reading_progress.set(client.get_reading_progress(&media_id).await.ok()); + } else { + detail_book_metadata.set(None); + detail_reading_progress.set(None); + } selected_media.set(Some(media)); current_view.set(View::Detail); } diff --git a/crates/pinakes-ui/src/client.rs b/crates/pinakes-ui/src/client.rs index f99f52c..64d1c4c 100644 --- a/crates/pinakes-ui/src/client.rs +++ b/crates/pinakes-ui/src/client.rs @@ -31,7 +31,7 @@ impl PartialEq for ApiClient { } } -// ── Response types ── +// Response types #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct MediaResponse { @@ -179,7 +179,7 @@ fn default_view() -> String { "library".to_string() } fn default_page_size() -> usize { - 48 + 50 } fn default_view_mode() -> String { "grid".to_string() @@ -279,7 +279,7 @@ pub struct DatabaseStatsResponse { pub backend_name: String, } -// ── Markdown Notes/Links Response Types ── +// Markdown notes/links response types #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct BacklinksResponse { @@ -363,6 +363,55 @@ pub struct CreateSavedSearchRequest { pub sort_order: Option, } +// Book management response types + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct BookMetadataResponse { + pub media_id: String, + pub isbn: Option, + pub isbn13: Option, + pub publisher: Option, + pub language: Option, + pub page_count: Option, + pub publication_date: Option, + pub series_name: Option, + pub series_index: Option, + pub format: Option, + pub authors: Vec, + #[serde(default)] + pub identifiers: HashMap>, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct BookAuthorResponse { + pub name: String, + pub role: String, + pub file_as: Option, + pub position: i32, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct ReadingProgressResponse { + pub media_id: String, + pub user_id: String, + pub current_page: i32, + pub total_pages: Option, + pub progress_percent: f64, + pub last_read_at: String, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct SeriesSummary { + pub name: String, + pub book_count: u64, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct AuthorSummary { + pub name: String, + pub book_count: u64, +} + impl ApiClient { pub fn new(base_url: &str, api_key: Option<&str>) -> Self { let mut headers = header::HeaderMap::new(); @@ -402,7 +451,7 @@ impl ApiClient { } } - // ── Media ── + // Media pub async fn list_media( &self, @@ -522,7 +571,7 @@ impl ApiClient { Ok(resp.count) } - // ── Import ── + // Import pub async fn import_file(&self, path: &str) -> Result { Ok( @@ -616,7 +665,7 @@ impl ApiClient { ) } - // ── Search ── + // Search pub async fn search( &self, @@ -646,7 +695,7 @@ impl ApiClient { ) } - // ── Tags ── + // Tags pub async fn list_tags(&self) -> Result> { Ok( @@ -730,7 +779,7 @@ impl ApiClient { ) } - // ── Custom Fields ── + // Custom fields pub async fn set_custom_field( &self, @@ -762,7 +811,7 @@ impl ApiClient { Ok(()) } - // ── Collections ── + // Collections pub async fn list_collections(&self) -> Result> { Ok( @@ -862,7 +911,7 @@ impl ApiClient { Ok(()) } - // ── Batch Operations ── + // Batch operations pub async fn batch_tag( &self, @@ -928,7 +977,7 @@ impl ApiClient { .await?) } - // ── Audit ── + // Audit pub async fn list_audit( &self, @@ -948,7 +997,7 @@ impl ApiClient { ) } - // ── Scan ── + // Scan pub async fn trigger_scan(&self) -> Result> { Ok( @@ -977,7 +1026,7 @@ impl ApiClient { ) } - // ── Config ── + // Config pub async fn get_config(&self) -> Result { Ok( @@ -1049,7 +1098,7 @@ impl ApiClient { ) } - // ── Database Management ── + // Database management pub async fn database_stats(&self) -> Result { Ok( @@ -1086,7 +1135,180 @@ impl ApiClient { Ok(()) } - // ── Duplicates ── + /// Download a database backup and save it to the given path. + pub async fn backup_database(&self, save_path: &str) -> Result<()> { + let bytes = self + .client + .post(self.url("/database/backup")) + .send() + .await? + .error_for_status()? + .bytes() + .await?; + tokio::fs::write(save_path, &bytes).await?; + Ok(()) + } + + // Books + + pub async fn get_book_metadata( + &self, + media_id: &str, + ) -> Result { + Ok( + self + .client + .get(self.url(&format!("/books/{media_id}/metadata"))) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + pub async fn list_books( + &self, + offset: u64, + limit: u64, + author: Option<&str>, + series: Option<&str>, + ) -> Result> { + let mut url = format!("/books?offset={offset}&limit={limit}"); + if let Some(a) = author { + url.push_str(&format!("&author={}", urlencoding::encode(a))); + } + if let Some(s) = series { + url.push_str(&format!("&series={}", urlencoding::encode(s))); + } + Ok( + self + .client + .get(self.url(&url)) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + pub async fn list_series(&self) -> Result> { + Ok( + self + .client + .get(self.url("/books/series")) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + pub async fn get_series_books( + &self, + series_name: &str, + ) -> Result> { + Ok( + self + .client + .get(self.url(&format!( + "/books/series/{}", + urlencoding::encode(series_name) + ))) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + pub async fn list_authors(&self) -> Result> { + Ok( + self + .client + .get(self.url("/books/authors")) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + pub async fn get_author_books( + &self, + author_name: &str, + ) -> Result> { + Ok( + self + .client + .get(self.url(&format!( + "/books/authors/{}/books", + urlencoding::encode(author_name) + ))) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + pub async fn get_reading_progress( + &self, + media_id: &str, + ) -> Result { + Ok( + self + .client + .get(self.url(&format!("/books/{media_id}/progress"))) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + pub async fn update_reading_progress( + &self, + media_id: &str, + current_page: i32, + ) -> Result<()> { + self + .client + .put(self.url(&format!("/books/{media_id}/progress"))) + .json(&serde_json::json!({"current_page": current_page})) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn get_reading_list( + &self, + status: Option<&str>, + ) -> Result> { + let mut url = "/books/reading-list".to_string(); + if let Some(s) = status { + url.push_str(&format!("?status={s}")); + } + Ok( + self + .client + .get(self.url(&url)) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + // Duplicates pub async fn list_duplicates(&self) -> Result> { Ok( @@ -1101,7 +1323,7 @@ impl ApiClient { ) } - // ── UI Config ── + // UI config pub async fn update_ui_config( &self, @@ -1120,7 +1342,7 @@ impl ApiClient { ) } - // ── Auth ── + // Auth pub async fn login( &self, @@ -1223,7 +1445,7 @@ impl ApiClient { ) } - // ── Saved Searches ── + // Saved searches pub async fn list_saved_searches(&self) -> Result> { Ok( @@ -1272,7 +1494,7 @@ impl ApiClient { Ok(()) } - // ── Markdown Notes/Links ── + // Markdown notes/links /// Get backlinks (incoming links) to a media item. pub async fn get_backlinks(&self, id: &str) -> Result { diff --git a/crates/pinakes-ui/src/components/books.rs b/crates/pinakes-ui/src/components/books.rs new file mode 100644 index 0000000..d2fa026 --- /dev/null +++ b/crates/pinakes-ui/src/components/books.rs @@ -0,0 +1,475 @@ +use dioxus::prelude::*; + +use super::utils::type_badge_class; +use crate::client::{AuthorSummary, MediaResponse, SeriesSummary}; + +#[derive(Debug, Clone, PartialEq)] +enum BooksTab { + AllBooks, + Series, + Authors, + ReadingList, +} + +#[component] +pub fn Books( + books: Vec, + series_list: Vec, + authors_list: Vec, + series_books: Vec, + author_books: Vec, + reading_list: Vec, + viewing_series: Option, + viewing_author: Option, + on_select: EventHandler, + on_load_books: EventHandler<(u64, u64, Option, Option)>, + on_load_series: EventHandler<()>, + on_load_authors: EventHandler<()>, + on_load_reading_list: EventHandler>, + on_view_series: EventHandler, + on_view_author: EventHandler, + on_back_to_list: EventHandler<()>, +) -> Element { + let mut active_tab = use_signal(|| BooksTab::AllBooks); + let mut filter_author = use_signal(String::new); + let mut filter_series = use_signal(String::new); + let mut reading_status_filter = use_signal(String::new); + let mut books_page = use_signal(|| 0u64); + let books_page_size = 50u64; + + // Series detail view + if let Some(ref series_name) = viewing_series { + let name = series_name.clone(); + return rsx! { + button { + class: "btn btn-ghost mb-16", + onclick: move |_| on_back_to_list.call(()), + "\u{2190} Back to Series" + } + + h3 { class: "mb-16", "Series: {name}" } + + if series_books.is_empty() { + div { class: "empty-state", + p { class: "empty-subtitle", "No books found in this series." } + } + } else { + table { class: "data-table", + thead { + tr { + th { "Title" } + th { "Type" } + th { "Artist" } + th { "File" } + } + } + tbody { + for item in series_books.iter() { + { + let title = item.title.clone().unwrap_or_else(|| item.file_name.clone()); + let artist = item.artist.clone().unwrap_or_default(); + let badge_class = type_badge_class(&item.media_type); + let row_click = { + let mid = item.id.clone(); + move |_| on_select.call(mid.clone()) + }; + rsx! { + tr { key: "{item.id}", class: "clickable-row", onclick: row_click, + td { "{title}" } + td { + span { class: "type-badge {badge_class}", "{item.media_type}" } + } + td { "{artist}" } + td { "{item.file_name}" } + } + } + } + } + } + } + } + }; + } + + // Author detail view + if let Some(ref author_name) = viewing_author { + let name = author_name.clone(); + return rsx! { + button { + class: "btn btn-ghost mb-16", + onclick: move |_| on_back_to_list.call(()), + "\u{2190} Back to Authors" + } + + h3 { class: "mb-16", "Author: {name}" } + + if author_books.is_empty() { + div { class: "empty-state", + p { class: "empty-subtitle", "No books found by this author." } + } + } else { + table { class: "data-table", + thead { + tr { + th { "Title" } + th { "Type" } + th { "File" } + } + } + tbody { + for item in author_books.iter() { + { + let title = item.title.clone().unwrap_or_else(|| item.file_name.clone()); + let badge_class = type_badge_class(&item.media_type); + let row_click = { + let mid = item.id.clone(); + move |_| on_select.call(mid.clone()) + }; + rsx! { + tr { key: "{item.id}", class: "clickable-row", onclick: row_click, + td { "{title}" } + td { + span { class: "type-badge {badge_class}", "{item.media_type}" } + } + td { "{item.file_name}" } + } + } + } + } + } + } + } + }; + } + + // Main tabbed view + rsx! { + div { class: "card", + div { class: "card-header", + h3 { class: "card-title", "Books" } + } + + // Tab bar + div { class: "form-row mb-16", + button { + class: if *active_tab.read() == BooksTab::AllBooks { "btn btn-primary" } else { "btn btn-secondary" }, + onclick: { + move |_| { + active_tab.set(BooksTab::AllBooks); + let author = { + let a = filter_author.read().clone(); + if a.is_empty() { None } else { Some(a) } + }; + let series = { + let s = filter_series.read().clone(); + if s.is_empty() { None } else { Some(s) } + }; + books_page.set(0); + on_load_books.call((0, books_page_size, author, series)); + } + }, + "All Books" + } + button { + class: if *active_tab.read() == BooksTab::Series { "btn btn-primary" } else { "btn btn-secondary" }, + onclick: move |_| { + active_tab.set(BooksTab::Series); + on_load_series.call(()); + }, + "Series" + } + button { + class: if *active_tab.read() == BooksTab::Authors { "btn btn-primary" } else { "btn btn-secondary" }, + onclick: move |_| { + active_tab.set(BooksTab::Authors); + on_load_authors.call(()); + }, + "Authors" + } + button { + class: if *active_tab.read() == BooksTab::ReadingList { "btn btn-primary" } else { "btn btn-secondary" }, + onclick: move |_| { + active_tab.set(BooksTab::ReadingList); + on_load_reading_list.call(None); + }, + "Reading List" + } + } + + // Tab content + match *active_tab.read() { + BooksTab::AllBooks => { + let search_click = { + move |_| { + let author = { + let a = filter_author.read().clone(); + if a.is_empty() { None } else { Some(a) } + }; + let series = { + let s = filter_series.read().clone(); + if s.is_empty() { None } else { Some(s) } + }; + books_page.set(0); + on_load_books.call((0, books_page_size, author, series)); + } + }; + rsx! { + // Filters + div { class: "form-row mb-16", + input { + r#type: "text", + placeholder: "Filter by author...", + value: "{filter_author}", + oninput: move |e| filter_author.set(e.value()), + } + input { + r#type: "text", + placeholder: "Filter by series...", + value: "{filter_series}", + oninput: move |e| filter_series.set(e.value()), + } + button { + class: "btn btn-primary", + onclick: search_click, + "Search" + } + } + + if books.is_empty() { + div { class: "empty-state", + p { class: "empty-subtitle", "No books found. Try adjusting your filters or import some books." } + } + } else { + table { class: "data-table", + thead { + tr { + th { "Title" } + th { "Type" } + th { "Artist" } + th { "File" } + } + } + tbody { + for item in books.iter() { + { + let title = item.title.clone().unwrap_or_else(|| item.file_name.clone()); + let artist = item.artist.clone().unwrap_or_default(); + let badge_class = type_badge_class(&item.media_type); + let row_click = { + let mid = item.id.clone(); + move |_| on_select.call(mid.clone()) + }; + rsx! { + tr { key: "{item.id}", class: "clickable-row", onclick: row_click, + td { "{title}" } + td { + span { class: "type-badge {badge_class}", "{item.media_type}" } + } + td { "{artist}" } + td { "{item.file_name}" } + } + } + } + } + } + } + + // Pagination + if books.len() as u64 >= books_page_size { + div { class: "form-row mt-16", + if *books_page.read() > 0 { + button { + class: "btn btn-secondary", + onclick: { + move |_| { + let page = *books_page.read() - 1; + books_page.set(page); + let author = { + let a = filter_author.read().clone(); + if a.is_empty() { None } else { Some(a) } + }; + let series = { + let s = filter_series.read().clone(); + if s.is_empty() { None } else { Some(s) } + }; + on_load_books.call((page * books_page_size, books_page_size, author, series)); + } + }, + "\u{2190} Previous" + } + } + span { class: "text-muted", "Page {books_page.read().checked_add(1).unwrap_or(1)}" } + button { + class: "btn btn-secondary", + onclick: { + move |_| { + let page = *books_page.read() + 1; + books_page.set(page); + let author = { + let a = filter_author.read().clone(); + if a.is_empty() { None } else { Some(a) } + }; + let series = { + let s = filter_series.read().clone(); + if s.is_empty() { None } else { Some(s) } + }; + on_load_books.call((page * books_page_size, books_page_size, author, series)); + } + }, + "Next \u{2192}" + } + } + } + } + } + }, + BooksTab::Series => { + rsx! { + if series_list.is_empty() { + div { class: "empty-state", + p { class: "empty-subtitle", "No series found." } + } + } else { + table { class: "data-table", + thead { + tr { + th { "Series Name" } + th { "Books" } + th { "" } + } + } + tbody { + for series in series_list.iter() { + { + let view_click = { + let name = series.name.clone(); + move |_| on_view_series.call(name.clone()) + }; + rsx! { + tr { key: "{series.name}", + td { "{series.name}" } + td { "{series.book_count}" } + td { + button { + class: "btn btn-sm btn-secondary", + onclick: view_click, + "View" + } + } + } + } + } + } + } + } + } + } + }, + BooksTab::Authors => { + rsx! { + if authors_list.is_empty() { + div { class: "empty-state", + p { class: "empty-subtitle", "No authors found." } + } + } else { + table { class: "data-table", + thead { + tr { + th { "Author" } + th { "Books" } + th { "" } + } + } + tbody { + for author in authors_list.iter() { + { + let view_click = { + let name = author.name.clone(); + move |_| on_view_author.call(name.clone()) + }; + rsx! { + tr { key: "{author.name}", + td { "{author.name}" } + td { "{author.book_count}" } + td { + button { + class: "btn btn-sm btn-secondary", + onclick: view_click, + "View" + } + } + } + } + } + } + } + } + } + } + }, + BooksTab::ReadingList => { + rsx! { + div { class: "form-row mb-16", + select { + value: "{reading_status_filter}", + onchange: { + move |e: Event| { + let val = e.value(); + reading_status_filter.set(val.clone()); + let status = if val.is_empty() { None } else { Some(val) }; + on_load_reading_list.call(status); + } + }, + option { value: "", "All statuses" } + option { value: "ToRead", "To Read" } + option { value: "Reading", "Reading" } + option { value: "Completed", "Completed" } + option { value: "Abandoned", "Abandoned" } + } + } + + if reading_list.is_empty() { + div { class: "empty-state", + p { class: "empty-subtitle", "No books in your reading list yet." } + } + } else { + table { class: "data-table", + thead { + tr { + th { "Title" } + th { "Type" } + th { "Artist" } + th { "File" } + } + } + tbody { + for item in reading_list.iter() { + { + let title = item.title.clone().unwrap_or_else(|| item.file_name.clone()); + let artist = item.artist.clone().unwrap_or_default(); + let badge_class = type_badge_class(&item.media_type); + let row_click = { + let mid = item.id.clone(); + move |_| on_select.call(mid.clone()) + }; + rsx! { + tr { key: "{item.id}", class: "clickable-row", onclick: row_click, + td { "{title}" } + td { + span { class: "type-badge {badge_class}", "{item.media_type}" } + } + td { "{artist}" } + td { "{item.file_name}" } + } + } + } + } + } + } + } + } + }, + } + } + } +} diff --git a/crates/pinakes-ui/src/components/detail.rs b/crates/pinakes-ui/src/components/detail.rs index a12ba7c..3bd7003 100644 --- a/crates/pinakes-ui/src/components/detail.rs +++ b/crates/pinakes-ui/src/components/detail.rs @@ -8,7 +8,14 @@ use super::{ pdf_viewer::PdfViewer, utils::{format_duration, format_size, media_category, type_badge_class}, }; -use crate::client::{ApiClient, MediaResponse, MediaUpdateEvent, TagResponse}; +use crate::client::{ + ApiClient, + BookMetadataResponse, + MediaResponse, + MediaUpdateEvent, + ReadingProgressResponse, + TagResponse, +}; #[component] pub fn Detail( @@ -36,6 +43,11 @@ pub fn Detail( #[props(default)] on_queue_previous: Option>, #[props(default)] on_track_ended: Option>, #[props(default)] on_add_to_queue: Option>, + #[props(default)] book_metadata: Option, + #[props(default)] reading_progress: Option, + #[props(default)] on_update_reading_progress: Option< + EventHandler<(String, i32)>, + >, ) -> Element { let mut editing = use_signal(|| false); let mut show_image_viewer = use_signal(|| false); @@ -815,6 +827,150 @@ pub fn Detail( } } + // Book Information section (for document-type media with metadata) + if let Some(ref bm) = book_metadata { + div { class: "card mb-16", + div { class: "card-header", + h4 { class: "card-title", "Book Information" } + } + div { class: "detail-grid", + if !bm.authors.is_empty() { + div { class: "detail-field", + span { class: "detail-label", "Authors" } + span { class: "detail-value", + {bm.authors.iter().map(|a| { + if a.role == "author" { a.name.clone() } + else { format!("{} ({})", a.name, a.role) } + }).collect::>().join(", ")} + } + } + } + if let Some(ref isbn) = bm.isbn13 { + div { class: "detail-field", + span { class: "detail-label", "ISBN-13" } + span { class: "detail-value mono", "{isbn}" } + } + } else if let Some(ref isbn) = bm.isbn { + div { class: "detail-field", + span { class: "detail-label", "ISBN" } + span { class: "detail-value mono", "{isbn}" } + } + } + if let Some(ref publisher) = bm.publisher { + div { class: "detail-field", + span { class: "detail-label", "Publisher" } + span { class: "detail-value", "{publisher}" } + } + } + if let Some(ref language) = bm.language { + div { class: "detail-field", + span { class: "detail-label", "Language" } + span { class: "detail-value", "{language}" } + } + } + if let Some(pages) = bm.page_count { + div { class: "detail-field", + span { class: "detail-label", "Pages" } + span { class: "detail-value", "{pages}" } + } + } + if let Some(ref series) = bm.series_name { + div { class: "detail-field", + span { class: "detail-label", "Series" } + span { class: "detail-value", + if let Some(idx) = bm.series_index { + {format!("{series} #{idx}")} + } else { + {series.clone()} + } + } + } + } + if let Some(ref date) = bm.publication_date { + div { class: "detail-field", + span { class: "detail-label", "Published" } + span { class: "detail-value", "{date}" } + } + } + if let Some(ref fmt) = bm.format { + div { class: "detail-field", + span { class: "detail-label", "Format" } + span { class: "detail-value", "{fmt}" } + } + } + } + } + } + + // Reading Progress section (for documents with tracked progress) + if let Some(ref rp) = reading_progress { + { + let media_id_for_progress = id.clone(); + let current = rp.current_page; + let total = rp.total_pages; + let percent = rp.progress_percent; + let last_read = rp.last_read_at.clone(); + rsx! { + div { class: "card mb-16", + div { class: "card-header", + h4 { class: "card-title", "Reading Progress" } + } + div { class: "detail-grid", + div { class: "detail-field", + span { class: "detail-label", "Current Page" } + span { class: "detail-value", + if let Some(t) = total { + {format!("{current} / {t}")} + } else { + {format!("{current}")} + } + } + } + div { class: "detail-field", + span { class: "detail-label", "Progress" } + span { class: "detail-value", "{percent:.0}%" } + } + div { class: "detail-field", + span { class: "detail-label", "Last Read" } + span { class: "detail-value", "{last_read}" } + } + } + if on_update_reading_progress.is_some() { + { + let mut page_input = use_signal(|| current.to_string()); + let handler = on_update_reading_progress; + let mid = media_id_for_progress.clone(); + rsx! { + div { class: "form-row mt-16", + input { + r#type: "number", + placeholder: "Page number", + value: "{page_input}", + oninput: move |e: Event| page_input.set(e.value()), + } + button { + class: "btn btn-sm btn-primary", + onclick: { + let mid = mid.clone(); + move |_| { + if let Some(ref h) = handler { + if let Ok(page) = page_input.read().parse::() { + h.call((mid.clone(), page)); + } + } + } + }, + "Update Page" + } + } + } + } + } + } + } + } + } + // Image viewer overlay if *show_image_viewer.read() { ImageViewer { diff --git a/crates/pinakes-ui/src/components/mod.rs b/crates/pinakes-ui/src/components/mod.rs index be1e5d8..605a528 100644 --- a/crates/pinakes-ui/src/components/mod.rs +++ b/crates/pinakes-ui/src/components/mod.rs @@ -1,5 +1,6 @@ pub mod audit; pub mod backlinks_panel; +pub mod books; pub mod breadcrumb; pub mod collections; pub mod database; diff --git a/crates/pinakes-ui/src/components/settings.rs b/crates/pinakes-ui/src/components/settings.rs index 5d3fa56..6666270 100644 --- a/crates/pinakes-ui/src/components/settings.rs +++ b/crates/pinakes-ui/src/components/settings.rs @@ -30,7 +30,7 @@ pub fn Settings( rsx! { div { class: "settings-layout", - // ── Configuration Source ── + // Configuration source div { class: "settings-card", div { class: "settings-card-header", h3 { class: "settings-card-title", "Configuration Source" } @@ -56,7 +56,7 @@ pub fn Settings( } } - // ── Server Health ── + // Server health div { class: "settings-card", div { class: "settings-card-header", h3 { class: "settings-card-title", "Server Info" } @@ -101,7 +101,7 @@ pub fn Settings( } } - // ── Root Directories ── + // Root directories div { class: "settings-card", div { class: "settings-card-header", div { class: "form-label-row", @@ -191,7 +191,7 @@ pub fn Settings( } } - // ── Scanning ── + // Scanning div { class: "settings-card", div { class: "settings-card-header", h3 { class: "settings-card-title", "Scanning" } @@ -394,7 +394,7 @@ pub fn Settings( } } - // ── UI Preferences ── + // UI preferences div { class: "settings-card", div { class: "settings-card-header", h3 { class: "settings-card-title", "UI Preferences" }