Files
IdeA/frontend/src/features/projects/ProjectIconPicker.tsx
Blomios 0f01d1f9b1 feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): bibliothèque d'icônes projet personnalisées importées par l'utilisateur — #358 (QA verte)
domain: variante ProjectIconSet::Custom, métadonnées CustomProjectIconAsset (id opaque, media type png/jpeg/webp, byte_size, dimensions décodées, sha256, nom de fichier original sans chemin, created_at), CustomProjectIconContent et port CustomProjectIconAssetStore (list/read/exists/create/delete) avec CustomProjectIconStoreError dédiée.
application: use case project_icon_assets — import validé png/jpeg/webp avec rejet du contenu SVG, dimensionnement et digest sha256, bibliothèque partagée à l'instance indépendante de tout projet, suppression refusée tant que l'asset est référencé par un projet.
infrastructure: store fichier store/project_icon_asset.rs — index canonique + octets, création atomique sous extension validée.
backend/web-server: contrat HTTP /api/project-icon-assets (GET liste, POST import, GET lecture, GET /usage, DELETE) avec prévol OPTIONS et tests contrat incluant le rejet SVG.
app-tauri: commandes import/list/read/get_usage/delete_custom_project_icon_asset exposées au frontend.
frontend: port ProjectIconAssetGateway (list/import/delete/getUrl sans exposer de chemin filesystem), gateways http+mock avec tests, DI, section Icônes personnalisées du ProjectIconPicker (import fichier accept png/jpeg/webp, sélection role=option, suppression avec retrait de sélection, erreurs lisibles i18n FR/EN), tests projectIcons étendus.

QA verte: contrats HTTP et Tauri identiques, suites cargo et npm ciblées vertes.
2026-09-15 07:57:51 +02:00

