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.
150 lines
10 KiB
TypeScript
150 lines
10 KiB
TypeScript
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>
|
||
);
|
||
}
|