feat(admin): add category filter and keyword search to icons devkit page

- add category sidebar with icon counts derived from Icon.category metadata
- extend search to match icon keywords via Icon.keywords array
- add AddRemoveIcon to shared icons with category and keywords metadata
- add icons README documenting category and keywords conventions
This commit is contained in:
2026-04-26 09:31:10 -04:00
parent b598ce7ed7
commit 7412de96ea
4 changed files with 443 additions and 41 deletions
+100 -40
View File
@@ -9,13 +9,28 @@ const ALL_ICONS = Object.entries(Icons);
export default function IconsPage() {
const [query, setQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState(null);
const toast = useToast();
const categories = useMemo(() => {
const map = new Map();
for (const [, Icon] of ALL_ICONS) {
const cat = Icon.category;
if (cat) map.set(cat, (map.get(cat) ?? 0) + 1);
}
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b));
}, []);
const filtered = useMemo(() => {
if (!query.trim()) return ALL_ICONS;
let list = ALL_ICONS;
if (selectedCategory) list = list.filter(([, Icon]) => Icon.category === selectedCategory);
if (!query.trim()) return list;
const q = query.trim().toLowerCase();
return ALL_ICONS.filter(([name]) => name.toLowerCase().includes(q));
}, [query]);
return list.filter(([name, Icon]) =>
name.toLowerCase().includes(q) ||
Icon.keywords?.some(k => k.toLowerCase().includes(q))
);
}, [query, selectedCategory]);
const handleCopy = (name) => {
navigator.clipboard.writeText(`<${name} className="w-5 h-5" />`);
@@ -29,45 +44,90 @@ export default function IconsPage() {
description={`${ALL_ICONS.length} icônes disponibles`}
/>
<div className="relative">
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Rechercher une icône..."
className="w-full rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-2.5 text-sm text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-blue-700/40"
/>
{query && (
<button
onClick={() => setQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-200 text-lg leading-none"
>
×
</button>
<div className="flex gap-4 items-start">
<div className="flex-1 min-w-0 flex flex-col gap-6">
<div className="relative">
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Rechercher une icône..."
className="w-full rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-2.5 text-sm text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-blue-700/40"
/>
{query && (
<button
onClick={() => setQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-200 text-lg leading-none"
>
×
</button>
)}
</div>
{filtered.length === 0 ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-12">
Aucune icône trouvée pour &ldquo;{query}&rdquo;
</p>
) : (
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 xl:grid-cols-12 2xl:grid-cols-[repeat(16,minmax(0,1fr))] gap-2">
{filtered.map(([name, IconComponent]) => (
<button
key={name}
onClick={() => handleCopy(name)}
title={name}
className="aspect-square flex flex-col items-center justify-center gap-2 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-900 hover:border-blue-500 dark:hover:border-blue-500 transition-colors duration-100 group cursor-pointer p-2"
>
<IconComponent className="w-7 h-7 text-black dark:text-white" />
<span className="text-[9px] text-neutral-500 dark:text-neutral-400 leading-tight text-center break-all line-clamp-2 group-hover:text-neutral-600 dark:group-hover:text-neutral-300 w-full px-1">
{name.replace('Icon', '')}
</span>
</button>
))}
</div>
)}
</div>
{categories.length > 0 && (
<div className="w-[185px] shrink-0 sticky top-4 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 overflow-hidden">
<div className="px-3 py-2.5 border-b border-neutral-200 dark:border-neutral-800">
<span className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wide">Catégories</span>
</div>
<div className="py-1">
<button
onClick={() => setSelectedCategory(null)}
className={`w-full flex items-center justify-between px-3 py-1.5 text-sm rounded-md mx-1 transition-colors ${
selectedCategory === null
? 'bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300 font-medium'
: 'text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800'
}`}
style={{ width: 'calc(100% - 8px)' }}
>
<span>Tout</span>
<span className={`text-xs tabular-nums ${selectedCategory === null ? 'text-blue-500 dark:text-blue-400' : 'text-neutral-400 dark:text-neutral-500'}`}>
{ALL_ICONS.length}
</span>
</button>
{categories.map(([cat, count]) => (
<button
key={cat}
onClick={() => setSelectedCategory(cat)}
className={`w-full flex items-center justify-between px-3 py-1.5 text-sm rounded-md mx-1 transition-colors ${
selectedCategory === cat
? 'bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300 font-medium'
: 'text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800'
}`}
style={{ width: 'calc(100% - 8px)' }}
>
<span className="truncate text-left">{cat}</span>
<span className={`text-xs tabular-nums shrink-0 ml-1 ${selectedCategory === cat ? 'text-blue-500 dark:text-blue-400' : 'text-neutral-400 dark:text-neutral-500'}`}>
{count}
</span>
</button>
))}
</div>
</div>
)}
</div>
{filtered.length === 0 ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-12">
Aucune icône trouvée pour &ldquo;{query}&rdquo;
</p>
) : (
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 xl:grid-cols-12 2xl:grid-cols-[repeat(16,minmax(0,1fr))] gap-2">
{filtered.map(([name, IconComponent]) => (
<button
key={name}
onClick={() => handleCopy(name)}
title={name}
className="aspect-square flex flex-col items-center justify-center gap-2 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-900 hover:border-blue-500 dark:hover:border-blue-500 transition-colors duration-100 group cursor-pointer p-2"
>
<IconComponent className="w-7 h-7 text-black dark:text-white" />
<span className="text-[9px] text-neutral-500 dark:text-neutral-400 leading-tight text-center break-all line-clamp-2 group-hover:text-neutral-600 dark:group-hover:text-neutral-300 w-full px-1">
{name.replace('Icon', '')}
</span>
</button>
))}
</div>
)}
</div>
);
}