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 }
gloo-timers = { workspace = true }
rand = { workspace = true }
urlencoding = { workspace = true }
[lints]
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};
fn main() {

View file

@ -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::<MediaResponse>::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::<MediaResponse>::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::<String>::None);
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_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<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());
},
}
}
}
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);
}

View file

@ -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<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 {
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<ImportResponse> {
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<Vec<TagResponse>> {
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<Vec<CollectionResponse>> {
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<Vec<ScanResponse>> {
Ok(
@ -977,7 +1026,7 @@ impl ApiClient {
)
}
// ── Config ──
// Config
pub async fn get_config(&self) -> Result<ConfigResponse> {
Ok(
@ -1049,7 +1098,7 @@ impl ApiClient {
)
}
// ── Database Management ──
// Database management
pub async fn database_stats(&self) -> Result<DatabaseStatsResponse> {
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<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>> {
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<Vec<SavedSearchResponse>> {
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<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,
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<EventHandler<()>>,
#[props(default)] on_track_ended: Option<EventHandler<()>>,
#[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 {
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::<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
if *show_image_viewer.read() {
ImageViewer {

View file

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

View file

@ -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" }