title editor

This commit is contained in:
2026-04-11 15:12:26 -06:00
parent 5dbba44e10
commit 2725f73c7d
5 changed files with 196 additions and 26 deletions

View File

@@ -160,6 +160,7 @@ pub async fn fetch_page_content(
struct PageUpdateRequest<'a> {
#[serde(rename = "pageId")]
page_id: &'a str,
title: &'a str,
content: &'a str,
operation: &'a str,
format: &'a str,
@@ -169,15 +170,19 @@ pub async fn save_page(
base_url: &str,
token: &str,
page_id: &str,
title: &str,
content: &str,
) -> Result<(), String> {
let client = auth_client()?;
info!("save_page: page_id={page_id} title={title:?} content_len={}", content.len());
let resp = client
.post(format!("{base_url}/pages/update"))
.header("Cookie", format!("authToken={token}"))
.json(&PageUpdateRequest {
page_id,
title,
content,
operation: "replace",
format: "markdown",

View File

@@ -140,9 +140,16 @@ impl MainView {
// ─── Editor view ─────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum EditorFocus {
Title,
Content,
}
pub struct EditorView {
pub page_id: String,
pub page_title: String,
pub focus: EditorFocus,
pub saving: bool,
pub status: Option<EditorStatus>,
}
@@ -158,6 +165,7 @@ impl EditorView {
Self {
page_id,
page_title,
focus: EditorFocus::Content,
saving: false,
status: None,
}

View File

@@ -17,7 +17,7 @@ use tracing::info;
use tracing_subscriber::fmt;
use api::{do_login, fetch_page_content, fetch_pages, fetch_spaces, save_page};
use app::{App, AppMsg, AppState, EditorStatus, EditorView, LoginField, Panel};
use app::{App, AppMsg, AppState, EditorFocus, EditorStatus, EditorView, LoginField, Panel};
use ui::{draw_editor, draw_login, draw_main};
// ─── Entry point ──────────────────────────────────────────────────────────────
@@ -129,6 +129,14 @@ async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::R
info!("page saved ok");
app.editor.saving = false;
app.editor.status = Some(EditorStatus::Saved);
// Update the title in the pages list immediately
let saved_id = app.editor.page_id.clone();
let saved_title = app.editor.page_title.clone();
if let Some(page) = app.main.pages.iter_mut().find(|p| p.id == saved_id) {
page.title = Some(saved_title);
}
// Refresh pages from server in the background
spawn_fetch_pages(&app, &tx);
}
AppMsg::SaveError(e) => {
info!("save error: {e}");
@@ -274,7 +282,7 @@ async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::R
// ── Editor keys ───────────────────────────────────────────
AppState::Editor => {
// Clear status on any keypress
// Clear "Saved!" status on any keypress
if matches!(app.editor.status, Some(EditorStatus::Saved)) {
app.editor.status = None;
}
@@ -285,7 +293,7 @@ async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::R
app.state = AppState::Main;
editor_textarea = None;
}
// Ctrl+S → save
// Ctrl+S → save both title and content
(KeyCode::Char('s'), m) if m.contains(KeyModifiers::CONTROL) => {
if !app.editor.saving {
app.editor.saving = true;
@@ -293,13 +301,14 @@ async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::R
let base_url = app.base_url.clone();
let token = app.token.clone();
let page_id = app.editor.page_id.clone();
let title = app.editor.page_title.clone();
let content = editor_textarea
.as_ref()
.map(|ta| ta.lines().join("\n"))
.unwrap_or_default();
let tx2 = tx.clone();
tokio::spawn(async move {
let msg = match save_page(&base_url, &token, &page_id, &content).await {
let msg = match save_page(&base_url, &token, &page_id, &title, &content).await {
Ok(()) => AppMsg::PageSaved,
Err(e) => AppMsg::SaveError(e),
};
@@ -307,10 +316,29 @@ async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::R
});
}
}
// All other keys → delegate to textarea
// Tab → toggle focus between title and content
(KeyCode::Tab, _) => {
app.editor.focus = match app.editor.focus {
EditorFocus::Title => EditorFocus::Content,
EditorFocus::Content => EditorFocus::Title,
};
}
// Title field keys
(KeyCode::Backspace, _) if app.editor.focus == EditorFocus::Title => {
app.editor.page_title.pop();
}
(KeyCode::Char(c), m)
if app.editor.focus == EditorFocus::Title
&& !m.contains(KeyModifiers::CONTROL) =>
{
app.editor.page_title.push(c);
}
// All other keys → delegate to content textarea
_ => {
if let Some(ta) = editor_textarea.as_mut() {
ta.input(key);
if app.editor.focus == EditorFocus::Content {
if let Some(ta) = editor_textarea.as_mut() {
ta.input(key);
}
}
}
}

View File

@@ -7,7 +7,7 @@ use ratatui::{
};
use tui_textarea::TextArea;
use crate::app::{EditorStatus, EditorView, LoginField, LoginForm, MainView, Panel};
use crate::app::{EditorFocus, EditorStatus, EditorView, LoginField, LoginForm, MainView, Panel};
// ─── Login screen ─────────────────────────────────────────────────────────────
@@ -180,38 +180,57 @@ pub fn draw_editor(f: &mut Frame, editor: &EditorView, textarea: &mut TextArea<'
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(3), Constraint::Length(1)])
.constraints([
Constraint::Length(3), // title input
Constraint::Min(1), // content textarea
Constraint::Length(1), // status bar
])
.split(size);
// Set textarea block with title
let border_style = if editor.saving {
// ── Title field ──
let title_focused = editor.focus == EditorFocus::Title;
let title_border = if editor.saving {
Style::default().fg(Color::Yellow)
} else {
} else if title_focused {
Style::default().fg(Color::Cyan)
};
let title = if editor.saving {
format!(" Saving: {} ", editor.page_title)
} else {
format!(" Edit: {} ", editor.page_title)
Style::default().fg(Color::DarkGray)
};
let title_display = format!(
"{}{}",
editor.page_title,
if title_focused { "" } else { "" }
);
f.render_widget(
Paragraph::new(title_display)
.block(Block::default().title(" Title ").borders(Borders::ALL).border_style(title_border)),
layout[0],
);
// ── Content textarea ──
let content_focused = editor.focus == EditorFocus::Content;
let content_border = if editor.saving {
Style::default().fg(Color::Yellow)
} else if content_focused {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
};
textarea.set_block(
Block::default()
.title(title)
.title(" Content ")
.borders(Borders::ALL)
.border_style(border_style),
.border_style(content_border),
);
textarea.set_line_number_style(Style::default().fg(Color::DarkGray));
textarea.set_cursor_line_style(Style::default().add_modifier(Modifier::UNDERLINED));
f.render_widget(textarea.widget(), layout[1]);
f.render_widget(textarea.widget(), layout[0]);
// Status bar
// ── Status bar ──
let status_line = match &editor.status {
Some(EditorStatus::Saved) => Line::from(vec![
Span::styled(" Saved! ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
Span::styled(" Ctrl+S: Save · Esc: Back", Style::default().fg(Color::DarkGray)),
Span::styled(" Ctrl+S: Save · Tab: Switch field · Esc: Back", Style::default().fg(Color::DarkGray)),
]),
Some(EditorStatus::Error(e)) => Line::from(vec![
Span::styled(
@@ -220,12 +239,11 @@ pub fn draw_editor(f: &mut Frame, editor: &EditorView, textarea: &mut TextArea<'
),
]),
None => Line::from(Span::styled(
" Ctrl+S: Save · Esc: Back to list",
" Ctrl+S: Save · Tab: Switch field · Esc: Back",
Style::default().fg(Color::DarkGray),
)),
};
f.render_widget(Paragraph::new(status_line), layout[1]);
f.render_widget(Paragraph::new(status_line), layout[2]);
}
// ─── Helpers ──────────────────────────────────────────────────────────────────