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:
@ -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">
|
||||
|
||||
Reference in New Issue
Block a user