emojis pages
This commit is contained in:
@@ -6,12 +6,14 @@ use serde::{Deserialize, Serialize};
|
||||
pub struct Space {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Page {
|
||||
pub id: String,
|
||||
pub title: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -252,6 +254,7 @@ pub struct SearchResultSpace {
|
||||
pub struct SearchResult {
|
||||
pub id: String,
|
||||
pub title: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub highlight: Option<String>,
|
||||
pub space: Option<SearchResultSpace>,
|
||||
}
|
||||
|
||||
157
src/main.rs
157
src/main.rs
@@ -2,8 +2,12 @@ mod api;
|
||||
mod app;
|
||||
mod ui;
|
||||
|
||||
use std::{io, sync::mpsc, time::{Duration, Instant}};
|
||||
use std::fs::OpenOptions;
|
||||
use std::{
|
||||
io,
|
||||
sync::mpsc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crossterm::{
|
||||
event::{self, Event, KeyCode, KeyModifiers},
|
||||
@@ -16,8 +20,14 @@ use tui_textarea::TextArea;
|
||||
use tracing::info;
|
||||
use tracing_subscriber::fmt;
|
||||
|
||||
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 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 ──────────────────────────────────────────────────────────────
|
||||
@@ -96,7 +106,10 @@ async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::R
|
||||
app.main.selected_space = 0;
|
||||
app.main.pages = vec![];
|
||||
if let Some(space) = app.main.spaces.first() {
|
||||
info!("auto-loading pages for space '{}' ({})", space.name, space.id);
|
||||
info!(
|
||||
"auto-loading pages for space '{}' ({})",
|
||||
space.name, space.id
|
||||
);
|
||||
app.main.loading_pages = true;
|
||||
spawn_fetch_pages_msg(&app.base_url, &app.token, &space.id, &tx);
|
||||
}
|
||||
@@ -107,7 +120,11 @@ async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::R
|
||||
app.main.pages = pages;
|
||||
app.main.selected_page = 0;
|
||||
}
|
||||
AppMsg::PageContentLoaded { page_id, title, content } => {
|
||||
AppMsg::PageContentLoaded {
|
||||
page_id,
|
||||
title,
|
||||
content,
|
||||
} => {
|
||||
info!(
|
||||
"page content loaded: id={page_id} title={title:?} content_len={}",
|
||||
content.len()
|
||||
@@ -155,7 +172,11 @@ async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::R
|
||||
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 },
|
||||
Ok(content) => AppMsg::PageContentLoaded {
|
||||
page_id,
|
||||
title,
|
||||
content,
|
||||
},
|
||||
Err(e) => AppMsg::ApiError(e),
|
||||
};
|
||||
let _ = tx2.send(msg);
|
||||
@@ -267,7 +288,7 @@ async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::R
|
||||
AppState::Search => draw_search(f, &app.search),
|
||||
})?;
|
||||
|
||||
if !event::poll(Duration::from_millis(50))? {
|
||||
if !event::poll(Duration::from_millis(200))? {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -296,7 +317,10 @@ async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::R
|
||||
let tx2 = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let msg = match do_login(&url, &email, &password).await {
|
||||
Ok(token) => AppMsg::LoginSuccess { token, base_url: url },
|
||||
Ok(token) => AppMsg::LoginSuccess {
|
||||
token,
|
||||
base_url: url,
|
||||
},
|
||||
Err(e) => AppMsg::LoginError(e),
|
||||
};
|
||||
let _ = tx2.send(msg);
|
||||
@@ -324,9 +348,13 @@ async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::R
|
||||
|
||||
// ── Delete confirm dialog intercepts all keys when open ──
|
||||
if let Some(dialog) = app.main.delete_dialog.as_mut() {
|
||||
if dialog.deleting { continue; }
|
||||
if dialog.deleting {
|
||||
continue;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Esc => { app.main.delete_dialog = None; }
|
||||
KeyCode::Esc => {
|
||||
app.main.delete_dialog = None;
|
||||
}
|
||||
KeyCode::Char('d') => {
|
||||
dialog.deleting = true;
|
||||
dialog.error = None;
|
||||
@@ -359,10 +387,16 @@ async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::R
|
||||
|
||||
// ── New-space dialog intercepts all keys when open ──
|
||||
if let Some(dialog) = app.main.new_space_dialog.as_mut() {
|
||||
if dialog.creating { continue; }
|
||||
if dialog.creating {
|
||||
continue;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Esc => { app.main.new_space_dialog = None; }
|
||||
KeyCode::Backspace => { dialog.name.pop(); }
|
||||
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());
|
||||
@@ -374,7 +408,8 @@ async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::R
|
||||
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 {
|
||||
let msg = match create_space(&base_url, &token, &name).await
|
||||
{
|
||||
Ok(space) => AppMsg::SpaceCreated(space),
|
||||
Err(e) => AppMsg::CreateSpaceError(e),
|
||||
};
|
||||
@@ -393,10 +428,16 @@ async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::R
|
||||
|
||||
// ── New-page dialog intercepts all keys when open ──
|
||||
if let Some(dialog) = app.main.new_page_dialog.as_mut() {
|
||||
if dialog.creating { continue; }
|
||||
if dialog.creating {
|
||||
continue;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Esc => { app.main.new_page_dialog = None; }
|
||||
KeyCode::Backspace => { dialog.title.pop(); }
|
||||
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());
|
||||
@@ -406,16 +447,23 @@ 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 title = dialog.title.trim().to_string();
|
||||
let space_id = app.main.spaces
|
||||
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 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);
|
||||
});
|
||||
}
|
||||
@@ -469,15 +517,25 @@ async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> 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()),
|
||||
}),
|
||||
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));
|
||||
@@ -511,13 +569,21 @@ 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 = page.id.clone();
|
||||
let title = page.title.clone().unwrap_or_else(|| "Untitled".into());
|
||||
let title =
|
||||
page.title.clone().unwrap_or_else(|| "Untitled".into());
|
||||
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 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);
|
||||
});
|
||||
}
|
||||
@@ -562,11 +628,18 @@ 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 = result.id.clone();
|
||||
let title = result.title.clone().unwrap_or_else(|| "Untitled".into());
|
||||
let title =
|
||||
result.title.clone().unwrap_or_else(|| "Untitled".into());
|
||||
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 },
|
||||
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);
|
||||
@@ -610,7 +683,11 @@ async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::R
|
||||
.unwrap_or_default();
|
||||
let tx2 = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let msg = match save_page(&base_url, &token, &page_id, &title, &content).await {
|
||||
let msg = match save_page(
|
||||
&base_url, &token, &page_id, &title, &content,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => AppMsg::PageSaved,
|
||||
Err(e) => AppMsg::SaveError(e),
|
||||
};
|
||||
|
||||
27
src/ui.rs
27
src/ui.rs
@@ -158,7 +158,13 @@ pub fn draw_main(f: &mut Frame, main: &MainView) {
|
||||
} else {
|
||||
main.spaces
|
||||
.iter()
|
||||
.map(|s| ListItem::new(s.name.as_str()))
|
||||
.map(|s| {
|
||||
let label = match &s.icon {
|
||||
Some(icon) if !icon.is_empty() => format!("{} {}", icon, s.name),
|
||||
_ => s.name.clone(),
|
||||
};
|
||||
ListItem::new(label)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
@@ -196,7 +202,14 @@ pub fn draw_main(f: &mut Frame, main: &MainView) {
|
||||
} else {
|
||||
main.pages
|
||||
.iter()
|
||||
.map(|p| ListItem::new(p.title.as_deref().unwrap_or("Untitled")))
|
||||
.map(|p| {
|
||||
let title = p.title.as_deref().unwrap_or("Untitled");
|
||||
let label = match &p.icon {
|
||||
Some(icon) if !icon.is_empty() => format!("{} {}", icon, title),
|
||||
_ => title.to_string(),
|
||||
};
|
||||
ListItem::new(label)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
@@ -605,9 +618,13 @@ pub fn draw_search(f: &mut Frame, search: &SearchView) {
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let title = r.title.as_deref().unwrap_or("Untitled");
|
||||
let label = match &r.icon {
|
||||
Some(icon) if !icon.is_empty() => format!("{} {}", icon, title),
|
||||
_ => title.to_string(),
|
||||
};
|
||||
let space = r.space.as_ref().map(|s| s.name.as_str()).unwrap_or("");
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::raw(title),
|
||||
Span::raw(label),
|
||||
Span::styled(format!(" [{space}]"), Style::default().fg(Color::DarkGray)),
|
||||
]))
|
||||
})
|
||||
@@ -639,6 +656,10 @@ pub fn draw_search(f: &mut Frame, search: &SearchView) {
|
||||
format!("Error: {err}")
|
||||
} else if let Some(result) = search.results.get(search.selected) {
|
||||
let title = result.title.as_deref().unwrap_or("Untitled");
|
||||
let title = match &result.icon {
|
||||
Some(icon) if !icon.is_empty() => format!("{} {}", icon, title),
|
||||
_ => title.to_string(),
|
||||
};
|
||||
let space = result
|
||||
.space
|
||||
.as_ref()
|
||||
|
||||
Reference in New Issue
Block a user