create new page
This commit is contained in:
61
src/api.rs
61
src/api.rs
@@ -254,3 +254,64 @@ pub async fn search_pages(
|
|||||||
info!("search_pages: {} results", results.len());
|
info!("search_pages: {} results", results.len());
|
||||||
Ok(results)
|
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::<serde_json::Value>(&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))
|
||||||
|
}
|
||||||
|
|||||||
16
src/app.rs
16
src/app.rs
@@ -116,6 +116,18 @@ pub enum Panel {
|
|||||||
Pages,
|
Pages,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct NewPageDialog {
|
||||||
|
pub title: String,
|
||||||
|
pub creating: bool,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NewPageDialog {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { title: String::new(), creating: false, error: None }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct MainView {
|
pub struct MainView {
|
||||||
pub spaces: Vec<Space>,
|
pub spaces: Vec<Space>,
|
||||||
pub pages: Vec<Page>,
|
pub pages: Vec<Page>,
|
||||||
@@ -126,6 +138,7 @@ pub struct MainView {
|
|||||||
pub loading_pages: bool,
|
pub loading_pages: bool,
|
||||||
pub loading_page_content: bool,
|
pub loading_page_content: bool,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
|
pub new_page_dialog: Option<NewPageDialog>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MainView {
|
impl MainView {
|
||||||
@@ -140,6 +153,7 @@ impl MainView {
|
|||||||
loading_pages: false,
|
loading_pages: false,
|
||||||
loading_page_content: false,
|
loading_page_content: false,
|
||||||
error: None,
|
error: None,
|
||||||
|
new_page_dialog: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,6 +265,8 @@ pub enum AppMsg {
|
|||||||
PageContentLoaded { page_id: String, title: String, content: String },
|
PageContentLoaded { page_id: String, title: String, content: String },
|
||||||
PageSaved,
|
PageSaved,
|
||||||
SaveError(String),
|
SaveError(String),
|
||||||
|
PageCreated { page_id: String, title: String },
|
||||||
|
CreateError(String),
|
||||||
SearchResults(Vec<SearchResult>),
|
SearchResults(Vec<SearchResult>),
|
||||||
SearchError(String),
|
SearchError(String),
|
||||||
ApiError(String),
|
ApiError(String),
|
||||||
|
|||||||
73
src/main.rs
73
src/main.rs
@@ -16,8 +16,8 @@ use tui_textarea::TextArea;
|
|||||||
use tracing::info;
|
use tracing::info;
|
||||||
use tracing_subscriber::fmt;
|
use tracing_subscriber::fmt;
|
||||||
|
|
||||||
use api::{do_login, fetch_page_content, fetch_pages, fetch_spaces, save_page, search_pages};
|
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, Panel, SearchView};
|
use app::{App, AppMsg, AppState, EditorFocus, EditorStatus, EditorView, LoginField, NewPageDialog, Panel, SearchView};
|
||||||
use ui::{draw_editor, draw_login, draw_main, draw_search};
|
use ui::{draw_editor, draw_login, draw_main, draw_search};
|
||||||
|
|
||||||
// ─── Entry point ──────────────────────────────────────────────────────────────
|
// ─── Entry point ──────────────────────────────────────────────────────────────
|
||||||
@@ -144,6 +144,30 @@ async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::R
|
|||||||
app.editor.saving = false;
|
app.editor.saving = false;
|
||||||
app.editor.status = Some(EditorStatus::Error(e));
|
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) => {
|
AppMsg::SearchResults(results) => {
|
||||||
info!("search results: {} items", results.len());
|
info!("search results: {} items", results.len());
|
||||||
app.search.loading = false;
|
app.search.loading = false;
|
||||||
@@ -255,6 +279,45 @@ async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::R
|
|||||||
if app.main.loading_page_content {
|
if app.main.loading_page_content {
|
||||||
continue;
|
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 {
|
match key.code {
|
||||||
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
|
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
|
||||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
@@ -292,6 +355,12 @@ async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> 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
|
// / or Ctrl+F → open search
|
||||||
KeyCode::Char('/') | KeyCode::Char('f')
|
KeyCode::Char('/') | KeyCode::Char('f')
|
||||||
if key.code == KeyCode::Char('/')
|
if key.code == KeyCode::Char('/')
|
||||||
|
|||||||
60
src/ui.rs
60
src/ui.rs
@@ -8,7 +8,8 @@ use ratatui::{
|
|||||||
use tui_textarea::TextArea;
|
use tui_textarea::TextArea;
|
||||||
|
|
||||||
use crate::app::{
|
use crate::app::{
|
||||||
EditorFocus, EditorStatus, EditorView, LoginField, LoginForm, MainView, Panel, SearchView,
|
EditorFocus, EditorStatus, EditorView, LoginField, LoginForm, MainView, NewPageDialog, Panel,
|
||||||
|
SearchView,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Login screen ─────────────────────────────────────────────────────────────
|
// ─── 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)),
|
Paragraph::new(right_content).block(Block::default().title("Info").borders(Borders::ALL)),
|
||||||
layout[1],
|
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 ────────────────────────────────────────────────────────────
|
// ─── Editor screen ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user