Skip to main content

max / makenotwork

8.5 KB · 250 lines History Blame Raw
1 //! Project detail screen — item list within a project.
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::api::Project;
12 use crate::format;
13
14 use super::App;
15
16 /// The item list's columns.
17 ///
18 /// Title carries the selection marker as well as the name, so it is what a row
19 /// identifies itself by and the price is what the creator is checking. Type and
20 /// status drop first.
21 const ITEM_COLUMNS: [Column<'static>; 4] = [
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: "Price",
38 width: Width::Fixed,
39 priority: Priority::Secondary,
40 sortable: false,
41 sorted: None,
42 },
43 Column {
44 name: "Status",
45 width: Width::Fixed,
46 priority: Priority::Secondary,
47 sortable: false,
48 sorted: None,
49 },
50 ];
51
52 /// The tracks the hand-written `Constraint`s carried.
53 const ITEM_SIZING: Sizing<'static> = Sizing {
54 lengths: &[("Title", 20), ("Type", 12), ("Price", 10), ("Status", 8)],
55 fallback: 8,
56 };
57
58 pub(crate) fn render(frame: &mut Frame, app: &App, project: &Project) {
59 let area = frame.area();
60
61 let title = Line::from(vec![
62 Span::styled(
63 " Makenot.work ",
64 Style::default().add_modifier(Modifier::BOLD),
65 ),
66 Span::raw(" ── "),
67 Span::styled(
68 &project.title,
69 Style::default().add_modifier(Modifier::BOLD),
70 ),
71 Span::raw(" "),
72 ]);
73
74 let block = Block::default()
75 .title(title)
76 .borders(Borders::ALL)
77 .border_style(Style::default().fg(app.theme.line_border));
78
79 let inner = block.inner(area);
80 frame.render_widget(block, area);
81
82 let has_confirm = app.confirm_action.is_some();
83 let has_status = app.item_status.is_some();
84
85 let chunks = Layout::vertical([
86 Constraint::Length(1), // spacer
87 Constraint::Length(1), // project info line
88 Constraint::Length(1), // spacer
89 Constraint::Length(1), // section header
90 Constraint::Min(3), // item list
91 Constraint::Length(u16::from(has_confirm || has_status)), // status/confirm
92 Constraint::Length(1), // keybindings
93 ])
94 .split(inner);
95
96 // Project info
97 let visibility = if project.is_public { "public" } else { "draft" };
98 let info = Paragraph::new(Line::from(vec![
99 Span::raw(" "),
100 Span::styled(&project.slug, Style::default().fg(app.theme.content_muted)),
101 Span::raw(" "),
102 Span::raw(format::format_project_type(&project.project_type)),
103 Span::raw(" "),
104 Span::raw(visibility),
105 Span::raw(" "),
106 // The detail line has the room for the full breakdown, so a project
107 // spanning two currencies shows both here rather than a `+1` marker.
108 Span::raw(project.revenue().display(app.currency())),
109 Span::styled(" revenue", Style::default().fg(app.theme.content_muted)),
110 ]));
111 frame.render_widget(info, chunks[1]);
112
113 // Section header
114 let header = Paragraph::new(Line::from(vec![
115 Span::raw(" "),
116 Span::styled("Items", Style::default().add_modifier(Modifier::BOLD)),
117 if app.items.is_empty() {
118 Span::raw("")
119 } else {
120 Span::raw(format!(" ({})", app.items.len()))
121 },
122 ]));
123 frame.render_widget(header, chunks[3]);
124
125 // Item list
126 if app.loading {
127 let loading = Paragraph::new(" Loading...");
128 frame.render_widget(loading, chunks[4]);
129 } else if app.items.is_empty() {
130 let empty = Paragraph::new(" No items in this project.");
131 frame.render_widget(empty, chunks[4]);
132 } else {
133 render_item_table(frame, app, chunks[4]);
134 }
135
136 // Status / confirmation line
137 if let Some(ref action) = app.confirm_action {
138 let msg = match action {
139 super::ConfirmAction::BulkPublish { count } => {
140 format!(" Publish {count} items? [y/n]")
141 }
142 super::ConfirmAction::BulkUnpublish { count } => {
143 format!(" Unpublish {count} items? [y/n]")
144 }
145 super::ConfirmAction::BulkDelete { count } => {
146 format!(" Delete {count} items? This cannot be undone. [y/n]")
147 }
148 _ => String::new(),
149 };
150 let confirm = Paragraph::new(msg).style(Style::default().fg(app.theme.status_warning));
151 frame.render_widget(confirm, chunks[5]);
152 } else if let Some(ref status) = app.item_status {
153 let style = if status.starts_with("Error") {
154 Style::default().fg(app.theme.status_danger)
155 } else {
156 Style::default().fg(app.theme.status_success)
157 };
158 let status_line = Paragraph::new(format!(" {status}")).style(style);
159 frame.render_widget(status_line, chunks[5]);
160 }
161
162 // Keybindings
163 let sel_count = app.selected_items.len();
164 let mut key_spans = vec![
165 Span::raw(" "),
166 Span::styled("[j/k]", Style::default().add_modifier(Modifier::BOLD)),
167 Span::raw(" Nav "),
168 ];
169
170 if !app.items.is_empty() {
171 key_spans.extend([
172 Span::styled("[Space]", Style::default().add_modifier(Modifier::BOLD)),
173 Span::raw(" Select "),
174 Span::styled("[Enter]", Style::default().add_modifier(Modifier::BOLD)),
175 Span::raw(" Open "),
176 Span::styled("[p]", Style::default().add_modifier(Modifier::BOLD)),
177 Span::raw(" Pub/Unpub "),
178 Span::styled("[d]", Style::default().add_modifier(Modifier::BOLD)),
179 Span::raw(" Delete "),
180 ]);
181 }
182
183 if sel_count > 0 {
184 key_spans.extend([
185 Span::styled(
186 format!("{sel_count} selected"),
187 Style::default()
188 .fg(app.theme.action_primary)
189 .add_modifier(Modifier::BOLD),
190 ),
191 Span::raw(" "),
192 ]);
193 }
194
195 key_spans.extend([
196 Span::styled("[b]", Style::default().add_modifier(Modifier::BOLD)),
197 Span::raw(" Blog "),
198 Span::styled("[t]", Style::default().add_modifier(Modifier::BOLD)),
199 Span::raw(" Tiers "),
200 Span::styled("[r]", Style::default().add_modifier(Modifier::BOLD)),
201 Span::raw(" Refresh "),
202 Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)),
203 Span::raw(" Back"),
204 ]);
205
206 let keys =
207 Paragraph::new(Line::from(key_spans)).style(Style::default().fg(app.theme.content_muted));
208 frame.render_widget(keys, chunks[6]);
209 }
210
211 fn render_item_table(frame: &mut Frame, app: &App, area: Rect) {
212 let area = super::indent(area, 2);
213 let has_selections = !app.selected_items.is_empty();
214
215 let rows: Vec<Vec<Cell>> = app
216 .items
217 .iter()
218 .enumerate()
219 .map(|(i, item)| {
220 // The checkbox appears only once something is selected, so a list
221 // nobody has marked up reads as a plain list.
222 let title = if has_selections {
223 let marker = if app.selected_items.contains(&i) {
224 "[x]"
225 } else {
226 "[ ]"
227 };
228 format!("{marker} {}", item.title)
229 } else {
230 item.title.clone()
231 };
232
233 vec![
234 Cell::new("Title", title),
235 Cell::new("Type", format::format_item_type(&item.item_type)),
236 Cell::new(
237 "Price",
238 format::format_price(item.price_cents, app.currency()),
239 ),
240 Cell::new("Status", if item.is_public { "public" } else { "draft" }),
241 ]
242 })
243 .collect();
244
245 let style = TableStyle::from_theme(&app.theme);
246 let table = table::table(&ITEM_COLUMNS, &rows, &ITEM_SIZING, &style, area.width);
247 let mut state = TableState::default().with_selected(Some(app.selected_index));
248 frame.render_stateful_widget(table, area, &mut state);
249 }
250