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
0dda2aec8f
commit
bb69f2fa37
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,
|
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 {
|
impl ApiClient {
|
||||||
pub fn new(base_url: &str, api_key: Option<&str>) -> Self {
|
pub fn new(base_url: &str, api_key: Option<&str>) -> Self {
|
||||||
let client = api_key.map_or_else(Client::new, |key| {
|
let client = api_key.map_or_else(Client::new, |key| {
|
||||||
|
|
@ -198,7 +254,13 @@ impl ApiClient {
|
||||||
Client::builder()
|
Client::builder()
|
||||||
.default_headers(headers)
|
.default_headers(headers)
|
||||||
.build()
|
.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 {
|
Self {
|
||||||
client,
|
client,
|
||||||
|
|
@ -627,4 +689,303 @@ impl ApiClient {
|
||||||
.error_for_status()?;
|
.error_for_status()?;
|
||||||
Ok(())
|
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>),
|
BookAuthors(Vec<crate::client::AuthorSummary>),
|
||||||
MediaUpdated,
|
MediaUpdated,
|
||||||
ReadingProgressUpdated,
|
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),
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,29 @@ pub enum Action {
|
||||||
ConfirmBatchDelete,
|
ConfirmBatchDelete,
|
||||||
BatchTag,
|
BatchTag,
|
||||||
UpdateReadingProgress,
|
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,
|
None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,6 +121,8 @@ pub fn handle_key(
|
||||||
(KeyCode::Char('d'), _) => {
|
(KeyCode::Char('d'), _) => {
|
||||||
match current_view {
|
match current_view {
|
||||||
View::Tags | View::Collections => Action::DeleteSelected,
|
View::Tags | View::Collections => Action::DeleteSelected,
|
||||||
|
View::Playlists => Action::DeletePlaylist,
|
||||||
|
View::Admin => Action::DeleteUser,
|
||||||
_ => Action::Delete,
|
_ => Action::Delete,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -111,16 +136,22 @@ pub fn handle_key(
|
||||||
(KeyCode::Char('p'), _) => {
|
(KeyCode::Char('p'), _) => {
|
||||||
match current_view {
|
match current_view {
|
||||||
View::Detail => Action::UpdateReadingProgress,
|
View::Detail => Action::UpdateReadingProgress,
|
||||||
_ => Action::None,
|
_ => Action::PlaylistsView,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
(KeyCode::Char('t'), _) => {
|
(KeyCode::Char('t'), _) => {
|
||||||
match current_view {
|
match current_view {
|
||||||
View::Tasks => Action::Toggle,
|
View::Tasks => Action::Toggle,
|
||||||
|
View::Detail => Action::ToggleTranscodes,
|
||||||
_ => Action::TagView,
|
_ => 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')
|
// Multi-select: Ctrl+A for SelectAll (must come before plain 'a')
|
||||||
(KeyCode::Char('a'), KeyModifiers::CONTROL) => {
|
(KeyCode::Char('a'), KeyModifiers::CONTROL) => {
|
||||||
match current_view {
|
match current_view {
|
||||||
|
|
@ -130,15 +161,22 @@ pub fn handle_key(
|
||||||
},
|
},
|
||||||
(KeyCode::Char('a'), _) => Action::AuditView,
|
(KeyCode::Char('a'), _) => Action::AuditView,
|
||||||
(KeyCode::Char('b'), _) => Action::BooksView,
|
(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('B'), _) => Action::DatabaseView,
|
||||||
(KeyCode::Char('Q'), _) => Action::QueueView,
|
(KeyCode::Char('Q'), _) => Action::QueueView,
|
||||||
(KeyCode::Char('X'), _) => Action::StatisticsView,
|
(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
|
// Use plain D/T for views in non-library contexts, keep for batch ops in
|
||||||
// library/search
|
// library/search
|
||||||
(KeyCode::Char('D'), _) => {
|
(KeyCode::Char('D'), _) => {
|
||||||
match current_view {
|
match current_view {
|
||||||
View::Library | View::Search => Action::BatchDelete,
|
View::Library | View::Search => Action::BatchDelete,
|
||||||
|
View::Admin => Action::DeleteDevice,
|
||||||
_ => Action::DuplicatesView,
|
_ => Action::DuplicatesView,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -157,9 +195,24 @@ pub fn handle_key(
|
||||||
},
|
},
|
||||||
(KeyCode::Char('s'), _) => Action::ScanTrigger,
|
(KeyCode::Char('s'), _) => Action::ScanTrigger,
|
||||||
(KeyCode::Char('r'), _) => Action::Refresh,
|
(KeyCode::Char('r'), _) => Action::Refresh,
|
||||||
(KeyCode::Char('n'), _) => Action::CreateTag,
|
(KeyCode::Char('n'), _) => {
|
||||||
(KeyCode::Char('+'), _) => Action::TagMedia,
|
match current_view {
|
||||||
(KeyCode::Char('-'), _) => Action::UntagMedia,
|
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'), _) => {
|
(KeyCode::Char('v'), _) => {
|
||||||
match current_view {
|
match current_view {
|
||||||
View::Database => Action::Vacuum,
|
View::Database => Action::Vacuum,
|
||||||
|
|
@ -169,11 +222,52 @@ pub fn handle_key(
|
||||||
(KeyCode::Char('x'), _) => {
|
(KeyCode::Char('x'), _) => {
|
||||||
match current_view {
|
match current_view {
|
||||||
View::Tasks => Action::RunNow,
|
View::Tasks => Action::RunNow,
|
||||||
|
View::Detail => Action::CancelTranscode,
|
||||||
_ => Action::None,
|
_ => Action::None,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
(KeyCode::Tab, _) => Action::NextTab,
|
(KeyCode::Char('f'), _) => {
|
||||||
(KeyCode::BackTab, _) => Action::PrevTab,
|
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::PageUp, _) => Action::PageUp,
|
||||||
(KeyCode::PageDown, _) => Action::PageDown,
|
(KeyCode::PageDown, _) => Action::PageDown,
|
||||||
// Multi-select keys
|
// 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
|
// Reading progress section
|
||||||
if let Some(ref progress) = state.reading_progress {
|
if let Some(ref progress) = state.reading_progress {
|
||||||
lines.push(Line::default());
|
lines.push(Line::default());
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
pub mod admin;
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
pub mod books;
|
pub mod books;
|
||||||
pub mod collections;
|
pub mod collections;
|
||||||
|
|
@ -7,6 +8,7 @@ pub mod duplicates;
|
||||||
pub mod import;
|
pub mod import;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
pub mod metadata_edit;
|
pub mod metadata_edit;
|
||||||
|
pub mod playlists;
|
||||||
pub mod queue;
|
pub mod queue;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
|
@ -109,6 +111,8 @@ pub fn render(f: &mut Frame, state: &AppState) {
|
||||||
View::Statistics => statistics::render(f, state, chunks[1]),
|
View::Statistics => statistics::render(f, state, chunks[1]),
|
||||||
View::Tasks => tasks::render(f, state, chunks[1]),
|
View::Tasks => tasks::render(f, state, chunks[1]),
|
||||||
View::Books => books::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]);
|
render_status_bar(f, state, chunks[2]);
|
||||||
|
|
@ -125,6 +129,8 @@ fn render_tabs(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
"Queue",
|
"Queue",
|
||||||
"Stats",
|
"Stats",
|
||||||
"Tasks",
|
"Tasks",
|
||||||
|
"Playlists",
|
||||||
|
"Admin",
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|t| Line::from(Span::styled(t, Style::default().fg(Color::White))))
|
.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::Queue => 6,
|
||||||
View::Statistics => 7,
|
View::Statistics => 7,
|
||||||
View::Tasks => 8,
|
View::Tasks => 8,
|
||||||
|
View::Playlists => 9,
|
||||||
|
View::Admin => 10,
|
||||||
};
|
};
|
||||||
|
|
||||||
let tabs = Tabs::new(titles)
|
let tabs = Tabs::new(titles)
|
||||||
|
|
@ -177,10 +185,17 @@ fn render_status_bar(f: &mut Frame, state: &AppState, area: Rect) {
|
||||||
.to_string()
|
.to_string()
|
||||||
},
|
},
|
||||||
View::Detail => {
|
View::Detail => {
|
||||||
" q:Quit Esc:Back o:Open e:Edit p:Page +:Tag -:Untag \
|
" q:Quit Esc:Back o:Open e:Edit p:Page +:Tag -:Untag f:Fav \
|
||||||
r:Refresh ?:Help"
|
R:Rate c:Comment E:Enrich U:Subtitles t:Transcode r:Refresh"
|
||||||
.to_string()
|
.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 => {
|
View::Import => {
|
||||||
" Enter:Import Esc:Cancel s:Scan libraries ?:Help".to_string()
|
" 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