pinakes-tui: cover more API routes in the TUI crate

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Id14b6f82d3b9f3c27bee9c214a1bdedc6a6a6964
This commit is contained in:
raf 2026-03-21 02:19:55 +03:00
commit bb69f2fa37
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
8 changed files with 1866 additions and 67 deletions

File diff suppressed because it is too large Load diff

View file

@ -186,6 +186,62 @@ pub struct AuthorSummary {
pub count: u64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PlaylistResponse {
pub id: String,
pub name: String,
pub description: Option<String>,
pub created_at: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CommentResponse {
pub text: String,
pub created_at: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TranscodeSessionResponse {
pub id: String,
pub profile: String,
pub status: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SubtitleEntry {
pub language: Option<String>,
pub format: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SubtitleListResponse {
pub subtitles: Vec<SubtitleEntry>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DeviceResponse {
pub id: String,
pub name: String,
pub device_type: Option<String>,
pub last_seen: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct WebhookInfo {
#[serde(default)]
pub id: String,
pub url: String,
pub events: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct UserResponse {
pub id: String,
pub username: String,
pub role: String,
pub created_at: String,
}
impl ApiClient {
pub fn new(base_url: &str, api_key: Option<&str>) -> Self {
let client = api_key.map_or_else(Client::new, |key| {
@ -198,7 +254,13 @@ impl ApiClient {
Client::builder()
.default_headers(headers)
.build()
.unwrap_or_default()
.unwrap_or_else(|e| {
tracing::warn!(
"failed to build authenticated HTTP client: {e}; falling back to \
unauthenticated client"
);
Client::new()
})
});
Self {
client,
@ -627,4 +689,303 @@ impl ApiClient {
.error_for_status()?;
Ok(())
}
pub async fn list_playlists(&self) -> Result<Vec<PlaylistResponse>> {
let resp = self
.client
.get(self.url("/playlists"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn create_playlist(
&self,
name: &str,
description: Option<&str>,
) -> Result<PlaylistResponse> {
let mut body = serde_json::json!({"name": name});
if let Some(desc) = description {
body["description"] = serde_json::Value::String(desc.to_string());
}
let resp = self
.client
.post(self.url("/playlists"))
.json(&body)
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn delete_playlist(&self, id: &str) -> Result<()> {
self
.client
.delete(self.url(&format!("/playlists/{id}")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn get_playlist_items(
&self,
id: &str,
) -> Result<Vec<MediaResponse>> {
let resp = self
.client
.get(self.url(&format!("/playlists/{id}/items")))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn remove_from_playlist(
&self,
playlist_id: &str,
media_id: &str,
) -> Result<()> {
self
.client
.delete(self.url(&format!("/playlists/{playlist_id}/items/{media_id}")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn shuffle_playlist(&self, id: &str) -> Result<()> {
self
.client
.post(self.url(&format!("/playlists/{id}/shuffle")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn rate_media(&self, media_id: &str, stars: u8) -> Result<()> {
self
.client
.post(self.url(&format!("/media/{media_id}/ratings")))
.json(&serde_json::json!({"stars": stars}))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn add_comment(
&self,
media_id: &str,
text: &str,
) -> Result<CommentResponse> {
let resp = self
.client
.post(self.url(&format!("/media/{media_id}/comments")))
.json(&serde_json::json!({"text": text}))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn list_comments(
&self,
media_id: &str,
) -> Result<Vec<CommentResponse>> {
let resp = self
.client
.get(self.url(&format!("/media/{media_id}/comments")))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn toggle_favorite(&self, media_id: &str) -> Result<()> {
// Try POST to add; if it fails with conflict, DELETE to remove
let post_resp = self
.client
.post(self.url("/favorites"))
.json(&serde_json::json!({"media_id": media_id}))
.send()
.await?;
if post_resp.status() == reqwest::StatusCode::CONFLICT
|| post_resp.status() == reqwest::StatusCode::UNPROCESSABLE_ENTITY
{
// Already a favorite: remove it
self
.client
.delete(self.url(&format!("/favorites/{media_id}")))
.send()
.await?
.error_for_status()?;
} else {
post_resp.error_for_status()?;
}
Ok(())
}
pub async fn enrich_media(&self, media_id: &str) -> Result<()> {
self
.client
.post(self.url(&format!("/media/{media_id}/enrich")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn start_transcode(
&self,
media_id: &str,
profile: &str,
) -> Result<TranscodeSessionResponse> {
let resp = self
.client
.post(self.url(&format!("/media/{media_id}/transcode")))
.json(&serde_json::json!({"profile": profile}))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn list_transcodes(&self) -> Result<Vec<TranscodeSessionResponse>> {
let resp = self
.client
.get(self.url("/transcode"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn cancel_transcode(&self, id: &str) -> Result<()> {
self
.client
.delete(self.url(&format!("/transcode/{id}")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn list_subtitles(
&self,
media_id: &str,
) -> Result<SubtitleListResponse> {
let resp = self
.client
.get(self.url(&format!("/media/{media_id}/subtitles")))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn list_sync_devices(&self) -> Result<Vec<DeviceResponse>> {
let resp = self
.client
.get(self.url("/sync/devices"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn delete_sync_device(&self, id: &str) -> Result<()> {
self
.client
.delete(self.url(&format!("/sync/devices/{id}")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn list_webhooks(&self) -> Result<Vec<WebhookInfo>> {
let resp = self
.client
.get(self.url("/webhooks"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn test_webhook(&self, id: &str) -> Result<()> {
self
.client
.post(self.url("/webhooks/test"))
.json(&serde_json::json!({"id": id}))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn list_users(&self) -> Result<Vec<UserResponse>> {
let resp = self
.client
.get(self.url("/users"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}
pub async fn delete_user(&self, id: &str) -> Result<()> {
self
.client
.delete(self.url(&format!("/users/{id}")))
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn add_to_playlist(
&self,
playlist_id: &str,
media_id: &str,
) -> Result<()> {
let body = serde_json::json!({"media_id": media_id});
let resp = self
.client
.post(self.url(&format!("/playlists/{playlist_id}/items")))
.json(&body)
.send()
.await?;
if resp.status().is_success() {
Ok(())
} else {
anyhow::bail!("add to playlist failed: {}", resp.status())
}
}
}

View file

@ -28,6 +28,23 @@ pub enum ApiResult {
BookAuthors(Vec<crate::client::AuthorSummary>),
MediaUpdated,
ReadingProgressUpdated,
// Playlists
PlaylistsLoaded(Vec<crate::client::PlaylistResponse>),
PlaylistItemsLoaded(Vec<crate::client::MediaResponse>),
// Social
CommentsLoaded(Vec<crate::client::CommentResponse>),
RatingSet(u8),
FavoriteToggled,
// Subtitles
SubtitlesLoaded(crate::client::SubtitleListResponse),
// Enrichment / transcode
EnrichmentTriggered,
TranscodeStarted,
// Admin
UsersLoaded(Vec<crate::client::UserResponse>),
SyncDevicesLoaded(Vec<crate::client::DeviceResponse>),
WebhooksLoaded(Vec<crate::client::WebhookInfo>),
TranscodesLoaded(Vec<crate::client::TranscodeSessionResponse>),
Error(String),
}

View file

@ -53,6 +53,29 @@ pub enum Action {
ConfirmBatchDelete,
BatchTag,
UpdateReadingProgress,
// Playlists
PlaylistsView,
CreatePlaylist,
DeletePlaylist,
RemoveFromPlaylist,
ShufflePlaylist,
AddToPlaylist,
// Social / detail
ToggleFavorite,
RateMedia,
AddComment,
EnrichMedia,
ToggleSubtitles,
// Transcode
ToggleTranscodes,
CancelTranscode,
// Admin
AdminView,
AdminTabNext,
AdminTabPrev,
DeleteUser,
DeleteDevice,
TestWebhook,
None,
}
@ -98,6 +121,8 @@ pub fn handle_key(
(KeyCode::Char('d'), _) => {
match current_view {
View::Tags | View::Collections => Action::DeleteSelected,
View::Playlists => Action::DeletePlaylist,
View::Admin => Action::DeleteUser,
_ => Action::Delete,
}
},
@ -111,16 +136,22 @@ pub fn handle_key(
(KeyCode::Char('p'), _) => {
match current_view {
View::Detail => Action::UpdateReadingProgress,
_ => Action::None,
_ => Action::PlaylistsView,
}
},
(KeyCode::Char('t'), _) => {
match current_view {
View::Tasks => Action::Toggle,
View::Detail => Action::ToggleTranscodes,
_ => Action::TagView,
}
},
(KeyCode::Char('c'), _) => Action::CollectionView,
(KeyCode::Char('c'), _) => {
match current_view {
View::Detail => Action::AddComment,
_ => Action::CollectionView,
}
},
// Multi-select: Ctrl+A for SelectAll (must come before plain 'a')
(KeyCode::Char('a'), KeyModifiers::CONTROL) => {
match current_view {
@ -130,15 +161,22 @@ pub fn handle_key(
},
(KeyCode::Char('a'), _) => Action::AuditView,
(KeyCode::Char('b'), _) => Action::BooksView,
(KeyCode::Char('S'), _) => Action::SettingsView,
(KeyCode::Char('S'), _) => {
match current_view {
View::Playlists => Action::ShufflePlaylist,
_ => Action::SettingsView,
}
},
(KeyCode::Char('B'), _) => Action::DatabaseView,
(KeyCode::Char('Q'), _) => Action::QueueView,
(KeyCode::Char('X'), _) => Action::StatisticsView,
(KeyCode::Char('A'), _) => Action::AdminView,
// 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,
View::Admin => Action::DeleteDevice,
_ => Action::DuplicatesView,
}
},
@ -157,9 +195,24 @@ pub fn handle_key(
},
(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('n'), _) => {
match current_view {
View::Playlists => Action::CreatePlaylist,
_ => Action::CreateTag,
}
},
(KeyCode::Char('+'), _) => {
match current_view {
View::Library | View::Search => Action::AddToPlaylist,
_ => Action::TagMedia,
}
},
(KeyCode::Char('-'), _) => {
match current_view {
View::Playlists => Action::RemoveFromPlaylist,
_ => Action::UntagMedia,
}
},
(KeyCode::Char('v'), _) => {
match current_view {
View::Database => Action::Vacuum,
@ -169,11 +222,52 @@ pub fn handle_key(
(KeyCode::Char('x'), _) => {
match current_view {
View::Tasks => Action::RunNow,
View::Detail => Action::CancelTranscode,
_ => Action::None,
}
},
(KeyCode::Tab, _) => Action::NextTab,
(KeyCode::BackTab, _) => Action::PrevTab,
(KeyCode::Char('f'), _) => {
match current_view {
View::Detail => Action::ToggleFavorite,
_ => Action::None,
}
},
(KeyCode::Char('R'), _) => {
match current_view {
View::Detail => Action::RateMedia,
_ => Action::None,
}
},
(KeyCode::Char('E'), _) => {
match current_view {
View::Detail => Action::EnrichMedia,
_ => Action::None,
}
},
(KeyCode::Char('U'), _) => {
match current_view {
View::Detail => Action::ToggleSubtitles,
_ => Action::None,
}
},
(KeyCode::Char('w'), _) => {
match current_view {
View::Admin => Action::TestWebhook,
_ => Action::None,
}
},
(KeyCode::Tab, _) => {
match current_view {
View::Admin => Action::AdminTabNext,
_ => Action::NextTab,
}
},
(KeyCode::BackTab, _) => {
match current_view {
View::Admin => Action::AdminTabPrev,
_ => Action::PrevTab,
}
},
(KeyCode::PageUp, _) => Action::PageUp,
(KeyCode::PageDown, _) => Action::PageDown,
// Multi-select keys

View file

@ -0,0 +1,174 @@
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Row, Table, Tabs},
};
use super::format_date;
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);
render_tab_bar(f, state, chunks[0]);
match state.admin_tab {
0 => render_users(f, state, chunks[1]),
1 => render_devices(f, state, chunks[1]),
_ => render_webhooks(f, state, chunks[1]),
}
}
fn render_tab_bar(f: &mut Frame, state: &AppState, area: Rect) {
let titles: Vec<Line> = vec!["Users", "Sync Devices", "Webhooks"]
.into_iter()
.map(|t| Line::from(Span::styled(t, Style::default().fg(Color::White))))
.collect();
let tabs = Tabs::new(titles)
.block(Block::default().borders(Borders::ALL).title(" Admin "))
.select(state.admin_tab)
.style(Style::default().fg(Color::Gray))
.highlight_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
f.render_widget(tabs, area);
}
fn render_users(f: &mut Frame, state: &AppState, area: Rect) {
let header = Row::new(vec!["Username", "Role", "Created"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.users_list
.iter()
.enumerate()
.map(|(i, user)| {
let style = if i == state.users_selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
let role_color = match user.role.as_str() {
"admin" => Color::Red,
"editor" => Color::Yellow,
_ => Color::White,
};
Style::default().fg(role_color)
};
Row::new(vec![
user.username.clone(),
user.role.clone(),
format_date(&user.created_at).to_string(),
])
.style(style)
})
.collect();
let title = format!(" Users ({}) ", state.users_list.len());
let table = Table::new(rows, [
Constraint::Percentage(40),
Constraint::Percentage(20),
Constraint::Percentage(40),
])
.header(header)
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(table, area);
}
fn render_devices(f: &mut Frame, state: &AppState, area: Rect) {
let header = Row::new(vec!["Name", "Type", "Last Seen"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.sync_devices
.iter()
.enumerate()
.map(|(i, dev)| {
let style = if i == state.sync_devices_selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default()
};
Row::new(vec![
dev.name.clone(),
dev.device_type.clone().unwrap_or_else(|| "-".into()),
dev
.last_seen
.as_deref()
.map_or("-", format_date)
.to_string(),
])
.style(style)
})
.collect();
let title = format!(" Sync Devices ({}) ", state.sync_devices.len());
let table = Table::new(rows, [
Constraint::Percentage(40),
Constraint::Percentage(20),
Constraint::Percentage(40),
])
.header(header)
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(table, area);
}
fn render_webhooks(f: &mut Frame, state: &AppState, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0)])
.split(area);
let header = Row::new(vec!["URL", "Events"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.webhooks
.iter()
.enumerate()
.map(|(i, wh)| {
let style = if i == state.webhooks_selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default()
};
let events = if wh.events.is_empty() {
"-".to_string()
} else {
wh.events.join(", ")
};
Row::new(vec![wh.url.clone(), events]).style(style)
})
.collect();
let title = format!(" Webhooks ({}) ", state.webhooks.len());
let table = Table::new(rows, [
Constraint::Percentage(50),
Constraint::Percentage(50),
])
.header(header)
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(table, chunks[0]);
}

View file

@ -252,6 +252,113 @@ pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
}
}
// Social section: rating, favorite, comments
{
let has_social = state.media_rating.is_some()
|| state.is_favorite
|| !state.media_comments.is_empty();
if has_social {
lines.push(Line::default());
lines.push(Line::from(Span::styled(
"--- Social ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
}
if state.is_favorite {
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Favorite"), label_style),
Span::styled("Yes", Style::default().fg(Color::Yellow)),
]));
}
if let Some(stars) = state.media_rating {
let stars_str = "*".repeat(stars as usize);
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Rating"), label_style),
Span::styled(format!("{stars_str} ({stars}/5)"), value_style),
]));
}
if !state.media_comments.is_empty() {
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(
format!("Comments ({})", state.media_comments.len()),
label_style,
),
]));
for comment in state.media_comments.iter().take(5) {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("[{}] {}", format_date(&comment.created_at), comment.text),
dim_style,
),
]));
}
if state.media_comments.len() > 5 {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("... and {} more", state.media_comments.len() - 5),
dim_style,
),
]));
}
}
}
// Subtitles section
if state.showing_subtitles {
lines.push(Line::default());
lines.push(Line::from(Span::styled(
"--- Subtitles ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
if state.subtitles.is_empty() {
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled("No subtitles found", dim_style),
]));
} else {
for sub in &state.subtitles {
let lang = sub.language.as_deref().unwrap_or("?");
let fmt = sub.format.as_deref().unwrap_or("?");
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(format!("[{lang}] {fmt}"), value_style),
]));
}
}
}
// Transcodes section
if state.showing_transcodes && !state.transcodes.is_empty() {
lines.push(Line::default());
lines.push(Line::from(Span::styled(
"--- Transcodes ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
for tc in &state.transcodes {
let status_color = match tc.status.as_str() {
"done" | "completed" => Color::Green,
"failed" | "error" => Color::Red,
_ => Color::Yellow,
};
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(format!("[{}] ", tc.profile), label_style),
Span::styled(&tc.status, Style::default().fg(status_color)),
]));
}
}
// Reading progress section
if let Some(ref progress) = state.reading_progress {
lines.push(Line::default());

View file

@ -1,3 +1,4 @@
pub mod admin;
pub mod audit;
pub mod books;
pub mod collections;
@ -7,6 +8,7 @@ pub mod duplicates;
pub mod import;
pub mod library;
pub mod metadata_edit;
pub mod playlists;
pub mod queue;
pub mod search;
pub mod settings;
@ -109,6 +111,8 @@ pub fn render(f: &mut Frame, state: &AppState) {
View::Statistics => statistics::render(f, state, chunks[1]),
View::Tasks => tasks::render(f, state, chunks[1]),
View::Books => books::render(f, state, chunks[1]),
View::Playlists => playlists::render(f, state, chunks[1]),
View::Admin => admin::render(f, state, chunks[1]),
}
render_status_bar(f, state, chunks[2]);
@ -125,6 +129,8 @@ fn render_tabs(f: &mut Frame, state: &AppState, area: Rect) {
"Queue",
"Stats",
"Tasks",
"Playlists",
"Admin",
]
.into_iter()
.map(|t| Line::from(Span::styled(t, Style::default().fg(Color::White))))
@ -144,6 +150,8 @@ fn render_tabs(f: &mut Frame, state: &AppState, area: Rect) {
View::Queue => 6,
View::Statistics => 7,
View::Tasks => 8,
View::Playlists => 9,
View::Admin => 10,
};
let tabs = Tabs::new(titles)
@ -177,10 +185,17 @@ fn render_status_bar(f: &mut Frame, state: &AppState, area: Rect) {
.to_string()
},
View::Detail => {
" q:Quit Esc:Back o:Open e:Edit p:Page +:Tag -:Untag \
r:Refresh ?:Help"
" q:Quit Esc:Back o:Open e:Edit p:Page +:Tag -:Untag f:Fav \
R:Rate c:Comment E:Enrich U:Subtitles t:Transcode r:Refresh"
.to_string()
},
View::Playlists => {
" q:Quit j/k:Nav n:New d:Delete Enter:Items S:Shuffle Esc:Back"
.to_string()
},
View::Admin => " q:Quit j/k:Nav Tab:Switch tab d:Del user/device \
w:Test webhook r:Refresh Esc:Back"
.to_string(),
View::Import => {
" Enter:Import Esc:Cancel s:Scan libraries ?:Help".to_string()
},

View file

@ -0,0 +1,117 @@
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Row, Table},
};
use super::format_date;
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
if state.viewing_playlist_items {
render_items(f, state, area);
} else {
render_list(f, state, area);
}
}
fn render_list(f: &mut Frame, state: &AppState, area: Rect) {
let header = Row::new(vec!["Name", "Description", "Created"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.playlists
.iter()
.enumerate()
.map(|(i, pl)| {
let style = if i == state.playlists_selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default()
};
Row::new(vec![
pl.name.clone(),
pl.description.clone().unwrap_or_else(|| "-".into()),
format_date(&pl.created_at).to_string(),
])
.style(style)
})
.collect();
let title = format!(" Playlists ({}) ", state.playlists.len());
let table = Table::new(rows, [
Constraint::Percentage(40),
Constraint::Percentage(40),
Constraint::Percentage(20),
])
.header(header)
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(table, area);
}
fn render_items(f: &mut Frame, state: &AppState, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Min(0)])
.split(area);
let pl_name = state
.playlists
.get(state.playlists_selected)
.map_or("Playlist", |p| p.name.as_str());
let hint = Paragraph::new(Line::from(vec![
Span::styled(
format!(" {pl_name} "),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled("Esc:Back d:Remove", Style::default().fg(Color::DarkGray)),
]));
f.render_widget(hint, chunks[0]);
let header = Row::new(vec!["File", "Type", "Title"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.playlist_items
.iter()
.enumerate()
.map(|(i, item)| {
let style = if i == state.playlist_items_selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default()
};
Row::new(vec![
item.file_name.clone(),
item.media_type.clone(),
item.title.clone().unwrap_or_else(|| "-".into()),
])
.style(style)
})
.collect();
let title = format!(" Items ({}) ", state.playlist_items.len());
let table = Table::new(rows, [
Constraint::Percentage(40),
Constraint::Percentage(20),
Constraint::Percentage(40),
])
.header(header)
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(table, chunks[1]);
}