treewide: fix various UI bugs; optimize crypto dependencies & format

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: If8fe8b38c1d9c4fecd40ff71f88d2ae06a6a6964
This commit is contained in:
raf 2026-02-10 12:56:05 +03:00
commit 3ccddce7fd
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
178 changed files with 58342 additions and 54241 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,456 +1,491 @@
use std::collections::HashMap;
use anyhow::Result;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Clone)]
pub struct ApiClient {
client: Client,
base_url: String,
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,
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,
pub field_type: String,
pub value: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ImportResponse {
pub media_id: String,
pub was_duplicate: bool,
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,
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,
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,
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,
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>,
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,
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>,
pub content_hash: String,
pub items: Vec<MediaResponse>,
}
/// Background job response from the API.
#[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,
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>,
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,
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,
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(),
}
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)
}
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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(())
}
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

@ -5,68 +5,68 @@ use tokio::sync::mpsc;
#[derive(Debug)]
pub enum AppEvent {
Key(KeyEvent),
Tick,
ApiResult(ApiResult),
Key(KeyEvent),
Tick,
ApiResult(ApiResult),
}
#[derive(Debug)]
pub enum ApiResult {
MediaList(Vec<crate::client::MediaResponse>),
SearchResults(crate::client::SearchResponse),
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),
MediaList(Vec<crate::client::MediaResponse>),
SearchResults(crate::client::SearchResponse),
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>,
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();
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");
}
}
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 }
}
Self { tx, rx }
}
pub fn sender(&self) -> mpsc::UnboundedSender<AppEvent> {
self.tx.clone()
}
pub fn sender(&self) -> mpsc::UnboundedSender<AppEvent> {
self.tx.clone()
}
pub async fn next(&mut self) -> Option<AppEvent> {
self.rx.recv().await
}
pub async fn next(&mut self) -> Option<AppEvent> {
self.rx.recv().await
}
}

View file

