Skip to main content

max / makenotwork

7.0 KB · 209 lines History Blame Raw
1 //! Project detail screen — item list within a project.
2
3 use ratatui::Frame;
4 use ratatui::layout::{Constraint, Layout};
5 use ratatui::style::{Color, Modifier, Style};
6 use ratatui::text::{Line, Span};
7 use ratatui::widgets::{Block, Borders, Paragraph, Row};
8
9 use crate::api::Project;
10 use crate::format;
11
12 use super::App;
13 use super::widgets;
14
15 pub(crate) fn render(frame: &mut Frame, app: &App, project: &Project) {
16 let area = frame.area();
17
18 let title = Line::from(vec![
19 Span::styled(
20 " Makenot.work ",
21 Style::default().add_modifier(Modifier::BOLD),
22 ),
23 Span::raw(" ── "),
24 Span::styled(
25 &project.title,
26 Style::default().add_modifier(Modifier::BOLD),
27 ),
28 Span::raw(" "),
29 ]);
30
31 let block = Block::default()
32 .title(title)
33 .borders(Borders::ALL)
34 .border_style(Style::default().fg(Color::Gray));
35
36 let inner = block.inner(area);
37 frame.render_widget(block, area);
38
39 let has_confirm = app.confirm_action.is_some();
40 let has_status = app.item_status.is_some();
41
42 let chunks = Layout::vertical([
43 Constraint::Length(1), // spacer
44 Constraint::Length(1), // project info line
45 Constraint::Length(1), // spacer
46 Constraint::Length(1), // section header
47 Constraint::Min(3), // item list
48 Constraint::Length(u16::from(has_confirm || has_status)), // status/confirm
49 Constraint::Length(1), // keybindings
50 ])
51 .split(inner);
52
53 // Project info
54 let visibility = if project.is_public { "public" } else { "draft" };
55 let info = Paragraph::new(Line::from(vec![
56 Span::raw(" "),
57 Span::styled(&project.slug, Style::default().fg(Color::DarkGray)),
58 Span::raw(" "),
59 Span::raw(format::format_project_type(&project.project_type)),
60 Span::raw(" "),
61 Span::raw(visibility),
62 Span::raw(" "),
63 // The detail line has the room for the full breakdown, so a project
64 // spanning two currencies shows both here rather than a `+1` marker.
65 Span::raw(project.revenue().display(app.currency())),
66 Span::styled(" revenue", Style::default().fg(Color::DarkGray)),
67 ]));
68 frame.render_widget(info, chunks[1]);
69
70 // Section header
71 let header = Paragraph::new(Line::from(vec![
72 Span::raw(" "),
73 Span::styled("Items", Style::default().add_modifier(Modifier::BOLD)),
74 if app.items.is_empty() {
75 Span::raw("")
76 } else {
77 Span::raw(format!(" ({})", app.items.len()))
78 },
79 ]));
80 frame.render_widget(header, chunks[3]);
81
82 // Item list
83 if app.loading {
84 let loading = Paragraph::new(" Loading...");
85 frame.render_widget(loading, chunks[4]);
86 } else if app.items.is_empty() {
87 let empty = Paragraph::new(" No items in this project.");
88 frame.render_widget(empty, chunks[4]);
89 } else {
90 render_item_table(frame, app, chunks[4]);
91 }
92
93 // Status / confirmation line
94 if let Some(ref action) = app.confirm_action {
95 let msg = match action {
96 super::ConfirmAction::BulkPublish { count } => {
97 format!(" Publish {count} items? [y/n]")
98 }
99 super::ConfirmAction::BulkUnpublish { count } => {
100 format!(" Unpublish {count} items? [y/n]")
101 }
102 super::ConfirmAction::BulkDelete { count } => {
103 format!(" Delete {count} items? This cannot be undone. [y/n]")
104 }
105 _ => String::new(),
106 };
107 let confirm = Paragraph::new(msg).style(Style::default().fg(Color::Yellow));
108 frame.render_widget(confirm, chunks[5]);
109 } else if let Some(ref status) = app.item_status {
110 let style = if status.starts_with("Error") {
111 Style::default().fg(Color::Red)
112 } else {
113 Style::default().fg(Color::Green)
114 };
115 let status_line = Paragraph::new(format!(" {status}")).style(style);
116 frame.render_widget(status_line, chunks[5]);
117 }
118
119 // Keybindings
120 let sel_count = app.selected_items.len();
121 let mut key_spans = vec![
122 Span::raw(" "),
123 Span::styled("[j/k]", Style::default().add_modifier(Modifier::BOLD)),
124 Span::raw(" Nav "),
125 ];
126
127 if !app.items.is_empty() {
128 key_spans.extend([
129 Span::styled("[Space]", Style::default().add_modifier(Modifier::BOLD)),
130 Span::raw(" Select "),
131 Span::styled("[Enter]", Style::default().add_modifier(Modifier::BOLD)),
132 Span::raw(" Open "),
133 Span::styled("[p]", Style::default().add_modifier(Modifier::BOLD)),
134 Span::raw(" Pub/Unpub "),
135 Span::styled("[d]", Style::default().add_modifier(Modifier::BOLD)),
136 Span::raw(" Delete "),
137 ]);
138 }
139
140 if sel_count > 0 {
141 key_spans.extend([
142 Span::styled(
143 format!("{sel_count} selected"),
144 Style::default()
145 .fg(Color::Cyan)
146 .add_modifier(Modifier::BOLD),
147 ),
148 Span::raw(" "),
149 ]);
150 }
151
152 key_spans.extend([
153 Span::styled("[b]", Style::default().add_modifier(Modifier::BOLD)),
154 Span::raw(" Blog "),
155 Span::styled("[t]", Style::default().add_modifier(Modifier::BOLD)),
156 Span::raw(" Tiers "),
157 Span::styled("[r]", Style::default().add_modifier(Modifier::BOLD)),
158 Span::raw(" Refresh "),
159 Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)),
160 Span::raw(" Back"),
161 ]);
162
163 let keys = Paragraph::new(Line::from(key_spans)).style(Style::default().fg(Color::DarkGray));
164 frame.render_widget(keys, chunks[6]);
165 }
166
167 fn render_item_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
168 let has_selections = !app.selected_items.is_empty();
169
170 let rows: Vec<Row> = app
171 .items
172 .iter()
173 .enumerate()
174 .map(|(i, item)| {
175 let visibility = if item.is_public { "public" } else { "draft" };
176 let marker = if app.selected_items.contains(&i) {
177 "[x]"
178 } else if has_selections {
179 "[ ]"
180 } else {
181 " "
182 };
183
184 Row::new(vec![
185 format!(" {} {}", marker, item.title),
186 format::format_item_type(&item.item_type).to_string(),
187 format::format_price(item.price_cents, app.currency()),
188 visibility.to_string(),
189 ])
190 .style(widgets::selected_style(i, Some(app.selected_index)))
191 })
192 .collect();
193
194 let widths = [
195 Constraint::Min(20),
196 Constraint::Length(12),
197 Constraint::Length(10),
198 Constraint::Length(8),
199 ];
200
201 widgets::render_table(
202 frame,
203 area,
204 &[" Title", "Type", "Price", "Status"],
205 &widths,
206 rows,
207 );
208 }
209