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:
parent
c18edc9146
commit
8129c5a6e7
8 changed files with 1866 additions and 67 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
174
crates/pinakes-tui/src/ui/admin.rs
Normal file
174
crates/pinakes-tui/src/ui/admin.rs
Normal 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]);
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
|
|
|
|||
117
crates/pinakes-tui/src/ui/playlists.rs
Normal file
117
crates/pinakes-tui/src/ui/playlists.rs
Normal 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]);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue