This commit is contained in:
2026-04-11 16:00:05 -06:00
parent 316de93781
commit d2d2bf1477
4 changed files with 329 additions and 66 deletions

View File

@@ -85,10 +85,15 @@ pub struct LoginForm {
impl LoginForm {
pub fn new() -> Self {
Self {
url: String::from("https://docmost.nakano47.com"),
email: String::from("chamagua1@proton.me"),
url: std::env::var("DOCMOST_URL")
.unwrap_or_else(|_| "http://localhost:3000".to_string()),
email: std::env::var("DOCMOST_EMAIL").unwrap_or_default(),
password: String::new(),
active_field: LoginField::Email,
active_field: if std::env::var("DOCMOST_EMAIL").is_ok() {
LoginField::Password
} else {
LoginField::Email
},
error: None,
submitting: false,
}

169
src/ui.rs
View File

@@ -7,19 +7,28 @@ use ratatui::{
};
use tui_textarea::TextArea;
use crate::app::{EditorFocus, EditorStatus, EditorView, LoginField, LoginForm, MainView, Panel, SearchView};
use crate::app::{
EditorFocus, EditorStatus, EditorView, LoginField, LoginForm, MainView, Panel, SearchView,
};
// ─── Login screen ─────────────────────────────────────────────────────────────
pub fn draw_login(f: &mut Frame, login: &LoginForm) {
let size = f.size();
f.render_widget(Block::default().style(Style::default().bg(Color::Black)), size);
f.render_widget(
Block::default().style(Style::default().bg(Color::Black)),
size,
);
let dialog = centered_rect(50, 18, size);
f.render_widget(Clear, dialog);
let title = if login.submitting { "Logging in…" } else { "Login — Docmost" };
let title = if login.submitting {
"Logging in…"
} else {
"Login — Docmost"
};
f.render_widget(
Block::default()
.title(title)
@@ -46,9 +55,30 @@ pub fn draw_login(f: &mut Frame, login: &LoginForm) {
])
.split(inner);
render_field(f, "Server URL", &login.url, false, login.active_field == LoginField::Url, rows[0]);
render_field(f, "Email", &login.email, false, login.active_field == LoginField::Email, rows[1]);
render_field(f, "Password", &login.password, true, login.active_field == LoginField::Password, rows[2]);
render_field(
f,
"Server URL",
&login.url,
false,
login.active_field == LoginField::Url,
rows[0],
);
render_field(
f,
"Email",
&login.email,
false,
login.active_field == LoginField::Email,
rows[1],
);
render_field(
f,
"Password",
&login.password,
true,
login.active_field == LoginField::Password,
rows[2],
);
let hint = if let Some(err) = &login.error {
Paragraph::new(Line::from(Span::styled(
@@ -74,7 +104,11 @@ fn render_field(
active: bool,
area: Rect,
) {
let display = if masked { "*".repeat(value.len()) } else { value.to_string() };
let display = if masked {
"*".repeat(value.len())
} else {
value.to_string()
};
let content = format!("{display}{}", if active { "" } else { "" });
let border_style = if active {
Style::default().fg(Color::Cyan)
@@ -82,8 +116,12 @@ fn render_field(
Style::default().fg(Color::DarkGray)
};
f.render_widget(
Paragraph::new(content)
.block(Block::default().title(label).borders(Borders::ALL).border_style(border_style)),
Paragraph::new(content).block(
Block::default()
.title(label)
.borders(Borders::ALL)
.border_style(border_style),
),
area,
);
}
@@ -105,12 +143,22 @@ pub fn draw_main(f: &mut Frame, main: &MainView) {
// ── Spaces panel (top-left) ──
let spaces_focused = main.focus == Panel::Spaces;
let spaces_title = if main.loading_spaces { "Spaces (loading…)" } else { "Spaces" };
let spaces_title = if main.loading_spaces {
"Spaces (loading…)"
} else {
"Spaces"
};
let space_items: Vec<ListItem> = if main.spaces.is_empty() && !main.loading_spaces {
vec![ListItem::new(Span::styled("No spaces", Style::default().fg(Color::DarkGray)))]
vec![ListItem::new(Span::styled(
"No spaces",
Style::default().fg(Color::DarkGray),
))]
} else {
main.spaces.iter().map(|s| ListItem::new(s.name.as_str())).collect()
main.spaces
.iter()
.map(|s| ListItem::new(s.name.as_str()))
.collect()
};
f.render_stateful_widget(
@@ -121,7 +169,11 @@ pub fn draw_main(f: &mut Frame, main: &MainView) {
.borders(Borders::ALL)
.border_style(panel_border(spaces_focused)),
)
.highlight_style(Style::default().bg(Color::Blue).add_modifier(Modifier::BOLD))
.highlight_style(
Style::default()
.bg(Color::Blue)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(""),
left_layout[0],
&mut list_state(main.selected_space),
@@ -129,10 +181,17 @@ pub fn draw_main(f: &mut Frame, main: &MainView) {
// ── Pages panel (bottom-left) ──
let pages_focused = main.focus == Panel::Pages;
let pages_title = if main.loading_pages { "Pages (loading…)" } else { "Pages" };
let pages_title = if main.loading_pages {
"Pages (loading…)"
} else {
"Pages"
};
let page_items: Vec<ListItem> = if main.pages.is_empty() && !main.loading_pages {
vec![ListItem::new(Span::styled("No pages", Style::default().fg(Color::DarkGray)))]
vec![ListItem::new(Span::styled(
"No pages",
Style::default().fg(Color::DarkGray),
))]
} else {
main.pages
.iter()
@@ -148,7 +207,11 @@ pub fn draw_main(f: &mut Frame, main: &MainView) {
.borders(Borders::ALL)
.border_style(panel_border(pages_focused)),
)
.highlight_style(Style::default().bg(Color::Blue).add_modifier(Modifier::BOLD))
.highlight_style(
Style::default()
.bg(Color::Blue)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(""),
left_layout[1],
&mut list_state(main.selected_page),
@@ -160,10 +223,18 @@ pub fn draw_main(f: &mut Frame, main: &MainView) {
} else if let Some(err) = &main.error {
format!("Error: {err}")
} else {
let space_name = main.spaces.get(main.selected_space).map(|s| s.name.as_str()).unwrap_or("-");
let page_title = main.pages.get(main.selected_page).and_then(|p| p.title.as_deref()).unwrap_or("-");
let space_name = main
.spaces
.get(main.selected_space)
.map(|s| s.name.as_str())
.unwrap_or("-");
let page_title = main
.pages
.get(main.selected_page)
.and_then(|p| p.title.as_deref())
.unwrap_or("-");
format!(
"Space: {space_name}\nPage: {page_title}\n\nEnter open editor\nTab switch panel\n↑↓/j k navigate\nq/Esc quit"
"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"
)
};
@@ -202,8 +273,12 @@ pub fn draw_editor(f: &mut Frame, editor: &EditorView, textarea: &mut TextArea<'
if title_focused { "" } else { "" }
);
f.render_widget(
Paragraph::new(title_display)
.block(Block::default().title(" Title ").borders(Borders::ALL).border_style(title_border)),
Paragraph::new(title_display).block(
Block::default()
.title(" Title ")
.borders(Borders::ALL)
.border_style(title_border),
),
layout[0],
);
@@ -229,15 +304,21 @@ pub fn draw_editor(f: &mut Frame, editor: &EditorView, textarea: &mut TextArea<'
// ── 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 · Tab: Switch field · Esc: Back", Style::default().fg(Color::DarkGray)),
]),
Some(EditorStatus::Error(e)) => Line::from(vec![
Span::styled(
format!(" Error: {e} "),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
" Saved! ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::styled(
" Ctrl+S: Save · Tab: Switch field · Esc: Back",
Style::default().fg(Color::DarkGray),
),
]),
Some(EditorStatus::Error(e)) => Line::from(vec![Span::styled(
format!(" Error: {e} "),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)]),
None => Line::from(Span::styled(
" Ctrl+S: Save · Tab: Switch field · Esc: Back",
Style::default().fg(Color::DarkGray),
@@ -262,7 +343,11 @@ pub fn draw_search(f: &mut Frame, search: &SearchView) {
// ── Query input ──
let query_display = format!("{}", search.query);
let query_title = if search.loading { " Search (searching…) " } else { " Search " };
let query_title = if search.loading {
" Search (searching…) "
} else {
" Search "
};
f.render_widget(
Paragraph::new(query_display).block(
Block::default()
@@ -281,8 +366,15 @@ pub fn draw_search(f: &mut Frame, search: &SearchView) {
// Results list
let result_items: Vec<ListItem> = if search.results.is_empty() && !search.loading {
let msg = if search.query.is_empty() { "Type to search…" } else { "No results" };
vec![ListItem::new(Span::styled(msg, Style::default().fg(Color::DarkGray)))]
let msg = if search.query.is_empty() {
"Type to search…"
} else {
"No results"
};
vec![ListItem::new(Span::styled(
msg,
Style::default().fg(Color::DarkGray),
))]
} else {
search
.results
@@ -306,7 +398,11 @@ pub fn draw_search(f: &mut Frame, search: &SearchView) {
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::White)),
)
.highlight_style(Style::default().bg(Color::Blue).add_modifier(Modifier::BOLD))
.highlight_style(
Style::default()
.bg(Color::Blue)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(""),
mid[0],
&mut list_state(search.selected),
@@ -319,7 +415,11 @@ 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 space = result.space.as_ref().map(|s| s.name.as_str()).unwrap_or("-");
let space = result
.space
.as_ref()
.map(|s| s.name.as_str())
.unwrap_or("-");
let highlight = result
.highlight
.as_deref()
@@ -348,7 +448,10 @@ pub fn draw_search(f: &mut Frame, search: &SearchView) {
// ── Hint bar ──
let hint = if search.opening_page {
Line::from(Span::styled(" Opening…", Style::default().fg(Color::Yellow)))
Line::from(Span::styled(
" Opening…",
Style::default().fg(Color::Yellow),
))
} else {
Line::from(Span::styled(
" ↑↓: navigate · Enter: open · Esc: back",