Skip to main content

max / makenotwork

5.6 KB · 184 lines History Blame Raw
1 //! Blog post management screen — list, create, delete posts.
2
3 use makeover_tui::makeover_layout::{Column, Priority, Width};
4 use makeover_tui::table::{self, Cell, Sizing, TableStyle};
5 use ratatui::Frame;
6 use ratatui::layout::{Constraint, Layout, Rect};
7 use ratatui::style::{Modifier, Style};
8 use ratatui::text::{Line, Span};
9 use ratatui::widgets::{Block, Borders, Paragraph, TableState};
10
11 use super::App;
12
13 /// The post list's columns. Status carries the scheduled time as well as
14 /// published-or-draft, which is why it is the widest fixed track and why it
15 /// outranks the slug and the date.
16 const POST_COLUMNS: [Column<'static>; 4] = [
17 Column {
18 name: "Title",
19 width: Width::Fill,
20 priority: Priority::Essential,
21 sortable: false,
22 sorted: None,
23 },
24 Column {
25 name: "Slug",
26 width: Width::Fixed,
27 priority: Priority::Optional,
28 sortable: false,
29 sorted: None,
30 },
31 Column {
32 name: "Status",
33 width: Width::Fixed,
34 priority: Priority::Secondary,
35 sortable: false,
36 sorted: None,
37 },
38 Column {
39 name: "Created",
40 width: Width::Fixed,
41 priority: Priority::Optional,
42 sortable: false,
43 sorted: None,
44 },
45 ];
46
47 /// The tracks the hand-written `Constraint`s carried.
48 const POST_SIZING: Sizing<'static> = Sizing {
49 lengths: &[("Title", 20), ("Slug", 20), ("Status", 22), ("Created", 12)],
50 fallback: 12,
51 };
52
53 pub(crate) fn render(frame: &mut Frame, app: &App) {
54 let area = frame.area();
55
56 let project_title = app.blog_project_title.as_deref().unwrap_or("Blog");
57
58 let title = Line::from(vec![
59 Span::styled(
60 " Makenot.work ",
61 Style::default().add_modifier(Modifier::BOLD),
62 ),
63 Span::raw(" -- "),
64 Span::styled(project_title, Style::default().add_modifier(Modifier::BOLD)),
65 Span::raw(" -- Blog "),
66 ]);
67
68 let block = Block::default()
69 .title(title)
70 .borders(Borders::ALL)
71 .border_style(Style::default().fg(app.theme.line_border));
72
73 let inner = block.inner(area);
74 frame.render_widget(block, area);
75
76 let chunks = Layout::vertical([
77 Constraint::Length(1), // spacer
78 Constraint::Length(1), // section header
79 Constraint::Min(3), // post list
80 Constraint::Length(1), // status line
81 Constraint::Length(1), // keybindings
82 ])
83 .split(inner);
84
85 // Section header
86 let count = app.blog_posts.len();
87 let header = Paragraph::new(Line::from(vec![
88 Span::raw(" "),
89 Span::styled("Posts", Style::default().add_modifier(Modifier::BOLD)),
90 if count == 0 {
91 Span::raw("")
92 } else {
93 Span::raw(format!(" ({count})"))
94 },
95 ]));
96 frame.render_widget(header, chunks[1]);
97
98 // Post list
99 if app.loading {
100 let loading = Paragraph::new(" Loading...");
101 frame.render_widget(loading, chunks[2]);
102 } else if app.blog_posts.is_empty() {
103 let empty = Paragraph::new(" No blog posts. Press [n] to create one.");
104 frame.render_widget(empty, chunks[2]);
105 } else {
106 render_post_table(frame, app, chunks[2]);
107 }
108
109 // Status line
110 if let Some(ref status) = app.blog_status {
111 let style = if status.starts_with("Error") {
112 Style::default().fg(app.theme.status_danger)
113 } else {
114 Style::default().fg(app.theme.status_success)
115 };
116 let status_line = Paragraph::new(Line::from(vec![
117 Span::raw(" "),
118 Span::styled(status.as_str(), style),
119 ]));
120 frame.render_widget(status_line, chunks[3]);
121 }
122
123 // Keybindings
124 let mut key_spans = vec![
125 Span::raw(" "),
126 Span::styled("[j/k]", Style::default().add_modifier(Modifier::BOLD)),
127 Span::raw(" Nav "),
128 Span::styled("[n]", Style::default().add_modifier(Modifier::BOLD)),
129 Span::raw(" New "),
130 ];
131
132 if !app.blog_posts.is_empty() {
133 key_spans.extend([
134 Span::styled("[d]", Style::default().add_modifier(Modifier::BOLD)),
135 Span::raw(" Delete "),
136 ]);
137 }
138
139 key_spans.extend([
140 Span::styled("[r]", Style::default().add_modifier(Modifier::BOLD)),
141 Span::raw(" Refresh "),
142 Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)),
143 Span::raw(" Back"),
144 ]);
145
146 let keys =
147 Paragraph::new(Line::from(key_spans)).style(Style::default().fg(app.theme.content_muted));
148 frame.render_widget(keys, chunks[4]);
149 }
150
151 fn render_post_table(frame: &mut Frame, app: &App, area: Rect) {
152 let area = super::indent(area, 2);
153
154 let rows: Vec<Vec<Cell>> = app
155 .blog_posts
156 .iter()
157 .map(|post| {
158 let status = if post.is_published {
159 "published".to_string()
160 } else if let Some(ref pa) = post.publish_at {
161 let scheduled = pa.get(..16).unwrap_or(pa);
162 format!("sched {scheduled}")
163 } else {
164 "draft".to_string()
165 };
166
167 vec![
168 Cell::new("Title", post.title.as_str()),
169 Cell::new("Slug", post.slug.as_str()),
170 Cell::new("Status", status),
171 Cell::new(
172 "Created",
173 post.created_at.get(..10).unwrap_or(&post.created_at),
174 ),
175 ]
176 })
177 .collect();
178
179 let style = TableStyle::from_theme(&app.theme);
180 let table = table::table(&POST_COLUMNS, &rows, &POST_SIZING, &style, area.width);
181 let mut state = TableState::default().with_selected(Some(app.selected_index));
182 frame.render_stateful_widget(table, area, &mut state);
183 }
184