treewide: fix various UI bugs; optimize crypto dependencies & format
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: If8fe8b38c1d9c4fecd40ff71f88d2ae06a6a6964
This commit is contained in:
parent
764aafa88d
commit
3ccddce7fd
178 changed files with 58342 additions and 54241 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
×tamp[..10]
|
||||
} else {
|
||||
timestamp
|
||||
}
|
||||
// Timestamps are typically "2024-01-15T10:30:00Z" or similar
|
||||
if timestamp.len() >= 10 {
|
||||
×tamp[..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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue