Skip to main content

max / makenotwork

6.9 KB · 207 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 Span::raw(format::format_cents(project.revenue_cents)),
64 Span::styled(" revenue", Style::default().fg(Color::DarkGray)),
65 ]));
66 frame.render_widget(info, chunks[1]);
67
68 // Section header
69 let header = Paragraph::new(Line::from(vec![
70 Span::raw(" "),
71 Span::styled("Items", Style::default().add_modifier(Modifier::BOLD)),
72 if app.items.is_empty() {
73 Span::raw("")
74 } else {
75 Span::raw(format!(" ({})", app.items.len()))
76 },
77 ]));
78 frame.render_widget(header, chunks[3]);
79
80 // Item list
81 if app.loading {
82 let loading = Paragraph::new(" Loading...");
83 frame.render_widget(loading, chunks[4]);
84 } else if app.items.is_empty() {
85 let empty = Paragraph::new(" No items in this project.");
86 frame.render_widget(empty, chunks[4]);
87 } else {
88 render_item_table(frame, app, chunks[4]);
89 }
90
91 // Status / confirmation line
92 if let Some(ref action) = app.confirm_action {
93 let msg = match action {
94 super::ConfirmAction::BulkPublish { count } => {
95 format!(" Publish {count} items? [y/n]")
96 }
97 super::ConfirmAction::BulkUnpublish { count } => {
98 format!(" Unpublish {count} items? [y/n]")
99 }
100 super::ConfirmAction::BulkDelete { count } => {
101 format!(" Delete {count} items? This cannot be undone. [y/n]")
102 }
103 _ => String::new(),
104 };
105 let confirm = Paragraph::new(msg).style(Style::default().fg(Color::Yellow));
106 frame.render_widget(confirm, chunks[5]);
107 } else if let Some(ref status) = app.item_status {
108 let style = if status.starts_with("Error") {
109 Style::default().fg(Color::Red)
110 } else {
111 Style::default().fg(Color::Green)
112 };
113 let status_line = Paragraph::new(format!(" {status}")).style(style);
114 frame.render_widget(status_line, chunks[5]);
115 }
116
117 // Keybindings
118 let sel_count = app.selected_items.len();
119 let mut key_spans = vec![
120 Span::raw(" "),
121 Span::styled("[j/k]", Style::default().add_modifier(Modifier::BOLD)),
122 Span::raw(" Nav "),
123 ];
124
125 if !app.items.is_empty() {
126 key_spans.extend([
127 Span::styled("[Space]", Style::default().add_modifier(Modifier::BOLD)),
128 Span::raw(" Select "),
129 Span::styled("[Enter]", Style::default().add_modifier(Modifier::BOLD)),
130 Span::raw(" Open "),
131 Span::styled("[p]", Style::default().add_modifier(Modifier::BOLD)),
132 Span::raw(" Pub/Unpub "),
133 Span::styled("[d]", Style::default().add_modifier(Modifier::BOLD)),
134 Span::raw(" Delete "),
135 ]);
136 }
137
138 if sel_count > 0 {
139 key_spans.extend([
140 Span::styled(
141 format!("{sel_count} selected"),
142 Style::default()
143 .fg(Color::Cyan)
144 .add_modifier(Modifier::BOLD),
145 ),
146 Span::raw(" "),
147 ]);
148 }
149
150 key_spans.extend([
151 Span::styled("[b]", Style::default().add_modifier(Modifier::BOLD)),
152 Span::raw(" Blog "),
153 Span::styled("[t]", Style::default().add_modifier(Modifier::BOLD)),
154 Span::raw(" Tiers "),
155 Span::styled("[r]", Style::default().add_modifier(Modifier::BOLD)),
156 Span::raw(" Refresh "),
157 Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)),
158 Span::raw(" Back"),
159 ]);
160
161 let keys = Paragraph::new(Line::from(key_spans)).style(Style::default().fg(Color::DarkGray));
162 frame.render_widget(keys, chunks[6]);
163 }
164
165 fn render_item_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
166 let has_selections = !app.selected_items.is_empty();
167
168 let rows: Vec<Row> = app
169 .items
170 .iter()
171 .enumerate()
172 .map(|(i, item)| {
173 let visibility = if item.is_public { "public" } else { "draft" };
174 let marker = if app.selected_items.contains(&i) {
175 "[x]"
176 } else if has_selections {
177 "[ ]"
178 } else {
179 " "
180 };
181
182 Row::new(vec![
183 format!(" {} {}", marker, item.title),
184 format::format_item_type(&item.item_type).to_string(),
185 format::format_price(item.price_cents),
186 visibility.to_string(),
187 ])
188 .style(widgets::selected_style(i, Some(app.selected_index)))
189 })
190 .collect();
191
192 let widths = [
193 Constraint::Min(20),
194 Constraint::Length(12),
195 Constraint::Length(10),
196 Constraint::Length(8),
197 ];
198
199 widgets::render_table(
200 frame,
201 area,
202 &[" Title", "Type", "Price", "Status"],
203 &widths,
204 rows,
205 );
206 }
207