initial commit

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I4a6b498153eccd5407510dd541b7f4816a6a6964
This commit is contained in:
raf 2026-01-30 22:05:46 +03:00
commit 6a73d11c4b
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
124 changed files with 34856 additions and 0 deletions

View file

@ -0,0 +1,20 @@
[package]
name = "pinakes-tui"
edition.workspace = true
version.workspace = true
license.workspace = true
[dependencies]
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
toml = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
anyhow = { workspace = true }
clap = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
reqwest = { workspace = true }
ratatui = { workspace = true }
crossterm = { workspace = true }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,455 @@
use anyhow::Result;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Clone)]
pub struct ApiClient {
client: Client,
base_url: String,
}
// Response types (mirror server DTOs)
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MediaResponse {
pub id: String,
pub path: String,
pub file_name: String,
pub media_type: String,
pub content_hash: String,
pub file_size: u64,
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub genre: Option<String>,
pub year: Option<i32>,
pub duration_secs: Option<f64>,
pub description: Option<String>,
#[serde(default)]
pub has_thumbnail: bool,
pub custom_fields: HashMap<String, CustomFieldResponse>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CustomFieldResponse {
pub field_type: String,
pub value: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ImportResponse {
pub media_id: String,
pub was_duplicate: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TagResponse {
pub id: String,
pub name: String,
pub parent_id: Option<String>,
pub created_at: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CollectionResponse {
pub id: String,
pub name: String,
pub description: Option<String>,
pub kind: String,
pub filter_query: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SearchResponse {
pub items: Vec<MediaResponse>,
pub total_count: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AuditEntryResponse {
pub id: String,
pub media_id: Option<String>,
pub action: String,
pub details: Option<String>,
pub timestamp: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ScanResponse {
pub files_found: usize,
pub files_processed: usize,
pub errors: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DatabaseStatsResponse {
pub media_count: u64,
pub tag_count: u64,
pub collection_count: u64,
pub audit_count: u64,
pub database_size_bytes: u64,
pub backend_name: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DuplicateGroupResponse {
pub content_hash: String,
pub items: Vec<MediaResponse>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct JobResponse {
pub id: String,
pub kind: serde_json::Value,
pub status: serde_json::Value,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ScheduledTaskResponse {
pub id: String,
pub name: String,
pub schedule: String,
pub enabled: bool,
pub last_run: Option<String>,
pub next_run: Option<String>,
pub last_status: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LibraryStatisticsResponse {
pub total_media: u64,
pub total_size_bytes: u64,
pub avg_file_size_bytes: u64,
pub media_by_type: Vec<TypeCount>,
pub storage_by_type: Vec<TypeCount>,
pub newest_item: Option<String>,
pub oldest_item: Option<String>,
pub top_tags: Vec<TypeCount>,
pub top_collections: Vec<TypeCount>,
pub total_tags: u64,
pub total_collections: u64,
pub total_duplicates: u64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TypeCount {
pub name: String,
pub count: u64,
}
impl ApiClient {
pub fn new(base_url: &str) -> Self {
Self {
client: Client::new(),
base_url: base_url.trim_end_matches('/').to_string(),
}
}
fn url(&self, path: &str) -> String {
format!("{}/api/v1{}", self.base_url, path)
}
pub async fn list_media(&self, offset: u64, limit: u64) -> Result<Vec<MediaResponse>> {
let resp = self
.client
.get(self.url("/media"))
.query(&[("offset", offset.to_string()), ("limit", limit.to_string())])
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn get_media(&self, id: &str) -> Result<MediaResponse> {
let resp = self
.client
.get(self.url(&format!("/media/{id}")))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn import_file(&self, path: &str) -> Result<ImportResponse> {
let resp = self
.client
.post(self.url("/media/import"))
.json(&serde_json::json!({"path": path}))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn delete_media(&self, id: &str) -> Result<()> {
self.client
.delete(self.url(&format!("/media/{id}")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn open_media(&self, id: &str) -> Result<()> {
self.client
.post(self.url(&format!("/media/{id}/open")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn search(&self, query: &str, offset: u64, limit: u64) -> Result<SearchResponse> {
let resp = self
.client
.get(self.url("/search"))
.query(&[
("q", query.to_string()),
("offset", offset.to_string()),
("limit", limit.to_string()),
])
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn list_tags(&self) -> Result<Vec<TagResponse>> {
let resp = self
.client
.get(self.url("/tags"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn create_tag(&self, name: &str, parent_id: Option<&str>) -> Result<TagResponse> {
let mut body = serde_json::json!({"name": name});
if let Some(pid) = parent_id {
body["parent_id"] = serde_json::Value::String(pid.to_string());
}
let resp = self
.client
.post(self.url("/tags"))
.json(&body)
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn delete_tag(&self, id: &str) -> Result<()> {
self.client
.delete(self.url(&format!("/tags/{id}")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn tag_media(&self, media_id: &str, tag_id: &str) -> Result<()> {
self.client
.post(self.url(&format!("/media/{media_id}/tags")))
.json(&serde_json::json!({"tag_id": tag_id}))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn untag_media(&self, media_id: &str, tag_id: &str) -> Result<()> {
self.client
.delete(self.url(&format!("/media/{media_id}/tags/{tag_id}")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn get_media_tags(&self, media_id: &str) -> Result<Vec<TagResponse>> {
let resp = self
.client
.get(self.url(&format!("/media/{media_id}/tags")))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn list_collections(&self) -> Result<Vec<CollectionResponse>> {
let resp = self
.client
.get(self.url("/collections"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn delete_collection(&self, id: &str) -> Result<()> {
self.client
.delete(self.url(&format!("/collections/{id}")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn trigger_scan(&self, path: Option<&str>) -> Result<Vec<ScanResponse>> {
let body = match path {
Some(p) => serde_json::json!({"path": p}),
None => serde_json::json!({"path": null}),
};
let resp = self
.client
.post(self.url("/scan"))
.json(&body)
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn list_audit(&self, offset: u64, limit: u64) -> Result<Vec<AuditEntryResponse>> {
let resp = self
.client
.get(self.url("/audit"))
.query(&[("offset", offset.to_string()), ("limit", limit.to_string())])
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn find_duplicates(&self) -> Result<Vec<DuplicateGroupResponse>> {
let resp = self
.client
.get(self.url("/duplicates"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn database_stats(&self) -> Result<DatabaseStatsResponse> {
let resp = self
.client
.get(self.url("/database/stats"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn list_jobs(&self) -> Result<Vec<JobResponse>> {
let resp = self
.client
.get(self.url("/jobs"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn vacuum_database(&self) -> Result<()> {
self.client
.post(self.url("/database/vacuum"))
.json(&serde_json::json!({}))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn update_media(
&self,
id: &str,
updates: serde_json::Value,
) -> Result<MediaResponse> {
let resp = self
.client
.patch(self.url(&format!("/media/{id}")))
.json(&updates)
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn library_statistics(&self) -> Result<LibraryStatisticsResponse> {
let resp = self
.client
.get(self.url("/statistics"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn list_scheduled_tasks(&self) -> Result<Vec<ScheduledTaskResponse>> {
let resp = self
.client
.get(self.url("/tasks/scheduled"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn toggle_scheduled_task(&self, id: &str) -> Result<()> {
self.client
.post(self.url(&format!("/tasks/scheduled/{id}/toggle")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn run_task_now(&self, id: &str) -> Result<()> {
self.client
.post(self.url(&format!("/tasks/scheduled/{id}/run-now")))
.send()
.await?
.error_for_status()?;
Ok(())
}
}

View file

@ -0,0 +1,74 @@
use std::time::Duration;
use crossterm::event::{self, Event as CrosstermEvent, KeyEvent};
use tokio::sync::mpsc;
#[derive(Debug)]
pub enum AppEvent {
Key(KeyEvent),
Tick,
ApiResult(ApiResult),
}
#[derive(Debug)]
#[allow(dead_code)]
pub enum ApiResult {
MediaList(Vec<crate::client::MediaResponse>),
SearchResults(crate::client::SearchResponse),
Tags(Vec<crate::client::TagResponse>),
AllTags(Vec<crate::client::TagResponse>),
Collections(Vec<crate::client::CollectionResponse>),
ImportDone(crate::client::ImportResponse),
ScanDone(Vec<crate::client::ScanResponse>),
AuditLog(Vec<crate::client::AuditEntryResponse>),
Duplicates(Vec<crate::client::DuplicateGroupResponse>),
DatabaseStats(crate::client::DatabaseStatsResponse),
Statistics(crate::client::LibraryStatisticsResponse),
ScheduledTasks(Vec<crate::client::ScheduledTaskResponse>),
MediaUpdated,
Error(String),
}
pub struct EventHandler {
tx: mpsc::UnboundedSender<AppEvent>,
rx: mpsc::UnboundedReceiver<AppEvent>,
}
impl EventHandler {
pub fn new(tick_rate: Duration) -> Self {
let (tx, rx) = mpsc::unbounded_channel();
let event_tx = tx.clone();
std::thread::spawn(move || {
loop {
match event::poll(tick_rate) {
Ok(true) => {
if let Ok(CrosstermEvent::Key(key)) = event::read()
&& event_tx.send(AppEvent::Key(key)).is_err()
{
break;
}
}
Ok(false) => {
if event_tx.send(AppEvent::Tick).is_err() {
break;
}
}
Err(e) => {
tracing::warn!(error = %e, "event poll failed");
}
}
}
});
Self { tx, rx }
}
pub fn sender(&self) -> mpsc::UnboundedSender<AppEvent> {
self.tx.clone()
}
pub async fn next(&mut self) -> Option<AppEvent> {
self.rx.recv().await
}
}

View file

@ -0,0 +1,97 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::app::View;
pub enum Action {
Quit,
NavigateUp,
NavigateDown,
NavigateLeft,
NavigateRight,
Select,
Back,
Search,
Import,
Delete,
DeleteSelected,
Open,
TagView,
CollectionView,
AuditView,
SettingsView,
DuplicatesView,
DatabaseView,
QueueView,
StatisticsView,
TasksView,
ScanTrigger,
Refresh,
NextTab,
PrevTab,
PageUp,
PageDown,
GoTop,
GoBottom,
CreateTag,
TagMedia,
UntagMedia,
Help,
Char(char),
Backspace,
None,
}
pub fn handle_key(key: KeyEvent, in_input_mode: bool, current_view: &View) -> Action {
if in_input_mode {
match key.code {
KeyCode::Esc => Action::Back,
KeyCode::Enter => Action::Select,
KeyCode::Char(c) => Action::Char(c),
KeyCode::Backspace => Action::Backspace,
_ => Action::None,
}
} else {
match (key.code, key.modifiers) {
(KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => Action::Quit,
(KeyCode::Up | KeyCode::Char('k'), _) => Action::NavigateUp,
(KeyCode::Down | KeyCode::Char('j'), _) => Action::NavigateDown,
(KeyCode::Left | KeyCode::Char('h'), _) => Action::NavigateLeft,
(KeyCode::Right | KeyCode::Char('l'), _) => Action::NavigateRight,
(KeyCode::Home, _) => Action::GoTop,
(KeyCode::End, _) => Action::GoBottom,
(KeyCode::Enter, _) => Action::Select,
(KeyCode::Esc, _) => Action::Back,
(KeyCode::Char('/'), _) => Action::Search,
(KeyCode::Char('?'), _) => Action::Help,
(KeyCode::Char('i'), _) => Action::Import,
(KeyCode::Char('d'), _) => match current_view {
View::Tags | View::Collections => Action::DeleteSelected,
_ => Action::Delete,
},
(KeyCode::Char('o'), _) => Action::Open,
(KeyCode::Char('e'), _) => match current_view {
View::Detail => Action::Select,
_ => Action::None,
},
(KeyCode::Char('t'), _) => Action::TagView,
(KeyCode::Char('c'), _) => Action::CollectionView,
(KeyCode::Char('a'), _) => Action::AuditView,
(KeyCode::Char('S'), _) => Action::SettingsView,
(KeyCode::Char('D'), _) => Action::DuplicatesView,
(KeyCode::Char('B'), _) => Action::DatabaseView,
(KeyCode::Char('Q'), _) => Action::QueueView,
(KeyCode::Char('X'), _) => Action::StatisticsView,
(KeyCode::Char('T'), _) => Action::TasksView,
(KeyCode::Char('s'), _) => Action::ScanTrigger,
(KeyCode::Char('r'), _) => Action::Refresh,
(KeyCode::Char('n'), _) => Action::CreateTag,
(KeyCode::Char('+'), _) => Action::TagMedia,
(KeyCode::Char('-'), _) => Action::UntagMedia,
(KeyCode::Tab, _) => Action::NextTab,
(KeyCode::BackTab, _) => Action::PrevTab,
(KeyCode::PageUp, _) => Action::PageUp,
(KeyCode::PageDown, _) => Action::PageDown,
_ => Action::None,
}
}
}

View file

@ -0,0 +1,55 @@
use anyhow::Result;
use clap::Parser;
use tracing_subscriber::EnvFilter;
mod app;
mod client;
mod event;
mod input;
mod ui;
/// Pinakes terminal UI client
#[derive(Parser)]
#[command(name = "pinakes-tui", version, about)]
struct Cli {
/// Server URL to connect to
#[arg(
short,
long,
env = "PINAKES_SERVER_URL",
default_value = "http://localhost:3000"
)]
server: String,
/// Set log level (trace, debug, info, warn, error)
#[arg(long, default_value = "warn")]
log_level: String,
/// Log to file instead of stderr (avoids corrupting TUI display)
#[arg(long)]
log_file: Option<std::path::PathBuf>,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
// Initialize logging - for TUI, must log to file to avoid corrupting the display
let env_filter = EnvFilter::try_new(&cli.log_level).unwrap_or_else(|_| EnvFilter::new("warn"));
if let Some(log_path) = &cli.log_file {
let file = std::fs::File::create(log_path)?;
tracing_subscriber::fmt()
.with_env_filter(env_filter)
.with_writer(file)
.with_ansi(false)
.init();
} else {
// When no log file specified, suppress all output to avoid TUI corruption
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::new("off"))
.init();
}
app::run(&cli.server).await
}

View file

@ -0,0 +1,85 @@
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, Borders, Cell, Row, Table};
use super::format_date;
use crate::app::AppState;
/// Return a color for an audit action string.
fn action_color(action: &str) -> Color {
match action {
"imported" | "import" | "created" => Color::Green,
"deleted" | "delete" | "removed" => Color::Red,
"tagged" | "tag_added" => Color::Cyan,
"untagged" | "tag_removed" => Color::Yellow,
"updated" | "modified" | "edited" => Color::Blue,
"scanned" | "scan" => Color::Magenta,
_ => Color::White,
}
}
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let header = Row::new(vec!["Action", "Media ID", "Details", "Date"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.audit_log
.iter()
.enumerate()
.map(|(i, entry)| {
let style = if Some(i) == state.audit_selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default()
};
let color = action_color(&entry.action);
let action_cell = Cell::from(Span::styled(
entry.action.clone(),
Style::default().fg(color).add_modifier(Modifier::BOLD),
));
// Truncate media ID for display
let media_display = entry
.media_id
.as_deref()
.map(|id| {
if id.len() > 12 {
format!("{}...", &id[..12])
} else {
id.to_string()
}
})
.unwrap_or_else(|| "-".into());
Row::new(vec![
action_cell,
Cell::from(media_display),
Cell::from(entry.details.clone().unwrap_or_else(|| "-".into())),
Cell::from(format_date(&entry.timestamp).to_string()),
])
.style(style)
})
.collect();
let title = format!(" Audit Log ({}) ", state.audit_log.len());
let table = Table::new(
rows,
[
ratatui::layout::Constraint::Percentage(18),
ratatui::layout::Constraint::Percentage(22),
ratatui::layout::Constraint::Percentage(40),
ratatui::layout::Constraint::Percentage(20),
],
)
.header(header)
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(table, area);
}

View file

@ -0,0 +1,64 @@
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Borders, Row, Table};
use super::format_date;
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let header = Row::new(vec!["Name", "Kind", "Description", "Members", "Created"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.collections
.iter()
.enumerate()
.map(|(i, col)| {
let style = if Some(i) == state.collection_selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default()
};
// We show the filter_query as a proxy for member info when kind is "smart"
let members_display = if col.kind == "smart" {
col.filter_query
.as_deref()
.map(|q| format!("filter: {q}"))
.unwrap_or_else(|| "-".to_string())
} else {
"-".to_string()
};
Row::new(vec![
col.name.clone(),
col.kind.clone(),
col.description.clone().unwrap_or_else(|| "-".into()),
members_display,
format_date(&col.created_at).to_string(),
])
.style(style)
})
.collect();
let title = format!(" Collections ({}) ", state.collections.len());
let table = Table::new(
rows,
[
ratatui::layout::Constraint::Percentage(25),
ratatui::layout::Constraint::Percentage(12),
ratatui::layout::Constraint::Percentage(28),
ratatui::layout::Constraint::Percentage(15),
ratatui::layout::Constraint::Percentage(20),
],
)
.header(header)
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(table, area);
}

View file

@ -0,0 +1,55 @@
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let label_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let value_style = Style::default().fg(Color::White);
let section_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let pad = " ";
let mut lines = vec![
Line::default(),
Line::from(Span::styled("--- Database Statistics ---", section_style)),
];
if let Some(ref stats) = state.database_stats {
for (key, value) in stats {
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(format!("{key:<20}"), label_style),
Span::styled(value.to_string(), value_style),
]));
}
} else {
lines.push(Line::from(vec![
Span::raw(pad),
Span::raw("Press 'r' to load database statistics"),
]));
}
lines.push(Line::default());
lines.push(Line::from(Span::styled("--- Actions ---", section_style)));
lines.push(Line::from(vec![
Span::raw(pad),
Span::raw("v: Vacuum database"),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::raw("Esc: Return to library"),
]));
let paragraph =
Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" Database "));
f.render_widget(paragraph, area);
}

View file

@ -0,0 +1,223 @@
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use super::{format_date, format_duration, format_size, media_type_color};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let item = match &state.selected_media {
Some(item) => item,
None => {
let msg = Paragraph::new("No item selected")
.block(Block::default().borders(Borders::ALL).title(" Detail "));
f.render_widget(msg, area);
return;
}
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0)])
.split(area);
let label_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let value_style = Style::default().fg(Color::White);
let dim_style = Style::default().fg(Color::DarkGray);
let pad = " ";
let label_width = 14;
let make_label = |name: &str| -> String { format!("{name:<label_width$}") };
let mut lines: Vec<Line> = Vec::new();
// Section: File Info
lines.push(Line::from(Span::styled(
"--- File Info ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Name"), label_style),
Span::styled(&item.file_name, value_style),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Path"), label_style),
Span::styled(&item.path, dim_style),
]));
let type_color = media_type_color(&item.media_type);
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Type"), label_style),
Span::styled(&item.media_type, Style::default().fg(type_color)),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Size"), label_style),
Span::styled(format_size(item.file_size), value_style),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Hash"), label_style),
Span::styled(&item.content_hash, dim_style),
]));
if item.has_thumbnail {
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Thumbnail"), label_style),
Span::styled("Yes", Style::default().fg(Color::Green)),
]));
}
lines.push(Line::default()); // blank line
// Section: Metadata
lines.push(Line::from(Span::styled(
"--- Metadata ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Title"), label_style),
Span::styled(item.title.as_deref().unwrap_or("-"), value_style),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Artist"), label_style),
Span::styled(item.artist.as_deref().unwrap_or("-"), value_style),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Album"), label_style),
Span::styled(item.album.as_deref().unwrap_or("-"), value_style),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Genre"), label_style),
Span::styled(item.genre.as_deref().unwrap_or("-"), value_style),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Year"), label_style),
Span::styled(
item.year
.map(|y| y.to_string())
.unwrap_or_else(|| "-".to_string()),
value_style,
),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Duration"), label_style),
Span::styled(
item.duration_secs
.map(format_duration)
.unwrap_or_else(|| "-".to_string()),
value_style,
),
]));
// Description
if let Some(ref desc) = item.description
&& !desc.is_empty()
{
lines.push(Line::default());
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Description"), label_style),
Span::styled(desc.as_str(), value_style),
]));
}
// Custom fields
if !item.custom_fields.is_empty() {
lines.push(Line::default());
lines.push(Line::from(Span::styled(
"--- Custom Fields ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
let mut fields: Vec<_> = item.custom_fields.iter().collect();
fields.sort_by_key(|(k, _)| k.as_str());
for (key, field) in fields {
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(format!("{key:<label_width$}"), label_style),
Span::styled(
format!("{} ({})", field.value, field.field_type),
value_style,
),
]));
}
}
// Tags section
if !state.tags.is_empty() {
lines.push(Line::default());
lines.push(Line::from(Span::styled(
"--- Tags ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
let tag_names: Vec<&str> = state.tags.iter().map(|t| t.name.as_str()).collect();
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(tag_names.join(", "), Style::default().fg(Color::Green)),
]));
}
lines.push(Line::default());
// Section: Timestamps
lines.push(Line::from(Span::styled(
"--- Timestamps ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Created"), label_style),
Span::styled(format_date(&item.created_at), dim_style),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Updated"), label_style),
Span::styled(format_date(&item.updated_at), dim_style),
]));
let title = if let Some(ref title_str) = item.title {
format!(" Detail: {} ", title_str)
} else {
format!(" Detail: {} ", item.file_name)
};
let detail = Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(detail, chunks[0]);
}

View file

@ -0,0 +1,56 @@
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let items: Vec<ListItem> = if state.duplicate_groups.is_empty() {
vec![ListItem::new(Line::from(Span::styled(
" No duplicates found. Press 'r' to refresh.",
Style::default().fg(Color::DarkGray),
)))]
} else {
let mut list_items = Vec::new();
for (i, group) in state.duplicate_groups.iter().enumerate() {
let header = format!(
"Group {} ({} items, hash: {})",
i + 1,
group.len(),
group
.first()
.map(|m| m.content_hash.as_str())
.unwrap_or("?")
);
list_items.push(ListItem::new(Line::from(Span::styled(
header,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
))));
for item in group {
let line = format!(" {} - {}", item.file_name, item.path);
let is_selected = state
.duplicates_selected
.map(|sel| sel == list_items.len())
.unwrap_or(false);
let style = if is_selected {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
list_items.push(ListItem::new(Line::from(Span::styled(line, style))));
}
list_items.push(ListItem::new(Line::default()));
}
list_items
};
let list = List::new(items).block(Block::default().borders(Borders::ALL).title(" Duplicates "));
f.render_widget(list, area);
}

View file

@ -0,0 +1,65 @@
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
let input = Paragraph::new(state.import_input.as_str())
.block(
Block::default()
.borders(Borders::ALL)
.title(" Import File (enter path and press Enter) "),
)
.style(if state.input_mode {
Style::default().fg(Color::Cyan)
} else {
Style::default()
});
f.render_widget(input, chunks[0]);
let label_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let key_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let help_lines = vec![
Line::default(),
Line::from(Span::styled(
" Import a file or trigger a library scan",
label_style,
)),
Line::default(),
Line::from(vec![
Span::styled(" Enter", key_style),
Span::raw(" Import the file at the entered path"),
]),
Line::from(vec![
Span::styled(" Esc", key_style),
Span::raw(" Cancel and return to library"),
]),
Line::from(vec![
Span::styled(" s", key_style),
Span::raw(" Trigger a full library scan (scans all configured directories)"),
]),
Line::default(),
Line::from(Span::styled(" Tips:", label_style)),
Line::from(" - Enter an absolute path to a media file (e.g. /home/user/music/song.mp3)"),
Line::from(" - The file will be copied into the managed library"),
Line::from(" - Duplicates are detected by content hash and will be skipped"),
Line::from(" - Press 's' (without typing a path) to scan all library directories"),
];
let help =
Paragraph::new(help_lines).block(Block::default().borders(Borders::ALL).title(" Help "));
f.render_widget(help, chunks[1]);
}

View file

@ -0,0 +1,75 @@
use ratatui::Frame;
use ratatui::layout::{Constraint, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, Borders, Cell, Row, Table};
use super::{format_duration, format_size, media_type_color};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let header = Row::new(vec!["Title / Name", "Type", "Duration", "Year", "Size"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.media_list
.iter()
.enumerate()
.map(|(i, item)| {
let style = if Some(i) == state.selected_index {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default()
};
let display_name = item.title.as_deref().unwrap_or(&item.file_name).to_string();
let type_color = media_type_color(&item.media_type);
let type_cell = Cell::from(Span::styled(
item.media_type.clone(),
Style::default().fg(type_color),
));
let duration = item
.duration_secs
.map(format_duration)
.unwrap_or_else(|| "-".to_string());
let year = item
.year
.map(|y| y.to_string())
.unwrap_or_else(|| "-".to_string());
Row::new(vec![
Cell::from(display_name),
type_cell,
Cell::from(duration),
Cell::from(year),
Cell::from(format_size(item.file_size)),
])
.style(style)
})
.collect();
let page = (state.page_offset / state.page_size) + 1;
let item_count = state.media_list.len();
let title = format!(" Library (page {page}, {item_count} items) ");
let table = Table::new(
rows,
[
Constraint::Percentage(35),
Constraint::Percentage(20),
Constraint::Percentage(15),
Constraint::Percentage(10),
Constraint::Percentage(20),
],
)
.header(header)
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(table, area);
}

View file

@ -0,0 +1,83 @@
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
// Header
let title = if let Some(ref media) = state.selected_media {
format!(" Edit: {} ", media.file_name)
} else {
" Edit Metadata ".to_string()
};
let header = Paragraph::new(Line::from(Span::styled(
&title,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)))
.block(Block::default().borders(Borders::ALL));
f.render_widget(header, chunks[0]);
// Edit fields
let label_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let value_style = Style::default().fg(Color::White);
let active_style = Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD);
let pad = " ";
let fields = [
("Title", &state.edit_title),
("Artist", &state.edit_artist),
("Album", &state.edit_album),
("Genre", &state.edit_genre),
("Year", &state.edit_year),
("Description", &state.edit_description),
];
let mut lines = Vec::new();
lines.push(Line::default());
for (i, (label, value)) in fields.iter().enumerate() {
let is_active = state.edit_field_index == Some(i);
let style = if is_active { active_style } else { label_style };
let cursor = if is_active { "> " } else { pad };
lines.push(Line::from(vec![
Span::raw(cursor),
Span::styled(format!("{label:<14}"), style),
Span::styled(value.as_str(), value_style),
if is_active {
Span::styled("_", Style::default().fg(Color::Green))
} else {
Span::raw("")
},
]));
}
lines.push(Line::default());
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(
"Tab: Next field Enter: Save Esc: Cancel",
Style::default().fg(Color::DarkGray),
),
]));
let editor =
Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" Fields "));
f.render_widget(editor, chunks[1]);
}

View file

@ -0,0 +1,190 @@
pub mod audit;
pub mod collections;
pub mod database;
pub mod detail;
pub mod duplicates;
pub mod import;
pub mod library;
pub mod metadata_edit;
pub mod queue;
pub mod search;
pub mod settings;
pub mod statistics;
pub mod tags;
pub mod tasks;
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Tabs};
use crate::app::{AppState, View};
/// Format a file size in bytes into a human-readable string.
pub fn format_size(bytes: u64) -> String {
if bytes < 1024 {
format!("{bytes} B")
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else if bytes < 1024 * 1024 * 1024 {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
} else {
format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
}
}
/// Format duration in seconds into hh:mm:ss format.
pub fn format_duration(secs: f64) -> String {
let total = secs as u64;
let h = total / 3600;
let m = (total % 3600) / 60;
let s = total % 60;
if h > 0 {
format!("{h:02}:{m:02}:{s:02}")
} else {
format!("{m:02}:{s:02}")
}
}
/// Trim a timestamp string to just the date portion (YYYY-MM-DD).
pub fn format_date(timestamp: &str) -> &str {
// Timestamps are typically "2024-01-15T10:30:00Z" or similar
if timestamp.len() >= 10 {
&timestamp[..10]
} else {
timestamp
}
}
/// Return a color based on media type string.
pub fn media_type_color(media_type: &str) -> Color {
match media_type {
t if t.starts_with("audio") => Color::Green,
t if t.starts_with("video") => Color::Magenta,
t if t.starts_with("image") => Color::Yellow,
t if t.starts_with("application/pdf") => Color::Red,
t if t.starts_with("text") => Color::Cyan,
_ => Color::White,
}
}
pub fn render(f: &mut Frame, state: &AppState) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(1),
])
.split(f.area());
render_tabs(f, state, chunks[0]);
match state.current_view {
View::Library => library::render(f, state, chunks[1]),
View::Search => search::render(f, state, chunks[1]),
View::Detail => detail::render(f, state, chunks[1]),
View::Tags => tags::render(f, state, chunks[1]),
View::Collections => collections::render(f, state, chunks[1]),
View::Audit => audit::render(f, state, chunks[1]),
View::Import => import::render(f, state, chunks[1]),
View::Settings => settings::render(f, state, chunks[1]),
View::Duplicates => duplicates::render(f, state, chunks[1]),
View::Database => database::render(f, state, chunks[1]),
View::MetadataEdit => metadata_edit::render(f, state, chunks[1]),
View::Queue => queue::render(f, state, chunks[1]),
View::Statistics => statistics::render(f, state, chunks[1]),
View::Tasks => tasks::render(f, state, chunks[1]),
}
render_status_bar(f, state, chunks[2]);
}
fn render_tabs(f: &mut Frame, state: &AppState, area: Rect) {
let titles: Vec<Line> = vec![
"Library",
"Search",
"Tags",
"Collections",
"Audit",
"Queue",
"Stats",
"Tasks",
]
.into_iter()
.map(|t| Line::from(Span::styled(t, Style::default().fg(Color::White))))
.collect();
let selected = match state.current_view {
View::Library | View::Detail | View::Import | View::Settings | View::MetadataEdit => 0,
View::Search => 1,
View::Tags => 2,
View::Collections => 3,
View::Audit | View::Duplicates | View::Database => 4,
View::Queue => 5,
View::Statistics => 6,
View::Tasks => 7,
};
let tabs = Tabs::new(titles)
.block(Block::default().borders(Borders::ALL).title(" Pinakes "))
.select(selected)
.style(Style::default().fg(Color::Gray))
.highlight_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
f.render_widget(tabs, area);
}
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 paragraph = Paragraph::new(Line::from(Span::styled(
status,
Style::default().fg(Color::DarkGray),
)));
f.render_widget(paragraph, area);
}

View file

@ -0,0 +1,69 @@
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let items: Vec<ListItem> = if state.play_queue.is_empty() {
vec![ListItem::new(Line::from(Span::styled(
" Queue is empty. Select items in the library and press 'q' to add.",
Style::default().fg(Color::DarkGray),
)))]
} else {
state
.play_queue
.iter()
.enumerate()
.map(|(i, item)| {
let is_current = state.queue_current_index == Some(i);
let is_selected = state.queue_selected == Some(i);
let prefix = if is_current { ">> " } else { " " };
let type_color = super::media_type_color(&item.media_type);
let id_suffix = if item.media_id.len() > 8 {
&item.media_id[item.media_id.len() - 8..]
} else {
&item.media_id
};
let text = if let Some(ref artist) = item.artist {
format!("{prefix}{} - {} [{}]", item.title, artist, id_suffix)
} else {
format!("{prefix}{} [{}]", item.title, id_suffix)
};
let style = if is_selected {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else if is_current {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(type_color)
};
ListItem::new(Line::from(Span::styled(text, style)))
})
.collect()
};
let repeat_str = match state.queue_repeat {
0 => "Off",
1 => "One",
_ => "All",
};
let shuffle_str = if state.queue_shuffle { "On" } else { "Off" };
let title = format!(
" Queue ({}) | Repeat: {} | Shuffle: {} ",
state.play_queue.len(),
repeat_str,
shuffle_str,
);
let list = List::new(items).block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(list, area);
}

View file

@ -0,0 +1,81 @@
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table};
use super::{format_size, media_type_color};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
// Search input
let input = Paragraph::new(state.search_input.as_str())
.block(
Block::default()
.borders(Borders::ALL)
.title(" Search (type and press Enter) "),
)
.style(if state.input_mode {
Style::default().fg(Color::Cyan)
} else {
Style::default()
});
f.render_widget(input, chunks[0]);
// Results
let header = Row::new(vec!["Name", "Type", "Artist", "Size"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.search_results
.iter()
.enumerate()
.map(|(i, item)| {
let style = if Some(i) == state.search_selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default()
};
let type_color = media_type_color(&item.media_type);
let type_cell = Cell::from(Span::styled(
item.media_type.clone(),
Style::default().fg(type_color),
));
Row::new(vec![
Cell::from(item.file_name.clone()),
type_cell,
Cell::from(item.artist.clone().unwrap_or_default()),
Cell::from(format_size(item.file_size)),
])
.style(style)
})
.collect();
let shown = state.search_results.len();
let total = state.search_total_count;
let results_title = format!(" Results: {shown} shown, {total} total ");
let table = Table::new(
rows,
[
Constraint::Percentage(35),
Constraint::Percentage(20),
Constraint::Percentage(25),
Constraint::Percentage(20),
],
)
.header(header)
.block(Block::default().borders(Borders::ALL).title(results_title));
f.render_widget(table, chunks[1]);
}

View file

@ -0,0 +1,82 @@
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let label_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let value_style = Style::default().fg(Color::White);
let section_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let pad = " ";
let lines = vec![
Line::default(),
Line::from(Span::styled("--- Connection ---", section_style)),
Line::from(vec![
Span::raw(pad),
Span::styled("Server URL: ", label_style),
Span::styled(&state.server_url, value_style),
]),
Line::default(),
Line::from(Span::styled("--- Library ---", section_style)),
Line::from(vec![
Span::raw(pad),
Span::styled("Total items: ", label_style),
Span::styled(state.total_media_count.to_string(), value_style),
]),
Line::from(vec![
Span::raw(pad),
Span::styled("Page size: ", label_style),
Span::styled(state.page_size.to_string(), value_style),
]),
Line::from(vec![
Span::raw(pad),
Span::styled("Current page: ", label_style),
Span::styled(
((state.page_offset / state.page_size) + 1).to_string(),
value_style,
),
]),
Line::default(),
Line::from(Span::styled("--- State ---", section_style)),
Line::from(vec![
Span::raw(pad),
Span::styled("Tags loaded: ", label_style),
Span::styled(state.tags.len().to_string(), value_style),
]),
Line::from(vec![
Span::raw(pad),
Span::styled("All tags: ", label_style),
Span::styled(state.all_tags.len().to_string(), value_style),
]),
Line::from(vec![
Span::raw(pad),
Span::styled("Collections: ", label_style),
Span::styled(state.collections.len().to_string(), value_style),
]),
Line::from(vec![
Span::raw(pad),
Span::styled("Audit entries: ", label_style),
Span::styled(state.audit_log.len().to_string(), value_style),
]),
Line::default(),
Line::from(Span::styled("--- Shortcuts ---", section_style)),
Line::from(vec![
Span::raw(pad),
Span::raw("Press Esc to return to the library view"),
]),
];
let settings =
Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" Settings "));
f.render_widget(settings, area);
}

View file

@ -0,0 +1,183 @@
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Row, Table};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let Some(ref stats) = state.library_stats else {
let msg = Paragraph::new("Loading statistics... (press X to refresh)")
.block(Block::default().borders(Borders::ALL).title(" Statistics "));
f.render_widget(msg, area);
return;
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(8), // Overview
Constraint::Length(10), // Media by type
Constraint::Min(6), // Top tags & collections
])
.split(area);
// Overview section
let overview_lines = vec![
Line::from(vec![
Span::styled(" Total Media: ", Style::default().fg(Color::Gray)),
Span::styled(
stats.total_media.to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled("Total Size: ", Style::default().fg(Color::Gray)),
Span::styled(
super::format_size(stats.total_size_bytes),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled(" Avg Size: ", Style::default().fg(Color::Gray)),
Span::styled(
super::format_size(stats.avg_file_size_bytes),
Style::default().fg(Color::White),
),
]),
Line::from(vec![
Span::styled(" Tags: ", Style::default().fg(Color::Gray)),
Span::styled(
stats.total_tags.to_string(),
Style::default().fg(Color::Green),
),
Span::raw(" "),
Span::styled("Collections: ", Style::default().fg(Color::Gray)),
Span::styled(
stats.total_collections.to_string(),
Style::default().fg(Color::Green),
),
Span::raw(" "),
Span::styled("Duplicates: ", Style::default().fg(Color::Gray)),
Span::styled(
stats.total_duplicates.to_string(),
Style::default().fg(Color::Yellow),
),
]),
Line::from(vec![
Span::styled(" Newest: ", Style::default().fg(Color::Gray)),
Span::styled(
stats
.newest_item
.as_deref()
.map(super::format_date)
.unwrap_or("-"),
Style::default().fg(Color::White),
),
Span::raw(" "),
Span::styled("Oldest: ", Style::default().fg(Color::Gray)),
Span::styled(
stats
.oldest_item
.as_deref()
.map(super::format_date)
.unwrap_or("-"),
Style::default().fg(Color::White),
),
]),
];
let overview = Paragraph::new(overview_lines)
.block(Block::default().borders(Borders::ALL).title(" Overview "));
f.render_widget(overview, chunks[0]);
// Media by Type table
let type_rows: Vec<Row> = stats
.media_by_type
.iter()
.map(|tc| {
let color = super::media_type_color(&tc.name);
Row::new(vec![
Span::styled(tc.name.clone(), Style::default().fg(color)),
Span::styled(tc.count.to_string(), Style::default().fg(Color::White)),
])
})
.collect();
let storage_rows: Vec<Row> = stats
.storage_by_type
.iter()
.map(|tc| {
Row::new(vec![
Span::styled(tc.name.clone(), Style::default().fg(Color::Gray)),
Span::styled(
super::format_size(tc.count),
Style::default().fg(Color::White),
),
])
})
.collect();
let type_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(chunks[1]);
let type_table = Table::new(type_rows, [Constraint::Min(20), Constraint::Length(10)]).block(
Block::default()
.borders(Borders::ALL)
.title(" Media by Type "),
);
f.render_widget(type_table, type_cols[0]);
let storage_table = Table::new(storage_rows, [Constraint::Min(20), Constraint::Length(12)])
.block(
Block::default()
.borders(Borders::ALL)
.title(" Storage by Type "),
);
f.render_widget(storage_table, type_cols[1]);
// Top tags and collections
let bottom_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(chunks[2]);
let tag_rows: Vec<Row> = stats
.top_tags
.iter()
.map(|tc| {
Row::new(vec![
Span::styled(tc.name.clone(), Style::default().fg(Color::Green)),
Span::styled(tc.count.to_string(), Style::default().fg(Color::White)),
])
})
.collect();
let tags_table = Table::new(tag_rows, [Constraint::Min(20), Constraint::Length(10)])
.block(Block::default().borders(Borders::ALL).title(" Top Tags "));
f.render_widget(tags_table, bottom_cols[0]);
let col_rows: Vec<Row> = stats
.top_collections
.iter()
.map(|tc| {
Row::new(vec![
Span::styled(tc.name.clone(), Style::default().fg(Color::Magenta)),
Span::styled(tc.count.to_string(), Style::default().fg(Color::White)),
])
})
.collect();
let cols_table = Table::new(col_rows, [Constraint::Min(20), Constraint::Length(10)]).block(
Block::default()
.borders(Borders::ALL)
.title(" Top Collections "),
);
f.render_widget(cols_table, bottom_cols[1]);
}

View file

@ -0,0 +1,61 @@
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Borders, Row, Table};
use super::format_date;
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let header = Row::new(vec!["Name", "Parent", "Created"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.tags
.iter()
.enumerate()
.map(|(i, tag)| {
let style = if Some(i) == state.tag_selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default()
};
// Resolve parent tag name from the tags list itself
let parent_display = match &tag.parent_id {
Some(pid) => state
.tags
.iter()
.find(|t| t.id == *pid)
.map(|t| t.name.clone())
.unwrap_or_else(|| pid.chars().take(8).collect::<String>() + "..."),
None => "-".to_string(),
};
Row::new(vec![
tag.name.clone(),
parent_display,
format_date(&tag.created_at).to_string(),
])
.style(style)
})
.collect();
let title = format!(" Tags ({}) ", state.tags.len());
let table = Table::new(
rows,
[
ratatui::layout::Constraint::Percentage(40),
ratatui::layout::Constraint::Percentage(30),
ratatui::layout::Constraint::Percentage(30),
],
)
.header(header)
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(table, area);
}

View file

@ -0,0 +1,63 @@
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let items: Vec<ListItem> = if state.scheduled_tasks.is_empty() {
vec![ListItem::new(Line::from(Span::styled(
" No scheduled tasks. Press T to refresh.",
Style::default().fg(Color::DarkGray),
)))]
} else {
state
.scheduled_tasks
.iter()
.enumerate()
.map(|(i, task)| {
let is_selected = state.scheduled_tasks_selected == Some(i);
let enabled_marker = if task.enabled { "[ON] " } else { "[OFF]" };
let enabled_color = if task.enabled {
Color::Green
} else {
Color::DarkGray
};
let last_run = task
.last_run
.as_deref()
.map(super::format_date)
.unwrap_or("-");
let next_run = task
.next_run
.as_deref()
.map(super::format_date)
.unwrap_or("-");
let status = task.last_status.as_deref().unwrap_or("-");
let text = format!(
" {enabled_marker} {:<20} {:<16} Last: {:<12} Next: {:<12} Status: {}",
task.name, task.schedule, last_run, next_run, status
);
let style = if is_selected {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(enabled_color)
};
ListItem::new(Line::from(Span::styled(text, style)))
})
.collect()
};
let title = format!(" Scheduled Tasks ({}) ", state.scheduled_tasks.len());
let list = List::new(items).block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(list, area);
}