feat(frontend): registre icônes de marque légales avec métadonnées et fallback, monogrammes A-Z et filigrane CLI agrandi — #335 (QA verte)

This commit is contained in:
2026-09-12 12:29:35 +02:00
parent 460fad4e60
commit 4c69914817
8 changed files with 356 additions and 70 deletions

View File

@ -2,7 +2,19 @@ import { useMemo, useState } from "react";
import type { ProjectIconRef } from "@/ports";
import { Button, Input } from "@/shared";
import { useI18n } from "@/i18n";
import { PROJECT_ICON_CATALOG, ProjectIcon } from "./projectIcons";
import {
PROJECT_ICON_CATALOG,
ProjectIcon,
type ProjectIconGroup,
} from "./projectIcons";
const GROUPS: readonly ProjectIconGroup[] = ["platforms", "technologies", "symbols", "letters"];
const GROUP_LABELS = {
platforms: "project.icon.group.platforms",
technologies: "project.icon.group.technologies",
symbols: "project.icon.group.symbols",
letters: "project.icon.group.letters",
} as const;
export function ProjectIconPicker({ value, onApply, onCancel }: {
value: ProjectIconRef | null;
@ -16,20 +28,33 @@ export function ProjectIconPicker({ value, onApply, onCancel }: {
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
return PROJECT_ICON_CATALOG.filter((entry) => words.every((word) => entry.keywords.some((key) => key.includes(word))));
}, [query]);
const groupedResults = useMemo(() => GROUPS.map((group) => ({
group,
entries: results.filter((entry) => entry.group === group),
})).filter(({ entries }) => entries.length > 0), [results]);
return (
<div role="dialog" aria-modal="true" aria-label={t("project.icon.picker")} className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/55 p-4">
<div className="flex max-h-[80vh] w-full max-w-xl flex-col gap-3 rounded-lg border border-border bg-surface p-4 shadow-xl">
<h3 className="text-base font-semibold text-content">{t("project.icon.picker")}</h3>
<Input autoFocus value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("project.icon.search")} aria-label={t("project.icon.search")} />
<div role="listbox" aria-label={t("project.icon.results")} className="grid min-h-28 grid-cols-4 gap-2 overflow-y-auto sm:grid-cols-6">
{results.map((entry) => {
const active = selected?.set === entry.set && selected.name === entry.name;
return <button key={`${entry.set}:${entry.name}`} type="button" role="option" aria-selected={active} title={entry.label} onClick={() => setSelected({ set: entry.set, name: entry.name })} className={`flex min-h-20 flex-col items-center justify-center gap-1 rounded border p-2 text-xs ${active ? "border-primary bg-primary/15 text-content" : "border-border bg-raised text-muted hover:text-content"}`}>
<ProjectIcon icon={entry} size={32} decorative />
<span className="line-clamp-2">{entry.label}</span>
</button>;
})}
<div role="listbox" aria-label={t("project.icon.results")} className="min-h-28 space-y-4 overflow-y-auto pr-1">
{groupedResults.map(({ group, entries }) => (
<div key={group} role="group" aria-labelledby={`project-icon-group-${group}`}>
<h4 id={`project-icon-group-${group}`} className="sticky top-0 z-10 mb-2 bg-surface py-1 text-xs font-semibold uppercase tracking-wide text-muted">
{t(GROUP_LABELS[group])}
</h4>
<div className="grid grid-cols-4 gap-2 sm:grid-cols-6">
{entries.map((entry) => {
const active = selected?.set === entry.set && selected.name === entry.name;
return <button key={`${entry.set}:${entry.name}`} type="button" role="option" aria-selected={active} title={entry.label} onClick={() => setSelected({ set: entry.set, name: entry.name })} className={`flex min-h-20 flex-col items-center justify-center gap-1 rounded border p-2 text-xs ${active ? "border-primary bg-primary/15 text-content" : "border-border bg-raised text-muted hover:text-content"}`}>
<ProjectIcon icon={entry} size={32} decorative />
<span className="line-clamp-2">{entry.label}</span>
</button>;
})}
</div>
</div>
))}
</div>
{results.length === 0 && <p role="status" className="text-sm text-muted">{t("project.icon.noResults")}</p>}
<div className="flex flex-wrap justify-between gap-2">