@ -3,148 +3,179 @@ 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,
Edit,
Vacuum,
Toggle,
RunNow,
Save,
Char(char),
Backspace,
// Multi-select actions
ToggleSelection,
SelectAll,
ClearSelection,
ToggleSelectionMode,
BatchDelete,
BatchTag,
None,
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,
Edit,
Vacuum,
Toggle,
RunNow,
Save,
Char(char),
Backspace,
// Multi-select actions
ToggleSelection,
SelectAll,
ClearSelection,
ToggleSelectionMode,
BatchDelete,
BatchTag,
None,
}
pub fn handle_key(key: KeyEvent, in_input_mode: bool, current_view: &View) -> Action {
if in_input_mode {
match (key.code, key.modifiers) {
(KeyCode::Esc, _) => Action::Back,
(KeyCode::Enter, _) => Action::Select,
(KeyCode::Char('s'), KeyModifiers::CONTROL) => match current_view {
View::MetadataEdit => Action::Save,
_ => 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::Edit,
_ => Action::None,
},
(KeyCode::Char('t'), _) => match current_view {
View::Tasks => Action::Toggle,
_ => Action::TagView,
},
(KeyCode::Char('c'), _) => Action::CollectionView,
// Multi-select: Ctrl+A for SelectAll (must come before plain 'a')
(KeyCode::Char('a'), KeyModifiers::CONTROL) => match current_view {
View::Library | View::Search => Action::SelectAll,
_ => Action::None,
},
(KeyCode::Char('a'), _) => Action::AuditView,
(KeyCode::Char('S'), _) => Action::SettingsView,
(KeyCode::Char('B'), _) => Action::DatabaseView,
(KeyCode::Char('Q'), _) => Action::QueueView,
(KeyCode::Char('X'), _) => Action::StatisticsView,
// Use plain D/T for views in non-library contexts, keep for batch ops in library/search
(KeyCode::Char('D'), _) => match current_view {
View::Library | View::Search => Action::BatchDelete,
_ => Action::DuplicatesView,
},
(KeyCode::Char('T'), _) => match current_view {
View::Library | View::Search => Action::BatchTag,
_ => Action::TasksView,
},
// Ctrl+S must come before plain 's' to ensure proper precedence
(KeyCode::Char('s'), KeyModifiers::CONTROL) => match current_view {
View::MetadataEdit => Action::Save,
_ => Action::None,
},
(KeyCode::Char('s'), _) => Action::ScanTrigger,
(KeyCode::Char('r'), _) => Action::Refresh,
(KeyCode::Char('n'), _) => Action::CreateTag,
(KeyCode::Char('+'), _) => Action::TagMedia,
(KeyCode::Char('-'), _) => Action::UntagMedia,
(KeyCode::Char('v'), _) => match current_view {
View::Database => Action::Vacuum,
_ => Action::ToggleSelectionMode,
},
(KeyCode::Char('x'), _) => match current_view {
View::Tasks => Action::RunNow,
_ => Action::None,
},
(KeyCode::Tab, _) => Action::NextTab,
(KeyCode::BackTab, _) => Action::PrevTab,
(KeyCode::PageUp, _) => Action::PageUp,
(KeyCode::PageDown, _) => Action::PageDown,
// Multi-select keys
(KeyCode::Char(' '), _) => match current_view {
View::Library | View::Search => Action::ToggleSelection,
_ => Action::None,
},
(KeyCode::Char('u'), _) => match current_view {
View::Library | View::Search => Action::ClearSelection,
_ => Action::None,
},
_ => Action::None,
pub fn handle_key(
key: KeyEvent,
in_input_mode: bool,
current_view: &View,
) -> Action {
if in_input_mode {
match (key.code, key.modifiers) {
(KeyCode::Esc, _) => Action::Back,
(KeyCode::Enter, _) => Action::Select,
(KeyCode::Char('s'), KeyModifiers::CONTROL) => {
match current_view {
View::MetadataEdit => Action::Save,
_ => 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::Edit,
_ => Action::None,
}
},
(KeyCode::Char('t'), _) => {
match current_view {
View::Tasks => Action::Toggle,
_ => Action::TagView,
}
},
(KeyCode::Char('c'), _) => Action::CollectionView,
// Multi-select: Ctrl+A for SelectAll (must come before plain 'a')
(KeyCode::Char('a'), KeyModifiers::CONTROL) => {
match current_view {
View::Library | View::Search => Action::SelectAll,
_ => Action::None,
}
},
(KeyCode::Char('a'), _) => Action::AuditView,
(KeyCode::Char('S'), _) => Action::SettingsView,
(KeyCode::Char('B'), _) => Action::DatabaseView,
(KeyCode::Char('Q'), _) => Action::QueueView,
(KeyCode::Char('X'), _) => Action::StatisticsView,
// Use plain D/T for views in non-library contexts, keep for batch ops in
// library/search
(KeyCode::Char('D'), _) => {
match current_view {
View::Library | View::Search => Action::BatchDelete,
_ => Action::DuplicatesView,
}
},
(KeyCode::Char('T'), _) => {
match current_view {
View::Library | View::Search => Action::BatchTag,
_ => Action::TasksView,
}
},
// Ctrl+S must come before plain 's' to ensure proper precedence
(KeyCode::Char('s'), KeyModifiers::CONTROL) => {
match current_view {
View::MetadataEdit => Action::Save,
_ => Action::None,
}
},
(KeyCode::Char('s'), _) => Action::ScanTrigger,
(KeyCode::Char('r'), _) => Action::Refresh,
(KeyCode::Char('n'), _) => Action::CreateTag,
(KeyCode::Char('+'), _) => Action::TagMedia,
(KeyCode::Char('-'), _) => Action::UntagMedia,
(KeyCode::Char('v'), _) => {
match current_view {
View::Database => Action::Vacuum,
_ => Action::ToggleSelectionMode,
}
},
(KeyCode::Char('x'), _) => {
match current_view {
View::Tasks => Action::RunNow,
_ => Action::None,
}
},
(KeyCode::Tab, _) => Action::NextTab,
(KeyCode::BackTab, _) => Action::PrevTab,
(KeyCode::PageUp, _) => Action::PageUp,
(KeyCode::PageDown, _) => Action::PageDown,
// Multi-select keys
(KeyCode::Char(' '), _) => {
match current_view {
View::Library | View::Search => Action::ToggleSelection,
_ => Action::None,
}
},
(KeyCode::Char('u'), _) => {
match current_view {
View::Library | View::Search => Action::ClearSelection,
_ => Action::None,
}
},
_ => Action::None,
}
}
}

View file

@ -12,44 +12,46 @@ mod ui;
#[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,
/// 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,
/// 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>,
/// 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();
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"));
// 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();
}
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
app::run(&cli.server).await
}

View file

@ -1,85 +1,84 @@
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 ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::Span,
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,
}
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(
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()
.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),
));
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)
// 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()
}
})
.collect();
.unwrap_or_else(|| "-".into());
let title = format!(" Audit Log ({}) ", state.audit_log.len());
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 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));
let title = format!(" Audit Log ({}) ", state.audit_log.len());
f.render_widget(table, area);
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

