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(null); const [query, setQuery] = useState(""); const [selected, setSelected] = useState(() => value?.set === "custom" ? value : toEmojiIconRef(value)); const [customAssets, setCustomAssets] = useState([]); const [loadingAssets, setLoadingAssets] = useState(true); const [assetBusy, setAssetBusy] = useState(false); const [assetError, setAssetError] = useState(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) { 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 (

{t("project.icon.picker")}

setQuery(event.target.value)} placeholder={t("project.icon.search")} aria-label={t("project.icon.search")} />

{tr("Mes icônes", "My icons")}

void importIcon(event)} aria-label={tr("Importer une icône", "Import an icon")} />

{tr("PNG, JPEG ou WebP — 2 Mo maximum.", "PNG, JPEG or WebP — 2 MB maximum.")}

{loadingAssets ?
{t("common.loading")}
: null} {!loadingAssets && customAssets.length === 0 ?

{tr("Aucune icône importée.", "No imported icons yet.")}

: null} {visibleCustomAssets.length > 0 ?
{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
; })}
: null}

Emojis

{groupedResults.map(({ group, entries }) =>
{t(GROUP_LABELS[group])}
{entries.map((entry) => { const active = selected?.set === entry.set && selected.name === entry.name; return ; })}
)}
{assetError ?

{assetError}

: null} {results.length === 0 && visibleCustomAssets.length === 0 ?

{t("project.icon.noResults")}

: null}
); }