tui - client app.
This commit is contained in:
1115
Cargo.lock
generated
1115
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ members = [
|
||||
"crates/adapters/template-askama",
|
||||
"crates/application",
|
||||
"crates/domain",
|
||||
"crates/presentation",
|
||||
"crates/presentation", "crates/tui",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
22
crates/tui/Cargo.toml
Normal file
22
crates/tui/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "tui"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
ratatui = "0.30.0"
|
||||
|
||||
keyring = "3"
|
||||
directories = "6"
|
||||
csv = "1"
|
||||
|
||||
reqwest = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
1168
crates/tui/src/app.rs
Normal file
1168
crates/tui/src/app.rs
Normal file
File diff suppressed because it is too large
Load Diff
231
crates/tui/src/client.rs
Normal file
231
crates/tui/src/client.rs
Normal file
@@ -0,0 +1,231 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
// ── DTOs (mirror backend dtos.rs exactly) ────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct LoginRequest {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct LoginResponse {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LogReviewRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub external_metadata_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub manual_title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub manual_release_year: Option<u16>,
|
||||
pub rating: u8,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub comment: Option<String>,
|
||||
pub watched_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DiaryResponse {
|
||||
pub items: Vec<DiaryEntryDto>,
|
||||
pub total_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DiaryEntryDto {
|
||||
pub movie: MovieDto,
|
||||
pub review: ReviewDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct MovieDto {
|
||||
pub id: Uuid,
|
||||
pub title: String,
|
||||
pub release_year: u16,
|
||||
pub director: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ReviewDto {
|
||||
pub id: Uuid,
|
||||
pub rating: u8,
|
||||
pub comment: Option<String>,
|
||||
pub watched_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ReviewHistoryResponse {
|
||||
pub movie: MovieDto,
|
||||
pub viewings: Vec<ReviewDto>,
|
||||
pub trend: String,
|
||||
}
|
||||
|
||||
// ── Error ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ApiError {
|
||||
#[error("network error: {0}")]
|
||||
Network(#[from] reqwest::Error),
|
||||
#[error("unauthorized")]
|
||||
Unauthorized,
|
||||
#[error("not found")]
|
||||
NotFound,
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
#[error("validation error: {0}")]
|
||||
Validation(String),
|
||||
#[error("server error {status}: {body}")]
|
||||
Unknown { status: u16, body: String },
|
||||
}
|
||||
|
||||
async fn check_status(resp: reqwest::Response) -> Result<reqwest::Response, ApiError> {
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
return Ok(resp);
|
||||
}
|
||||
let body = resp.text().await.map_err(ApiError::Network)?;
|
||||
Err(match status.as_u16() {
|
||||
401 => ApiError::Unauthorized,
|
||||
403 => ApiError::Forbidden,
|
||||
404 => ApiError::NotFound,
|
||||
400 => ApiError::Validation(body),
|
||||
code => ApiError::Unknown { status: code, body },
|
||||
})
|
||||
}
|
||||
|
||||
// ── Client ────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct ApiClient {
|
||||
base_url: std::sync::RwLock<String>,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl ApiClient {
|
||||
pub fn new(url: &str) -> Self {
|
||||
Self {
|
||||
base_url: std::sync::RwLock::new(url.to_string()),
|
||||
http: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_url(&self, url: &str) {
|
||||
*self.base_url.write().unwrap() = url.to_string();
|
||||
}
|
||||
|
||||
fn url(&self) -> String {
|
||||
self.base_url.read().unwrap().clone()
|
||||
}
|
||||
|
||||
pub async fn login(&self, email: &str, password: &str) -> Result<LoginResponse, ApiError> {
|
||||
let resp = self
|
||||
.http
|
||||
.post(format!("{}/api/auth/login", self.url()))
|
||||
.json(&LoginRequest { email: email.into(), password: password.into() })
|
||||
.send()
|
||||
.await?;
|
||||
Ok(check_status(resp).await?.json().await?)
|
||||
}
|
||||
|
||||
pub async fn get_diary(
|
||||
&self,
|
||||
token: &str,
|
||||
offset: u32,
|
||||
limit: u32,
|
||||
) -> Result<DiaryResponse, ApiError> {
|
||||
let resp = self
|
||||
.http
|
||||
.get(format!("{}/api/diary", self.url()))
|
||||
.query(&[("offset", offset), ("limit", limit)])
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(check_status(resp).await?.json().await?)
|
||||
}
|
||||
|
||||
pub async fn get_movie_history(
|
||||
&self,
|
||||
token: &str,
|
||||
movie_id: Uuid,
|
||||
) -> Result<ReviewHistoryResponse, ApiError> {
|
||||
let resp = self
|
||||
.http
|
||||
.get(format!("{}/api/movies/{}/history", self.url(), movie_id))
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(check_status(resp).await?.json().await?)
|
||||
}
|
||||
|
||||
pub async fn create_review(
|
||||
&self,
|
||||
token: &str,
|
||||
req: &LogReviewRequest,
|
||||
) -> Result<(), ApiError> {
|
||||
let resp = self
|
||||
.http
|
||||
.post(format!("{}/api/reviews", self.url()))
|
||||
.bearer_auth(token)
|
||||
.json(req)
|
||||
.send()
|
||||
.await?;
|
||||
check_status(resp).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_review(&self, token: &str, review_id: Uuid) -> Result<(), ApiError> {
|
||||
let resp = self
|
||||
.http
|
||||
.delete(format!("{}/api/reviews/{}", self.url(), review_id))
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await?;
|
||||
check_status(resp).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn apierror_unauthorized_display() {
|
||||
let err = ApiError::Unauthorized;
|
||||
assert!(matches!(err, ApiError::Unauthorized));
|
||||
assert_eq!(err.to_string(), "unauthorized");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apierror_validation_display() {
|
||||
let err = ApiError::Validation("rating must be 0-5".into());
|
||||
assert!(err.to_string().contains("validation error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_review_request_skips_none_fields() {
|
||||
let req = LogReviewRequest {
|
||||
external_metadata_id: None,
|
||||
manual_title: Some("The Matrix".into()),
|
||||
manual_release_year: None,
|
||||
rating: 5,
|
||||
comment: None,
|
||||
watched_at: "2024-01-15T20:00:00".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(!json.contains("external_metadata_id"));
|
||||
assert!(!json.contains("manual_release_year"));
|
||||
assert!(json.contains("\"manual_title\":\"The Matrix\""));
|
||||
assert!(json.contains("\"rating\":5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_client_update_url() {
|
||||
let client = ApiClient::new("http://localhost:3000");
|
||||
assert!(client.url().contains("3000"));
|
||||
client.update_url("http://localhost:8080");
|
||||
assert!(client.url().contains("8080"));
|
||||
}
|
||||
}
|
||||
70
crates/tui/src/config.rs
Normal file
70
crates/tui/src/config.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
use anyhow::Result;
|
||||
use directories::ProjectDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub api_url: String,
|
||||
}
|
||||
|
||||
const KEYRING_SERVICE: &str = "movie-tui";
|
||||
const KEYRING_USER: &str = "jwt-token";
|
||||
|
||||
fn config_path() -> Option<PathBuf> {
|
||||
ProjectDirs::from("com", "movies", "movie-tui")
|
||||
.map(|dirs| dirs.config_dir().join("config.json"))
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> Option<Config> {
|
||||
let path = config_path()?;
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
serde_json::from_str(&content).ok()
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let path = config_path().ok_or_else(|| anyhow::anyhow!("no config dir"))?;
|
||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||
let content = serde_json::to_string_pretty(self)?;
|
||||
std::fs::write(path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_token() -> Option<String> {
|
||||
keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER)
|
||||
.ok()
|
||||
.and_then(|e| e.get_password().ok())
|
||||
}
|
||||
|
||||
pub fn save_token(token: &str) -> Result<()> {
|
||||
let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER)?;
|
||||
entry.set_password(token)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear_token() -> Result<()> {
|
||||
let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER)?;
|
||||
let _ = entry.delete_credential(); // ignore NotFound
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn config_roundtrip() {
|
||||
let config = Config { api_url: "http://localhost:3000".into() };
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let decoded: Config = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(decoded.api_url, "http://localhost:3000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_returns_none_when_no_file() {
|
||||
// Tests that load() doesn't panic — may return Some or None depending on system state
|
||||
let _ = Config::load();
|
||||
}
|
||||
}
|
||||
4
crates/tui/src/lib.rs
Normal file
4
crates/tui/src/lib.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod app;
|
||||
pub mod client;
|
||||
pub mod config;
|
||||
pub mod ui;
|
||||
304
crates/tui/src/main.rs
Normal file
304
crates/tui/src/main.rs
Normal file
@@ -0,0 +1,304 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use ratatui::crossterm::event::{self, Event, KeyCode, KeyModifiers};
|
||||
|
||||
use tui::app::{
|
||||
self, Action, App, BulkImportStage, Command, Screen, SettingsField, Tab,
|
||||
};
|
||||
use tui::client::ApiClient;
|
||||
use tui::config::Config;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let mut config = Config::load();
|
||||
|
||||
// env var override
|
||||
if let Ok(url) = std::env::var("MOVIES_API_URL") {
|
||||
match &mut config {
|
||||
Some(c) => c.api_url = url,
|
||||
None => config = Some(Config { api_url: url }),
|
||||
}
|
||||
}
|
||||
|
||||
let initial_url = config.as_ref().map(|c| c.api_url.as_str()).unwrap_or("http://localhost:3000");
|
||||
let client = Arc::new(ApiClient::new(initial_url));
|
||||
let saved_token = Config::load_token();
|
||||
let mut app = App::new(config, saved_token.clone());
|
||||
|
||||
let (tx, mut rx) = mpsc::channel::<Action>(64);
|
||||
let mut terminal = ratatui::init();
|
||||
|
||||
// If we start directly in Main (saved token), trigger an initial diary load
|
||||
if matches!(app.screen, Screen::Main(_)) {
|
||||
if let Some(token) = &saved_token {
|
||||
let c = client.clone();
|
||||
let t = token.clone();
|
||||
let tx2 = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let action = match c.get_diary(&t, 0, 20).await {
|
||||
Ok(r) => Action::DiaryLoaded { entries: r.items, total: r.total_count },
|
||||
Err(e) => Action::DiaryLoadFailed(e.to_string()),
|
||||
};
|
||||
let _ = tx2.send(action).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let result = async {
|
||||
loop {
|
||||
terminal.draw(|f| tui::ui::render(f, &app))?;
|
||||
|
||||
// Poll keyboard — non-blocking with short timeout
|
||||
if event::poll(Duration::from_millis(50))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if key.kind != ratatui::crossterm::event::KeyEventKind::Press {
|
||||
continue;
|
||||
}
|
||||
if let Some(action) = key_to_action(&app, key) {
|
||||
if matches!(action, Action::Quit) {
|
||||
break;
|
||||
}
|
||||
let cmds = app::update(&mut app, action);
|
||||
for cmd in cmds {
|
||||
handle_command(cmd, &app, &client, &tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drain async results
|
||||
while let Ok(action) = rx.try_recv() {
|
||||
let cmds = app::update(&mut app, action);
|
||||
for cmd in cmds {
|
||||
handle_command(cmd, &app, &client, &tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok::<(), anyhow::Error>(())
|
||||
}.await;
|
||||
|
||||
ratatui::restore();
|
||||
result
|
||||
}
|
||||
|
||||
// ── Command executor ──────────────────────────────────────────────────────────
|
||||
|
||||
fn handle_command(cmd: Command, app: &App, client: &Arc<ApiClient>, tx: &mpsc::Sender<Action>) {
|
||||
match cmd {
|
||||
Command::SaveConfig(url) => {
|
||||
let config = Config { api_url: url.clone() };
|
||||
if let Err(e) = config.save() {
|
||||
let tx2 = tx.clone();
|
||||
let msg = format!("Failed to save config: {e}");
|
||||
tokio::spawn(async move { let _ = tx2.send(Action::DiaryLoadFailed(msg)).await; });
|
||||
}
|
||||
client.update_url(&url);
|
||||
}
|
||||
|
||||
Command::SaveToken(token) => {
|
||||
if let Err(e) = Config::save_token(&token) {
|
||||
let tx2 = tx.clone();
|
||||
let msg = format!("Token not saved to keychain: {e}");
|
||||
tokio::spawn(async move { let _ = tx2.send(Action::DiaryLoadFailed(msg)).await; });
|
||||
}
|
||||
}
|
||||
|
||||
Command::ClearToken => {
|
||||
let _ = Config::clear_token(); // ignore NotFound errors on logout
|
||||
}
|
||||
|
||||
Command::Login { email, password } => {
|
||||
let c = client.clone();
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let action = match c.login(&email, &password).await {
|
||||
Ok(r) => Action::AuthOk(r.token),
|
||||
Err(e) => Action::AuthFail(e.to_string()),
|
||||
};
|
||||
let _ = tx.send(action).await;
|
||||
});
|
||||
}
|
||||
|
||||
Command::LoadDiary { offset } => {
|
||||
let token = match &app.token {
|
||||
Some(t) => t.clone(),
|
||||
None => return,
|
||||
};
|
||||
let c = client.clone();
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let action = match c.get_diary(&token, offset, 20).await {
|
||||
Ok(r) => Action::DiaryLoaded { entries: r.items, total: r.total_count },
|
||||
Err(e) => Action::DiaryLoadFailed(e.to_string()),
|
||||
};
|
||||
let _ = tx.send(action).await;
|
||||
});
|
||||
}
|
||||
|
||||
Command::LoadHistory { movie_id } => {
|
||||
let token = match &app.token {
|
||||
Some(t) => t.clone(),
|
||||
None => return,
|
||||
};
|
||||
let c = client.clone();
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let action = match c.get_movie_history(&token, movie_id).await {
|
||||
Ok(r) => Action::HistoryLoaded(r),
|
||||
Err(e) => Action::HistoryLoadFailed(e.to_string()),
|
||||
};
|
||||
let _ = tx.send(action).await;
|
||||
});
|
||||
}
|
||||
|
||||
Command::CreateReview(req) => {
|
||||
let token = match &app.token {
|
||||
Some(t) => t.clone(),
|
||||
None => return,
|
||||
};
|
||||
let c = client.clone();
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let action = match c.create_review(&token, &req).await {
|
||||
Ok(()) => Action::ReviewCreated,
|
||||
Err(e) => Action::ReviewCreateFailed(e.to_string()),
|
||||
};
|
||||
let _ = tx.send(action).await;
|
||||
});
|
||||
}
|
||||
|
||||
Command::DeleteReview(id) => {
|
||||
let token = match &app.token {
|
||||
Some(t) => t.clone(),
|
||||
None => return,
|
||||
};
|
||||
let c = client.clone();
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let action = match c.delete_review(&token, id).await {
|
||||
Ok(()) => Action::ReviewDeleted(id),
|
||||
Err(e) => Action::ReviewDeleteFailed(e.to_string()),
|
||||
};
|
||||
let _ = tx.send(action).await;
|
||||
});
|
||||
}
|
||||
|
||||
Command::ImportNext(index) => {
|
||||
let token = match &app.token {
|
||||
Some(t) => t.clone(),
|
||||
None => return,
|
||||
};
|
||||
let req = match &app.screen {
|
||||
Screen::Main(m) => match m.bulk_import.valid_requests.get(index) {
|
||||
Some(r) => r.clone(),
|
||||
None => return,
|
||||
},
|
||||
_ => return,
|
||||
};
|
||||
let c = client.clone();
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let error = c.create_review(&token, &req).await.err().map(|e| e.to_string());
|
||||
let _ = tx.send(Action::BulkItemDone { index, error }).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Key → Action ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn key_to_action(app: &App, key: ratatui::crossterm::event::KeyEvent) -> Option<Action> {
|
||||
// Ctrl+C always quits
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
|
||||
return Some(Action::Quit);
|
||||
}
|
||||
|
||||
match &app.screen {
|
||||
Screen::Setup(_) => match key.code {
|
||||
KeyCode::Char(c) => Some(Action::InputChar(c)),
|
||||
KeyCode::Backspace => Some(Action::Backspace),
|
||||
KeyCode::Enter => Some(Action::SetupSubmit),
|
||||
KeyCode::Esc => Some(Action::Escape),
|
||||
_ => None,
|
||||
},
|
||||
|
||||
Screen::Login(_) => match key.code {
|
||||
KeyCode::Char(c) => Some(Action::InputChar(c)),
|
||||
KeyCode::Backspace => Some(Action::Backspace),
|
||||
KeyCode::Tab => Some(Action::FocusNext),
|
||||
KeyCode::BackTab => Some(Action::FocusPrev),
|
||||
KeyCode::Enter => Some(Action::LoginSubmit),
|
||||
_ => None,
|
||||
},
|
||||
|
||||
Screen::Main(m) => match m.tab {
|
||||
Tab::Diary => match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => Some(Action::ScrollUp),
|
||||
KeyCode::Down | KeyCode::Char('j') => Some(Action::ScrollDown),
|
||||
KeyCode::Enter => Some(Action::OpenHistory),
|
||||
KeyCode::Char('d') => Some(Action::DeleteInit),
|
||||
KeyCode::Char('y') if m.diary.delete_pending.is_some() => Some(Action::DeleteConfirm),
|
||||
KeyCode::Char('n') if m.diary.delete_pending.is_some() => Some(Action::DeleteCancel),
|
||||
KeyCode::Esc => Some(Action::Escape),
|
||||
KeyCode::Char('q') => Some(Action::Quit),
|
||||
KeyCode::Tab => Some(Action::TabNext),
|
||||
KeyCode::BackTab => Some(Action::TabPrev),
|
||||
KeyCode::Char('>') | KeyCode::Char('m') => Some(Action::LoadMore),
|
||||
KeyCode::Char('1') => Some(Action::TabSelect(Tab::Diary)),
|
||||
KeyCode::Char('2') => Some(Action::TabSelect(Tab::AddReview)),
|
||||
KeyCode::Char('3') => Some(Action::TabSelect(Tab::BulkImport)),
|
||||
KeyCode::Char('4') => Some(Action::TabSelect(Tab::Settings)),
|
||||
_ => None,
|
||||
},
|
||||
|
||||
Tab::AddReview => match key.code {
|
||||
KeyCode::Char(c) => Some(Action::InputChar(c)),
|
||||
KeyCode::Backspace => Some(Action::Backspace),
|
||||
KeyCode::Tab => Some(Action::FocusNext),
|
||||
KeyCode::BackTab => Some(Action::FocusPrev),
|
||||
KeyCode::Left => Some(Action::RatingDown),
|
||||
KeyCode::Right => Some(Action::RatingUp),
|
||||
KeyCode::Enter => Some(Action::ReviewSubmit),
|
||||
KeyCode::Esc => Some(Action::Escape),
|
||||
_ => None,
|
||||
},
|
||||
|
||||
Tab::BulkImport => {
|
||||
let in_path = m.bulk_import.stage == BulkImportStage::EnterPath;
|
||||
match key.code {
|
||||
KeyCode::Char(c) if in_path => Some(Action::InputChar(c)),
|
||||
KeyCode::Backspace if in_path => Some(Action::Backspace),
|
||||
KeyCode::Enter => match m.bulk_import.stage {
|
||||
BulkImportStage::EnterPath => Some(Action::BulkParseFile),
|
||||
BulkImportStage::Preview => Some(Action::BulkImportAll),
|
||||
_ => None,
|
||||
},
|
||||
KeyCode::Esc => Some(Action::BulkCancel),
|
||||
KeyCode::Tab if !in_path => Some(Action::TabNext),
|
||||
KeyCode::BackTab if !in_path => Some(Action::TabPrev),
|
||||
KeyCode::Char('q') if !in_path => Some(Action::Quit),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
Tab::Settings => {
|
||||
let on_url = m.settings.focused == SettingsField::ApiUrl;
|
||||
match key.code {
|
||||
KeyCode::Char(c) if on_url => Some(Action::InputChar(c)),
|
||||
KeyCode::Backspace if on_url => Some(Action::Backspace),
|
||||
KeyCode::Tab => Some(Action::FocusNext),
|
||||
KeyCode::BackTab => Some(Action::FocusPrev),
|
||||
KeyCode::Enter => match m.settings.focused {
|
||||
SettingsField::Save | SettingsField::ApiUrl => Some(Action::SettingsSave),
|
||||
SettingsField::Logout => Some(Action::SettingsLogout),
|
||||
},
|
||||
KeyCode::Esc => Some(Action::Escape),
|
||||
KeyCode::Char('q') => Some(Action::Quit),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
712
crates/tui/src/ui.rs
Normal file
712
crates/tui/src/ui.rs
Normal file
@@ -0,0 +1,712 @@
|
||||
use crate::app::{
|
||||
AddReviewField, AddReviewState, App, BulkImportStage, BulkImportState, DiaryState, LoginField,
|
||||
LoginState, Screen, SettingsField, SettingsState, SetupState, StatusMsg, Tab,
|
||||
};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Alignment, Constraint, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Gauge, List, ListItem, ListState, Paragraph, Wrap},
|
||||
};
|
||||
|
||||
const APP_TITLE: &str = "Movies diary manager";
|
||||
|
||||
pub fn render(frame: &mut Frame, app: &App) {
|
||||
match &app.screen {
|
||||
Screen::Setup(s) => draw_setup(frame, frame.area(), s),
|
||||
Screen::Login(s) => draw_login(frame, frame.area(), s),
|
||||
Screen::Main(m) => {
|
||||
let rows = Layout::vertical([
|
||||
Constraint::Length(3),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(frame.area());
|
||||
|
||||
draw_tab_bar(frame, rows[0], m.tab);
|
||||
|
||||
match m.tab {
|
||||
Tab::Diary => draw_diary(frame, rows[1], &m.diary),
|
||||
Tab::AddReview => draw_add_review(frame, rows[1], &m.add_review),
|
||||
Tab::BulkImport => draw_bulk_import(frame, rows[1], &m.bulk_import),
|
||||
Tab::Settings => draw_settings(frame, rows[1], &m.settings),
|
||||
}
|
||||
|
||||
draw_status_bar(frame, rows[2], app.status.as_ref(), app.loading);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Setup ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn draw_setup(frame: &mut Frame, area: Rect, state: &SetupState) {
|
||||
let popup = centered_rect(60, 14, area);
|
||||
let block = Block::default()
|
||||
.title(format!(" {APP_TITLE} — Setup "))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Cyan));
|
||||
frame.render_widget(block, popup);
|
||||
|
||||
let inner = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(1),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.margin(1)
|
||||
.split(popup);
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new("Enter the API server URL to continue.").alignment(Alignment::Center),
|
||||
inner[1],
|
||||
);
|
||||
|
||||
let url_display = format!("{}_", state.api_url);
|
||||
let url_widget = Paragraph::new(url_display).block(
|
||||
Block::default()
|
||||
.title("API URL")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Yellow)),
|
||||
);
|
||||
frame.render_widget(url_widget, inner[2]);
|
||||
|
||||
if let Some(err) = &state.error {
|
||||
frame.render_widget(
|
||||
Paragraph::new(Span::styled(err.as_str(), Style::default().fg(Color::Red))),
|
||||
inner[3],
|
||||
);
|
||||
}
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new("Enter to save and continue").alignment(Alignment::Center),
|
||||
inner[4],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Login ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn draw_login(frame: &mut Frame, area: Rect, state: &LoginState) {
|
||||
let popup = centered_rect(60, 16, area);
|
||||
let block = Block::default()
|
||||
.title(format!(" {APP_TITLE} — Login "))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Cyan));
|
||||
frame.render_widget(block, popup);
|
||||
|
||||
let rows = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(3),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.margin(1)
|
||||
.split(popup);
|
||||
|
||||
let email_style = if state.focused == LoginField::Email {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
let pass_style = if state.focused == LoginField::Password {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
let email_display = if state.focused == LoginField::Email {
|
||||
format!("{}_", state.email)
|
||||
} else {
|
||||
state.email.clone()
|
||||
};
|
||||
let pass_display = if state.focused == LoginField::Password {
|
||||
format!("{}_", "*".repeat(state.password.len()))
|
||||
} else {
|
||||
"*".repeat(state.password.len())
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(email_display).block(
|
||||
Block::default()
|
||||
.title("Email")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(email_style),
|
||||
),
|
||||
rows[1],
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(pass_display).block(
|
||||
Block::default()
|
||||
.title("Password")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(pass_style),
|
||||
),
|
||||
rows[3],
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new("Tab: next field Enter: login").alignment(Alignment::Center),
|
||||
rows[4],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tab bar ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn draw_tab_bar(frame: &mut Frame, area: Rect, active: Tab) {
|
||||
let tabs = [
|
||||
(Tab::Diary, "1: Diary"),
|
||||
(Tab::AddReview, "2: Add Review"),
|
||||
(Tab::BulkImport, "3: Bulk Import"),
|
||||
(Tab::Settings, "4: Settings"),
|
||||
];
|
||||
|
||||
let spans: Vec<Span> = tabs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, (tab, label))| {
|
||||
let style = if *tab == active {
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
let sep = if i < tabs.len() - 1 { " │ " } else { "" };
|
||||
vec![Span::styled(format!(" {label} "), style), Span::raw(sep)]
|
||||
})
|
||||
.collect();
|
||||
|
||||
let tab_line = Paragraph::new(Line::from(spans))
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::DarkGray)),
|
||||
)
|
||||
.alignment(Alignment::Left);
|
||||
|
||||
frame.render_widget(tab_line, area);
|
||||
}
|
||||
|
||||
// ── Diary ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn draw_diary(frame: &mut Frame, area: Rect, state: &DiaryState) {
|
||||
let cols =
|
||||
Layout::horizontal([Constraint::Percentage(60), Constraint::Percentage(40)]).split(area);
|
||||
|
||||
// Left: entry list
|
||||
let items: Vec<ListItem> = state
|
||||
.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, e)| {
|
||||
let stars_str = stars(e.review.rating);
|
||||
let watched = &e.review.watched_at[..10.min(e.review.watched_at.len())];
|
||||
let title = truncate(&e.movie.title, 24);
|
||||
let line = format!("{watched} {title:<24} {stars_str}");
|
||||
let style = if i == state.selected {
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
ListItem::new(line).style(style)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let can_load_more = (state.offset as u64 + state.entries.len() as u64) < state.total;
|
||||
let list_title = if can_load_more {
|
||||
format!(" Diary ({} entries) [m: load more] ", state.total)
|
||||
} else {
|
||||
format!(" Diary ({} entries) ", state.total)
|
||||
};
|
||||
let mut list_state = ListState::default();
|
||||
list_state.select(Some(state.selected));
|
||||
let list = List::new(items).block(Block::default().title(list_title).borders(Borders::ALL));
|
||||
frame.render_stateful_widget(list, cols[0], &mut list_state);
|
||||
|
||||
// Delete confirmation overlay
|
||||
if state.delete_pending.is_some() {
|
||||
let confirm = Paragraph::new(vec![
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
"Delete this review?",
|
||||
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(" y: confirm n/Esc: cancel"),
|
||||
])
|
||||
.block(
|
||||
Block::default()
|
||||
.title(" Confirm Delete ")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Red)),
|
||||
)
|
||||
.alignment(Alignment::Center);
|
||||
let overlay = centered_rect(40, 8, cols[0]);
|
||||
frame.render_widget(ratatui::widgets::Clear, overlay);
|
||||
frame.render_widget(confirm, overlay);
|
||||
}
|
||||
|
||||
// Right: history panel
|
||||
let history_block = Block::default()
|
||||
.title(" Movie History ")
|
||||
.borders(Borders::ALL);
|
||||
match &state.history {
|
||||
None => {
|
||||
let hint = Paragraph::new(vec![
|
||||
Line::from(""),
|
||||
Line::from("Select an entry and"),
|
||||
Line::from("press Enter to view"),
|
||||
Line::from("movie history."),
|
||||
])
|
||||
.block(history_block)
|
||||
.alignment(Alignment::Center);
|
||||
frame.render_widget(hint, cols[1]);
|
||||
}
|
||||
Some(h) => {
|
||||
let mut lines = vec![
|
||||
Line::from(Span::styled(
|
||||
format!("{} ({})", h.movie.title, h.movie.release_year),
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from("─".repeat(cols[1].width.saturating_sub(2) as usize)),
|
||||
];
|
||||
for v in &h.viewings {
|
||||
let watched = &v.watched_at[..10.min(v.watched_at.len())];
|
||||
lines.push(Line::from(format!("{watched} {}", stars(v.rating))));
|
||||
if let Some(c) = &v.comment {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {}", truncate(c, 30)),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(format!("Trend: {}", h.trend)));
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines)
|
||||
.block(history_block)
|
||||
.wrap(Wrap { trim: true }),
|
||||
cols[1],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add Review ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn draw_add_review(frame: &mut Frame, area: Rect, state: &AddReviewState) {
|
||||
let block = Block::default().title(" Add Review ").borders(Borders::ALL);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
// rows[0]=ExternalId [1]=Title [2]=Year [3]=Rating [4]=WatchedAt [5]=Comment [6]=Submit [7]=hint
|
||||
let rows = Layout::vertical([
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(1),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let fs = |f: AddReviewField| {
|
||||
if state.focused == f {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default()
|
||||
}
|
||||
};
|
||||
let ft = |s: &str, f: AddReviewField| {
|
||||
if state.focused == f {
|
||||
format!("{s}_")
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(ft(&state.external_id, AddReviewField::ExternalId)).block(
|
||||
Block::default()
|
||||
.title("External ID (TMDB/OMDB)")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(fs(AddReviewField::ExternalId)),
|
||||
),
|
||||
rows[0],
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(ft(&state.title, AddReviewField::Title)).block(
|
||||
Block::default()
|
||||
.title("Title")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(fs(AddReviewField::Title)),
|
||||
),
|
||||
rows[1],
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(ft(&state.year, AddReviewField::Year)).block(
|
||||
Block::default()
|
||||
.title("Year")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(fs(AddReviewField::Year)),
|
||||
),
|
||||
rows[2],
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!(
|
||||
"{} \u{2190} \u{2192} to adjust",
|
||||
stars(state.rating)
|
||||
))
|
||||
.block(
|
||||
Block::default()
|
||||
.title("Rating (0-5)")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(fs(AddReviewField::Rating)),
|
||||
),
|
||||
rows[3],
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(ft(&state.watched_at, AddReviewField::WatchedAt)).block(
|
||||
Block::default()
|
||||
.title("Watched at (YYYY-MM-DDTHH:MM:SS)")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(fs(AddReviewField::WatchedAt)),
|
||||
),
|
||||
rows[4],
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(ft(&state.comment, AddReviewField::Comment)).block(
|
||||
Block::default()
|
||||
.title("Comment (optional)")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(fs(AddReviewField::Comment)),
|
||||
),
|
||||
rows[5],
|
||||
);
|
||||
|
||||
let submit_style = if state.focused == AddReviewField::Submit {
|
||||
Style::default()
|
||||
.fg(Color::Green)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new("[ Submit ]")
|
||||
.style(submit_style)
|
||||
.alignment(Alignment::Center),
|
||||
rows[6],
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new("Tab: next field \u{2190}\u{2192}: rating Enter: submit")
|
||||
.style(Style::default().fg(Color::DarkGray)),
|
||||
rows[7],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bulk Import ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn draw_bulk_import(frame: &mut Frame, area: Rect, state: &BulkImportState) {
|
||||
let block = Block::default()
|
||||
.title(" Bulk Import ")
|
||||
.borders(Borders::ALL);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let rows = Layout::vertical([Constraint::Length(3), Constraint::Fill(1)]).split(inner);
|
||||
|
||||
// File path field (always visible)
|
||||
let path_style = if state.stage == BulkImportStage::EnterPath {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
let path_display = if state.stage == BulkImportStage::EnterPath {
|
||||
format!("{}_", state.file_path)
|
||||
} else {
|
||||
state.file_path.clone()
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(path_display).block(
|
||||
Block::default()
|
||||
.title("File path (CSV)")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(path_style),
|
||||
),
|
||||
rows[0],
|
||||
);
|
||||
|
||||
match &state.stage {
|
||||
BulkImportStage::EnterPath => {
|
||||
frame.render_widget(
|
||||
Paragraph::new("Enter to parse the file.").alignment(Alignment::Center),
|
||||
rows[1],
|
||||
);
|
||||
}
|
||||
|
||||
BulkImportStage::Preview => {
|
||||
let valid = state.parsed.iter().filter(|r| r.result.is_ok()).count();
|
||||
let errors = state.parsed.iter().filter(|r| r.result.is_err()).count();
|
||||
let summary = format!("{valid} reviews ready, {errors} errors");
|
||||
|
||||
let mut lines: Vec<Line> = vec![
|
||||
Line::from(Span::styled(summary, Style::default().fg(Color::Green))),
|
||||
Line::from(""),
|
||||
];
|
||||
for row in &state.parsed {
|
||||
let (icon, text) = match &row.result {
|
||||
Ok(r) => (
|
||||
"\u{2713}",
|
||||
format!(
|
||||
"Row {}: {} \u{2014} rating {}",
|
||||
row.row,
|
||||
r.manual_title
|
||||
.as_deref()
|
||||
.or(r.external_metadata_id.as_deref())
|
||||
.unwrap_or("?"),
|
||||
r.rating
|
||||
),
|
||||
),
|
||||
Err(e) => ("\u{2717}", format!("Row {}: {}", row.row, e)),
|
||||
};
|
||||
let style = if row.result.is_ok() {
|
||||
Style::default()
|
||||
} else {
|
||||
Style::default().fg(Color::Red)
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!("{icon} "), style),
|
||||
Span::raw(text),
|
||||
]));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(
|
||||
"Enter: import all Esc: back",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
|
||||
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }), rows[1]);
|
||||
}
|
||||
|
||||
BulkImportStage::Importing { done } => {
|
||||
let total = state.valid_requests.len();
|
||||
let ratio = if total > 0 {
|
||||
(*done as f64 / total as f64).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let gauge_area = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(3),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.split(rows[1]);
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!("Importing... {done} / {total}"))
|
||||
.alignment(Alignment::Center),
|
||||
gauge_area[0],
|
||||
);
|
||||
frame.render_widget(
|
||||
Gauge::default()
|
||||
.gauge_style(Style::default().fg(Color::Green))
|
||||
.ratio(ratio),
|
||||
gauge_area[1],
|
||||
);
|
||||
|
||||
let results: Vec<Line> = state
|
||||
.results
|
||||
.iter()
|
||||
.enumerate()
|
||||
.take(*done)
|
||||
.map(|(i, r)| {
|
||||
let title = state
|
||||
.valid_requests
|
||||
.get(i)
|
||||
.and_then(|r| {
|
||||
r.manual_title
|
||||
.as_deref()
|
||||
.or(r.external_metadata_id.as_deref())
|
||||
})
|
||||
.unwrap_or("?");
|
||||
match r {
|
||||
None => Line::from(Span::styled(
|
||||
format!("\u{2713} {title}"),
|
||||
Style::default().fg(Color::Green),
|
||||
)),
|
||||
Some(e) => Line::from(Span::styled(
|
||||
format!("\u{2717} {title}: {e}"),
|
||||
Style::default().fg(Color::Red),
|
||||
)),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
frame.render_widget(
|
||||
Paragraph::new(results).wrap(Wrap { trim: true }),
|
||||
gauge_area[2],
|
||||
);
|
||||
}
|
||||
|
||||
BulkImportStage::Done => {
|
||||
let failed = state.results.iter().filter(|r| r.is_some()).count();
|
||||
let total = state.results.len();
|
||||
let summary = format!("Done! {} succeeded, {} failed.", total - failed, failed);
|
||||
let style = if failed > 0 {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default().fg(Color::Green)
|
||||
};
|
||||
|
||||
let mut lines = vec![Line::from(Span::styled(summary, style)), Line::from("")];
|
||||
for (i, r) in state.results.iter().enumerate() {
|
||||
if let Some(err) = r {
|
||||
let title = state
|
||||
.valid_requests
|
||||
.get(i)
|
||||
.and_then(|r| {
|
||||
r.manual_title
|
||||
.as_deref()
|
||||
.or(r.external_metadata_id.as_deref())
|
||||
})
|
||||
.unwrap_or("?");
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("\u{2717} {title}: {err}"),
|
||||
Style::default().fg(Color::Red),
|
||||
)));
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(
|
||||
"Esc: start over",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }), rows[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Settings ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fn draw_settings(frame: &mut Frame, area: Rect, state: &SettingsState) {
|
||||
let block = Block::default().title(" Settings ").borders(Borders::ALL);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let rows = Layout::vertical([
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let url_style = if state.focused == SettingsField::ApiUrl {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
let url_display = if state.focused == SettingsField::ApiUrl {
|
||||
format!("{}_", state.api_url)
|
||||
} else {
|
||||
state.api_url.clone()
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(url_display).block(
|
||||
Block::default()
|
||||
.title("API URL")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(url_style),
|
||||
),
|
||||
rows[0],
|
||||
);
|
||||
|
||||
let save_style = if state.focused == SettingsField::Save {
|
||||
Style::default()
|
||||
.fg(Color::Green)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
let logout_style = if state.focused == SettingsField::Logout {
|
||||
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
|
||||
let buttons = Line::from(vec![
|
||||
Span::styled("[ Save ]", save_style),
|
||||
Span::raw(" "),
|
||||
Span::styled("[ Logout ]", logout_style),
|
||||
]);
|
||||
frame.render_widget(
|
||||
Paragraph::new(buttons).alignment(Alignment::Center),
|
||||
rows[1],
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new("Tab: next Enter: activate").style(Style::default().fg(Color::DarkGray)),
|
||||
rows[2],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Status bar ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn draw_status_bar(frame: &mut Frame, area: Rect, status: Option<&StatusMsg>, loading: bool) {
|
||||
let (text, color) = if loading {
|
||||
("Loading...", Color::Yellow)
|
||||
} else {
|
||||
match status {
|
||||
None => ("q: quit Tab: next tab", Color::DarkGray),
|
||||
Some(s) if s.is_error => (s.text.as_str(), Color::Red),
|
||||
Some(s) => (s.text.as_str(), Color::Green),
|
||||
}
|
||||
};
|
||||
frame.render_widget(Paragraph::new(text).style(Style::default().fg(color)), area);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn stars(rating: u8) -> String {
|
||||
format!(
|
||||
"{}{}",
|
||||
"\u{2605}".repeat(rating as usize),
|
||||
"\u{2606}".repeat(5usize.saturating_sub(rating as usize))
|
||||
)
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
&s[..s
|
||||
.char_indices()
|
||||
.nth(max - 1)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(s.len())]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn centered_rect(width_pct: u16, height: u16, area: Rect) -> Rect {
|
||||
let v_chunks = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(height.min(area.height)),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let h_chunks = Layout::horizontal([
|
||||
Constraint::Percentage((100 - width_pct) / 2),
|
||||
Constraint::Percentage(width_pct),
|
||||
Constraint::Percentage((100 - width_pct) / 2),
|
||||
])
|
||||
.split(v_chunks[1]);
|
||||
|
||||
h_chunks[1]
|
||||
}
|
||||
Reference in New Issue
Block a user