150 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import { useGateways } from "@/app/di";
import type { CustomProjectIconAsset, ProjectIconRef } from "@/ports";
import { Button, Input, Spinner } from "@/shared";
import { useI18n } from "@/i18n";
import { PROJECT_ICON_CATALOG, ProjectIcon, type ProjectIconGroup, toEmojiIconRef } from "./projectIcons";
const GROUPS: readonly ProjectIconGroup[] = ["smileys", "nature", "food", "activities", "travel", "objects", "symbols"];
const GROUP_LABELS = {
smileys: "project.icon.group.smileys", nature: "project.icon.group.nature", food: "project.icon.group.food",
activities: "project.icon.group.activities", travel: "project.icon.group.travel", objects: "project.icon.group.objects",
symbols: "project.icon.group.symbols",
} as const;
const MAX_ICON_BYTES = 2 * 1024 * 1024;
const ACCEPTED_ICON_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]);
function readableError(error: unknown, fallback: string): string {
if (error && typeof error === "object" && "message" in error && typeof error.message === "string") return error.message;
return fallback;
}
export function ProjectIconPicker({ value, onApply, onCancel }: {
value: ProjectIconRef | null;
onApply(value: ProjectIconRef | null): void;
onCancel(): void;
}) {
const { projectIconAsset } = useGateways();
const { t, tr } = useI18n();
const fileInput = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<ProjectIconRef | null>(() => value?.set === "custom" ? value : toEmojiIconRef(value));
const [customAssets, setCustomAssets] = useState<CustomProjectIconAsset[]>([]);
const [loadingAssets, setLoadingAssets] = useState(true);
const [assetBusy, setAssetBusy] = useState(false);
const [assetError, setAssetError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoadingAssets(true);
void projectIconAsset.list().then((assets) => {
if (!cancelled) setCustomAssets(assets);
}).catch((error) => {
if (!cancelled) setAssetError(readableError(error, tr("Impossible de charger vos icônes.", "Unable to load your icons.")));
}).finally(() => { if (!cancelled) setLoadingAssets(false); });
return () => { cancelled = true; };
}, [projectIconAsset, tr]);
const words = useMemo(() => query.trim().toLowerCase().split(/\s+/).filter(Boolean), [query]);
const results = useMemo(() => PROJECT_ICON_CATALOG.filter((entry) =>
words.every((word) => entry.keywords.some((key) => key.includes(word)))), [words]);
const visibleCustomAssets = useMemo(() => customAssets.filter((asset) => {
const searchable = `${asset.originalFilename} ${asset.id}`.toLowerCase();
return words.every((word) => searchable.includes(word));
}), [customAssets, words]);
const groupedResults = useMemo(() => GROUPS.map((group) => ({ group, entries: results.filter((entry) => entry.group === group) }))
.filter(({ entries }) => entries.length > 0), [results]);
async function importIcon(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (!ACCEPTED_ICON_TYPES.has(file.type)) {
setAssetError(tr("Format non pris en charge. Choisissez un fichier PNG, JPEG ou WebP.", "Unsupported format. Choose a PNG, JPEG or WebP file."));
return;
}
if (file.size > MAX_ICON_BYTES) {
setAssetError(tr("Cette icône dépasse la limite de 2 Mo.", "This icon exceeds the 2 MB limit."));
return;
}
setAssetBusy(true);
setAssetError(null);
try {
const imported = await projectIconAsset.import(file);
setCustomAssets((current) => [imported, ...current.filter(({ id }) => id !== imported.id)]);
setSelected({ set: "custom", name: imported.id });
} catch (error) {
setAssetError(readableError(error, tr("Impossible d’importer cette icône.", "Unable to import this icon.")));
} finally { setAssetBusy(false); }
}
async function deleteIcon(asset: CustomProjectIconAsset) {
if (asset.usedByProjectCount > 0) return;
setAssetBusy(true);
setAssetError(null);
try {
await projectIconAsset.delete(asset.id);
setCustomAssets((current) => current.filter(({ id }) => id !== asset.id));
if (selected?.set === "custom" && selected.name === asset.id) setSelected(null);
} catch (error) {
setAssetError(readableError(error, tr("Impossible de supprimer cette icône.", "Unable to delete this icon.")));
} finally { setAssetBusy(false); }
}
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="min-h-28 space-y-5 overflow-y-auto pr-1">
<section aria-labelledby="project-icon-custom-heading">
<div className="sticky top-0 z-20 mb-2 flex items-center justify-between gap-2 bg-surface py-1">
<h4 id="project-icon-custom-heading" className="text-xs font-semibold uppercase tracking-wide text-muted">{tr("Mes icônes", "My icons")}</h4>
<Button size="sm" variant="secondary" disabled={assetBusy} onClick={() => fileInput.current?.click()}>
{assetBusy ? <Spinner size={14} /> : null}{tr("Importer une icône", "Import an icon")}
</Button>
<input ref={fileInput} type="file" className="sr-only" accept="image/png,image/jpeg,image/webp" onChange={(event) => void importIcon(event)} aria-label={tr("Importer une icône", "Import an icon")} />
</div>
<p className="mb-2 text-xs text-muted">{tr("PNG, JPEG ou WebP — 2 Mo maximum.", "PNG, JPEG or WebP — 2 MB maximum.")}</p>
{loadingAssets ? <div className="flex items-center gap-2 text-sm text-muted"><Spinner size={14} />{t("common.loading")}</div> : null}
{!loadingAssets && customAssets.length === 0 ? <p className="text-sm text-muted">{tr("Aucune icône importée.", "No imported icons yet.")}</p> : null}
{visibleCustomAssets.length > 0 ? <div className="grid grid-cols-4 gap-2 sm:grid-cols-6">
{visibleCustomAssets.map((asset) => {
const active = selected?.set === "custom" && selected.name === asset.id;
const inUse = asset.usedByProjectCount > 0;
const usage = tr(`Utilisée par ${asset.usedByProjectCount} projet${asset.usedByProjectCount > 1 ? "s" : ""}`, `Used by ${asset.usedByProjectCount} project${asset.usedByProjectCount === 1 ? "" : "s"}`);
return <div key={asset.id} className="relative min-w-0">
<button type="button" role="option" aria-selected={active} aria-label={asset.originalFilename} onClick={() => setSelected({ set: "custom", name: asset.id })} className={`flex min-h-20 w-full 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={{ set: "custom", name: asset.id }} size={36} decorative />
<span className="line-clamp-2 break-all">{asset.originalFilename}</span>
</button>
<button type="button" disabled={assetBusy} aria-disabled={inUse || undefined} title={inUse ? usage : tr("Supprimer de la bibliothèque", "Delete from library")} aria-label={inUse ? `${tr("Suppression impossible", "Cannot delete")}: ${usage}` : `${tr("Supprimer", "Delete")} ${asset.originalFilename}`} onClick={() => inUse ? setAssetError(`${tr("Suppression impossible", "Cannot delete")}: ${usage}.`) : void deleteIcon(asset)} className="absolute right-1 top-1 rounded bg-surface/90 px-1.5 py-0.5 text-xs text-muted hover:text-content disabled:cursor-not-allowed disabled:opacity-50">×</button>
</div>;
})}
</div> : null}
</section>
<section aria-labelledby="project-icon-emoji-heading">
<h4 id="project-icon-emoji-heading" className="sticky top-0 z-10 mb-2 bg-surface py-1 text-xs font-semibold uppercase tracking-wide text-muted">Emojis</h4>
<div className="space-y-4">{groupedResults.map(({ group, entries }) => <div key={group} role="group" aria-labelledby={`project-icon-group-${group}`}>
<h5 id={`project-icon-group-${group}`} className="mb-2 text-xs font-semibold text-muted">{t(GROUP_LABELS[group])}</h5>
<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>
</section>
</div>
{assetError ? <p role="alert" className="text-sm text-danger">{assetError}</p> : null}
{results.length === 0 && visibleCustomAssets.length === 0 ? <p role="status" className="text-sm text-muted">{t("project.icon.noResults")}</p> : null}
<div className="flex flex-wrap justify-between gap-2">
<Button variant="ghost" disabled={!selected} onClick={() => setSelected(null)}>{t("project.icon.remove")}</Button>
<div className="flex gap-2"><Button variant="ghost" onClick={onCancel}>{t("common.cancel")}</Button><Button disabled={assetBusy} onClick={() => onApply(selected)}>{t("common.apply")}</Button></div>
</div>
</div>
</div>
);
}