tui - client app.

This commit is contained in:
2026-05-07 00:14:47 +02:00
parent 7a66661932
commit e1f2442e77
9 changed files with 3571 additions and 57 deletions

22
crates/tui/Cargo.toml Normal file
View 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

File diff suppressed because it is too large Load Diff

231
crates/tui/src/client.rs Normal file
View 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
View 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
View 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
View 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
View 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]
}