Skip to main content

nightshade_api/web/
asset_grid.rs

1//! A searchable grid of thumbnail asset cards.
2
3use leptos::prelude::*;
4
5/// A card in an [`AssetGrid`], with a stable `id`, a `label`, a `thumbnail`
6/// image source, and an optional `subtitle`.
7#[derive(Clone)]
8pub struct AssetItem {
9    /// Stable identifier passed to the selection callback.
10    pub id: String,
11    /// Primary caption, also matched against the search query.
12    pub label: String,
13    /// Image source used for the card thumbnail.
14    pub thumbnail: String,
15    /// Optional secondary caption shown beneath the label.
16    pub subtitle: String,
17}
18
19impl AssetItem {
20    /// Builds an item from an `id`, `label`, and `thumbnail`, leaving the
21    /// subtitle empty.
22    pub fn new(
23        id: impl Into<String>,
24        label: impl Into<String>,
25        thumbnail: impl Into<String>,
26    ) -> Self {
27        Self {
28            id: id.into(),
29            label: label.into(),
30            thumbnail: thumbnail.into(),
31            subtitle: String::new(),
32        }
33    }
34
35    /// Sets the item's subtitle, returning the updated item.
36    pub fn with_subtitle(mut self, subtitle: impl Into<String>) -> Self {
37        self.subtitle = subtitle.into();
38        self
39    }
40}
41
42/// A grid of thumbnail cards built from `items`, invoking `on_select` with an
43/// item's id when its card is clicked. When `searchable` is set a filter box
44/// (labeled by `placeholder`) narrows cards by label.
45#[component]
46pub fn AssetGrid(
47    #[prop(into)] items: Signal<Vec<AssetItem>>,
48    on_select: Callback<String>,
49    #[prop(default = true)] searchable: bool,
50    #[prop(into, optional)] placeholder: String,
51) -> impl IntoView {
52    let query = RwSignal::new(String::new());
53    let placeholder = if placeholder.is_empty() {
54        "Search assets…".to_string()
55    } else {
56        placeholder
57    };
58
59    let filtered = move || {
60        let needle = query.get().to_lowercase();
61        items
62            .get()
63            .into_iter()
64            .filter(|item| needle.is_empty() || item.label.to_lowercase().contains(&needle))
65            .collect::<Vec<_>>()
66    };
67
68    view! {
69        <div class="nightshade-asset-grid">
70            {searchable
71                .then(|| {
72                    view! {
73                        <input
74                            class="nightshade-asset-search"
75                            type="search"
76                            placeholder=placeholder.clone()
77                            prop:value=move || query.get()
78                            on:input=move |event| query.set(event_target_value(&event))
79                        />
80                    }
81                })}
82            <div class="nightshade-asset-cards">
83                {move || {
84                    let rows = filtered();
85                    if rows.is_empty() {
86                        return view! { <div class="nightshade-asset-empty">"No assets"</div> }
87                            .into_any();
88                    }
89                    rows.into_iter()
90                        .map(|item| {
91                            let id = item.id.clone();
92                            let label = item.label.clone();
93                            let subtitle = item.subtitle.clone();
94                            view! {
95                                <button
96                                    class="nightshade-asset-card"
97                                    title=label.clone()
98                                    on:click=move |_| on_select.run(id.clone())
99                                >
100                                    <span class="nightshade-asset-thumb">
101                                        <img loading="lazy" src=item.thumbnail alt=label.clone() />
102                                    </span>
103                                    <span class="nightshade-asset-label">{label.clone()}</span>
104                                    {(!subtitle.is_empty())
105                                        .then(|| {
106                                            view! {
107                                                <span class="nightshade-asset-subtitle">{subtitle}</span>
108                                            }
109                                        })}
110                                </button>
111                            }
112                        })
113                        .collect_view()
114                        .into_any()
115                }}
116            </div>
117        </div>
118    }
119}