@ -1,64 +1,66 @@
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Borders, Row, Table};
use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
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 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()
};
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()
};
// 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();
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 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));
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);
f.render_widget(table, area);
}

View file

@ -1,55 +1,57 @@
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 ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
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 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 pad = " ";
let mut lines = vec![
Line::default(),
Line::from(Span::styled("--- Database Statistics ---", section_style)),
];
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"),
]));
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),
]));
}
lines.push(Line::default());
lines.push(Line::from(Span::styled("--- Actions ---", section_style)));
} else {
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"),
Span::raw(pad),
Span::raw("Press 'r' to load database statistics"),
]));
}
let paragraph =
Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" Database "));
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"),
]));
f.render_widget(paragraph, area);
let paragraph = Paragraph::new(lines)
.block(Block::default().borders(Borders::ALL).title(" Database "));
f.render_widget(paragraph, area);
}

View file

@ -1,223 +1,229 @@
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 ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
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 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 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 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 pad = " ";
let label_width = 14;
let make_label = |name: &str| -> String { format!("{name:<label_width$}") };
let mut lines: Vec<Line> = Vec::new();
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),
)));
// 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("Name"), label_style),
Span::styled(&item.file_name, value_style),
Span::raw(pad),
Span::styled(make_label("Thumbnail"), label_style),
Span::styled("Yes", Style::default().fg(Color::Green)),
]));
}
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Path"), label_style),
Span::styled(&item.path, dim_style),
]));
lines.push(Line::default()); // blank line
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)),
]));
// 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("Size"), label_style),
Span::styled(format_size(item.file_size), value_style),
]));
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("Hash"), label_style),
Span::styled(&item.content_hash, dim_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),
]));
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::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::default()); // blank line
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),
]));
// 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("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("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::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),
]));
}
// Section: Timestamps
// Custom fields
if !item.custom_fields.is_empty() {
lines.push(Line::default());
lines.push(Line::from(Span::styled(
"--- Timestamps ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
"--- Custom Fields ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(vec![
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(make_label("Created"), label_style),
Span::styled(format_date(&item.created_at), dim_style),
]));
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(make_label("Updated"), label_style),
Span::styled(format_date(&item.updated_at), dim_style),
Span::raw(pad),
Span::styled(tag_names.join(", "), Style::default().fg(Color::Green)),
]));
}
let title = if let Some(ref title_str) = item.title {
format!(" Detail: {} ", title_str)
} else {
format!(" Detail: {} ", item.file_name)
};
lines.push(Line::default());
let detail = Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(title));
// Section: Timestamps
lines.push(Line::from(Span::styled(
"--- Timestamps ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
f.render_widget(detail, chunks[0]);
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

@ -1,59 +1,62 @@
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 ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
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() {
// Show truncated hash (first 16 chars) for identification
let hash_display = if group.content_hash.len() > 16 {
&group.content_hash[..16]
} else {
&group.content_hash
};
let header = format!(
"Group {} ({} items, hash: {}...)",
i + 1,
group.items.len(),
hash_display
);
list_items.push(ListItem::new(Line::from(Span::styled(
header,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
))));
for item in &group.items {
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 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() {
// Show truncated hash (first 16 chars) for identification
let hash_display = if group.content_hash.len() > 16 {
&group.content_hash[..16]
} else {
&group.content_hash
};
let header = format!(
"Group {} ({} items, hash: {}...)",
i + 1,
group.items.len(),
hash_display
);
list_items.push(ListItem::new(Line::from(Span::styled(
header,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
))));
for item in &group.items {
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 "));
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(" Duplicates "));
f.render_widget(list, area);
f.render_widget(list, area);
}

View file

@ -1,65 +1,77 @@
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 ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
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 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 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 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_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]);
let help = Paragraph::new(help_lines)
.block(Block::default().borders(Borders::ALL).title(" Help "));
f.render_widget(help, chunks[1]);
}

View file

@ -1,97 +1,101 @@
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 ratatui::{
Frame,
layout::{Constraint, Rect},
style::{Color, Modifier, Style},
text::Span,
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(
let header =
Row::new(vec!["", "Title / Name", "Type", "Duration", "Year", "Size"])
.style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.media_list
.iter()
.enumerate()
.map(|(i, item)| {
let is_cursor = Some(i) == state.selected_index;
let is_selected = state.selected_items.contains(&item.id);
let rows: Vec<Row> = state
.media_list
.iter()
.enumerate()
.map(|(i, item)| {
let is_cursor = Some(i) == state.selected_index;
let is_selected = state.selected_items.contains(&item.id);
let style = if is_cursor {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else if is_selected {
Style::default().fg(Color::Black).bg(Color::Green)
} else {
Style::default()
};
let style = if is_cursor {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else if is_selected {
Style::default().fg(Color::Black).bg(Color::Green)
} else {
Style::default()
};
// Selection marker
let marker = if is_selected { "[*]" } else { "[ ]" };
let marker_style = if is_selected {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
// Selection marker
let marker = if is_selected { "[*]" } else { "[ ]" };
let marker_style = if is_selected {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let display_name = item.title.as_deref().unwrap_or(&item.file_name).to_string();
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 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 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());
let year = item
.year
.map(|y| y.to_string())
.unwrap_or_else(|| "-".to_string());
Row::new(vec![
Cell::from(Span::styled(marker, marker_style)),
Cell::from(display_name),
type_cell,
Cell::from(duration),
Cell::from(year),
Cell::from(format_size(item.file_size)),
])
.style(style)
})
.collect();
Row::new(vec![
Cell::from(Span::styled(marker, marker_style)),
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 selected_count = state.selected_items.len();
let title = if selected_count > 0 {
format!(" Library (page {page}, {item_count} items, {selected_count} selected) ")
} else {
format!(" Library (page {page}, {item_count} items) ")
};
let table = Table::new(
rows,
[
Constraint::Length(3), // Selection marker
Constraint::Percentage(33), // Title
Constraint::Percentage(18), // Type
Constraint::Percentage(13), // Duration
Constraint::Percentage(8), // Year
Constraint::Percentage(18), // Size
],
let page = (state.page_offset / state.page_size) + 1;
let item_count = state.media_list.len();
let selected_count = state.selected_items.len();
let title = if selected_count > 0 {
format!(
" Library (page {page}, {item_count} items, {selected_count} selected) "
)
.header(header)
.block(Block::default().borders(Borders::ALL).title(title));
} else {
format!(" Library (page {page}, {item_count} items) ")
};
f.render_widget(table, area);
let table = Table::new(rows, [
Constraint::Length(3), // Selection marker
Constraint::Percentage(33), // Title
Constraint::Percentage(18), // Type
Constraint::Percentage(13), // Duration
Constraint::Percentage(8), // Year
Constraint::Percentage(18), // Size
])
.header(header)
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(table, area);
}

View file

@ -1,83 +1,85 @@
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 ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
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 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()
};
// 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));
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]);
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 = " ";
// 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 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());
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());
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(pad),
Span::styled(
"Tab: Next field Enter: Save Esc: Cancel",
Style::default().fg(Color::DarkGray),
),
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("")
},
]));
}
let editor =
Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" Fields "));
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),
),
]));
f.render_widget(editor, chunks[1]);
let editor = Paragraph::new(lines)
.block(Block::default().borders(Borders::ALL).title(" Fields "));
f.render_widget(editor, chunks[1]);
}

