From b9e939b5d7aa16307d2d7658ef3a2ed4bd5d9a19 Mon Sep 17 00:00:00 2001 From: Nino Date: Sat, 11 Apr 2026 16:53:02 -0600 Subject: [PATCH] delete pages/spaces --- src/api.rs | 128 +++++++++++++++++++++++++++++++++++++++++++++ src/app.rs | 54 +++++++++++++++++++ src/main.rs | 136 +++++++++++++++++++++++++++++++++++++++++++++++- src/ui.rs | 146 +++++++++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 455 insertions(+), 9 deletions(-) diff --git a/src/api.rs b/src/api.rs index 5248fac..2132197 100644 --- a/src/api.rs +++ b/src/api.rs @@ -6,6 +6,14 @@ use tracing::info; use crate::app::{ApiList, LoginRequest, Page, SearchResult, SidebarPagesRequest, Space}; +/// Convert a display name into a slug accepted by Docmost: lowercase, letters and digits only. +pub fn slugify(name: &str) -> String { + name.to_lowercase() + .chars() + .filter(|c| c.is_alphanumeric()) + .collect() +} + pub fn auth_client() -> Result { Client::builder() .timeout(Duration::from_secs(10)) @@ -315,3 +323,123 @@ pub async fn create_page( Ok((id, returned_title)) } + +#[derive(Serialize)] +struct CreateSpaceRequest<'a> { + name: &'a str, + slug: String, +} + +pub async fn create_space( + base_url: &str, + token: &str, + name: &str, +) -> Result { + let client = auth_client()?; + let slug = slugify(name); + + info!("create_space: name={name:?} slug={slug:?}"); + + let resp = client + .post(format!("{base_url}/spaces/create")) + .header("Cookie", format!("authToken={token}")) + .json(&CreateSpaceRequest { name, slug }) + .send() + .await + .map_err(|e| format!("Connection error: {e}"))?; + + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + + info!("create_space: 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 space error {status}: {body}")); + } + + let v: serde_json::Value = + serde_json::from_str(&body).map_err(|e| format!("Parse error: {e}"))?; + + let space_val = v.get("data").unwrap_or(&v); + let space: Space = + serde_json::from_value(space_val.clone()).map_err(|e| format!("Parse space error: {e}"))?; + + Ok(space) +} + +#[derive(Serialize)] +struct DeletePageRequest<'a> { + #[serde(rename = "pageId")] + page_id: &'a str, + #[serde(rename = "permanentlyDelete")] + permanently_delete: bool, +} + +pub async fn delete_page(base_url: &str, token: &str, page_id: &str) -> Result<(), String> { + let client = auth_client()?; + + info!("delete_page: page_id={page_id}"); + + let resp = client + .post(format!("{base_url}/pages/delete")) + .header("Cookie", format!("authToken={token}")) + .json(&DeletePageRequest { page_id, permanently_delete: false }) + .send() + .await + .map_err(|e| format!("Connection error: {e}"))?; + + let status = resp.status(); + info!("delete_page: status={status}"); + + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + 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!("Delete page error {status}")); + } + + Ok(()) +} + +#[derive(Serialize)] +struct DeleteSpaceRequest<'a> { + #[serde(rename = "spaceId")] + space_id: &'a str, +} + +pub async fn delete_space(base_url: &str, token: &str, space_id: &str) -> Result<(), String> { + let client = auth_client()?; + + info!("delete_space: space_id={space_id}"); + + let resp = client + .post(format!("{base_url}/spaces/delete")) + .header("Cookie", format!("authToken={token}")) + .json(&DeleteSpaceRequest { space_id }) + .send() + .await + .map_err(|e| format!("Connection error: {e}"))?; + + let status = resp.status(); + info!("delete_space: status={status}"); + + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + 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!("Delete space error {status}")); + } + + Ok(()) +} diff --git a/src/app.rs b/src/app.rs index 95ac870..5534e3d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -128,6 +128,51 @@ impl NewPageDialog { } } +pub struct NewSpaceDialog { + pub name: String, + pub creating: bool, + pub error: Option, +} + +impl NewSpaceDialog { + pub fn new() -> Self { + Self { name: String::new(), creating: false, error: None } + } +} + +#[derive(Debug, Clone)] +pub enum DeleteTarget { + Space { id: String, name: String }, + Page { id: String, title: String }, +} + +impl DeleteTarget { + pub fn display_name(&self) -> &str { + match self { + DeleteTarget::Space { name, .. } => name, + DeleteTarget::Page { title, .. } => title, + } + } + pub fn kind(&self) -> &str { + match self { + DeleteTarget::Space { .. } => "space", + DeleteTarget::Page { .. } => "page", + } + } +} + +pub struct DeleteConfirmDialog { + pub target: DeleteTarget, + pub deleting: bool, + pub error: Option, +} + +impl DeleteConfirmDialog { + pub fn new(target: DeleteTarget) -> Self { + Self { target, deleting: false, error: None } + } +} + pub struct MainView { pub spaces: Vec, pub pages: Vec, @@ -139,6 +184,8 @@ pub struct MainView { pub loading_page_content: bool, pub error: Option, pub new_page_dialog: Option, + pub new_space_dialog: Option, + pub delete_dialog: Option, } impl MainView { @@ -154,6 +201,8 @@ impl MainView { loading_page_content: false, error: None, new_page_dialog: None, + new_space_dialog: None, + delete_dialog: None, } } } @@ -267,6 +316,11 @@ pub enum AppMsg { SaveError(String), PageCreated { page_id: String, title: String }, CreateError(String), + SpaceCreated(Space), + CreateSpaceError(String), + SpaceDeleted(String), + PageDeleted(String), + DeleteError(String), SearchResults(Vec), SearchError(String), ApiError(String), diff --git a/src/main.rs b/src/main.rs index ff68702..5a63368 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::{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 api::{create_page, create_space, delete_page, delete_space, do_login, fetch_page_content, fetch_pages, fetch_spaces, save_page, search_pages}; +use app::{App, AppMsg, AppState, DeleteConfirmDialog, DeleteTarget, EditorFocus, EditorStatus, EditorView, LoginField, NewPageDialog, NewSpaceDialog, Panel, SearchView}; use ui::{draw_editor, draw_login, draw_main, draw_search}; // ─── Entry point ────────────────────────────────────────────────────────────── @@ -168,6 +168,48 @@ async fn run_app(terminal: &mut Terminal>) -> io::R d.error = Some(e); } } + AppMsg::SpaceDeleted(space_id) => { + info!("space deleted: {space_id}"); + app.main.delete_dialog = None; + app.main.spaces.retain(|s| s.id != space_id); + app.main.selected_space = 0; + app.main.pages = vec![]; + if let Some(space) = app.main.spaces.first() { + app.main.loading_pages = true; + spawn_fetch_pages_msg(&app.base_url, &app.token, &space.id, &tx); + } + } + AppMsg::PageDeleted(page_id) => { + info!("page deleted: {page_id}"); + app.main.delete_dialog = None; + app.main.pages.retain(|p| p.id != page_id); + if app.main.selected_page >= app.main.pages.len() { + app.main.selected_page = app.main.pages.len().saturating_sub(1); + } + } + AppMsg::DeleteError(e) => { + info!("delete error: {e}"); + if let Some(d) = app.main.delete_dialog.as_mut() { + d.deleting = false; + d.error = Some(e); + } + } + AppMsg::SpaceCreated(space) => { + info!("space created: id={} name={:?}", space.id, space.name); + app.main.new_space_dialog = None; + // Add to list and select it + app.main.spaces.push(space); + app.main.selected_space = app.main.spaces.len() - 1; + app.main.pages = vec![]; + app.main.loading_pages = false; + } + AppMsg::CreateSpaceError(e) => { + info!("create space error: {e}"); + if let Some(d) = app.main.new_space_dialog.as_mut() { + d.creating = false; + d.error = Some(e); + } + } AppMsg::SearchResults(results) => { info!("search results: {} items", results.len()); app.search.loading = false; @@ -280,6 +322,75 @@ async fn run_app(terminal: &mut Terminal>) -> io::R continue; } + // ── Delete confirm dialog intercepts all keys when open ── + if let Some(dialog) = app.main.delete_dialog.as_mut() { + if dialog.deleting { continue; } + match key.code { + KeyCode::Esc => { app.main.delete_dialog = None; } + KeyCode::Char('d') => { + dialog.deleting = true; + dialog.error = None; + let base_url = app.base_url.clone(); + let token = app.token.clone(); + let target = dialog.target.clone(); + let tx2 = tx.clone(); + tokio::spawn(async move { + let msg = match &target { + DeleteTarget::Space { id, .. } => { + match delete_space(&base_url, &token, id).await { + Ok(()) => AppMsg::SpaceDeleted(id.clone()), + Err(e) => AppMsg::DeleteError(e), + } + } + DeleteTarget::Page { id, .. } => { + match delete_page(&base_url, &token, id).await { + Ok(()) => AppMsg::PageDeleted(id.clone()), + Err(e) => AppMsg::DeleteError(e), + } + } + }; + let _ = tx2.send(msg); + }); + } + _ => {} + } + continue; + } + + // ── New-space dialog intercepts all keys when open ── + if let Some(dialog) = app.main.new_space_dialog.as_mut() { + if dialog.creating { continue; } + match key.code { + KeyCode::Esc => { app.main.new_space_dialog = None; } + KeyCode::Backspace => { dialog.name.pop(); } + KeyCode::Enter => { + if dialog.name.trim().is_empty() { + dialog.error = Some("Name cannot be empty.".into()); + } else { + dialog.creating = true; + dialog.error = None; + let base_url = app.base_url.clone(); + let token = app.token.clone(); + let name = dialog.name.trim().to_string(); + let tx2 = tx.clone(); + tokio::spawn(async move { + let msg = match create_space(&base_url, &token, &name).await { + Ok(space) => AppMsg::SpaceCreated(space), + Err(e) => AppMsg::CreateSpaceError(e), + }; + let _ = tx2.send(msg); + }); + } + } + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + dialog.error = None; + dialog.name.push(c); + } + _ => {} + } + continue; + } + // ── New-page dialog intercepts all keys when open ── if let Some(dialog) = app.main.new_page_dialog.as_mut() { if dialog.creating { continue; } @@ -355,6 +466,27 @@ async fn run_app(terminal: &mut Terminal>) -> io::R } } }, + // d → delete focused item + KeyCode::Char('d') => { + let target = match app.main.focus { + Panel::Spaces => app.main.spaces + .get(app.main.selected_space) + .map(|s| DeleteTarget::Space { id: s.id.clone(), name: s.name.clone() }), + Panel::Pages => app.main.pages + .get(app.main.selected_page) + .map(|p| DeleteTarget::Page { + id: p.id.clone(), + title: p.title.clone().unwrap_or_else(|| "Untitled".into()), + }), + }; + if let Some(target) = target { + app.main.delete_dialog = Some(DeleteConfirmDialog::new(target)); + } + } + // Ctrl+N → new space + KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.main.new_space_dialog = Some(NewSpaceDialog::new()); + } // n → new page in current space KeyCode::Char('n') => { if !app.main.spaces.is_empty() { diff --git a/src/ui.rs b/src/ui.rs index e6e6453..f9253cb 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -8,8 +8,8 @@ use ratatui::{ use tui_textarea::TextArea; use crate::app::{ - EditorFocus, EditorStatus, EditorView, LoginField, LoginForm, MainView, NewPageDialog, Panel, - SearchView, + DeleteConfirmDialog, EditorFocus, EditorStatus, EditorView, LoginField, LoginForm, MainView, + NewPageDialog, NewSpaceDialog, Panel, SearchView, }; // ─── Login screen ───────────────────────────────────────────────────────────── @@ -235,7 +235,7 @@ pub fn draw_main(f: &mut Frame, main: &MainView) { .and_then(|p| p.title.as_deref()) .unwrap_or("-"); format!( - "Space: {space_name}\nPage: {page_title}\n\n_______________________\nEnter open editor\nTab switch panel\n↑↓/j k navigate\nq/Esc quit\n//Ctrl+F search" + "Space: {space_name}\nPage: {page_title}\n\n_______________________\nEnter open editor\nTab switch panel\n↑↓/j k navigate\nq/Esc quit\n//Ctrl+F search\n n +new page\n Ctrl+n New Space" ) }; @@ -244,9 +244,13 @@ pub fn draw_main(f: &mut Frame, main: &MainView) { layout[1], ); - // ── New-page dialog (overlay) ── - if let Some(dialog) = &main.new_page_dialog { + // ── Dialogs (overlays, only one shown at a time) ── + if let Some(dialog) = &main.delete_dialog { + draw_delete_confirm_dialog(f, dialog, size); + } else if let Some(dialog) = &main.new_page_dialog { draw_new_page_dialog(f, dialog, size); + } else if let Some(dialog) = &main.new_space_dialog { + draw_new_space_dialog(f, dialog, size); } } @@ -254,7 +258,11 @@ 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 " }; + let title = if dialog.creating { + " Creating… " + } else { + " New Page " + }; f.render_widget( Block::default() .title(title) @@ -272,7 +280,11 @@ fn draw_new_page_dialog(f: &mut Frame, dialog: &NewPageDialog, size: Rect) { let rows = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Length(1), Constraint::Length(2)]) + .constraints([ + Constraint::Length(3), + Constraint::Length(1), + Constraint::Length(2), + ]) .split(inner); // Title input @@ -385,6 +397,126 @@ pub fn draw_editor(f: &mut Frame, editor: &EditorView, textarea: &mut TextArea<' f.render_widget(Paragraph::new(status_line), layout[2]); } +fn draw_delete_confirm_dialog(f: &mut Frame, dialog: &DeleteConfirmDialog, size: Rect) { + let area = centered_rect(52, 9, size); + f.render_widget(Clear, area); + + let kind = dialog.target.kind(); + let name = dialog.target.display_name(); + + let title = if dialog.deleting { + format!(" Deleting {kind}… ") + } else { + format!(" Delete {kind} ") + }; + + f.render_widget( + Block::default() + .title(title) + .borders(Borders::ALL) + .style(Style::default().fg(Color::Red)), + 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(2), Constraint::Length(1), Constraint::Length(2)]) + .split(inner); + + // Warning message + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::raw(" Move "), + Span::styled(name, Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format!(" to trash?")), + ])), + rows[0], + ); + + // Error or confirmation 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(Line::from(vec![ + Span::styled(" d", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), + Span::styled(": confirm ", Style::default().fg(Color::DarkGray)), + Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)), + Span::styled(": cancel", Style::default().fg(Color::DarkGray)), + ])) + }; + f.render_widget(hint, rows[2]); +} + +fn draw_new_space_dialog(f: &mut Frame, dialog: &NewSpaceDialog, size: Rect) { + let area = centered_rect(50, 9, size); + f.render_widget(Clear, area); + + let title = if dialog.creating { + " Creating space… " + } else { + " New Space " + }; + 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); + + // Name input + auto-slug preview + let slug_preview = crate::api::slugify(&dialog.name); + let input_content = format!("{}▌", dialog.name); + f.render_widget( + Paragraph::new(input_content).block( + Block::default() + .title(format!(" Name (slug: {slug_preview}) ")) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ), + rows[0], + ); + + 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]); +} + // ─── Search screen ─────────────────────────────────────────────────────────── pub fn draw_search(f: &mut Frame, search: &SearchView) {