//! Item detail screen — view/edit item fields, versions, publish status. use ratatui::Frame; use ratatui::layout::{Constraint, Layout}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph, Row}; use crate::api::ItemDetail; use crate::format; use crate::staging; use super::App; use super::widgets; pub fn render(frame: &mut Frame, app: &App) { let area = frame.area(); let item = match &app.item_detail { Some(d) => d, None => { let loading = Paragraph::new(" Loading..."); frame.render_widget(loading, area); return; } }; let status_label = if item.is_public { "Published" } else { "Draft" }; let title = Line::from(vec![ Span::styled(" Makenot.work ", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" -- "), Span::styled(&item.title, Style::default().add_modifier(Modifier::BOLD)), Span::raw(format!(" -- {} -- {} ", format::format_item_type(&item.item_type), status_label)), ]); let block = Block::default() .title(title) .borders(Borders::ALL) .border_style(Style::default().fg(Color::Gray)); let inner = block.inner(area); frame.render_widget(block, area); let chunks = Layout::vertical([ Constraint::Length(1), // spacer Constraint::Length(7), // item info (6 fields + tags) Constraint::Length(1), // spacer Constraint::Length(1), // versions header Constraint::Min(3), // versions list Constraint::Length(1), // status line Constraint::Length(1), // keybindings ]) .split(inner); // Item info render_item_info(frame, app, item, chunks[1]); // Versions header let version_count = app.item_versions.len(); let version_header = Paragraph::new(Line::from(vec![ Span::raw(" "), Span::styled("Versions", Style::default().add_modifier(Modifier::BOLD)), if version_count == 0 { Span::raw("") } else { Span::raw(format!(" ({})", version_count)) }, ])); frame.render_widget(version_header, chunks[3]); // Versions list if app.loading { let loading = Paragraph::new(" Loading..."); frame.render_widget(loading, chunks[4]); } else if app.item_versions.is_empty() { let empty = Paragraph::new(" No versions."); frame.render_widget(empty, chunks[4]); } else { render_versions_table(frame, app, chunks[4]); } // Status line if let Some(ref status) = app.item_status { let style = if status.starts_with("Error") { Style::default().fg(Color::Red) } else { Style::default().fg(Color::Green) }; let status_line = Paragraph::new(Line::from(vec![ Span::raw(" "), Span::styled(status.as_str(), style), ])); frame.render_widget(status_line, chunks[5]); } // Keybindings let mut key_spans = vec![Span::raw(" ")]; if app.item_editing.is_some() { key_spans.extend([ Span::styled("[Enter]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" Confirm "), Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" Cancel"), ]); } else { key_spans.extend([ Span::styled("[e]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" Edit "), ]); if item.is_public { key_spans.extend([ Span::styled("[u]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" Unpublish "), ]); } else { key_spans.extend([ Span::styled("[p]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" Publish "), ]); } key_spans.extend([ Span::styled("[d]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" Delete "), Span::styled("[t]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" Tag "), Span::styled("[l]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" Keys "), 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(Color::DarkGray)); frame.render_widget(keys, chunks[6]); } fn render_item_info( frame: &mut Frame, app: &App, item: &ItemDetail, area: ratatui::layout::Rect, ) { let price = format::format_price(item.price_cents); let desc_preview = item .description .as_deref() .unwrap_or("(no description)") .chars() .take(60) .collect::(); // Show edit indicator for editing field let editing = app.item_editing; let edit_marker = |field: ItemEditField| -> &str { if editing == Some(field) { "> " } else { " " } }; let lines = vec![ Line::from(vec![ Span::raw(edit_marker(ItemEditField::Title)), Span::styled("Title: ", Style::default().fg(Color::DarkGray)), if editing == Some(ItemEditField::Title) { Span::styled( format!("{}_", app.edit_buffer), Style::default().add_modifier(Modifier::UNDERLINED), ) } else { Span::raw(item.title.clone()) }, ]), Line::from(vec![ Span::raw(edit_marker(ItemEditField::Description)), Span::styled("Description: ", Style::default().fg(Color::DarkGray)), if editing == Some(ItemEditField::Description) { Span::styled( format!("{}_", app.edit_buffer), Style::default().add_modifier(Modifier::UNDERLINED), ) } else { Span::raw(desc_preview) }, ]), Line::from(vec![ Span::raw(edit_marker(ItemEditField::Price)), Span::styled("Price: ", Style::default().fg(Color::DarkGray)), if editing == Some(ItemEditField::Price) { Span::styled( format!("${}_", app.edit_buffer), Style::default().add_modifier(Modifier::UNDERLINED), ) } else { Span::raw(price) }, ]), Line::from(vec![ Span::raw(" "), Span::styled("Type: ", Style::default().fg(Color::DarkGray)), Span::raw(format::format_item_type(&item.item_type)), Span::raw(" "), Span::styled("Slug: ", Style::default().fg(Color::DarkGray)), Span::raw(item.slug.clone()), ]), Line::from(vec![ Span::raw(" "), Span::styled("Sales: ", Style::default().fg(Color::DarkGray)), Span::raw(item.sales_count.to_string()), Span::raw(" "), Span::styled("Downloads: ", Style::default().fg(Color::DarkGray)), Span::raw(item.download_count.to_string()), if item.play_count > 0 { Span::raw(format!(" Plays: {}", item.play_count)) } else { Span::raw("") }, ]), Line::from(vec![ Span::raw(" "), Span::styled("Audio: ", Style::default().fg(Color::DarkGray)), Span::raw(if item.has_audio { "yes" } else { "no" }), Span::raw(" "), Span::styled("Cover: ", Style::default().fg(Color::DarkGray)), Span::raw(if item.has_cover { "yes" } else { "no" }), ]), Line::from({ let mut spans = vec![ Span::raw(" "), Span::styled("Tags: ", Style::default().fg(Color::DarkGray)), ]; if app.item_tags.is_empty() { spans.push(Span::styled("(none)", Style::default().fg(Color::DarkGray))); } else { for (i, tag) in app.item_tags.iter().enumerate() { if i > 0 { spans.push(Span::raw(", ")); } if tag.is_primary { spans.push(Span::styled(&tag.name, Style::default().add_modifier(Modifier::BOLD))); } else { spans.push(Span::raw(&tag.name)); } } } spans }), ]; let info = Paragraph::new(lines); frame.render_widget(info, area); } fn render_versions_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { let rows: Vec = app .item_versions .iter() .map(|v| { let current = if v.is_current { "*" } else { " " }; let size = v .file_size_bytes .map(|b| staging::format_bytes(b as u64)) .unwrap_or_else(|| "--".to_string()); let name = v.file_name.as_deref().unwrap_or("--"); let date = v.created_at.get(..10).unwrap_or(&v.created_at); Row::new(vec![ format!(" {}{}", current, v.version_number), name.to_string(), size, v.download_count.to_string(), date.to_string(), ]) }) .collect(); let widths = [ Constraint::Length(12), Constraint::Min(16), Constraint::Length(10), Constraint::Length(10), Constraint::Length(12), ]; widgets::render_table(frame, area, &[" Version", "File", "Size", "Downloads", "Date"], &widths, rows); } /// Which field is being edited on the item detail screen. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ItemEditField { Title, Description, Price, }