View file

@ -13,178 +13,188 @@ 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 ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
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))
}
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}")
}
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
}
// 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,
}
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());
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]);
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]),
}
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]);
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 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 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),
);
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);
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 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);
let paragraph = Paragraph::new(Line::from(Span::styled(
status,
Style::default().fg(Color::DarkGray),
)));
f.render_widget(paragraph, area);
}

View file

@ -1,69 +1,72 @@
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 ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
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 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)
};
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()
};
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 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));
let list =
List::new(items).block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(list, area);
f.render_widget(list, area);
}

View file

@ -1,103 +1,104 @@
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 ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::Span,
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);
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 is_cursor = Some(i) == state.search_selected;
let is_selected = state.selected_items.contains(&item.id);
let style = if is_cursor {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else if is_selected {
Style::default().fg(Color::Black).bg(Color::Green)
} else {
Style::default()
};
// Selection marker
let marker = if is_selected { "[*]" } else { "[ ]" };
let marker_style = if is_selected {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
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(Span::styled(marker, marker_style)),
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 selected_count = state.selected_items.len();
let results_title = if selected_count > 0 {
format!(" Results: {shown} shown, {total} total, {selected_count} selected ")
} else {
format!(" Results: {shown} shown, {total} total ")
};
let table = Table::new(
rows,
[
Constraint::Length(3), // Selection marker
Constraint::Percentage(33), // Name
Constraint::Percentage(18), // Type
Constraint::Percentage(23), // Artist
Constraint::Percentage(18), // Size
],
// Search input
let input = Paragraph::new(state.search_input.as_str())
.block(
Block::default()
.borders(Borders::ALL)
.title(" Search (type and press Enter) "),
)
.header(header)
.block(Block::default().borders(Borders::ALL).title(results_title));
.style(if state.input_mode {
Style::default().fg(Color::Cyan)
} else {
Style::default()
});
f.render_widget(input, chunks[0]);
f.render_widget(table, chunks[1]);
// 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 is_cursor = Some(i) == state.search_selected;
let is_selected = state.selected_items.contains(&item.id);
let style = if is_cursor {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else if is_selected {
Style::default().fg(Color::Black).bg(Color::Green)
} else {
Style::default()
};
// Selection marker
let marker = if is_selected { "[*]" } else { "[ ]" };
let marker_style = if is_selected {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
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(Span::styled(marker, marker_style)),
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 selected_count = state.selected_items.len();
let results_title = if selected_count > 0 {
format!(
" Results: {shown} shown, {total} total, {selected_count} selected "
)
} else {
format!(" Results: {shown} shown, {total} total ")
};
let table = Table::new(rows, [
Constraint::Length(3), // Selection marker
Constraint::Percentage(33), // Name
Constraint::Percentage(18), // Type
Constraint::Percentage(23), // Artist
Constraint::Percentage(18), // Size
])
.header(header)
.block(Block::default().borders(Borders::ALL).title(results_title));
f.render_widget(table, chunks[1]);
}

View file

@ -1,82 +1,84 @@
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 ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
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 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 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 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 "));
let settings = Paragraph::new(lines)
.block(Block::default().borders(Borders::ALL).title(" Settings "));
f.render_widget(settings, area);
f.render_widget(settings, area);
}

View file

@ -1,183 +1,189 @@
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 ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
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 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);
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),
),
]),
];
// 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]);
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();
// 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 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_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 "),
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]);
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(
let storage_table =
Table::new(storage_rows, [Constraint::Min(20), Constraint::Length(12)])
.block(
Block::default()
.borders(Borders::ALL)
.title(" Top Collections "),
.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]);
f.render_widget(cols_table, bottom_cols[1]);
}

View file

@ -1,61 +1,62 @@
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Borders, Row, Table};
use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
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(
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()
.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(),
};
// 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();
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 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));
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);
f.render_widget(table, area);
}

View file

@ -1,69 +1,73 @@
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 ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
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 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("-");
// Show abbreviated task ID (first 8 chars)
let task_id_short = if task.id.len() > 8 {
&task.id[..8]
} else {
&task.id
};
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("-");
// Show abbreviated task ID (first 8 chars)
let task_id_short = if task.id.len() > 8 {
&task.id[..8]
} else {
&task.id
};
let text = format!(
" {enabled_marker} [{task_id_short}] {:<20} {:<16} Last: {:<12} Next: {:<12} Status: {}",
task.name, task.schedule, last_run, next_run, status
);
let text = format!(
" {enabled_marker} [{task_id_short}] {:<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)
};
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()
};
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));
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);
f.render_widget(list, area);
}