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