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:
parent
66861b8a20
commit
adaab9de21
8 changed files with 1116 additions and 28 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
475
crates/pinakes-ui/src/components/books.rs
Normal file
475
crates/pinakes-ui/src/components/books.rs
Normal 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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
pub mod audit;
|
||||
pub mod backlinks_panel;
|
||||
pub mod books;
|
||||
pub mod breadcrumb;
|
||||
pub mod collections;
|
||||
pub mod database;
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue