Skip to content

Commit 3edab6b

Browse files
authored
Merge pull request #25 from mcinquin/admin-search
feat(admin): add list for brand/set/variation in admin section
2 parents 42026d0 + 703ad8f commit 3edab6b

3 files changed

Lines changed: 186 additions & 51 deletions

File tree

messages/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@
274274
"brandPlaceholder": "e.g. Panini",
275275
"setBrandPlaceholder": "Brand",
276276
"setNamePlaceholder": "Set name",
277+
"variationBrandPlaceholder": "Brand (filters sets)",
277278
"variationSetPlaceholder": "Set",
278279
"batchBrandsPlaceholder": "Panini\nTopps\nbrand\nUpper Deck",
279280
"batchSetsPlaceholder": "Panini;Prizm\nTopps,Chrome\nbrand;set\nPanini;Select",

messages/fr.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@
274274
"brandPlaceholder": "ex. Panini",
275275
"setBrandPlaceholder": "Marque",
276276
"setNamePlaceholder": "Nom du set",
277+
"variationBrandPlaceholder": "Marque (filtre les sets)",
277278
"variationSetPlaceholder": "Set",
278279
"batchBrandsPlaceholder": "Panini\nTopps\nmarque\nUpper Deck",
279280
"batchSetsPlaceholder": "Panini;Prizm\nTopps,Chrome\nmarque;set\nPanini;Select",

src/components/admin/admin-catalog-section.tsx

Lines changed: 184 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useMemo, useState } from "react";
3+
import { useId, useMemo, useState } from "react";
44
import { References } from "@/lib/types";
55
import {
66
parseBrandSetRows,
@@ -20,6 +20,154 @@ interface AdminCatalogSectionProps {
2020
onReferencesChange: (references: References) => void;
2121
}
2222

23+
interface CatalogComboboxProps {
24+
value: string;
25+
onChange: (value: string) => void;
26+
placeholder: string;
27+
suggestions: string[];
28+
disabled?: boolean;
29+
}
30+
31+
function uniqueSorted(values: string[]): string[] {
32+
return Array.from(
33+
new Set(values.map((value) => value.trim()).filter(Boolean))
34+
).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
35+
}
36+
37+
function setsLinkedToBrand(references: References, brand: string): string[] {
38+
const query = brand.trim().toLowerCase();
39+
if (!query) return references.sets;
40+
41+
const exactBrand = references.brands.find(
42+
(item) => item.toLowerCase() === query
43+
);
44+
if (exactBrand) {
45+
return references.brandSets[exactBrand] ?? [];
46+
}
47+
48+
return uniqueSorted(
49+
references.brands
50+
.filter((item) => item.toLowerCase().includes(query))
51+
.flatMap((item) => references.brandSets[item] ?? [])
52+
);
53+
}
54+
55+
function variationsLinkedToSet(references: References, setName: string): string[] {
56+
const query = setName.trim().toLowerCase();
57+
if (!query) return references.variations;
58+
59+
const exactSet = references.sets.find((item) => item.toLowerCase() === query);
60+
if (exactSet) {
61+
return references.setVariations[exactSet] ?? [];
62+
}
63+
64+
return uniqueSorted(
65+
references.sets
66+
.filter((item) => item.toLowerCase().includes(query))
67+
.flatMap((item) => references.setVariations[item] ?? [])
68+
);
69+
}
70+
71+
function CatalogCombobox({
72+
value,
73+
onChange,
74+
placeholder,
75+
suggestions,
76+
disabled,
77+
}: CatalogComboboxProps) {
78+
const inputId = useId();
79+
const listboxId = `${inputId}-listbox`;
80+
const [open, setOpen] = useState(false);
81+
const [activeIndex, setActiveIndex] = useState(0);
82+
const query = value.trim().toLowerCase();
83+
const visibleSuggestions = useMemo(
84+
() =>
85+
suggestions
86+
.filter((suggestion) => suggestion.toLowerCase().includes(query))
87+
.slice(0, 8),
88+
[query, suggestions]
89+
);
90+
91+
function selectSuggestion(nextValue: string): void {
92+
onChange(nextValue);
93+
setOpen(false);
94+
setActiveIndex(0);
95+
}
96+
97+
return (
98+
<div className="relative">
99+
<Input
100+
id={inputId}
101+
value={value}
102+
onChange={(event) => {
103+
onChange(event.target.value);
104+
setOpen(true);
105+
setActiveIndex(0);
106+
}}
107+
onFocus={() => setOpen(true)}
108+
onBlur={() => setOpen(false)}
109+
onKeyDown={(event) => {
110+
if (!open && ["ArrowDown", "ArrowUp"].includes(event.key)) {
111+
setOpen(true);
112+
return;
113+
}
114+
if (event.key === "Escape") {
115+
setOpen(false);
116+
return;
117+
}
118+
if (visibleSuggestions.length === 0) return;
119+
if (event.key === "ArrowDown") {
120+
event.preventDefault();
121+
setActiveIndex((index) => (index + 1) % visibleSuggestions.length);
122+
}
123+
if (event.key === "ArrowUp") {
124+
event.preventDefault();
125+
setActiveIndex(
126+
(index) =>
127+
(index - 1 + visibleSuggestions.length) %
128+
visibleSuggestions.length
129+
);
130+
}
131+
if (event.key === "Enter" && open) {
132+
event.preventDefault();
133+
selectSuggestion(visibleSuggestions[activeIndex]);
134+
}
135+
}}
136+
placeholder={placeholder}
137+
disabled={disabled}
138+
role="combobox"
139+
aria-expanded={open && visibleSuggestions.length > 0}
140+
aria-controls={listboxId}
141+
aria-autocomplete="list"
142+
/>
143+
{open && visibleSuggestions.length > 0 && !disabled && (
144+
<div
145+
id={listboxId}
146+
role="listbox"
147+
className="absolute left-0 right-0 top-full z-30 mt-1 max-h-56 overflow-auto rounded-md border border-border bg-popover p-1 text-xs shadow-lg"
148+
>
149+
{visibleSuggestions.map((suggestion, index) => (
150+
<button
151+
key={suggestion}
152+
type="button"
153+
role="option"
154+
aria-selected={index === activeIndex}
155+
className="block w-full rounded px-2 py-1.5 text-left hover:bg-accent aria-selected:bg-accent"
156+
onMouseDown={(event) => {
157+
event.preventDefault();
158+
selectSuggestion(suggestion);
159+
}}
160+
onMouseEnter={() => setActiveIndex(index)}
161+
>
162+
{suggestion}
163+
</button>
164+
))}
165+
</div>
166+
)}
167+
</div>
168+
);
169+
}
170+
23171
export function AdminCatalogSection({
24172
references,
25173
onReferencesChange,
@@ -28,22 +176,26 @@ export function AdminCatalogSection({
28176
const [brand, setBrand] = useState("");
29177
const [brandForSet, setBrandForSet] = useState("");
30178
const [setName, setSetName] = useState("");
179+
const [brandForVariation, setBrandForVariation] = useState("");
31180
const [setForVariation, setSetForVariation] = useState("");
32181
const [variation, setVariation] = useState("");
33182
const [error, setError] = useState<string | null>(null);
34183
const [loading, setLoading] = useState(false);
35184

36-
const setsForBrand = useMemo(() => {
37-
const key = brandForSet.trim();
38-
if (!key) return [];
39-
return references.brandSets[key] ?? [];
40-
}, [references.brandSets, brandForSet]);
185+
const setsForBrand = useMemo(
186+
() => setsLinkedToBrand(references, brandForSet),
187+
[references, brandForSet]
188+
);
41189

42-
const variationsForSet = useMemo(() => {
43-
const key = setForVariation.trim();
44-
if (!key) return [];
45-
return references.setVariations[key] ?? [];
46-
}, [references.setVariations, setForVariation]);
190+
const setsForVariationBrand = useMemo(
191+
() => setsLinkedToBrand(references, brandForVariation),
192+
[references, brandForVariation]
193+
);
194+
195+
const variationsForSet = useMemo(
196+
() => variationsLinkedToSet(references, setForVariation),
197+
[references, setForVariation]
198+
);
47199

48200
async function run(action: () => Promise<References>) {
49201
setError(null);
@@ -119,35 +271,22 @@ export function AdminCatalogSection({
119271
<TabsContent value="sets" className="space-y-4 pt-4">
120272
<div className="grid gap-6 lg:grid-cols-2">
121273
<div className="space-y-3 rounded-lg border border-border p-4">
122-
<Label htmlFor="admin-set-brand">{t("admin.players.unitAdd")}</Label>
274+
<Label>{t("admin.players.unitAdd")}</Label>
123275
<div className="space-y-2">
124-
<Input
125-
id="admin-set-brand"
126-
list="admin-brands-list"
276+
<CatalogCombobox
127277
value={brandForSet}
128-
onChange={(e) => setBrandForSet(e.target.value)}
278+
onChange={setBrandForSet}
129279
placeholder={t("admin.catalog.setBrandPlaceholder")}
280+
suggestions={references.brands}
130281
disabled={loading}
131282
/>
132-
<datalist id="admin-brands-list">
133-
{references.brands.map((item) => (
134-
<option key={item} value={item} />
135-
))}
136-
</datalist>
137-
<Input
138-
list={setsForBrand.length > 0 ? "admin-sets-list" : undefined}
283+
<CatalogCombobox
139284
value={setName}
140-
onChange={(e) => setSetName(e.target.value)}
285+
onChange={setSetName}
141286
placeholder={t("admin.catalog.setNamePlaceholder")}
287+
suggestions={setsForBrand}
142288
disabled={loading}
143289
/>
144-
{setsForBrand.length > 0 && (
145-
<datalist id="admin-sets-list">
146-
{setsForBrand.map((item) => (
147-
<option key={item} value={item} />
148-
))}
149-
</datalist>
150-
)}
151290
<Button
152291
type="button"
153292
disabled={loading}
@@ -186,35 +325,29 @@ export function AdminCatalogSection({
186325
<TabsContent value="variations" className="space-y-4 pt-4">
187326
<div className="grid gap-6 lg:grid-cols-2">
188327
<div className="space-y-3 rounded-lg border border-border p-4">
189-
<Label htmlFor="admin-variation-set">{t("admin.players.unitAdd")}</Label>
328+
<Label>{t("admin.players.unitAdd")}</Label>
190329
<div className="space-y-2">
191-
<Input
192-
id="admin-variation-set"
193-
list="admin-variation-sets-list"
330+
<CatalogCombobox
331+
value={brandForVariation}
332+
onChange={setBrandForVariation}
333+
placeholder={t("admin.catalog.variationBrandPlaceholder")}
334+
suggestions={references.brands}
335+
disabled={loading}
336+
/>
337+
<CatalogCombobox
194338
value={setForVariation}
195-
onChange={(e) => setSetForVariation(e.target.value)}
339+
onChange={setSetForVariation}
196340
placeholder={t("admin.catalog.variationSetPlaceholder")}
341+
suggestions={setsForVariationBrand}
197342
disabled={loading}
198343
/>
199-
<datalist id="admin-variation-sets-list">
200-
{references.sets.map((item) => (
201-
<option key={item} value={item} />
202-
))}
203-
</datalist>
204-
<Input
205-
list={variationsForSet.length > 0 ? "admin-variations-list" : undefined}
344+
<CatalogCombobox
206345
value={variation}
207-
onChange={(e) => setVariation(e.target.value)}
346+
onChange={setVariation}
208347
placeholder={t("admin.catalog.variationPlaceholder")}
348+
suggestions={variationsForSet}
209349
disabled={loading}
210350
/>
211-
{variationsForSet.length > 0 && (
212-
<datalist id="admin-variations-list">
213-
{variationsForSet.map((item) => (
214-
<option key={item} value={item} />
215-
))}
216-
</datalist>
217-
)}
218351
<Button
219352
type="button"
220353
disabled={loading}

0 commit comments

Comments
 (0)