pinakes-tui: add book management view and api key authentication

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I20f205d9e06a93a89e8f4433ed6f80576a6a6964
This commit is contained in:
raf 2026-03-08 00:42:34 +03:00
commit 66861b8a20
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
18 changed files with 917 additions and 251 deletions

View file

@ -1,4 +1,5 @@
pub mod audit;
pub mod books;
pub mod collections;
pub mod database;
pub mod detail;
@ -24,6 +25,10 @@ use ratatui::{
use crate::app::{AppState, View};
/// Format a file size in bytes into a human-readable string.
#[expect(
clippy::cast_precision_loss,
reason = "file sizes beyond 2^52 bytes are unlikely in practice"
)]
pub fn format_size(bytes: u64) -> String {
if bytes < 1024 {
format!("{bytes} B")
@ -37,6 +42,11 @@ pub fn format_size(bytes: u64) -> String {
}
/// Format duration in seconds into hh:mm:ss format.
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "duration seconds are always non-negative and within u64 range"
)]
pub fn format_duration(secs: f64) -> String {
let total = secs as u64;
let h = total / 3600;
@ -98,6 +108,7 @@ pub fn render(f: &mut Frame, state: &AppState) {
View::Queue => queue::render(f, state, chunks[1]),
View::Statistics => statistics::render(f, state, chunks[1]),
View::Tasks => tasks::render(f, state, chunks[1]),
View::Books => books::render(f, state, chunks[1]),
}
render_status_bar(f, state, chunks[2]);
@ -110,6 +121,7 @@ fn render_tabs(f: &mut Frame, state: &AppState, area: Rect) {
"Tags",
"Collections",
"Audit",
"Books",
"Queue",
"Stats",
"Tasks",
@ -128,9 +140,10 @@ fn render_tabs(f: &mut Frame, state: &AppState, area: Rect) {
View::Tags => 2,
View::Collections => 3,
View::Audit | View::Duplicates | View::Database => 4,
View::Queue => 5,
View::Statistics => 6,
View::Tasks => 7,
View::Books => 5,
View::Queue => 6,
View::Statistics => 7,
View::Tasks => 8,
};
let tabs = Tabs::new(titles)
@ -147,50 +160,60 @@ fn render_tabs(f: &mut Frame, state: &AppState, area: Rect) {
}
fn render_status_bar(f: &mut Frame, state: &AppState, area: Rect) {
let status = if let Some(ref msg) = state.status_message {
msg.clone()
} else {
match state.current_view {
View::Tags => {
" q:Quit j/k:Nav Home/End:Top/Bot n:New d:Delete r:Refresh \
Tab:Switch"
.to_string()
},
View::Collections => {
" q:Quit j/k:Nav Home/End:Top/Bot d:Delete r:Refresh Tab:Switch"
.to_string()
},
View::Audit => {
" q:Quit j/k:Nav Home/End:Top/Bot r:Refresh Tab:Switch".to_string()
},
View::Detail => {
" q:Quit Esc:Back o:Open e:Edit +:Tag -:Untag r:Refresh ?:Help"
.to_string()
},
View::Import => {
" Enter:Import Esc:Cancel s:Scan libraries ?:Help".to_string()
},
View::Settings => " q:Quit Esc:Back ?:Help".to_string(),
View::Duplicates => " q:Quit j/k:Nav r:Refresh Esc:Back".to_string(),
View::Database => " q:Quit v:Vacuum r:Refresh Esc:Back".to_string(),
View::MetadataEdit => {
" Tab:Next field Enter:Save Esc:Cancel".to_string()
},
View::Queue => {
" q:Quit j/k:Nav Enter:Play d:Remove N:Next P:Prev R:Repeat \
S:Shuffle C:Clear"
.to_string()
},
View::Statistics => " q:Quit r:Refresh Esc:Back ?:Help".to_string(),
View::Tasks => {
" q:Quit j/k:Nav Enter:Toggle R:Run Now r:Refresh Esc:Back"
.to_string()
},
_ => " q:Quit /:Search i:Import o:Open t:Tags c:Coll a:Audit \
D:Dupes B:DB Q:Queue X:Stats T:Tasks ?:Help"
.to_string(),
}
};
let status = state.status_message.as_ref().map_or_else(
|| {
match state.current_view {
View::Tags => {
" q:Quit j/k:Nav Home/End:Top/Bot n:New d:Delete r:Refresh \
Tab:Switch"
.to_string()
},
View::Collections => {
" q:Quit j/k:Nav Home/End:Top/Bot d:Delete r:Refresh Tab:Switch"
.to_string()
},
View::Audit => {
" q:Quit j/k:Nav Home/End:Top/Bot r:Refresh Tab:Switch"
.to_string()
},
View::Detail => {
" q:Quit Esc:Back o:Open e:Edit p:Page +:Tag -:Untag \
r:Refresh ?:Help"
.to_string()
},
View::Import => {
" Enter:Import Esc:Cancel s:Scan libraries ?:Help".to_string()
},
View::Settings => " q:Quit Esc:Back ?:Help".to_string(),
View::Duplicates => " q:Quit j/k:Nav r:Refresh Esc:Back".to_string(),
View::Database => " q:Quit v:Vacuum r:Refresh Esc:Back".to_string(),
View::MetadataEdit => {
" Tab:Next field Enter:Save Esc:Cancel".to_string()
},
View::Queue => {
" q:Quit j/k:Nav Enter:Play d:Remove N:Next P:Prev R:Repeat \
S:Shuffle C:Clear"
.to_string()
},
View::Statistics => " q:Quit r:Refresh Esc:Back ?:Help".to_string(),
View::Tasks => {
" q:Quit j/k:Nav Enter:Toggle R:Run Now r:Refresh Esc:Back"
.to_string()
},
View::Books => {
" q:Quit j/k:Nav Home/End:Top/Bot Tab:Sub-view r:Refresh \
Esc:Back"
.to_string()
},
_ => {
" q:Quit /:Search i:Import o:Open t:Tags c:Coll a:Audit \
b:Books D:Dupes B:DB Q:Queue X:Stats T:Tasks ?:Help"
.to_string()
},
}
},
String::clone,
);
let paragraph = Paragraph::new(Line::from(Span::styled(
status,