export feature
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -123,7 +123,10 @@ impl ApiClient {
|
||||
let resp = self
|
||||
.http
|
||||
.post(format!("{}/api/auth/login", self.url()))
|
||||
.json(&LoginRequest { email: email.into(), password: password.into() })
|
||||
.json(&LoginRequest {
|
||||
email: email.into(),
|
||||
password: password.into(),
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
Ok(check_status(resp).await?.json().await?)
|
||||
@@ -159,11 +162,7 @@ impl ApiClient {
|
||||
Ok(check_status(resp).await?.json().await?)
|
||||
}
|
||||
|
||||
pub async fn create_review(
|
||||
&self,
|
||||
token: &str,
|
||||
req: &LogReviewRequest,
|
||||
) -> Result<(), ApiError> {
|
||||
pub async fn create_review(&self, token: &str, req: &LogReviewRequest) -> Result<(), ApiError> {
|
||||
let resp = self
|
||||
.http
|
||||
.post(format!("{}/api/reviews", self.url()))
|
||||
|
||||
@@ -83,7 +83,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn config_roundtrip() {
|
||||
let config = Config { api_url: "http://localhost:3000".into() };
|
||||
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");
|
||||
|
||||
@@ -4,9 +4,7 @@ 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::app::{self, Action, App, BulkImportStage, Command, Screen, SettingsField, Tab};
|
||||
use tui::client::ApiClient;
|
||||
use tui::config::Config;
|
||||
|
||||
@@ -29,9 +27,14 @@ async fn run() -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
let initial_url = config.as_ref().map(|c| c.api_url.as_str()).unwrap_or("http://localhost:3000");
|
||||
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 = tokio::task::spawn_blocking(Config::load_token).await.unwrap_or(None);
|
||||
let saved_token = tokio::task::spawn_blocking(Config::load_token)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
let mut app = App::new(config, saved_token.clone());
|
||||
|
||||
let (tx, mut rx) = mpsc::channel::<Action>(64);
|
||||
@@ -45,7 +48,10 @@ async fn run() -> anyhow::Result<()> {
|
||||
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 },
|
||||
Ok(r) => Action::DiaryLoaded {
|
||||
entries: r.items,
|
||||
total: r.total_count,
|
||||
},
|
||||
Err(e) => Action::DiaryLoadFailed(e.to_string()),
|
||||
};
|
||||
let _ = tx2.send(action).await;
|
||||
@@ -84,7 +90,8 @@ async fn run() -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
Ok::<(), anyhow::Error>(())
|
||||
}.await;
|
||||
}
|
||||
.await;
|
||||
|
||||
ratatui::restore();
|
||||
result
|
||||
@@ -95,11 +102,15 @@ async fn run() -> anyhow::Result<()> {
|
||||
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() };
|
||||
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; });
|
||||
tokio::spawn(async move {
|
||||
let _ = tx2.send(Action::DiaryLoadFailed(msg)).await;
|
||||
});
|
||||
}
|
||||
client.update_url(&url);
|
||||
}
|
||||
@@ -136,12 +147,17 @@ fn handle_command(cmd: Command, app: &App, client: &Arc<ApiClient>, tx: &mpsc::S
|
||||
}
|
||||
|
||||
Command::LoadDiary { offset } => {
|
||||
let Some(token) = app.token.clone() else { return };
|
||||
let Some(token) = app.token.clone() else {
|
||||
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 },
|
||||
Ok(r) => Action::DiaryLoaded {
|
||||
entries: r.items,
|
||||
total: r.total_count,
|
||||
},
|
||||
Err(e) => Action::DiaryLoadFailed(e.to_string()),
|
||||
};
|
||||
let _ = tx.send(action).await;
|
||||
@@ -149,7 +165,9 @@ fn handle_command(cmd: Command, app: &App, client: &Arc<ApiClient>, tx: &mpsc::S
|
||||
}
|
||||
|
||||
Command::LoadHistory { movie_id } => {
|
||||
let Some(token) = app.token.clone() else { return };
|
||||
let Some(token) = app.token.clone() else {
|
||||
return;
|
||||
};
|
||||
let c = client.clone();
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
@@ -162,7 +180,9 @@ fn handle_command(cmd: Command, app: &App, client: &Arc<ApiClient>, tx: &mpsc::S
|
||||
}
|
||||
|
||||
Command::CreateReview(req) => {
|
||||
let Some(token) = app.token.clone() else { return };
|
||||
let Some(token) = app.token.clone() else {
|
||||
return;
|
||||
};
|
||||
let c = client.clone();
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
@@ -175,7 +195,9 @@ fn handle_command(cmd: Command, app: &App, client: &Arc<ApiClient>, tx: &mpsc::S
|
||||
}
|
||||
|
||||
Command::DeleteReview(id) => {
|
||||
let Some(token) = app.token.clone() else { return };
|
||||
let Some(token) = app.token.clone() else {
|
||||
return;
|
||||
};
|
||||
let c = client.clone();
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
@@ -188,7 +210,9 @@ fn handle_command(cmd: Command, app: &App, client: &Arc<ApiClient>, tx: &mpsc::S
|
||||
}
|
||||
|
||||
Command::ImportNext(index) => {
|
||||
let Some(token) = app.token.clone() else { return };
|
||||
let Some(token) = app.token.clone() else {
|
||||
return;
|
||||
};
|
||||
let req = match &app.screen {
|
||||
Screen::Main(m) => match m.bulk_import.valid_requests.get(index) {
|
||||
Some(r) => r.clone(),
|
||||
@@ -199,7 +223,11 @@ fn handle_command(cmd: Command, app: &App, client: &Arc<ApiClient>, tx: &mpsc::S
|
||||
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 error = c
|
||||
.create_review(&token, &req)
|
||||
.await
|
||||
.err()
|
||||
.map(|e| e.to_string());
|
||||
let _ = tx.send(Action::BulkItemDone { index, error }).await;
|
||||
});
|
||||
}
|
||||
@@ -248,8 +276,12 @@ fn key_to_action(app: &App, key: ratatui::crossterm::event::KeyEvent) -> Option<
|
||||
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::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),
|
||||
|
||||
@@ -99,8 +99,20 @@ fn draw_login(frame: &mut Frame, area: Rect, state: &LoginState) {
|
||||
.split(popup);
|
||||
|
||||
let pass_masked = "*".repeat(state.password.len());
|
||||
render_input(frame, rows[1], "Email", &state.email, state.focused == LoginField::Email);
|
||||
render_input(frame, rows[3], "Password", &pass_masked, state.focused == LoginField::Password);
|
||||
render_input(
|
||||
frame,
|
||||
rows[1],
|
||||
"Email",
|
||||
&state.email,
|
||||
state.focused == LoginField::Email,
|
||||
);
|
||||
render_input(
|
||||
frame,
|
||||
rows[3],
|
||||
"Password",
|
||||
&pass_masked,
|
||||
state.focused == LoginField::Password,
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new("Tab: next field Enter: login").alignment(Alignment::Center),
|
||||
rows[4],
|
||||
@@ -175,9 +187,16 @@ fn draw_diary(frame: &mut Frame, area: Rect, state: &DiaryState) {
|
||||
let can_load_prev = state.offset > 0;
|
||||
let page = state.offset / 20 + 1;
|
||||
let total_pages = state.total.div_ceil(20).max(1);
|
||||
let mut title = format!(" Diary ({} entries, page {}/{}) ", state.total, page, total_pages);
|
||||
if can_load_prev { title.push_str("[b: prev] "); }
|
||||
if can_load_more { title.push_str("[m: next] "); }
|
||||
let mut title = format!(
|
||||
" Diary ({} entries, page {}/{}) ",
|
||||
state.total, page, total_pages
|
||||
);
|
||||
if can_load_prev {
|
||||
title.push_str("[b: prev] ");
|
||||
}
|
||||
if can_load_more {
|
||||
title.push_str("[m: next] ");
|
||||
}
|
||||
let mut list_state = ListState::default();
|
||||
list_state.select(Some(state.selected));
|
||||
let list = List::new(items).block(Block::default().title(title).borders(Borders::ALL));
|
||||
@@ -273,23 +292,61 @@ fn draw_add_review(frame: &mut Frame, area: Rect, state: &AddReviewState) {
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
render_input(frame, rows[0], "External ID (TMDB/OMDB)", &state.external_id, state.focused == AddReviewField::ExternalId);
|
||||
render_input(frame, rows[1], "Title", &state.title, state.focused == AddReviewField::Title);
|
||||
render_input(frame, rows[2], "Year", &state.year, state.focused == AddReviewField::Year);
|
||||
render_input(
|
||||
frame,
|
||||
rows[0],
|
||||
"External ID (TMDB/OMDB)",
|
||||
&state.external_id,
|
||||
state.focused == AddReviewField::ExternalId,
|
||||
);
|
||||
render_input(
|
||||
frame,
|
||||
rows[1],
|
||||
"Title",
|
||||
&state.title,
|
||||
state.focused == AddReviewField::Title,
|
||||
);
|
||||
render_input(
|
||||
frame,
|
||||
rows[2],
|
||||
"Year",
|
||||
&state.year,
|
||||
state.focused == AddReviewField::Year,
|
||||
);
|
||||
|
||||
let rating_active = state.focused == AddReviewField::Rating;
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!("{} \u{2190} \u{2192} to adjust", stars(state.rating))).block(
|
||||
Paragraph::new(format!(
|
||||
"{} \u{2190} \u{2192} to adjust",
|
||||
stars(state.rating)
|
||||
))
|
||||
.block(
|
||||
Block::default()
|
||||
.title("Rating (0-5)")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(if rating_active { Style::default().fg(Color::Yellow) } else { Style::default() }),
|
||||
.border_style(if rating_active {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default()
|
||||
}),
|
||||
),
|
||||
rows[3],
|
||||
);
|
||||
|
||||
render_input(frame, rows[4], "Watched at (YYYY-MM-DDTHH:MM:SS)", &state.watched_at, state.focused == AddReviewField::WatchedAt);
|
||||
render_input(frame, rows[5], "Comment (optional)", &state.comment, state.focused == AddReviewField::Comment);
|
||||
render_input(
|
||||
frame,
|
||||
rows[4],
|
||||
"Watched at (YYYY-MM-DDTHH:MM:SS)",
|
||||
&state.watched_at,
|
||||
state.focused == AddReviewField::WatchedAt,
|
||||
);
|
||||
render_input(
|
||||
frame,
|
||||
rows[5],
|
||||
"Comment (optional)",
|
||||
&state.comment,
|
||||
state.focused == AddReviewField::Comment,
|
||||
);
|
||||
|
||||
let submit_style = if state.focused == AddReviewField::Submit {
|
||||
Style::default()
|
||||
@@ -507,7 +564,13 @@ fn draw_settings(frame: &mut Frame, area: Rect, state: &SettingsState) {
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
render_input(frame, rows[0], "API URL", &state.api_url, state.focused == SettingsField::ApiUrl);
|
||||
render_input(
|
||||
frame,
|
||||
rows[0],
|
||||
"API URL",
|
||||
&state.api_url,
|
||||
state.focused == SettingsField::ApiUrl,
|
||||
);
|
||||
|
||||
let save_style = if state.focused == SettingsField::Save {
|
||||
Style::default()
|
||||
@@ -555,11 +618,22 @@ fn draw_status_bar(frame: &mut Frame, area: Rect, status: Option<&StatusMsg>, lo
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn render_input(frame: &mut Frame, area: Rect, title: &str, value: &str, active: bool) {
|
||||
let text = if active { format!("{value}_") } else { value.to_string() };
|
||||
let border_style = if active { Style::default().fg(Color::Yellow) } else { Style::default() };
|
||||
let text = if active {
|
||||
format!("{value}_")
|
||||
} else {
|
||||
value.to_string()
|
||||
};
|
||||
let border_style = if active {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(text).block(
|
||||
Block::default().title(title).borders(Borders::ALL).border_style(border_style),
|
||||
Block::default()
|
||||
.title(title)
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style),
|
||||
),
|
||||
area,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user