pinakes-ui: add book management component and reading progress display

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I877f0856ac5392266a9ba4f607a8d73c6a6a6964
This commit is contained in:
raf 2026-03-08 00:42:38 +03:00
commit adaab9de21
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
8 changed files with 1116 additions and 28 deletions

View file

@ -25,6 +25,7 @@ ammonia = { workspace = true }
dioxus-free-icons = { workspace = true } dioxus-free-icons = { workspace = true }
gloo-timers = { workspace = true } gloo-timers = { workspace = true }
rand = { workspace = true } rand = { workspace = true }
urlencoding = { workspace = true }
[lints] [lints]
workspace = true workspace = true

View file

@ -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}; use std::{fs, path::Path};
fn main() { fn main() {

View file

@ -10,6 +10,7 @@ use dioxus_free_icons::{
icons::fa_solid_icons::{ icons::fa_solid_icons::{
FaArrowRightFromBracket, FaArrowRightFromBracket,
FaBook, FaBook,
FaBookOpen,
FaChartBar, FaChartBar,
FaChevronLeft, FaChevronLeft,
FaChevronRight, FaChevronRight,
@ -43,6 +44,7 @@ use crate::{
client::*, client::*,
components::{ components::{
audit, audit,
books,
collections, collections,
database, database,
detail, detail,
@ -69,6 +71,7 @@ enum View {
Detail, Detail,
Tags, Tags,
Collections, Collections,
Books,
Audit, Audit,
Import, Import,
Duplicates, Duplicates,
@ -87,6 +90,7 @@ impl View {
Self::Detail => "Detail", Self::Detail => "Detail",
Self::Tags => "Tags", Self::Tags => "Tags",
Self::Collections => "Collections", Self::Collections => "Collections",
Self::Books => "Books",
Self::Audit => "Audit Log", Self::Audit => "Audit Log",
Self::Import => "Import", Self::Import => "Import",
Self::Duplicates => "Duplicates", Self::Duplicates => "Duplicates",
@ -112,7 +116,7 @@ pub fn App() -> Element {
let mut media_list = use_signal(Vec::<MediaResponse>::new); let mut media_list = use_signal(Vec::<MediaResponse>::new);
let mut media_total_count = use_signal(|| 0u64); let mut media_total_count = use_signal(|| 0u64);
let mut media_page = 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 media_sort = use_signal(|| "created_at_desc".to_string());
let mut search_results = use_signal(Vec::<MediaResponse>::new); let mut search_results = use_signal(Vec::<MediaResponse>::new);
let mut search_total = use_signal(|| 0u64); 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 preview_total_size = use_signal(|| 0u64);
let mut viewing_collection = use_signal(|| Option::<String>::None); let mut viewing_collection = use_signal(|| Option::<String>::None);
let mut collection_members = use_signal(Vec::<MediaResponse>::new); let mut collection_members = use_signal(Vec::<MediaResponse>::new);
// Phase 4A: Book management
let mut books_list = use_signal(Vec::<MediaResponse>::new);
let mut books_series_list =
use_signal(Vec::<crate::client::SeriesSummary>::new);
let mut books_authors_list =
use_signal(Vec::<crate::client::AuthorSummary>::new);
let mut books_series_detail = use_signal(Vec::<MediaResponse>::new);
let mut books_author_detail = use_signal(Vec::<MediaResponse>::new);
let mut viewing_series = use_signal(|| Option::<String>::None);
let mut viewing_author = use_signal(|| Option::<String>::None);
let mut books_reading_list = use_signal(Vec::<MediaResponse>::new);
let mut detail_book_metadata =
use_signal(|| Option::<crate::client::BookMetadataResponse>::None);
let mut detail_reading_progress =
use_signal(|| Option::<crate::client::ReadingProgressResponse>::None);
let mut server_connected = use_signal(|| false); let mut server_connected = use_signal(|| false);
let mut server_checking = use_signal(|| true); let mut server_checking = use_signal(|| true);
let mut loading = 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-item-text", "Collections" }
span { class: "nav-badge", "{collections_list.read().len()}" } 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", div { class: "nav-section",
@ -886,6 +928,17 @@ pub fn App() -> Element {
Ok(item) => { Ok(item) => {
let mtags = client.get_media_tags(&id).await.unwrap_or_default(); let mtags = client.get_media_tags(&id).await.unwrap_or_default();
media_tags.set(mtags); 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)); selected_media.set(Some(item));
current_view.set(View::Detail); current_view.set(View::Detail);
} }
@ -1090,6 +1143,13 @@ pub fn App() -> Element {
if let Ok(item) = client.get_media(&id).await { if let Ok(item) = client.get_media(&id).await {
let mtags = client.get_media_tags(&id).await.unwrap_or_default(); let mtags = client.get_media_tags(&id).await.unwrap_or_default();
media_tags.set(mtags); 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)); selected_media.set(Some(item));
current_view.set(View::Detail); 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! { None => rsx! {
@ -1618,6 +1697,13 @@ pub fn App() -> Element {
if let Ok(item) = client.get_media(&id).await { if let Ok(item) = client.get_media(&id).await {
let mtags = client.get_media_tags(&id).await.unwrap_or_default(); let mtags = client.get_media_tags(&id).await.unwrap_or_default();
media_tags.set(mtags); 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)); selected_media.set(Some(item));
current_view.set(View::Detail); current_view.set(View::Detail);
} }
@ -1669,6 +1755,13 @@ pub fn App() -> Element {
if let Ok(item) = client.get_media(&id).await { if let Ok(item) = client.get_media(&id).await {
let mtags = client.get_media_tags(&id).await.unwrap_or_default(); let mtags = client.get_media_tags(&id).await.unwrap_or_default();
media_tags.set(mtags); 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)); selected_media.set(Some(item));
current_view.set(View::Detail); current_view.set(View::Detail);
} }
@ -2181,9 +2274,136 @@ pub fn App() -> Element {
} }
}, },
on_backup: { on_backup: {
move |_path: String| { let client = client.read().clone();
show_toast("Backup not yet implemented on server".into(), false); 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<String>, Option<String>)| {
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<String>| {
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());
}, },
} }
} }
@ -2435,6 +2655,13 @@ pub fn App() -> Element {
if let Ok(mtags) = client.get_media_tags(&media_id).await { if let Ok(mtags) = client.get_media_tags(&media_id).await {
media_tags.set(mtags); 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)); selected_media.set(Some(media));
current_view.set(View::Detail); current_view.set(View::Detail);
} }

View file

@ -31,7 +31,7 @@ impl PartialEq for ApiClient {
} }
} }
// ── Response types ── // Response types
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct MediaResponse { pub struct MediaResponse {
@ -179,7 +179,7 @@ fn default_view() -> String {
"library".to_string() "library".to_string()
} }
fn default_page_size() -> usize { fn default_page_size() -> usize {
48 50
} }
fn default_view_mode() -> String { fn default_view_mode() -> String {
"grid".to_string() "grid".to_string()
@ -279,7 +279,7 @@ pub struct DatabaseStatsResponse {
pub backend_name: String, pub backend_name: String,
} }
// ── Markdown Notes/Links Response Types ── // Markdown notes/links response types
#[derive(Debug, Clone, PartialEq, Deserialize)] #[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct BacklinksResponse { pub struct BacklinksResponse {
@ -363,6 +363,55 @@ pub struct CreateSavedSearchRequest {
pub sort_order: Option<String>, pub sort_order: Option<String>,
} }
// Book management response types
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct BookMetadataResponse {
pub media_id: String,
pub isbn: Option<String>,
pub isbn13: Option<String>,
pub publisher: Option<String>,
pub language: Option<String>,
pub page_count: Option<i32>,
pub publication_date: Option<String>,
pub series_name: Option<String>,
pub series_index: Option<f64>,
pub format: Option<String>,
pub authors: Vec<BookAuthorResponse>,
#[serde(default)]
pub identifiers: HashMap<String, Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct BookAuthorResponse {
pub name: String,
pub role: String,
pub file_as: Option<String>,
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<i32>,
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 { impl ApiClient {
pub fn new(base_url: &str, api_key: Option<&str>) -> Self { pub fn new(base_url: &str, api_key: Option<&str>) -> Self {
let mut headers = header::HeaderMap::new(); let mut headers = header::HeaderMap::new();
@ -402,7 +451,7 @@ impl ApiClient {
} }
} }
// ── Media ── // Media
pub async fn list_media( pub async fn list_media(
&self, &self,
@ -522,7 +571,7 @@ impl ApiClient {
Ok(resp.count) Ok(resp.count)
} }
// ── Import ── // Import
pub async fn import_file(&self, path: &str) -> Result<ImportResponse> { pub async fn import_file(&self, path: &str) -> Result<ImportResponse> {
Ok( Ok(
@ -616,7 +665,7 @@ impl ApiClient {
) )
} }
// ── Search ── // Search
pub async fn search( pub async fn search(
&self, &self,
@ -646,7 +695,7 @@ impl ApiClient {
) )
} }
// ── Tags ── // Tags
pub async fn list_tags(&self) -> Result<Vec<TagResponse>> { pub async fn list_tags(&self) -> Result<Vec<TagResponse>> {
Ok( Ok(
@ -730,7 +779,7 @@ impl ApiClient {
) )
} }
// ── Custom Fields ── // Custom fields
pub async fn set_custom_field( pub async fn set_custom_field(
&self, &self,
@ -762,7 +811,7 @@ impl ApiClient {
Ok(()) Ok(())
} }
// ── Collections ── // Collections
pub async fn list_collections(&self) -> Result<Vec<CollectionResponse>> { pub async fn list_collections(&self) -> Result<Vec<CollectionResponse>> {
Ok( Ok(
@ -862,7 +911,7 @@ impl ApiClient {
Ok(()) Ok(())
} }
// ── Batch Operations ── // Batch operations
pub async fn batch_tag( pub async fn batch_tag(
&self, &self,
@ -928,7 +977,7 @@ impl ApiClient {
.await?) .await?)
} }
// ── Audit ── // Audit
pub async fn list_audit( pub async fn list_audit(
&self, &self,
@ -948,7 +997,7 @@ impl ApiClient {
) )
} }
// ── Scan ── // Scan
pub async fn trigger_scan(&self) -> Result<Vec<ScanResponse>> { pub async fn trigger_scan(&self) -> Result<Vec<ScanResponse>> {
Ok( Ok(
@ -977,7 +1026,7 @@ impl ApiClient {
) )
} }
// ── Config ── // Config
pub async fn get_config(&self) -> Result<ConfigResponse> { pub async fn get_config(&self) -> Result<ConfigResponse> {
Ok( Ok(
@ -1049,7 +1098,7 @@ impl ApiClient {
) )
} }
// ── Database Management ── // Database management
pub async fn database_stats(&self) -> Result<DatabaseStatsResponse> { pub async fn database_stats(&self) -> Result<DatabaseStatsResponse> {
Ok( Ok(
@ -1086,7 +1135,180 @@ impl ApiClient {
Ok(()) 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<BookMetadataResponse> {
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<Vec<MediaResponse>> {
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<Vec<SeriesSummary>> {
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<Vec<MediaResponse>> {
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<Vec<AuthorSummary>> {
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<Vec<MediaResponse>> {
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<ReadingProgressResponse> {
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<Vec<MediaResponse>> {
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<Vec<DuplicateGroupResponse>> { pub async fn list_duplicates(&self) -> Result<Vec<DuplicateGroupResponse>> {
Ok( Ok(
@ -1101,7 +1323,7 @@ impl ApiClient {
) )
} }
// ── UI Config ── // UI config
pub async fn update_ui_config( pub async fn update_ui_config(
&self, &self,
@ -1120,7 +1342,7 @@ impl ApiClient {
) )
} }
// ── Auth ── // Auth
pub async fn login( pub async fn login(
&self, &self,
@ -1223,7 +1445,7 @@ impl ApiClient {
) )
} }
// ── Saved Searches ── // Saved searches
pub async fn list_saved_searches(&self) -> Result<Vec<SavedSearchResponse>> { pub async fn list_saved_searches(&self) -> Result<Vec<SavedSearchResponse>> {
Ok( Ok(
@ -1272,7 +1494,7 @@ impl ApiClient {
Ok(()) Ok(())
} }
// ── Markdown Notes/Links ── // Markdown notes/links
/// Get backlinks (incoming links) to a media item. /// Get backlinks (incoming links) to a media item.
pub async fn get_backlinks(&self, id: &str) -> Result<BacklinksResponse> { pub async fn get_backlinks(&self, id: &str) -> Result<BacklinksResponse> {

View file

@ -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<MediaResponse>,
series_list: Vec<SeriesSummary>,
authors_list: Vec<AuthorSummary>,
series_books: Vec<MediaResponse>,
author_books: Vec<MediaResponse>,
reading_list: Vec<MediaResponse>,
viewing_series: Option<String>,
viewing_author: Option<String>,
on_select: EventHandler<String>,
on_load_books: EventHandler<(u64, u64, Option<String>, Option<String>)>,
on_load_series: EventHandler<()>,
on_load_authors: EventHandler<()>,
on_load_reading_list: EventHandler<Option<String>>,
on_view_series: EventHandler<String>,
on_view_author: EventHandler<String>,
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<FormData>| {
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}" }
}
}
}
}
}
}
}
}
},
}
}
}
}

View file

@ -8,7 +8,14 @@ use super::{
pdf_viewer::PdfViewer, pdf_viewer::PdfViewer,
utils::{format_duration, format_size, media_category, type_badge_class}, 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] #[component]
pub fn Detail( pub fn Detail(
@ -36,6 +43,11 @@ pub fn Detail(
#[props(default)] on_queue_previous: Option<EventHandler<()>>, #[props(default)] on_queue_previous: Option<EventHandler<()>>,
#[props(default)] on_track_ended: Option<EventHandler<()>>, #[props(default)] on_track_ended: Option<EventHandler<()>>,
#[props(default)] on_add_to_queue: Option<EventHandler<QueueItem>>, #[props(default)] on_add_to_queue: Option<EventHandler<QueueItem>>,
#[props(default)] book_metadata: Option<BookMetadataResponse>,
#[props(default)] reading_progress: Option<ReadingProgressResponse>,
#[props(default)] on_update_reading_progress: Option<
EventHandler<(String, i32)>,
>,
) -> Element { ) -> Element {
let mut editing = use_signal(|| false); let mut editing = use_signal(|| false);
let mut show_image_viewer = 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::<Vec<_>>().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<FormData>| 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::<i32>() {
h.call((mid.clone(), page));
}
}
}
},
"Update Page"
}
}
}
}
}
}
}
}
}
// Image viewer overlay // Image viewer overlay
if *show_image_viewer.read() { if *show_image_viewer.read() {
ImageViewer { ImageViewer {

View file

@ -1,5 +1,6 @@
pub mod audit; pub mod audit;
pub mod backlinks_panel; pub mod backlinks_panel;
pub mod books;
pub mod breadcrumb; pub mod breadcrumb;
pub mod collections; pub mod collections;
pub mod database; pub mod database;

View file

@ -30,7 +30,7 @@ pub fn Settings(
rsx! { rsx! {
div { class: "settings-layout", div { class: "settings-layout",
// ── Configuration Source ── // Configuration source
div { class: "settings-card", div { class: "settings-card",
div { class: "settings-card-header", div { class: "settings-card-header",
h3 { class: "settings-card-title", "Configuration Source" } 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",
div { class: "settings-card-header", div { class: "settings-card-header",
h3 { class: "settings-card-title", "Server Info" } 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",
div { class: "settings-card-header", div { class: "settings-card-header",
div { class: "form-label-row", div { class: "form-label-row",
@ -191,7 +191,7 @@ pub fn Settings(
} }
} }
// ── Scanning ── // Scanning
div { class: "settings-card", div { class: "settings-card",
div { class: "settings-card-header", div { class: "settings-card-header",
h3 { class: "settings-card-title", "Scanning" } 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",
div { class: "settings-card-header", div { class: "settings-card-header",
h3 { class: "settings-card-title", "UI Preferences" } h3 { class: "settings-card-title", "UI Preferences" }