//! Project detail screen — item list within a project. use makeover_tui::makeover_layout::{Column, Priority, Width}; use makeover_tui::table::{self, Cell, Sizing, TableStyle}; use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph, TableState}; use crate::api::Project; use crate::format; use super::App; /// The item list's columns. /// /// Title carries the selection marker as well as the name, so it is what a row /// identifies itself by and the price is what the creator is checking. Type and /// status drop first. const ITEM_COLUMNS: [Column<'static>; 4] = [ Column { name: "Title", width: Width::Fill, priority: Priority::Essential, sortable: false, sorted: None, }, Column { name: "Type", width: Width::Fixed, priority: Priority::Optional, sortable: false, sorted: None, }, Column { name: "Price", width: Width::Fixed, priority: Priority::Secondary, sortable: false, sorted: None, }, Column { name: "Status", width: Width::Fixed, priority: Priority::Secondary, sortable: false, sorted: None, }, ]; /// The tracks the hand-written `Constraint`s carried. const ITEM_SIZING: Sizing<'static> = Sizing { lengths: &[("Title", 20), ("Type", 12), ("Price", 10), ("Status", 8)], fallback: 8, }; pub(crate) fn render(frame: &mut Frame, app: &App, project: &Project) { let area = frame.area(); let title = Line::from(vec![ Span::styled( " Makenot.work ", Style::default().add_modifier(Modifier::BOLD), ), Span::raw(" ── "), Span::styled( &project.title, Style::default().add_modifier(Modifier::BOLD), ), Span::raw(" "), ]); let block = Block::default() .title(title) .borders(Borders::ALL) .border_style(Style::default().fg(app.theme.line_border)); let inner = block.inner(area); frame.render_widget(block, area); let has_confirm = app.confirm_action.is_some(); let has_status = app.item_status.is_some(); let chunks = Layout::vertical([ Constraint::Length(1), // spacer Constraint::Length(1), // project info line Constraint::Length(1), // spacer Constraint::Length(1), // section header Constraint::Min(3), // item list Constraint::Length(u16::from(has_confirm || has_status)), // status/confirm Constraint::Length(1), // keybindings ]) .split(inner); // Project info let visibility = if project.is_public { "public" } else { "draft" }; let info = Paragraph::new(Line::from(vec![ Span::raw(" "), Span::styled(&project.slug, Style::default().fg(app.theme.content_muted)), Span::raw(" "), Span::raw(format::format_project_type(&project.project_type)), Span::raw(" "), Span::raw(visibility), Span::raw(" "), // The detail line has the room for the full breakdown, so a project // spanning two currencies shows both here rather than a `+1` marker. Span::raw(project.revenue().display(app.currency())), Span::styled(" revenue", Style::default().fg(app.theme.content_muted)), ])); frame.render_widget(info, chunks[1]); // Section header let header = Paragraph::new(Line::from(vec![ Span::raw(" "), Span::styled("Items", Style::default().add_modifier(Modifier::BOLD)), if app.items.is_empty() { Span::raw("") } else { Span::raw(format!(" ({})", app.items.len())) }, ])); frame.render_widget(header, chunks[3]); // Item list if app.loading { let loading = Paragraph::new(" Loading..."); frame.render_widget(loading, chunks[4]); } else if app.items.is_empty() { let empty = Paragraph::new(" No items in this project."); frame.render_widget(empty, chunks[4]); } else { render_item_table(frame, app, chunks[4]); } // Status / confirmation line if let Some(ref action) = app.confirm_action { let msg = match action { super::ConfirmAction::BulkPublish { count } => { format!(" Publish {count} items? [y/n]") } super::ConfirmAction::BulkUnpublish { count } => { format!(" Unpublish {count} items? [y/n]") } super::ConfirmAction::BulkDelete { count } => { format!(" Delete {count} items? This cannot be undone. [y/n]") } _ => String::new(), }; let confirm = Paragraph::new(msg).style(Style::default().fg(app.theme.status_warning)); frame.render_widget(confirm, chunks[5]); } else if let Some(ref status) = app.item_status { let style = if status.starts_with("Error") { Style::default().fg(app.theme.status_danger) } else { Style::default().fg(app.theme.status_success) }; let status_line = Paragraph::new(format!(" {status}")).style(style); frame.render_widget(status_line, chunks[5]); } // Keybindings let sel_count = app.selected_items.len(); let mut key_spans = vec![ Span::raw(" "), Span::styled("[j/k]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" Nav "), ]; if !app.items.is_empty() { key_spans.extend([ Span::styled("[Space]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" Select "), Span::styled("[Enter]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" Open "), Span::styled("[p]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" Pub/Unpub "), Span::styled("[d]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" Delete "), ]); } if sel_count > 0 { key_spans.extend([ Span::styled( format!("{sel_count} selected"), Style::default() .fg(app.theme.action_primary) .add_modifier(Modifier::BOLD), ), Span::raw(" "), ]); } key_spans.extend([ Span::styled("[b]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" Blog "), Span::styled("[t]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" Tiers "), Span::styled("[r]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" Refresh "), Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" Back"), ]); let keys = Paragraph::new(Line::from(key_spans)).style(Style::default().fg(app.theme.content_muted)); frame.render_widget(keys, chunks[6]); } fn render_item_table(frame: &mut Frame, app: &App, area: Rect) { let area = super::indent(area, 2); let has_selections = !app.selected_items.is_empty(); let rows: Vec> = app .items .iter() .enumerate() .map(|(i, item)| { // The checkbox appears only once something is selected, so a list // nobody has marked up reads as a plain list. let title = if has_selections { let marker = if app.selected_items.contains(&i) { "[x]" } else { "[ ]" }; format!("{marker} {}", item.title) } else { item.title.clone() }; vec![ Cell::new("Title", title), Cell::new("Type", format::format_item_type(&item.item_type)), Cell::new( "Price", format::format_price(item.price_cents, app.currency()), ), Cell::new("Status", if item.is_public { "public" } else { "draft" }), ] }) .collect(); let style = TableStyle::from_theme(&app.theme); let table = table::table(&ITEM_COLUMNS, &rows, &ITEM_SIZING, &style, area.width); let mut state = TableState::default().with_selected(Some(app.selected_index)); frame.render_stateful_widget(table, area, &mut state); }