Skip to main content

ui_storybook/components/library/
recording_card.rs

1//! `RecordingCard` + `LibraryGrid` (M-UI.15 / AUT-135). Card renders
2//! one library tile (thumbnail + footer + processing overlay); grid
3//! arranges cards under a `LibraryToolbar` (filter pills + sort).
4//!
5//! No real video — `ThumbnailView` is a CSS-gradient mock so SSR + the
6//! mdBook iframe both render deterministically.
7
8use leptos::prelude::*;
9
10/// Card lifecycle state.
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub enum RecordingCardState {
13    /// Encoded + playable.
14    Ready,
15    /// Encoder running. `percent` is `0..=100`.
16    Processing {
17        /// Completion percent.
18        percent: u8,
19    },
20    /// Encode aborted — render the error overlay.
21    Failed,
22}
23
24impl RecordingCardState {
25    /// Stable kebab-case slug.
26    #[must_use]
27    pub fn slug(self) -> &'static str {
28        match self {
29            RecordingCardState::Ready => "ready",
30            RecordingCardState::Processing { .. } => "processing",
31            RecordingCardState::Failed => "failed",
32        }
33    }
34}
35
36/// CSS-gradient mock thumbnail. The grid renders a colored block,
37/// not a real video frame, so SSR snapshots stay deterministic.
38#[derive(Clone, Debug, PartialEq)]
39pub struct ThumbnailView {
40    /// Inline CSS `background:` value (gradient or solid). Empty
41    /// string renders the missing-thumbnail placeholder.
42    pub css_background: String,
43}
44
45/// Engagement metrics shown in the card footer.
46#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
47pub struct RecordingMetricsView {
48    /// Total views.
49    pub views: u32,
50    /// Comment count.
51    pub comments: u32,
52    /// Reaction count.
53    pub reactions: u32,
54}
55
56/// View-model for one card.
57#[derive(Clone, Debug, PartialEq)]
58pub struct RecordingCardView {
59    /// Stable id (file uuid in production).
60    pub id: String,
61    /// Display title.
62    pub title: String,
63    /// Pre-formatted captured-at label ("Captured 2026-05-09").
64    pub subtitle: String,
65    /// Pre-formatted duration ("1m 24s").
66    pub duration_label: String,
67    /// Optional category pill text ("Tutorial").
68    pub category: Option<String>,
69    /// Metric counts.
70    pub metrics: RecordingMetricsView,
71    /// Thumbnail (mock gradient).
72    pub thumbnail: ThumbnailView,
73    /// Lifecycle state.
74    pub state: RecordingCardState,
75    /// `true` when this card is the selected one.
76    pub selected: bool,
77}
78
79/// Toolbar layout mode.
80#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
81pub enum LibraryLayoutMode {
82    /// Card grid (default).
83    #[default]
84    Grid,
85    /// List rows.
86    List,
87}
88
89impl LibraryLayoutMode {
90    /// Stable kebab-case slug.
91    #[must_use]
92    pub fn slug(self) -> &'static str {
93        match self {
94            LibraryLayoutMode::Grid => "grid",
95            LibraryLayoutMode::List => "list",
96        }
97    }
98}
99
100/// Filter chip view-model.
101#[derive(Clone, Debug, PartialEq, Eq)]
102pub struct RecordingFilterView {
103    /// Stable id (`"all"`, `"tutorial"`, `"demo"`).
104    pub id: &'static str,
105    /// Display label.
106    pub label: &'static str,
107    /// `true` for the active chip.
108    pub active: bool,
109}
110
111/// View-model for the toolbar above the grid.
112#[derive(Clone, Debug, PartialEq, Eq)]
113pub struct LibraryToolbarView {
114    /// Filter chips in display order.
115    pub filters: Vec<RecordingFilterView>,
116    /// Pre-formatted sort label ("Most recent", "Most viewed").
117    pub sort_label: &'static str,
118    /// Active layout.
119    pub layout: LibraryLayoutMode,
120}
121
122/// View-model for the whole grid.
123#[derive(Clone, Debug, PartialEq)]
124pub struct LibraryGridView {
125    /// Toolbar above the grid.
126    pub toolbar: LibraryToolbarView,
127    /// Cards in display order.
128    pub recordings: Vec<RecordingCardView>,
129    /// Empty-state message shown when `recordings` is empty.
130    pub empty_message: &'static str,
131}
132
133#[component]
134pub fn RecordingCard(view: RecordingCardView) -> impl IntoView {
135    let mut class = format!("recording-card recording-card-{}", view.state.slug());
136    if view.selected {
137        class.push_str(" recording-card-selected");
138    }
139    let thumb_style = if view.thumbnail.css_background.is_empty() {
140        "background: var(--surface-elevated);".to_owned()
141    } else {
142        format!("background: {};", view.thumbnail.css_background)
143    };
144    let category_pill = view.category.as_ref().map(|c| {
145        let text = c.clone();
146        view! { <span class="recording-card-category">{text}</span> }
147    });
148    let metrics_row = view! {
149        <span class="recording-card-metrics" aria-label="Engagement">
150            <span class="recording-card-metric">"▶ " {view.metrics.views}</span>
151            <span class="recording-card-metric">"💬 " {view.metrics.comments}</span>
152            <span class="recording-card-metric">"❤ " {view.metrics.reactions}</span>
153        </span>
154    };
155    let overlay = render_state_overlay(view.state);
156    view! {
157        <article class=class data-id=view.id role="group" aria-label="Recording">
158            <div class="recording-card-thumb" style=thumb_style>
159                {overlay}
160                <span class="recording-card-duration">{view.duration_label}</span>
161                {category_pill}
162            </div>
163            <div class="recording-card-body">
164                <span class="recording-card-title">{view.title}</span>
165                <span class="recording-card-subtitle">{view.subtitle}</span>
166                {metrics_row}
167            </div>
168        </article>
169    }
170}
171
172fn render_state_overlay(state: RecordingCardState) -> Option<impl IntoView> {
173    match state {
174        RecordingCardState::Ready => None,
175        RecordingCardState::Processing { percent } => {
176            let p = percent.min(100);
177            let fill = format!("width: {p}%;");
178            Some(
179                view! {
180                    <span class="recording-card-overlay recording-card-overlay-processing">
181                        <span class="recording-card-overlay-label">"Processing " {p} "%"</span>
182                        <span class="recording-card-overlay-bar">
183                            <span class="recording-card-overlay-fill" style=fill></span>
184                        </span>
185                    </span>
186                }
187                .into_any(),
188            )
189        }
190        RecordingCardState::Failed => Some(
191            view! {
192                <span class="recording-card-overlay recording-card-overlay-failed">
193                    <span class="recording-card-overlay-label">"Encode failed"</span>
194                </span>
195            }
196            .into_any(),
197        ),
198    }
199}
200
201#[component]
202pub fn LibraryToolbar(view: LibraryToolbarView) -> impl IntoView {
203    let chips: Vec<_> = view
204        .filters
205        .iter()
206        .map(|f| {
207            let mut class = String::from("library-filter");
208            if f.active {
209                class.push_str(" library-filter-active");
210            }
211            let id = f.id;
212            let label = f.label;
213            let pressed = f.active;
214            view! {
215                <button class=class data-filter=id aria-pressed=pressed>{label}</button>
216            }
217        })
218        .collect();
219    let layout = view.layout;
220    view! {
221        <div class="library-toolbar" data-layout=layout.slug()>
222            <div class="library-filters" role="toolbar" aria-label="Filters">{chips}</div>
223            <div class="library-toolbar-right">
224                <button class="library-sort">{view.sort_label} " ▾"</button>
225                <span class="library-layout-toggle" role="group" aria-label="Layout">
226                    <button
227                        class=if matches!(layout, LibraryLayoutMode::Grid) { "library-layout-btn library-layout-btn-active" } else { "library-layout-btn" }
228                        data-layout="grid"
229                        aria-pressed=matches!(layout, LibraryLayoutMode::Grid)
230                    >"▦"</button>
231                    <button
232                        class=if matches!(layout, LibraryLayoutMode::List) { "library-layout-btn library-layout-btn-active" } else { "library-layout-btn" }
233                        data-layout="list"
234                        aria-pressed=matches!(layout, LibraryLayoutMode::List)
235                    >"≡"</button>
236                </span>
237            </div>
238        </div>
239    }
240}
241
242#[component]
243pub fn LibraryGrid(view: LibraryGridView) -> impl IntoView {
244    let LibraryGridView {
245        toolbar,
246        recordings,
247        empty_message,
248    } = view;
249    let layout = toolbar.layout;
250    let cards: Vec<_> = recordings
251        .into_iter()
252        .map(|r| view! { <RecordingCard view=r /> })
253        .collect();
254    let body = if cards.is_empty() {
255        view! {
256            <div class="library-empty" role="status">
257                <span class="library-empty-icon" aria-hidden="true">"⊘"</span>
258                <span class="library-empty-message">{empty_message}</span>
259            </div>
260        }
261        .into_any()
262    } else {
263        let class = format!("library-grid library-grid-{}", layout.slug());
264        view! { <div class=class>{cards}</div> }.into_any()
265    };
266    view! {
267        <section class="library-grid-host" aria-label="Recordings">
268            <LibraryToolbar view=toolbar />
269            {body}
270        </section>
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn state_slugs_unique() {
280        let slugs = [
281            RecordingCardState::Ready.slug(),
282            RecordingCardState::Processing { percent: 33 }.slug(),
283            RecordingCardState::Failed.slug(),
284        ];
285        let mut sorted = slugs.to_vec();
286        sorted.sort_unstable();
287        sorted.dedup();
288        assert_eq!(sorted.len(), slugs.len());
289    }
290
291    #[test]
292    fn layout_mode_slugs_kebab() {
293        for s in [
294            LibraryLayoutMode::Grid.slug(),
295            LibraryLayoutMode::List.slug(),
296        ] {
297            assert!(s.chars().all(|c| c.is_ascii_lowercase()));
298        }
299    }
300}