ui_storybook/components/library/
recording_card.rs1use leptos::prelude::*;
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub enum RecordingCardState {
13 Ready,
15 Processing {
17 percent: u8,
19 },
20 Failed,
22}
23
24impl RecordingCardState {
25 #[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#[derive(Clone, Debug, PartialEq)]
39pub struct ThumbnailView {
40 pub css_background: String,
43}
44
45#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
47pub struct RecordingMetricsView {
48 pub views: u32,
50 pub comments: u32,
52 pub reactions: u32,
54}
55
56#[derive(Clone, Debug, PartialEq)]
58pub struct RecordingCardView {
59 pub id: String,
61 pub title: String,
63 pub subtitle: String,
65 pub duration_label: String,
67 pub category: Option<String>,
69 pub metrics: RecordingMetricsView,
71 pub thumbnail: ThumbnailView,
73 pub state: RecordingCardState,
75 pub selected: bool,
77}
78
79#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
81pub enum LibraryLayoutMode {
82 #[default]
84 Grid,
85 List,
87}
88
89impl LibraryLayoutMode {
90 #[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#[derive(Clone, Debug, PartialEq, Eq)]
102pub struct RecordingFilterView {
103 pub id: &'static str,
105 pub label: &'static str,
107 pub active: bool,
109}
110
111#[derive(Clone, Debug, PartialEq, Eq)]
113pub struct LibraryToolbarView {
114 pub filters: Vec<RecordingFilterView>,
116 pub sort_label: &'static str,
118 pub layout: LibraryLayoutMode,
120}
121
122#[derive(Clone, Debug, PartialEq)]
124pub struct LibraryGridView {
125 pub toolbar: LibraryToolbarView,
127 pub recordings: Vec<RecordingCardView>,
129 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}