From 869a27c220fb6e897b128ebea4c71bb768f3d356 Mon Sep 17 00:00:00 2001 From: Nino Date: Sat, 11 Apr 2026 16:14:59 -0600 Subject: [PATCH] create new page --- src/api.rs | 61 ++++++++++++++++++++++++++++++++++++++++++++ src/app.rs | 16 ++++++++++++ src/main.rs | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++-- src/ui.rs | 60 ++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 207 insertions(+), 3 deletions(-) diff --git a/src/api.rs b/src/api.rs index e2c83d7..5248fac 100644 --- a/src/api.rs +++ b/src/api.rs @@ -254,3 +254,64 @@ pub async fn search_pages( info!("search_pages: {} results", results.len()); Ok(results) } + +#[derive(Serialize)] +struct CreatePageRequest<'a> { + #[serde(rename = "spaceId")] + space_id: &'a str, + title: &'a str, +} + +/// Returns (page_id, title) of the newly created page. +pub async fn create_page( + base_url: &str, + token: &str, + space_id: &str, + title: &str, +) -> Result<(String, String), String> { + let client = auth_client()?; + + info!("create_page: space_id={space_id} title={title:?}"); + + let resp = client + .post(format!("{base_url}/pages/create")) + .header("Cookie", format!("authToken={token}")) + .json(&CreatePageRequest { space_id, title }) + .send() + .await + .map_err(|e| format!("Connection error: {e}"))?; + + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + + info!("create_page: status={status} body={body}"); + + if !status.is_success() { + if let Ok(v) = serde_json::from_str::(&body) { + if let Some(msg) = v.get("message").and_then(|m| m.as_str()) { + return Err(msg.to_string()); + } + } + return Err(format!("Create error {status}: {body}")); + } + + let v: serde_json::Value = + serde_json::from_str(&body).map_err(|e| format!("Parse error: {e}"))?; + + // Response may be wrapped under "data" + let page = v.get("data").unwrap_or(&v); + + let id = page + .get("id") + .and_then(|i| i.as_str()) + .ok_or_else(|| format!("No id in response: {body}"))? + .to_string(); + + let returned_title = page + .get("title") + .and_then(|t| t.as_str()) + .unwrap_or(title) + .to_string(); + + Ok((id, returned_title)) +} diff --git a/src/app.rs b/src/app.rs index e52c322..95ac870 100644 --- a/src/app.rs +++ b/src/app.rs @@ -116,6 +116,18 @@ pub enum Panel { Pages, } +pub struct NewPageDialog { + pub title: String, + pub creating: bool, + pub error: Option, +} + +impl NewPageDialog { + pub fn new() -> Self { + Self { title: String::new(), creating: false, error: None } + } +} + pub struct MainView { pub spaces: Vec, pub pages: Vec, @@ -126,6 +138,7 @@ pub struct MainView { pub loading_pages: bool, pub loading_page_content: bool, pub error: Option, + pub new_page_dialog: Option, } impl MainView { @@ -140,6 +153,7 @@ impl MainView { loading_pages: false, loading_page_content: false, error: None, + new_page_dialog: None, } } } @@ -251,6 +265,8 @@ pub enum AppMsg { PageContentLoaded { page_id: String, title: String, content: String }, PageSaved, SaveError(String), + PageCreated { page_id: String, title: String }, + CreateError(String), SearchResults(Vec), SearchError(String), ApiError(String), diff --git a/src/main.rs b/src/main.rs index c33a851..ff68702 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,8 +16,8 @@ use tui_textarea::TextArea; use tracing::info; use tracing_subscriber::fmt; -use api::{do_login, fetch_page_content, fetch_pages, fetch_spaces, save_page, search_pages}; -use app::{App, AppMsg, AppState, EditorFocus, EditorStatus, EditorView, LoginField, Panel, SearchView}; +use api::{create_page, do_login, fetch_page_content, fetch_pages, fetch_spaces, save_page, search_pages}; +use app::{App, AppMsg, AppState, EditorFocus, EditorStatus, EditorView, LoginField, NewPageDialog, Panel, SearchView}; use ui::{draw_editor, draw_login, draw_main, draw_search}; // ─── Entry point ────────────────────────────────────────────────────────────── @@ -144,6 +144,30 @@ async fn run_app(terminal: &mut Terminal>) -> io::R app.editor.saving = false; app.editor.status = Some(EditorStatus::Error(e)); } + AppMsg::PageCreated { page_id, title } => { + info!("page created: id={page_id} title={title:?}"); + app.main.new_page_dialog = None; + // Refresh pages list then open the new page in the editor + spawn_fetch_pages(&app, &tx); + app.main.loading_page_content = true; + let base_url = app.base_url.clone(); + let token = app.token.clone(); + let tx2 = tx.clone(); + tokio::spawn(async move { + let msg = match fetch_page_content(&base_url, &token, &page_id).await { + Ok(content) => AppMsg::PageContentLoaded { page_id, title, content }, + Err(e) => AppMsg::ApiError(e), + }; + let _ = tx2.send(msg); + }); + } + AppMsg::CreateError(e) => { + info!("create error: {e}"); + if let Some(d) = app.main.new_page_dialog.as_mut() { + d.creating = false; + d.error = Some(e); + } + } AppMsg::SearchResults(results) => { info!("search results: {} items", results.len()); app.search.loading = false; @@ -255,6 +279,45 @@ async fn run_app(terminal: &mut Terminal>) -> io::R if app.main.loading_page_content { continue; } + + // ── New-page dialog intercepts all keys when open ── + if let Some(dialog) = app.main.new_page_dialog.as_mut() { + if dialog.creating { continue; } + match key.code { + KeyCode::Esc => { app.main.new_page_dialog = None; } + KeyCode::Backspace => { dialog.title.pop(); } + KeyCode::Enter => { + if dialog.title.trim().is_empty() { + dialog.error = Some("Title cannot be empty.".into()); + } else { + dialog.creating = true; + dialog.error = None; + let base_url = app.base_url.clone(); + let token = app.token.clone(); + let title = dialog.title.trim().to_string(); + let space_id = app.main.spaces + .get(app.main.selected_space) + .map(|s| s.id.clone()) + .unwrap_or_default(); + let tx2 = tx.clone(); + tokio::spawn(async move { + let msg = match create_page(&base_url, &token, &space_id, &title).await { + Ok((page_id, title)) => AppMsg::PageCreated { page_id, title }, + Err(e) => AppMsg::CreateError(e), + }; + let _ = tx2.send(msg); + }); + } + } + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + dialog.error = None; + dialog.title.push(c); + } + _ => {} + } + continue; + } + match key.code { KeyCode::Char('q') | KeyCode::Esc => return Ok(()), KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -292,6 +355,12 @@ async fn run_app(terminal: &mut Terminal>) -> io::R } } }, + // n → new page in current space + KeyCode::Char('n') => { + if !app.main.spaces.is_empty() { + app.main.new_page_dialog = Some(NewPageDialog::new()); + } + } // / or Ctrl+F → open search KeyCode::Char('/') | KeyCode::Char('f') if key.code == KeyCode::Char('/') diff --git a/src/ui.rs b/src/ui.rs index 9f6b22c..e6e6453 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -8,7 +8,8 @@ use ratatui::{ use tui_textarea::TextArea; use crate::app::{ - EditorFocus, EditorStatus, EditorView, LoginField, LoginForm, MainView, Panel, SearchView, + EditorFocus, EditorStatus, EditorView, LoginField, LoginForm, MainView, NewPageDialog, Panel, + SearchView, }; // ─── Login screen ───────────────────────────────────────────────────────────── @@ -242,6 +243,63 @@ pub fn draw_main(f: &mut Frame, main: &MainView) { Paragraph::new(right_content).block(Block::default().title("Info").borders(Borders::ALL)), layout[1], ); + + // ── New-page dialog (overlay) ── + if let Some(dialog) = &main.new_page_dialog { + draw_new_page_dialog(f, dialog, size); + } +} + +fn draw_new_page_dialog(f: &mut Frame, dialog: &NewPageDialog, size: Rect) { + let area = centered_rect(50, 9, size); + f.render_widget(Clear, area); + + let title = if dialog.creating { " Creating… " } else { " New Page " }; + f.render_widget( + Block::default() + .title(title) + .borders(Borders::ALL) + .style(Style::default().fg(Color::Cyan)), + area, + ); + + let inner = Rect { + x: area.x + 2, + y: area.y + 1, + width: area.width.saturating_sub(4), + height: area.height.saturating_sub(2), + }; + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Length(1), Constraint::Length(2)]) + .split(inner); + + // Title input + let input_content = format!("{}▌", dialog.title); + f.render_widget( + Paragraph::new(input_content).block( + Block::default() + .title(" Title ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ), + rows[0], + ); + + // Error or hint + let hint = if let Some(err) = &dialog.error { + Paragraph::new(Span::styled( + format!(" {err}"), + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + )) + } else { + Paragraph::new(Span::styled( + " Enter: create · Esc: cancel", + Style::default().fg(Color::DarkGray), + )) + }; + f.render_widget(hint, rows[2]); } // ─── Editor screen ────────────────────────────────────────────────────────────