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.
This commit is contained in:
@ -1,69 +1,147 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { ProjectIconRef } from "@/ports";
|
||||
import { Button, Input } from "@/shared";
|
||||
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";
|
||||
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",
|
||||
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 { t } = useI18n();
|
||||
const { projectIconAsset } = useGateways();
|
||||
const { t, tr } = useI18n();
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState<ProjectIconRef | null>(() => toEmojiIconRef(value));
|
||||
const results = useMemo(() => {
|
||||
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]);
|
||||
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-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 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>
|
||||
{results.length === 0 && <p role="status" className="text-sm text-muted">{t("project.icon.noResults")}</p>}
|
||||
{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 onClick={() => onApply(selected)}>{t("common.apply")}</Button></div>
|
||||
<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>
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createMockGateways } from "@/adapters/mock";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import type { CustomProjectIconAsset } from "@/ports";
|
||||
import { ProjectIconPicker } from "./ProjectIconPicker";
|
||||
import {
|
||||
PROJECT_ICON_CATALOG,
|
||||
@ -63,8 +67,12 @@ describe("project emoji catalogue", () => {
|
||||
});
|
||||
|
||||
describe("ProjectIconPicker", () => {
|
||||
function renderWithGateways(ui: ReactNode, gateways = createMockGateways()) {
|
||||
return render(<DIProvider gateways={gateways}>{ui}</DIProvider>);
|
||||
}
|
||||
|
||||
function renderPicker(value = null as Parameters<typeof ProjectIconPicker>[0]["value"]) {
|
||||
render(<ProjectIconPicker value={value} onApply={vi.fn()} onCancel={vi.fn()} />);
|
||||
renderWithGateways(<ProjectIconPicker value={value} onApply={vi.fn()} onCancel={vi.fn()} />);
|
||||
}
|
||||
|
||||
it("shows phone-like emoji categories only", () => {
|
||||
@ -89,7 +97,7 @@ describe("ProjectIconPicker", () => {
|
||||
|
||||
it("selects and persists an emoji reference", () => {
|
||||
const onApply = vi.fn();
|
||||
render(<ProjectIconPicker value={null} onApply={onApply} onCancel={vi.fn()} />);
|
||||
renderWithGateways(<ProjectIconPicker value={null} onApply={onApply} onCancel={vi.fn()} />);
|
||||
fireEvent.change(screen.getByLabelText("Search icons in English"), { target: { value: "rocket" } });
|
||||
fireEvent.click(screen.getByRole("option", { name: "Rocket" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Appliquer" }));
|
||||
@ -98,8 +106,62 @@ describe("ProjectIconPicker", () => {
|
||||
|
||||
it("normalizes a known legacy selection before applying it", () => {
|
||||
const onApply = vi.fn();
|
||||
render(<ProjectIconPicker value={{ set: "devicon", name: "python" }} onApply={onApply} onCancel={vi.fn()} />);
|
||||
renderWithGateways(<ProjectIconPicker value={{ set: "devicon", name: "python" }} onApply={onApply} onCancel={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Appliquer" }));
|
||||
expect(onApply).toHaveBeenCalledWith({ set: "emoji", name: "snake" });
|
||||
});
|
||||
|
||||
it("imports a supported image into Mes icônes and selects its opaque id", async () => {
|
||||
const gateways = createMockGateways();
|
||||
const imported: CustomProjectIconAsset = {
|
||||
id: "asset-opaque-1", mediaType: "image/png", byteSize: 12, width: 32, height: 32,
|
||||
sha256: "abc", originalFilename: "équipe.png", createdAt: 1, usedByProjectCount: 0,
|
||||
};
|
||||
vi.spyOn(gateways.projectIconAsset, "import").mockResolvedValue(imported);
|
||||
vi.spyOn(gateways.projectIconAsset, "getUrl").mockResolvedValue("data:image/png;base64,AA==");
|
||||
const onApply = vi.fn();
|
||||
const { container } = renderWithGateways(<ProjectIconPicker value={null} onApply={onApply} onCancel={vi.fn()} />, gateways);
|
||||
|
||||
expect(screen.getByText("Mes icônes")).toBeTruthy();
|
||||
expect(screen.getByText("PNG, JPEG ou WebP — 2 Mo maximum.")).toBeTruthy();
|
||||
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { files: [new File(["png"], "équipe.png", { type: "image/png" })] } });
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("option", { name: "équipe.png" })).toBeTruthy());
|
||||
fireEvent.click(screen.getByRole("button", { name: "Appliquer" }));
|
||||
expect(onApply).toHaveBeenCalledWith({ set: "custom", name: "asset-opaque-1" });
|
||||
});
|
||||
|
||||
it("blocks deletion while a custom icon is used by projects", async () => {
|
||||
const gateways = createMockGateways();
|
||||
vi.spyOn(gateways.projectIconAsset, "list").mockResolvedValue([{
|
||||
id: "used-icon", mediaType: "image/webp", byteSize: 10, width: 16, height: 16,
|
||||
sha256: "def", originalFilename: "used.webp", createdAt: 1, usedByProjectCount: 2,
|
||||
}]);
|
||||
const deleteSpy = vi.spyOn(gateways.projectIconAsset, "delete");
|
||||
renderWithGateways(<ProjectIconPicker value={null} onApply={vi.fn()} onCancel={vi.fn()} />, gateways);
|
||||
|
||||
const remove = await screen.findByRole("button", { name: /Suppression impossible: Utilisée par 2 projets/ });
|
||||
expect(remove.getAttribute("aria-disabled")).toBe("true");
|
||||
fireEvent.click(remove);
|
||||
expect(screen.getByRole("alert").textContent).toContain("Utilisée par 2 projets");
|
||||
expect(deleteSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom ProjectIcon", () => {
|
||||
it("renders a contained image and falls back cleanly when the asset is missing", async () => {
|
||||
const gateways = createMockGateways();
|
||||
vi.spyOn(gateways.projectIconAsset, "getUrl").mockResolvedValue("data:image/png;base64,AA==");
|
||||
const { getByRole, rerender } = render(
|
||||
<DIProvider gateways={gateways}><ProjectIcon icon={{ set: "custom", name: "asset-1" }} /></DIProvider>,
|
||||
);
|
||||
await waitFor(() => expect(getByRole("img").querySelector("img")?.style.objectFit).toBe("contain"));
|
||||
expect(getByRole("img").dataset.resolvedProjectIcon).toBe("custom:asset-1");
|
||||
|
||||
vi.spyOn(gateways.projectIconAsset, "getUrl").mockRejectedValueOnce({ code: "NOT_FOUND" });
|
||||
rerender(<DIProvider gateways={gateways}><ProjectIcon icon={{ set: "custom", name: "missing" }} fallbackLabel="Alpha" /></DIProvider>);
|
||||
await waitFor(() => expect(getByRole("img").dataset.resolvedProjectIcon).toBe("initial"));
|
||||
expect(getByRole("img").textContent).toBe("A");
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { CSSProperties, HTMLAttributes } from "react";
|
||||
import { useEffect, useState, type CSSProperties, type HTMLAttributes } from "react";
|
||||
import { useOptionalGateways } from "@/app/di";
|
||||
import type { ProjectIconRef } from "@/ports";
|
||||
|
||||
export type ProjectIconGroup =
|
||||
@ -206,8 +207,24 @@ export function ProjectIcon({ icon: ref, fallbackLabel, size = 32, className, st
|
||||
decorative?: boolean;
|
||||
surface?: ProjectIconSurface;
|
||||
} & Omit<HTMLAttributes<HTMLSpanElement>, "children" | "style" | "className">) {
|
||||
const gateways = useOptionalGateways();
|
||||
const entry = resolveProjectIcon(ref, surface);
|
||||
const label = entry?.label ?? fallbackInitial(ref, fallbackLabel);
|
||||
const customAssetId = ref?.set === "custom" ? ref.name : null;
|
||||
const [customUrl, setCustomUrl] = useState<string | null>(null);
|
||||
const [customMissing, setCustomMissing] = useState(false);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setCustomUrl(null);
|
||||
setCustomMissing(false);
|
||||
if (!customAssetId || !gateways) return () => { cancelled = true; };
|
||||
void gateways.projectIconAsset.getUrl(customAssetId).then((url) => {
|
||||
if (!cancelled) setCustomUrl(url);
|
||||
}).catch(() => {
|
||||
if (!cancelled) setCustomMissing(true);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [customAssetId, gateways]);
|
||||
const label = customAssetId ? (fallbackLabel ? `${fallbackLabel} — custom icon` : "Custom icon") : entry?.label ?? fallbackInitial(ref, fallbackLabel);
|
||||
const numericSize = typeof size === "number" ? size : null;
|
||||
const glyph = entry?.glyph ?? fallbackInitial(ref, fallbackLabel);
|
||||
return (
|
||||
@ -219,7 +236,7 @@ export function ProjectIcon({ icon: ref, fallbackLabel, size = 32, className, st
|
||||
aria-label={decorative ? undefined : label}
|
||||
title={decorative ? undefined : label}
|
||||
data-project-icon={ref ? `${ref.set}:${ref.name}` : "none"}
|
||||
data-resolved-project-icon={entry ? `emoji:${entry.name}` : "initial"}
|
||||
data-resolved-project-icon={customUrl && !customMissing ? `custom:${customAssetId}` : entry ? `emoji:${entry.name}` : "initial"}
|
||||
style={{
|
||||
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
||||
width: size, height: size, fontSize: numericSize ? Math.max(16, numericSize * 0.72) : "62%",
|
||||
@ -227,7 +244,15 @@ export function ProjectIcon({ icon: ref, fallbackLabel, size = 32, className, st
|
||||
fontWeight: entry ? 400 : 700, lineHeight: 1, flexShrink: 0, ...style,
|
||||
}}
|
||||
>
|
||||
{glyph}
|
||||
{customUrl && !customMissing ? (
|
||||
<img
|
||||
src={customUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
onError={() => setCustomMissing(true)}
|
||||
style={{ width: "100%", height: "100%", objectFit: "contain", display: "block" }}
|
||||
/>
|
||||
) : glyph}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user