Skip to main content

max / makenotwork

7.4 KB · 236 lines History Blame Raw
1 //! Home screen — project list with stats overview.
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 crate::format;
12
13 use super::App;
14
15 /// The project list's columns.
16 ///
17 /// Title is the only one without which a row stops saying which project it is,
18 /// so it is the only [`Priority::Essential`] one. Revenue and status are what
19 /// this screen is opened for, which puts them above the type and the count when
20 /// the window narrows.
21 const PROJECT_COLUMNS: [Column<'static>; 5] = [
22 Column {
23 name: "Title",
24 width: Width::Fill,
25 priority: Priority::Essential,
26 sortable: false,
27 sorted: None,
28 },
29 Column {
30 name: "Type",
31 width: Width::Fixed,
32 priority: Priority::Optional,
33 sortable: false,
34 sorted: None,
35 },
36 Column {
37 name: "Status",
38 width: Width::Fixed,
39 priority: Priority::Secondary,
40 sortable: false,
41 sorted: None,
42 },
43 Column {
44 name: "Items",
45 width: Width::Fixed,
46 priority: Priority::Optional,
47 sortable: false,
48 sorted: None,
49 },
50 Column {
51 name: "Revenue",
52 width: Width::Fixed,
53 priority: Priority::Secondary,
54 sortable: false,
55 sorted: None,
56 },
57 ];
58
59 /// The tracks the hand-written `Constraint`s carried, lifted rather than
60 /// re-chosen. Revenue is wider than the amount alone needs: a three-character
61 /// symbol (`CA$`) plus the `+N` multi-currency marker has to fit without
62 /// truncating.
63 const PROJECT_SIZING: Sizing<'static> = Sizing {
64 lengths: &[
65 ("Title", 20),
66 ("Type", 12),
67 ("Status", 8),
68 ("Items", 7),
69 ("Revenue", 15),
70 ],
71 fallback: 8,
72 };
73
74 pub(crate) fn render(frame: &mut Frame, app: &App) {
75 let area = frame.area();
76
77 let tier_label = app
78 .user
79 .creator_tier
80 .as_deref()
81 .map_or("No tier", format::format_tier);
82
83 let title = Line::from(vec![
84 Span::styled(
85 " Makenot.work ",
86 Style::default().add_modifier(Modifier::BOLD),
87 ),
88 Span::raw(" ── "),
89 Span::styled(
90 &app.user.username,
91 Style::default().add_modifier(Modifier::BOLD),
92 ),
93 Span::raw(" ── "),
94 Span::raw(tier_label),
95 Span::raw(" "),
96 ]);
97
98 let block = Block::default()
99 .title(title)
100 .borders(Borders::ALL)
101 .border_style(Style::default().fg(app.theme.line_border));
102
103 let inner = block.inner(area);
104 frame.render_widget(block, area);
105
106 let chunks = Layout::vertical([
107 Constraint::Length(1), // spacer
108 Constraint::Length(3), // stats bar
109 Constraint::Length(1), // spacer
110 Constraint::Length(1), // section header
111 Constraint::Min(3), // project list
112 Constraint::Length(1), // keybindings
113 ])
114 .split(inner);
115
116 // Stats bar
117 render_stats(frame, app, chunks[1]);
118
119 // Section header
120 let header = Paragraph::new(Line::from(vec![
121 Span::raw(" "),
122 Span::styled("Projects", Style::default().add_modifier(Modifier::BOLD)),
123 if app.projects.is_empty() {
124 Span::raw("")
125 } else {
126 Span::raw(format!(" ({})", app.projects.len()))
127 },
128 ]));
129 frame.render_widget(header, chunks[3]);
130
131 // Project list
132 if app.loading {
133 let loading = Paragraph::new(" Loading...");
134 frame.render_widget(loading, chunks[4]);
135 } else if app.projects.is_empty() {
136 let empty = Paragraph::new(" No projects yet. Create one at makenot.work/dashboard");
137 frame.render_widget(empty, chunks[4]);
138 } else {
139 render_project_table(frame, app, chunks[4]);
140 }
141
142 // Keybindings
143 let keys = Paragraph::new(Line::from(vec![
144 Span::raw(" "),
145 Span::styled("[j/k]", Style::default().add_modifier(Modifier::BOLD)),
146 Span::raw(" Nav "),
147 Span::styled("[Enter]", Style::default().add_modifier(Modifier::BOLD)),
148 Span::raw(" Open "),
149 Span::styled("[u]", Style::default().add_modifier(Modifier::BOLD)),
150 Span::raw(" Upload "),
151 Span::styled("[a]", Style::default().add_modifier(Modifier::BOLD)),
152 Span::raw(" Analytics "),
153 Span::styled("[p]", Style::default().add_modifier(Modifier::BOLD)),
154 Span::raw(" Promo "),
155 Span::styled("[c]", Style::default().add_modifier(Modifier::BOLD)),
156 Span::raw(" Collections "),
157 Span::styled("[s]", Style::default().add_modifier(Modifier::BOLD)),
158 Span::raw(" Settings "),
159 Span::styled("[q]", Style::default().add_modifier(Modifier::BOLD)),
160 Span::raw(" Quit"),
161 ]))
162 .style(Style::default().fg(app.theme.content_muted));
163 frame.render_widget(keys, chunks[5]);
164 }
165
166 fn render_stats(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
167 let stats_chunks = Layout::horizontal([
168 Constraint::Ratio(1, 4),
169 Constraint::Ratio(1, 4),
170 Constraint::Ratio(1, 4),
171 Constraint::Ratio(1, 4),
172 ])
173 .split(area);
174
175 let (revenue, sales, followers, items) = if let Some(ref s) = app.stats {
176 (
177 format::format_cents(s.current_revenue_cents, app.currency()),
178 s.current_sales.to_string(),
179 s.current_followers.to_string(),
180 s.total_items.to_string(),
181 )
182 } else {
183 ("--".into(), "--".into(), "--".into(), "--".into())
184 };
185
186 let stat_items = [
187 ("Revenue", &revenue),
188 ("Sales", &sales),
189 ("Followers", &followers),
190 ("Items", &items),
191 ];
192
193 for (i, (label, value)) in stat_items.iter().enumerate() {
194 let block = Block::default()
195 .borders(Borders::ALL)
196 .border_style(Style::default().fg(app.theme.content_muted));
197 let inner = block.inner(stats_chunks[i]);
198 frame.render_widget(block, stats_chunks[i]);
199
200 let text = Paragraph::new(Line::from(vec![
201 Span::styled(
202 format!(" {value}"),
203 Style::default().add_modifier(Modifier::BOLD),
204 ),
205 Span::styled(
206 format!(" {label}"),
207 Style::default().fg(app.theme.content_muted),
208 ),
209 ]));
210 frame.render_widget(text, inner);
211 }
212 }
213
214 fn render_project_table(frame: &mut Frame, app: &App, area: Rect) {
215 let area = super::indent(area, 2);
216
217 let rows: Vec<Vec<Cell>> = app
218 .projects
219 .iter()
220 .map(|p| {
221 vec![
222 Cell::new("Title", p.title.as_str()),
223 Cell::new("Type", format::format_project_type(&p.project_type)),
224 Cell::new("Status", if p.is_public { "public" } else { "draft" }),
225 Cell::new("Items", p.item_count.to_string()),
226 Cell::new("Revenue", p.revenue().display_compact(app.currency())),
227 ]
228 })
229 .collect();
230
231 let style = TableStyle::from_theme(&app.theme);
232 let table = table::table(&PROJECT_COLUMNS, &rows, &PROJECT_SIZING, &style, area.width);
233 let mut state = TableState::default().with_selected(Some(app.selected_index));
234 frame.render_stateful_widget(table, area, &mut state);
235 }
236