Skip to content

Commit 9152695

Browse files
Copilotgarrytrinder
andcommitted
Add metadata filter dropdowns to sample gallery
Adds client-side filtering by sample metadata (MOCKS, PLUGIN, PRESET, PROXY VERSION) to the SampleGallery component. Metadata keys and values are extracted at build time from sample frontmatter. Filters combine with the existing text search using AND logic. Co-authored-by: garrytrinder <11563347+garrytrinder@users.noreply.github.com>
1 parent 7433339 commit 9152695

File tree

1 file changed

+98
-5
lines changed

1 file changed

+98
-5
lines changed

src/components/SampleGallery.astro

Lines changed: 98 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,47 @@ interface Props {
66
}
77
88
const { samples } = Astro.props;
9+
10+
// Build metadata filters at build time
11+
// Exclude SAMPLE ID (unique per sample, not useful for filtering)
12+
const EXCLUDED_KEYS = new Set(['SAMPLE ID']);
13+
const metadataFilters = new Map<string, Map<string, string>>();
14+
15+
for (const sample of samples) {
16+
for (const { key, value } of sample.data.metadata) {
17+
if (EXCLUDED_KEYS.has(key)) continue;
18+
if (!metadataFilters.has(key)) {
19+
metadataFilters.set(key, new Map());
20+
}
21+
// Use case-insensitive deduplication, keeping the first encountered form
22+
const valuesMap = metadataFilters.get(key)!;
23+
const lowerValue = value.toLowerCase();
24+
if (!valuesMap.has(lowerValue)) {
25+
valuesMap.set(lowerValue, value);
26+
}
27+
}
28+
}
29+
30+
// Sort keys alphabetically, values alphabetically within each key
31+
const sortedFilters = [...metadataFilters.entries()]
32+
.sort(([a], [b]) => a.localeCompare(b))
33+
.map(([key, valuesMap]) => [key, [...valuesMap.values()].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))] as const);
34+
35+
// Build per-card metadata JSON for client-side filtering
36+
function getMetadataMap(sample: CollectionEntry<'samples'>): Record<string, string> {
37+
const map: Record<string, string> = {};
38+
for (const { key, value } of sample.data.metadata) {
39+
if (!EXCLUDED_KEYS.has(key)) {
40+
map[key] = value;
41+
}
42+
}
43+
return map;
44+
}
945
---
1046

1147
<div id="sample-gallery">
1248
<!-- Search bar -->
13-
<div class="mb-10 max-w-xl mx-auto">
49+
<div class="mb-6 max-w-xl mx-auto">
1450
<div class="relative">
1551
<svg class="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 pointer-events-none" style="color: var(--text-faint);" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
1652
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
@@ -24,6 +60,33 @@ const { samples } = Astro.props;
2460
</div>
2561
</div>
2662

63+
{sortedFilters.length > 0 && (
64+
<div id="metadata-filters" class="mb-8 flex flex-wrap items-center justify-center gap-4">
65+
{sortedFilters.map(([key, values]) => (
66+
<div class="flex items-center gap-2">
67+
<label
68+
for={`filter-${key.toLowerCase().replace(/\s+/g, '-')}`}
69+
class="text-xs font-medium uppercase tracking-wide"
70+
style="color: var(--text-faint);"
71+
>
72+
{key}
73+
</label>
74+
<select
75+
id={`filter-${key.toLowerCase().replace(/\s+/g, '-')}`}
76+
class="metadata-filter rounded-lg border px-3 py-1.5 text-sm focus:outline-none focus:border-purple-500 transition-all duration-300 cursor-pointer"
77+
style="background: var(--bg-secondary); border-color: var(--border-secondary); color: var(--text-primary);"
78+
data-filter-key={key}
79+
>
80+
<option value="">All</option>
81+
{values.map((value) => (
82+
<option value={value}>{value}</option>
83+
))}
84+
</select>
85+
</div>
86+
))}
87+
</div>
88+
)}
89+
2790
<p id="sample-count" class="text-sm mb-8 text-center" style="color: var(--text-faint);">
2891
{samples.length} samples
2992
</p>
@@ -37,6 +100,7 @@ const { samples } = Astro.props;
37100
data-title={sample.data.title.toLowerCase()}
38101
data-description={sample.data.shortDescription.toLowerCase()}
39102
data-authors={sample.data.authors.map(a => a.name.toLowerCase()).join(' ')}
103+
data-metadata={JSON.stringify(getMetadataMap(sample))}
40104
>
41105
{sample.data.thumbnails?.[0] && (
42106
<div class="overflow-hidden">
@@ -75,30 +139,59 @@ const { samples } = Astro.props;
75139
</div>
76140

77141
<p id="no-results" class="text-center py-16 hidden" style="color: var(--text-faint);">
78-
No samples match your search.
142+
No samples match your search or filters.
79143
</p>
80144
</div>
81145

82146
<script>
83147
const searchInput = document.getElementById('sample-search') as HTMLInputElement;
148+
const filterSelects = document.querySelectorAll('.metadata-filter') as NodeListOf<HTMLSelectElement>;
84149
const cards = document.querySelectorAll('.sample-card') as NodeListOf<HTMLElement>;
85150
const countEl = document.getElementById('sample-count')!;
86151
const noResults = document.getElementById('no-results')!;
87152

88-
searchInput.addEventListener('input', () => {
153+
function applyFilters() {
89154
const query = searchInput.value.toLowerCase().trim();
155+
156+
// Collect active metadata filters
157+
const activeFilters: Record<string, string> = {};
158+
filterSelects.forEach((select) => {
159+
const key = select.dataset.filterKey!;
160+
const value = select.value;
161+
if (value) {
162+
activeFilters[key] = value;
163+
}
164+
});
165+
90166
let visible = 0;
91167

92168
cards.forEach((card) => {
93169
const title = card.dataset.title || '';
94170
const description = card.dataset.description || '';
95171
const authors = card.dataset.authors || '';
96-
const matches = !query || title.includes(query) || description.includes(query) || authors.includes(query);
172+
const metadata: Record<string, string> = JSON.parse(card.dataset.metadata || '{}');
173+
174+
// Text search match
175+
const textMatch = !query || title.includes(query) || description.includes(query) || authors.includes(query);
176+
177+
// Metadata filter match (all active filters must match)
178+
let metadataMatch = true;
179+
for (const [key, value] of Object.entries(activeFilters)) {
180+
if ((metadata[key] || '').toLowerCase() !== value.toLowerCase()) {
181+
metadataMatch = false;
182+
break;
183+
}
184+
}
185+
186+
const matches = textMatch && metadataMatch;
97187
card.style.display = matches ? '' : 'none';
98188
if (matches) visible++;
99189
});
100190

101191
countEl.textContent = `${visible} sample${visible !== 1 ? 's' : ''}`;
102192
noResults.classList.toggle('hidden', visible > 0);
103-
});
193+
}
194+
195+
searchInput.addEventListener('input', applyFilters);
196+
filterSelects.forEach((select) => select.addEventListener('change', applyFilters));
104197
</script>

0 commit comments

Comments
 (0)