initial commit
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I4a6b498153eccd5407510dd541b7f4816a6a6964
This commit is contained in:
commit
6a73d11c4b
124 changed files with 34856 additions and 0 deletions
85
crates/pinakes-tui/src/ui/audit.rs
Normal file
85
crates/pinakes-tui/src/ui/audit.rs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
use ratatui::Frame;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::{Block, Borders, Cell, Row, Table};
|
||||
|
||||
use super::format_date;
|
||||
use crate::app::AppState;
|
||||
|
||||
/// Return a color for an audit action string.
|
||||
fn action_color(action: &str) -> Color {
|
||||
match action {
|
||||
"imported" | "import" | "created" => Color::Green,
|
||||
"deleted" | "delete" | "removed" => Color::Red,
|
||||
"tagged" | "tag_added" => Color::Cyan,
|
||||
"untagged" | "tag_removed" => Color::Yellow,
|
||||
"updated" | "modified" | "edited" => Color::Blue,
|
||||
"scanned" | "scan" => Color::Magenta,
|
||||
_ => Color::White,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||
let header = Row::new(vec!["Action", "Media ID", "Details", "Date"]).style(
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
|
||||
let rows: Vec<Row> = state
|
||||
.audit_log
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, entry)| {
|
||||
let style = if Some(i) == state.audit_selected {
|
||||
Style::default().fg(Color::Black).bg(Color::Cyan)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
let color = action_color(&entry.action);
|
||||
let action_cell = Cell::from(Span::styled(
|
||||
entry.action.clone(),
|
||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
|
||||
// Truncate media ID for display
|
||||
let media_display = entry
|
||||
.media_id
|
||||
.as_deref()
|
||||
.map(|id| {
|
||||
if id.len() > 12 {
|
||||
format!("{}...", &id[..12])
|
||||
} else {
|
||||
id.to_string()
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "-".into());
|
||||
|
||||
Row::new(vec![
|
||||
action_cell,
|
||||
Cell::from(media_display),
|
||||
Cell::from(entry.details.clone().unwrap_or_else(|| "-".into())),
|
||||
Cell::from(format_date(&entry.timestamp).to_string()),
|
||||
])
|
||||
.style(style)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let title = format!(" Audit Log ({}) ", state.audit_log.len());
|
||||
|
||||
let table = Table::new(
|
||||
rows,
|
||||
[
|
||||
ratatui::layout::Constraint::Percentage(18),
|
||||
ratatui::layout::Constraint::Percentage(22),
|
||||
ratatui::layout::Constraint::Percentage(40),
|
||||
ratatui::layout::Constraint::Percentage(20),
|
||||
],
|
||||
)
|
||||
.header(header)
|
||||
.block(Block::default().borders(Borders::ALL).title(title));
|
||||
|
||||
f.render_widget(table, area);
|
||||
}
|
||||
64
crates/pinakes-tui/src/ui/collections.rs
Normal file
64
crates/pinakes-tui/src/ui/collections.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
use ratatui::Frame;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::widgets::{Block, Borders, Row, Table};
|
||||
|
||||
use super::format_date;
|
||||
use crate::app::AppState;
|
||||
|
||||
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||
let header = Row::new(vec!["Name", "Kind", "Description", "Members", "Created"]).style(
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
|
||||
let rows: Vec<Row> = state
|
||||
.collections
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, col)| {
|
||||
let style = if Some(i) == state.collection_selected {
|
||||
Style::default().fg(Color::Black).bg(Color::Cyan)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
// We show the filter_query as a proxy for member info when kind is "smart"
|
||||
let members_display = if col.kind == "smart" {
|
||||
col.filter_query
|
||||
.as_deref()
|
||||
.map(|q| format!("filter: {q}"))
|
||||
.unwrap_or_else(|| "-".to_string())
|
||||
} else {
|
||||
"-".to_string()
|
||||
};
|
||||
|
||||
Row::new(vec![
|
||||
col.name.clone(),
|
||||
col.kind.clone(),
|
||||
col.description.clone().unwrap_or_else(|| "-".into()),
|
||||
members_display,
|
||||
format_date(&col.created_at).to_string(),
|
||||
])
|
||||
.style(style)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let title = format!(" Collections ({}) ", state.collections.len());
|
||||
|
||||
let table = Table::new(
|
||||
rows,
|
||||
[
|
||||
ratatui::layout::Constraint::Percentage(25),
|
||||
ratatui::layout::Constraint::Percentage(12),
|
||||
ratatui::layout::Constraint::Percentage(28),
|
||||
ratatui::layout::Constraint::Percentage(15),
|
||||
ratatui::layout::Constraint::Percentage(20),
|
||||
],
|
||||
)
|
||||
.header(header)
|
||||
.block(Block::default().borders(Borders::ALL).title(title));
|
||||
|
||||
f.render_widget(table, area);
|
||||
}
|
||||
55
crates/pinakes-tui/src/ui/database.rs
Normal file
55
crates/pinakes-tui/src/ui/database.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
use ratatui::Frame;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
|
||||
use crate::app::AppState;
|
||||
|
||||
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||
let label_style = Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let value_style = Style::default().fg(Color::White);
|
||||
let section_style = Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
|
||||
let pad = " ";
|
||||
|
||||
let mut lines = vec![
|
||||
Line::default(),
|
||||
Line::from(Span::styled("--- Database Statistics ---", section_style)),
|
||||
];
|
||||
|
||||
if let Some(ref stats) = state.database_stats {
|
||||
for (key, value) in stats {
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::styled(format!("{key:<20}"), label_style),
|
||||
Span::styled(value.to_string(), value_style),
|
||||
]));
|
||||
}
|
||||
} else {
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::raw("Press 'r' to load database statistics"),
|
||||
]));
|
||||
}
|
||||
|
||||
lines.push(Line::default());
|
||||
lines.push(Line::from(Span::styled("--- Actions ---", section_style)));
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::raw("v: Vacuum database"),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::raw("Esc: Return to library"),
|
||||
]));
|
||||
|
||||
let paragraph =
|
||||
Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" Database "));
|
||||
|
||||
f.render_widget(paragraph, area);
|
||||
}
|
||||
223
crates/pinakes-tui/src/ui/detail.rs
Normal file
223
crates/pinakes-tui/src/ui/detail.rs
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
use ratatui::Frame;
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
|
||||
use super::{format_date, format_duration, format_size, media_type_color};
|
||||
use crate::app::AppState;
|
||||
|
||||
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||
let item = match &state.selected_media {
|
||||
Some(item) => item,
|
||||
None => {
|
||||
let msg = Paragraph::new("No item selected")
|
||||
.block(Block::default().borders(Borders::ALL).title(" Detail "));
|
||||
f.render_widget(msg, area);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(0)])
|
||||
.split(area);
|
||||
|
||||
let label_style = Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let value_style = Style::default().fg(Color::White);
|
||||
let dim_style = Style::default().fg(Color::DarkGray);
|
||||
|
||||
let pad = " ";
|
||||
let label_width = 14;
|
||||
let make_label = |name: &str| -> String { format!("{name:<label_width$}") };
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
// Section: File Info
|
||||
lines.push(Line::from(Span::styled(
|
||||
"--- File Info ---",
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::styled(make_label("Name"), label_style),
|
||||
Span::styled(&item.file_name, value_style),
|
||||
]));
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::styled(make_label("Path"), label_style),
|
||||
Span::styled(&item.path, dim_style),
|
||||
]));
|
||||
|
||||
let type_color = media_type_color(&item.media_type);
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::styled(make_label("Type"), label_style),
|
||||
Span::styled(&item.media_type, Style::default().fg(type_color)),
|
||||
]));
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::styled(make_label("Size"), label_style),
|
||||
Span::styled(format_size(item.file_size), value_style),
|
||||
]));
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::styled(make_label("Hash"), label_style),
|
||||
Span::styled(&item.content_hash, dim_style),
|
||||
]));
|
||||
|
||||
if item.has_thumbnail {
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::styled(make_label("Thumbnail"), label_style),
|
||||
Span::styled("Yes", Style::default().fg(Color::Green)),
|
||||
]));
|
||||
}
|
||||
|
||||
lines.push(Line::default()); // blank line
|
||||
|
||||
// Section: Metadata
|
||||
lines.push(Line::from(Span::styled(
|
||||
"--- Metadata ---",
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::styled(make_label("Title"), label_style),
|
||||
Span::styled(item.title.as_deref().unwrap_or("-"), value_style),
|
||||
]));
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::styled(make_label("Artist"), label_style),
|
||||
Span::styled(item.artist.as_deref().unwrap_or("-"), value_style),
|
||||
]));
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::styled(make_label("Album"), label_style),
|
||||
Span::styled(item.album.as_deref().unwrap_or("-"), value_style),
|
||||
]));
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::styled(make_label("Genre"), label_style),
|
||||
Span::styled(item.genre.as_deref().unwrap_or("-"), value_style),
|
||||
]));
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::styled(make_label("Year"), label_style),
|
||||
Span::styled(
|
||||
item.year
|
||||
.map(|y| y.to_string())
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
value_style,
|
||||
),
|
||||
]));
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::styled(make_label("Duration"), label_style),
|
||||
Span::styled(
|
||||
item.duration_secs
|
||||
.map(format_duration)
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
value_style,
|
||||
),
|
||||
]));
|
||||
|
||||
// Description
|
||||
if let Some(ref desc) = item.description
|
||||
&& !desc.is_empty()
|
||||
{
|
||||
lines.push(Line::default());
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::styled(make_label("Description"), label_style),
|
||||
Span::styled(desc.as_str(), value_style),
|
||||
]));
|
||||
}
|
||||
|
||||
// Custom fields
|
||||
if !item.custom_fields.is_empty() {
|
||||
lines.push(Line::default());
|
||||
lines.push(Line::from(Span::styled(
|
||||
"--- Custom Fields ---",
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
let mut fields: Vec<_> = item.custom_fields.iter().collect();
|
||||
fields.sort_by_key(|(k, _)| k.as_str());
|
||||
for (key, field) in fields {
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::styled(format!("{key:<label_width$}"), label_style),
|
||||
Span::styled(
|
||||
format!("{} ({})", field.value, field.field_type),
|
||||
value_style,
|
||||
),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
// Tags section
|
||||
if !state.tags.is_empty() {
|
||||
lines.push(Line::default());
|
||||
lines.push(Line::from(Span::styled(
|
||||
"--- Tags ---",
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
let tag_names: Vec<&str> = state.tags.iter().map(|t| t.name.as_str()).collect();
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::styled(tag_names.join(", "), Style::default().fg(Color::Green)),
|
||||
]));
|
||||
}
|
||||
|
||||
lines.push(Line::default());
|
||||
|
||||
// Section: Timestamps
|
||||
lines.push(Line::from(Span::styled(
|
||||
"--- Timestamps ---",
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::styled(make_label("Created"), label_style),
|
||||
Span::styled(format_date(&item.created_at), dim_style),
|
||||
]));
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::styled(make_label("Updated"), label_style),
|
||||
Span::styled(format_date(&item.updated_at), dim_style),
|
||||
]));
|
||||
|
||||
let title = if let Some(ref title_str) = item.title {
|
||||
format!(" Detail: {} ", title_str)
|
||||
} else {
|
||||
format!(" Detail: {} ", item.file_name)
|
||||
};
|
||||
|
||||
let detail = Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(title));
|
||||
|
||||
f.render_widget(detail, chunks[0]);
|
||||
}
|
||||
56
crates/pinakes-tui/src/ui/duplicates.rs
Normal file
56
crates/pinakes-tui/src/ui/duplicates.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
use ratatui::Frame;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem};
|
||||
|
||||
use crate::app::AppState;
|
||||
|
||||
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||
let items: Vec<ListItem> = if state.duplicate_groups.is_empty() {
|
||||
vec![ListItem::new(Line::from(Span::styled(
|
||||
" No duplicates found. Press 'r' to refresh.",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)))]
|
||||
} else {
|
||||
let mut list_items = Vec::new();
|
||||
for (i, group) in state.duplicate_groups.iter().enumerate() {
|
||||
let header = format!(
|
||||
"Group {} ({} items, hash: {})",
|
||||
i + 1,
|
||||
group.len(),
|
||||
group
|
||||
.first()
|
||||
.map(|m| m.content_hash.as_str())
|
||||
.unwrap_or("?")
|
||||
);
|
||||
list_items.push(ListItem::new(Line::from(Span::styled(
|
||||
header,
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))));
|
||||
for item in group {
|
||||
let line = format!(" {} - {}", item.file_name, item.path);
|
||||
let is_selected = state
|
||||
.duplicates_selected
|
||||
.map(|sel| sel == list_items.len())
|
||||
.unwrap_or(false);
|
||||
let style = if is_selected {
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::White)
|
||||
};
|
||||
list_items.push(ListItem::new(Line::from(Span::styled(line, style))));
|
||||
}
|
||||
list_items.push(ListItem::new(Line::default()));
|
||||
}
|
||||
list_items
|
||||
};
|
||||
|
||||
let list = List::new(items).block(Block::default().borders(Borders::ALL).title(" Duplicates "));
|
||||
|
||||
f.render_widget(list, area);
|
||||
}
|
||||
65
crates/pinakes-tui/src/ui/import.rs
Normal file
65
crates/pinakes-tui/src/ui/import.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
use ratatui::Frame;
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
|
||||
use crate::app::AppState;
|
||||
|
||||
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(3), Constraint::Min(0)])
|
||||
.split(area);
|
||||
|
||||
let input = Paragraph::new(state.import_input.as_str())
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Import File (enter path and press Enter) "),
|
||||
)
|
||||
.style(if state.input_mode {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else {
|
||||
Style::default()
|
||||
});
|
||||
f.render_widget(input, chunks[0]);
|
||||
|
||||
let label_style = Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let key_style = Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
|
||||
let help_lines = vec![
|
||||
Line::default(),
|
||||
Line::from(Span::styled(
|
||||
" Import a file or trigger a library scan",
|
||||
label_style,
|
||||
)),
|
||||
Line::default(),
|
||||
Line::from(vec![
|
||||
Span::styled(" Enter", key_style),
|
||||
Span::raw(" Import the file at the entered path"),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" Esc", key_style),
|
||||
Span::raw(" Cancel and return to library"),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" s", key_style),
|
||||
Span::raw(" Trigger a full library scan (scans all configured directories)"),
|
||||
]),
|
||||
Line::default(),
|
||||
Line::from(Span::styled(" Tips:", label_style)),
|
||||
Line::from(" - Enter an absolute path to a media file (e.g. /home/user/music/song.mp3)"),
|
||||
Line::from(" - The file will be copied into the managed library"),
|
||||
Line::from(" - Duplicates are detected by content hash and will be skipped"),
|
||||
Line::from(" - Press 's' (without typing a path) to scan all library directories"),
|
||||
];
|
||||
|
||||
let help =
|
||||
Paragraph::new(help_lines).block(Block::default().borders(Borders::ALL).title(" Help "));
|
||||
f.render_widget(help, chunks[1]);
|
||||
}
|
||||
75
crates/pinakes-tui/src/ui/library.rs
Normal file
75
crates/pinakes-tui/src/ui/library.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
use ratatui::Frame;
|
||||
use ratatui::layout::{Constraint, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::{Block, Borders, Cell, Row, Table};
|
||||
|
||||
use super::{format_duration, format_size, media_type_color};
|
||||
use crate::app::AppState;
|
||||
|
||||
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||
let header = Row::new(vec!["Title / Name", "Type", "Duration", "Year", "Size"]).style(
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
|
||||
let rows: Vec<Row> = state
|
||||
.media_list
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, item)| {
|
||||
let style = if Some(i) == state.selected_index {
|
||||
Style::default().fg(Color::Black).bg(Color::Cyan)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
let display_name = item.title.as_deref().unwrap_or(&item.file_name).to_string();
|
||||
|
||||
let type_color = media_type_color(&item.media_type);
|
||||
let type_cell = Cell::from(Span::styled(
|
||||
item.media_type.clone(),
|
||||
Style::default().fg(type_color),
|
||||
));
|
||||
|
||||
let duration = item
|
||||
.duration_secs
|
||||
.map(format_duration)
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
|
||||
let year = item
|
||||
.year
|
||||
.map(|y| y.to_string())
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
|
||||
Row::new(vec![
|
||||
Cell::from(display_name),
|
||||
type_cell,
|
||||
Cell::from(duration),
|
||||
Cell::from(year),
|
||||
Cell::from(format_size(item.file_size)),
|
||||
])
|
||||
.style(style)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let page = (state.page_offset / state.page_size) + 1;
|
||||
let item_count = state.media_list.len();
|
||||
let title = format!(" Library (page {page}, {item_count} items) ");
|
||||
|
||||
let table = Table::new(
|
||||
rows,
|
||||
[
|
||||
Constraint::Percentage(35),
|
||||
Constraint::Percentage(20),
|
||||
Constraint::Percentage(15),
|
||||
Constraint::Percentage(10),
|
||||
Constraint::Percentage(20),
|
||||
],
|
||||
)
|
||||
.header(header)
|
||||
.block(Block::default().borders(Borders::ALL).title(title));
|
||||
|
||||
f.render_widget(table, area);
|
||||
}
|
||||
83
crates/pinakes-tui/src/ui/metadata_edit.rs
Normal file
83
crates/pinakes-tui/src/ui/metadata_edit.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
use ratatui::Frame;
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
|
||||
use crate::app::AppState;
|
||||
|
||||
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(3), Constraint::Min(0)])
|
||||
.split(area);
|
||||
|
||||
// Header
|
||||
let title = if let Some(ref media) = state.selected_media {
|
||||
format!(" Edit: {} ", media.file_name)
|
||||
} else {
|
||||
" Edit Metadata ".to_string()
|
||||
};
|
||||
|
||||
let header = Paragraph::new(Line::from(Span::styled(
|
||||
&title,
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.block(Block::default().borders(Borders::ALL));
|
||||
|
||||
f.render_widget(header, chunks[0]);
|
||||
|
||||
// Edit fields
|
||||
let label_style = Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let value_style = Style::default().fg(Color::White);
|
||||
let active_style = Style::default()
|
||||
.fg(Color::Green)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let pad = " ";
|
||||
|
||||
let fields = [
|
||||
("Title", &state.edit_title),
|
||||
("Artist", &state.edit_artist),
|
||||
("Album", &state.edit_album),
|
||||
("Genre", &state.edit_genre),
|
||||
("Year", &state.edit_year),
|
||||
("Description", &state.edit_description),
|
||||
];
|
||||
|
||||
let mut lines = Vec::new();
|
||||
lines.push(Line::default());
|
||||
|
||||
for (i, (label, value)) in fields.iter().enumerate() {
|
||||
let is_active = state.edit_field_index == Some(i);
|
||||
let style = if is_active { active_style } else { label_style };
|
||||
let cursor = if is_active { "> " } else { pad };
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(cursor),
|
||||
Span::styled(format!("{label:<14}"), style),
|
||||
Span::styled(value.as_str(), value_style),
|
||||
if is_active {
|
||||
Span::styled("_", Style::default().fg(Color::Green))
|
||||
} else {
|
||||
Span::raw("")
|
||||
},
|
||||
]));
|
||||
}
|
||||
|
||||
lines.push(Line::default());
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::styled(
|
||||
"Tab: Next field Enter: Save Esc: Cancel",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
]));
|
||||
|
||||
let editor =
|
||||
Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" Fields "));
|
||||
|
||||
f.render_widget(editor, chunks[1]);
|
||||
}
|
||||
190
crates/pinakes-tui/src/ui/mod.rs
Normal file
190
crates/pinakes-tui/src/ui/mod.rs
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
pub mod audit;
|
||||
pub mod collections;
|
||||
pub mod database;
|
||||
pub mod detail;
|
||||
pub mod duplicates;
|
||||
pub mod import;
|
||||
pub mod library;
|
||||
pub mod metadata_edit;
|
||||
pub mod queue;
|
||||
pub mod search;
|
||||
pub mod settings;
|
||||
pub mod statistics;
|
||||
pub mod tags;
|
||||
pub mod tasks;
|
||||
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph, Tabs};
|
||||
|
||||
use crate::app::{AppState, View};
|
||||
|
||||
/// Format a file size in bytes into a human-readable string.
|
||||
pub fn format_size(bytes: u64) -> String {
|
||||
if bytes < 1024 {
|
||||
format!("{bytes} B")
|
||||
} else if bytes < 1024 * 1024 {
|
||||
format!("{:.1} KB", bytes as f64 / 1024.0)
|
||||
} else if bytes < 1024 * 1024 * 1024 {
|
||||
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
|
||||
} else {
|
||||
format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Format duration in seconds into hh:mm:ss format.
|
||||
pub fn format_duration(secs: f64) -> String {
|
||||
let total = secs as u64;
|
||||
let h = total / 3600;
|
||||
let m = (total % 3600) / 60;
|
||||
let s = total % 60;
|
||||
if h > 0 {
|
||||
format!("{h:02}:{m:02}:{s:02}")
|
||||
} else {
|
||||
format!("{m:02}:{s:02}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Trim a timestamp string to just the date portion (YYYY-MM-DD).
|
||||
pub fn format_date(timestamp: &str) -> &str {
|
||||
// Timestamps are typically "2024-01-15T10:30:00Z" or similar
|
||||
if timestamp.len() >= 10 {
|
||||
×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,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(f: &mut Frame, state: &AppState) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(f.area());
|
||||
|
||||
render_tabs(f, state, chunks[0]);
|
||||
|
||||
match state.current_view {
|
||||
View::Library => library::render(f, state, chunks[1]),
|
||||
View::Search => search::render(f, state, chunks[1]),
|
||||
View::Detail => detail::render(f, state, chunks[1]),
|
||||
View::Tags => tags::render(f, state, chunks[1]),
|
||||
View::Collections => collections::render(f, state, chunks[1]),
|
||||
View::Audit => audit::render(f, state, chunks[1]),
|
||||
View::Import => import::render(f, state, chunks[1]),
|
||||
View::Settings => settings::render(f, state, chunks[1]),
|
||||
View::Duplicates => duplicates::render(f, state, chunks[1]),
|
||||
View::Database => database::render(f, state, chunks[1]),
|
||||
View::MetadataEdit => metadata_edit::render(f, state, chunks[1]),
|
||||
View::Queue => queue::render(f, state, chunks[1]),
|
||||
View::Statistics => statistics::render(f, state, chunks[1]),
|
||||
View::Tasks => tasks::render(f, state, chunks[1]),
|
||||
}
|
||||
|
||||
render_status_bar(f, state, chunks[2]);
|
||||
}
|
||||
|
||||
fn render_tabs(f: &mut Frame, state: &AppState, area: Rect) {
|
||||
let titles: Vec<Line> = vec![
|
||||
"Library",
|
||||
"Search",
|
||||
"Tags",
|
||||
"Collections",
|
||||
"Audit",
|
||||
"Queue",
|
||||
"Stats",
|
||||
"Tasks",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|t| Line::from(Span::styled(t, Style::default().fg(Color::White))))
|
||||
.collect();
|
||||
|
||||
let selected = match state.current_view {
|
||||
View::Library | View::Detail | View::Import | View::Settings | View::MetadataEdit => 0,
|
||||
View::Search => 1,
|
||||
View::Tags => 2,
|
||||
View::Collections => 3,
|
||||
View::Audit | View::Duplicates | View::Database => 4,
|
||||
View::Queue => 5,
|
||||
View::Statistics => 6,
|
||||
View::Tasks => 7,
|
||||
};
|
||||
|
||||
let tabs = Tabs::new(titles)
|
||||
.block(Block::default().borders(Borders::ALL).title(" Pinakes "))
|
||||
.select(selected)
|
||||
.style(Style::default().fg(Color::Gray))
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
|
||||
f.render_widget(tabs, area);
|
||||
}
|
||||
|
||||
fn render_status_bar(f: &mut Frame, state: &AppState, area: Rect) {
|
||||
let status = if let Some(ref msg) = state.status_message {
|
||||
msg.clone()
|
||||
} else {
|
||||
match state.current_view {
|
||||
View::Tags => {
|
||||
" q:Quit j/k:Nav Home/End:Top/Bot n:New d:Delete r:Refresh Tab:Switch"
|
||||
.to_string()
|
||||
}
|
||||
View::Collections => {
|
||||
" q:Quit j/k:Nav Home/End:Top/Bot d:Delete r:Refresh Tab:Switch".to_string()
|
||||
}
|
||||
View::Audit => {
|
||||
" q:Quit j/k:Nav Home/End:Top/Bot r:Refresh Tab:Switch".to_string()
|
||||
}
|
||||
View::Detail => {
|
||||
" q:Quit Esc:Back o:Open e:Edit +:Tag -:Untag r:Refresh ?:Help".to_string()
|
||||
}
|
||||
View::Import => {
|
||||
" Enter:Import Esc:Cancel s:Scan libraries ?:Help".to_string()
|
||||
}
|
||||
View::Settings => " q:Quit Esc:Back ?:Help".to_string(),
|
||||
View::Duplicates => " q:Quit j/k:Nav r:Refresh Esc:Back".to_string(),
|
||||
View::Database => " q:Quit v:Vacuum r:Refresh Esc:Back".to_string(),
|
||||
View::MetadataEdit => {
|
||||
" Tab:Next field Enter:Save Esc:Cancel".to_string()
|
||||
}
|
||||
View::Queue => {
|
||||
" q:Quit j/k:Nav Enter:Play d:Remove N:Next P:Prev R:Repeat S:Shuffle C:Clear"
|
||||
.to_string()
|
||||
}
|
||||
View::Statistics => " q:Quit r:Refresh Esc:Back ?:Help".to_string(),
|
||||
View::Tasks => {
|
||||
" q:Quit j/k:Nav Enter:Toggle R:Run Now r:Refresh Esc:Back".to_string()
|
||||
}
|
||||
_ => {
|
||||
" q:Quit /:Search i:Import o:Open t:Tags c:Coll a:Audit D:Dupes B:DB Q:Queue X:Stats T:Tasks ?:Help"
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let paragraph = Paragraph::new(Line::from(Span::styled(
|
||||
status,
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
f.render_widget(paragraph, area);
|
||||
}
|
||||
69
crates/pinakes-tui/src/ui/queue.rs
Normal file
69
crates/pinakes-tui/src/ui/queue.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
use ratatui::Frame;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem};
|
||||
|
||||
use crate::app::AppState;
|
||||
|
||||
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||
let items: Vec<ListItem> = if state.play_queue.is_empty() {
|
||||
vec![ListItem::new(Line::from(Span::styled(
|
||||
" Queue is empty. Select items in the library and press 'q' to add.",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)))]
|
||||
} else {
|
||||
state
|
||||
.play_queue
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, item)| {
|
||||
let is_current = state.queue_current_index == Some(i);
|
||||
let is_selected = state.queue_selected == Some(i);
|
||||
let prefix = if is_current { ">> " } else { " " };
|
||||
let type_color = super::media_type_color(&item.media_type);
|
||||
let id_suffix = if item.media_id.len() > 8 {
|
||||
&item.media_id[item.media_id.len() - 8..]
|
||||
} else {
|
||||
&item.media_id
|
||||
};
|
||||
let text = if let Some(ref artist) = item.artist {
|
||||
format!("{prefix}{} - {} [{}]", item.title, artist, id_suffix)
|
||||
} else {
|
||||
format!("{prefix}{} [{}]", item.title, id_suffix)
|
||||
};
|
||||
|
||||
let style = if is_selected {
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if is_current {
|
||||
Style::default()
|
||||
.fg(Color::Green)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(type_color)
|
||||
};
|
||||
|
||||
ListItem::new(Line::from(Span::styled(text, style)))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let repeat_str = match state.queue_repeat {
|
||||
0 => "Off",
|
||||
1 => "One",
|
||||
_ => "All",
|
||||
};
|
||||
let shuffle_str = if state.queue_shuffle { "On" } else { "Off" };
|
||||
let title = format!(
|
||||
" Queue ({}) | Repeat: {} | Shuffle: {} ",
|
||||
state.play_queue.len(),
|
||||
repeat_str,
|
||||
shuffle_str,
|
||||
);
|
||||
|
||||
let list = List::new(items).block(Block::default().borders(Borders::ALL).title(title));
|
||||
|
||||
f.render_widget(list, area);
|
||||
}
|
||||
81
crates/pinakes-tui/src/ui/search.rs
Normal file
81
crates/pinakes-tui/src/ui/search.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
use ratatui::Frame;
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table};
|
||||
|
||||
use super::{format_size, media_type_color};
|
||||
use crate::app::AppState;
|
||||
|
||||
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(3), Constraint::Min(0)])
|
||||
.split(area);
|
||||
|
||||
// Search input
|
||||
let input = Paragraph::new(state.search_input.as_str())
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Search (type and press Enter) "),
|
||||
)
|
||||
.style(if state.input_mode {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else {
|
||||
Style::default()
|
||||
});
|
||||
f.render_widget(input, chunks[0]);
|
||||
|
||||
// Results
|
||||
let header = Row::new(vec!["Name", "Type", "Artist", "Size"]).style(
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
|
||||
let rows: Vec<Row> = state
|
||||
.search_results
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, item)| {
|
||||
let style = if Some(i) == state.search_selected {
|
||||
Style::default().fg(Color::Black).bg(Color::Cyan)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
let type_color = media_type_color(&item.media_type);
|
||||
let type_cell = Cell::from(Span::styled(
|
||||
item.media_type.clone(),
|
||||
Style::default().fg(type_color),
|
||||
));
|
||||
|
||||
Row::new(vec![
|
||||
Cell::from(item.file_name.clone()),
|
||||
type_cell,
|
||||
Cell::from(item.artist.clone().unwrap_or_default()),
|
||||
Cell::from(format_size(item.file_size)),
|
||||
])
|
||||
.style(style)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let shown = state.search_results.len();
|
||||
let total = state.search_total_count;
|
||||
let results_title = format!(" Results: {shown} shown, {total} total ");
|
||||
|
||||
let table = Table::new(
|
||||
rows,
|
||||
[
|
||||
Constraint::Percentage(35),
|
||||
Constraint::Percentage(20),
|
||||
Constraint::Percentage(25),
|
||||
Constraint::Percentage(20),
|
||||
],
|
||||
)
|
||||
.header(header)
|
||||
.block(Block::default().borders(Borders::ALL).title(results_title));
|
||||
|
||||
f.render_widget(table, chunks[1]);
|
||||
}
|
||||
82
crates/pinakes-tui/src/ui/settings.rs
Normal file
82
crates/pinakes-tui/src/ui/settings.rs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
use ratatui::Frame;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
|
||||
use crate::app::AppState;
|
||||
|
||||
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||
let label_style = Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let value_style = Style::default().fg(Color::White);
|
||||
let section_style = Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
|
||||
let pad = " ";
|
||||
|
||||
let lines = vec![
|
||||
Line::default(),
|
||||
Line::from(Span::styled("--- Connection ---", section_style)),
|
||||
Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::styled("Server URL: ", label_style),
|
||||
Span::styled(&state.server_url, value_style),
|
||||
]),
|
||||
Line::default(),
|
||||
Line::from(Span::styled("--- Library ---", section_style)),
|
||||
Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::styled("Total items: ", label_style),
|
||||
Span::styled(state.total_media_count.to_string(), value_style),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::styled("Page size: ", label_style),
|
||||
Span::styled(state.page_size.to_string(), value_style),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::styled("Current page: ", label_style),
|
||||
Span::styled(
|
||||
((state.page_offset / state.page_size) + 1).to_string(),
|
||||
value_style,
|
||||
),
|
||||
]),
|
||||
Line::default(),
|
||||
Line::from(Span::styled("--- State ---", section_style)),
|
||||
Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::styled("Tags loaded: ", label_style),
|
||||
Span::styled(state.tags.len().to_string(), value_style),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::styled("All tags: ", label_style),
|
||||
Span::styled(state.all_tags.len().to_string(), value_style),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::styled("Collections: ", label_style),
|
||||
Span::styled(state.collections.len().to_string(), value_style),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::styled("Audit entries: ", label_style),
|
||||
Span::styled(state.audit_log.len().to_string(), value_style),
|
||||
]),
|
||||
Line::default(),
|
||||
Line::from(Span::styled("--- Shortcuts ---", section_style)),
|
||||
Line::from(vec![
|
||||
Span::raw(pad),
|
||||
Span::raw("Press Esc to return to the library view"),
|
||||
]),
|
||||
];
|
||||
|
||||
let settings =
|
||||
Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" Settings "));
|
||||
|
||||
f.render_widget(settings, area);
|
||||
}
|
||||
183
crates/pinakes-tui/src/ui/statistics.rs
Normal file
183
crates/pinakes-tui/src/ui/statistics.rs
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
use ratatui::Frame;
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph, Row, Table};
|
||||
|
||||
use crate::app::AppState;
|
||||
|
||||
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||
let Some(ref stats) = state.library_stats else {
|
||||
let msg = Paragraph::new("Loading statistics... (press X to refresh)")
|
||||
.block(Block::default().borders(Borders::ALL).title(" Statistics "));
|
||||
f.render_widget(msg, area);
|
||||
return;
|
||||
};
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(8), // Overview
|
||||
Constraint::Length(10), // Media by type
|
||||
Constraint::Min(6), // Top tags & collections
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Overview section
|
||||
let overview_lines = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(" Total Media: ", Style::default().fg(Color::Gray)),
|
||||
Span::styled(
|
||||
stats.total_media.to_string(),
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled("Total Size: ", Style::default().fg(Color::Gray)),
|
||||
Span::styled(
|
||||
super::format_size(stats.total_size_bytes),
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" Avg Size: ", Style::default().fg(Color::Gray)),
|
||||
Span::styled(
|
||||
super::format_size(stats.avg_file_size_bytes),
|
||||
Style::default().fg(Color::White),
|
||||
),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" Tags: ", Style::default().fg(Color::Gray)),
|
||||
Span::styled(
|
||||
stats.total_tags.to_string(),
|
||||
Style::default().fg(Color::Green),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled("Collections: ", Style::default().fg(Color::Gray)),
|
||||
Span::styled(
|
||||
stats.total_collections.to_string(),
|
||||
Style::default().fg(Color::Green),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled("Duplicates: ", Style::default().fg(Color::Gray)),
|
||||
Span::styled(
|
||||
stats.total_duplicates.to_string(),
|
||||
Style::default().fg(Color::Yellow),
|
||||
),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" Newest: ", Style::default().fg(Color::Gray)),
|
||||
Span::styled(
|
||||
stats
|
||||
.newest_item
|
||||
.as_deref()
|
||||
.map(super::format_date)
|
||||
.unwrap_or("-"),
|
||||
Style::default().fg(Color::White),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled("Oldest: ", Style::default().fg(Color::Gray)),
|
||||
Span::styled(
|
||||
stats
|
||||
.oldest_item
|
||||
.as_deref()
|
||||
.map(super::format_date)
|
||||
.unwrap_or("-"),
|
||||
Style::default().fg(Color::White),
|
||||
),
|
||||
]),
|
||||
];
|
||||
|
||||
let overview = Paragraph::new(overview_lines)
|
||||
.block(Block::default().borders(Borders::ALL).title(" Overview "));
|
||||
f.render_widget(overview, chunks[0]);
|
||||
|
||||
// Media by Type table
|
||||
let type_rows: Vec<Row> = stats
|
||||
.media_by_type
|
||||
.iter()
|
||||
.map(|tc| {
|
||||
let color = super::media_type_color(&tc.name);
|
||||
Row::new(vec![
|
||||
Span::styled(tc.name.clone(), Style::default().fg(color)),
|
||||
Span::styled(tc.count.to_string(), Style::default().fg(Color::White)),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
|
||||
let storage_rows: Vec<Row> = stats
|
||||
.storage_by_type
|
||||
.iter()
|
||||
.map(|tc| {
|
||||
Row::new(vec![
|
||||
Span::styled(tc.name.clone(), Style::default().fg(Color::Gray)),
|
||||
Span::styled(
|
||||
super::format_size(tc.count),
|
||||
Style::default().fg(Color::White),
|
||||
),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
|
||||
let type_cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(chunks[1]);
|
||||
|
||||
let type_table = Table::new(type_rows, [Constraint::Min(20), Constraint::Length(10)]).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Media by Type "),
|
||||
);
|
||||
f.render_widget(type_table, type_cols[0]);
|
||||
|
||||
let storage_table = Table::new(storage_rows, [Constraint::Min(20), Constraint::Length(12)])
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Storage by Type "),
|
||||
);
|
||||
f.render_widget(storage_table, type_cols[1]);
|
||||
|
||||
// Top tags and collections
|
||||
let bottom_cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(chunks[2]);
|
||||
|
||||
let tag_rows: Vec<Row> = stats
|
||||
.top_tags
|
||||
.iter()
|
||||
.map(|tc| {
|
||||
Row::new(vec![
|
||||
Span::styled(tc.name.clone(), Style::default().fg(Color::Green)),
|
||||
Span::styled(tc.count.to_string(), Style::default().fg(Color::White)),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
|
||||
let tags_table = Table::new(tag_rows, [Constraint::Min(20), Constraint::Length(10)])
|
||||
.block(Block::default().borders(Borders::ALL).title(" Top Tags "));
|
||||
f.render_widget(tags_table, bottom_cols[0]);
|
||||
|
||||
let col_rows: Vec<Row> = stats
|
||||
.top_collections
|
||||
.iter()
|
||||
.map(|tc| {
|
||||
Row::new(vec![
|
||||
Span::styled(tc.name.clone(), Style::default().fg(Color::Magenta)),
|
||||
Span::styled(tc.count.to_string(), Style::default().fg(Color::White)),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
|
||||
let cols_table = Table::new(col_rows, [Constraint::Min(20), Constraint::Length(10)]).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Top Collections "),
|
||||
);
|
||||
f.render_widget(cols_table, bottom_cols[1]);
|
||||
}
|
||||
61
crates/pinakes-tui/src/ui/tags.rs
Normal file
61
crates/pinakes-tui/src/ui/tags.rs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
use ratatui::Frame;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::widgets::{Block, Borders, Row, Table};
|
||||
|
||||
use super::format_date;
|
||||
use crate::app::AppState;
|
||||
|
||||
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||
let header = Row::new(vec!["Name", "Parent", "Created"]).style(
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
|
||||
let rows: Vec<Row> = state
|
||||
.tags
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, tag)| {
|
||||
let style = if Some(i) == state.tag_selected {
|
||||
Style::default().fg(Color::Black).bg(Color::Cyan)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
// Resolve parent tag name from the tags list itself
|
||||
let parent_display = match &tag.parent_id {
|
||||
Some(pid) => state
|
||||
.tags
|
||||
.iter()
|
||||
.find(|t| t.id == *pid)
|
||||
.map(|t| t.name.clone())
|
||||
.unwrap_or_else(|| pid.chars().take(8).collect::<String>() + "..."),
|
||||
None => "-".to_string(),
|
||||
};
|
||||
|
||||
Row::new(vec![
|
||||
tag.name.clone(),
|
||||
parent_display,
|
||||
format_date(&tag.created_at).to_string(),
|
||||
])
|
||||
.style(style)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let title = format!(" Tags ({}) ", state.tags.len());
|
||||
|
||||
let table = Table::new(
|
||||
rows,
|
||||
[
|
||||
ratatui::layout::Constraint::Percentage(40),
|
||||
ratatui::layout::Constraint::Percentage(30),
|
||||
ratatui::layout::Constraint::Percentage(30),
|
||||
],
|
||||
)
|
||||
.header(header)
|
||||
.block(Block::default().borders(Borders::ALL).title(title));
|
||||
|
||||
f.render_widget(table, area);
|
||||
}
|
||||
63
crates/pinakes-tui/src/ui/tasks.rs
Normal file
63
crates/pinakes-tui/src/ui/tasks.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
use ratatui::Frame;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem};
|
||||
|
||||
use crate::app::AppState;
|
||||
|
||||
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
|
||||
let items: Vec<ListItem> = if state.scheduled_tasks.is_empty() {
|
||||
vec![ListItem::new(Line::from(Span::styled(
|
||||
" No scheduled tasks. Press T to refresh.",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)))]
|
||||
} else {
|
||||
state
|
||||
.scheduled_tasks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, task)| {
|
||||
let is_selected = state.scheduled_tasks_selected == Some(i);
|
||||
let enabled_marker = if task.enabled { "[ON] " } else { "[OFF]" };
|
||||
let enabled_color = if task.enabled {
|
||||
Color::Green
|
||||
} else {
|
||||
Color::DarkGray
|
||||
};
|
||||
|
||||
let last_run = task
|
||||
.last_run
|
||||
.as_deref()
|
||||
.map(super::format_date)
|
||||
.unwrap_or("-");
|
||||
let next_run = task
|
||||
.next_run
|
||||
.as_deref()
|
||||
.map(super::format_date)
|
||||
.unwrap_or("-");
|
||||
let status = task.last_status.as_deref().unwrap_or("-");
|
||||
|
||||
let text = format!(
|
||||
" {enabled_marker} {:<20} {:<16} Last: {:<12} Next: {:<12} Status: {}",
|
||||
task.name, task.schedule, last_run, next_run, status
|
||||
);
|
||||
|
||||
let style = if is_selected {
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(enabled_color)
|
||||
};
|
||||
|
||||
ListItem::new(Line::from(Span::styled(text, style)))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let title = format!(" Scheduled Tasks ({}) ", state.scheduled_tasks.len());
|
||||
let list = List::new(items).block(Block::default().borders(Borders::ALL).title(title));
|
||||
|
||||
f.render_widget(list, area);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue