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

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