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:
@ -122,6 +122,11 @@ export class HttpInvoker {
|
||||
return this.request<T>("POST", "/api/invoke", { command, args }, `command '${command}'`);
|
||||
}
|
||||
|
||||
/** Resolves a same-server REST path for browser-owned resources such as images. */
|
||||
absoluteUrl(path: string): string {
|
||||
return `${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends one authenticated REST request and maps the response exactly like
|
||||
* {@link invoke} (401 routing + `ErrorDto` preservation).
|
||||
|
||||
@ -32,6 +32,7 @@ import {
|
||||
HttpPermissionGateway,
|
||||
HttpProfileGateway,
|
||||
HttpProjectGateway,
|
||||
HttpProjectIconAssetGateway,
|
||||
HttpSkillGateway,
|
||||
HttpTemplateGateway,
|
||||
HttpWorkStateGateway,
|
||||
@ -126,6 +127,7 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
|
||||
input: new HttpInputGateway(http),
|
||||
terminal: new HttpTerminalGateway(ws),
|
||||
project: new HttpProjectGateway(http),
|
||||
projectIconAsset: new HttpProjectIconAssetGateway(http),
|
||||
layout: new HttpLayoutGateway(http),
|
||||
git: new HttpGitGateway(http),
|
||||
remote: new WebRemoteGateway(),
|
||||
|
||||
27
frontend/src/adapters/http/projectIconAssetGateway.test.ts
Normal file
27
frontend/src/adapters/http/projectIconAssetGateway.test.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { HttpInvoker } from "./httpInvoker";
|
||||
import { HttpProjectIconAssetGateway } from "./requestResponseGateways";
|
||||
|
||||
describe("HttpProjectIconAssetGateway", () => {
|
||||
it("maps list/import/delete/read URLs to the dedicated REST resource", async () => {
|
||||
const http = new HttpInvoker({ baseUrl: "https://idea.test" });
|
||||
const request = vi.spyOn(http, "request").mockResolvedValue({ id: "asset-1" } as never);
|
||||
const gateway = new HttpProjectIconAssetGateway(http);
|
||||
|
||||
await gateway.list();
|
||||
const file = {
|
||||
name: "icon.webp", type: "image/webp",
|
||||
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
||||
} as File;
|
||||
await gateway.import(file);
|
||||
await gateway.delete("asset/with spaces");
|
||||
|
||||
expect(request).toHaveBeenNthCalledWith(1, "GET", "/api/project-icon-assets", undefined, "custom project icons");
|
||||
expect(request).toHaveBeenNthCalledWith(2, "POST", "/api/project-icon-assets", {
|
||||
originalFilename: "icon.webp", mediaType: "image/webp", dataBase64: "AQID",
|
||||
}, "custom project icon import");
|
||||
expect(request).toHaveBeenNthCalledWith(3, "DELETE", "/api/project-icon-assets/asset%2Fwith%20spaces", undefined, "custom project icon deletion");
|
||||
await expect(gateway.getUrl("asset-1")).resolves.toBe("https://idea.test/api/project-icon-assets/asset-1");
|
||||
});
|
||||
});
|
||||
@ -74,6 +74,8 @@ import type {
|
||||
PermissionGateway,
|
||||
ProfileGateway,
|
||||
ProjectGateway,
|
||||
ProjectIconAssetGateway,
|
||||
CustomProjectIconAsset,
|
||||
ProjectSettings,
|
||||
SaveOpenCodeProviderProfileInput,
|
||||
SkillGateway,
|
||||
@ -170,6 +172,43 @@ export class HttpProjectGateway implements ProjectGateway {
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpProjectIconAssetGateway implements ProjectIconAssetGateway {
|
||||
constructor(private readonly http: HttpInvoker) {}
|
||||
|
||||
list(): Promise<CustomProjectIconAsset[]> {
|
||||
return this.http.request("GET", "/api/project-icon-assets", undefined, "custom project icons");
|
||||
}
|
||||
|
||||
async import(file: File): Promise<CustomProjectIconAsset> {
|
||||
const dataBase64 = bytesToBase64(new Uint8Array(await file.arrayBuffer()));
|
||||
return this.http.request("POST", "/api/project-icon-assets", {
|
||||
originalFilename: file.name,
|
||||
mediaType: file.type,
|
||||
dataBase64,
|
||||
}, "custom project icon import");
|
||||
}
|
||||
|
||||
async delete(assetId: string): Promise<void> {
|
||||
await this.http.request("DELETE", projectIconAssetPath(assetId), undefined, "custom project icon deletion");
|
||||
}
|
||||
|
||||
async getUrl(assetId: string): Promise<string> {
|
||||
return this.http.absoluteUrl(projectIconAssetPath(assetId));
|
||||
}
|
||||
}
|
||||
|
||||
function projectIconAssetPath(assetId: string): string {
|
||||
return `/api/project-icon-assets/${encodeURIComponent(assetId)}`;
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function projectFilesPath(endpoint: string, projectId: string, path: string): string {
|
||||
const params = new URLSearchParams({ projectId, path });
|
||||
return `${endpoint}?${params.toString()}`;
|
||||
|
||||
@ -16,6 +16,7 @@ import { TauriSystemGateway } from "./system";
|
||||
import { TauriAgentGateway } from "./agent";
|
||||
import { TauriInputGateway } from "./input";
|
||||
import { TauriProjectGateway } from "./project";
|
||||
import { TauriProjectIconAssetGateway } from "./projectIconAsset";
|
||||
import { TauriTerminalGateway } from "./terminal";
|
||||
import { TauriLayoutGateway } from "./layout";
|
||||
import { TauriProfileGateway } from "./profile";
|
||||
@ -66,6 +67,7 @@ export function createTauriGateways(): Gateways {
|
||||
input: new TauriInputGateway(),
|
||||
terminal: new TauriTerminalGateway(),
|
||||
project: new TauriProjectGateway(),
|
||||
projectIconAsset: new TauriProjectIconAssetGateway(),
|
||||
layout: new TauriLayoutGateway(),
|
||||
git: new TauriGitGateway(),
|
||||
remote: new TauriRemoteGateway(),
|
||||
@ -101,6 +103,7 @@ export {
|
||||
TauriAgentGateway,
|
||||
TauriInputGateway,
|
||||
TauriProjectGateway,
|
||||
TauriProjectIconAssetGateway,
|
||||
TauriTerminalGateway,
|
||||
TauriLayoutGateway,
|
||||
TauriProfileGateway,
|
||||
|
||||
@ -138,6 +138,8 @@ import type {
|
||||
OpenTerminalOptions,
|
||||
ProfileGateway,
|
||||
ProjectGateway,
|
||||
ProjectIconAssetGateway,
|
||||
CustomProjectIconAsset,
|
||||
ProjectSettings,
|
||||
PermissionGateway,
|
||||
PluginConfigDocumentReadInput,
|
||||
@ -1287,6 +1289,59 @@ export class MockTerminalGateway implements TerminalGateway {
|
||||
}
|
||||
}
|
||||
|
||||
export class MockProjectIconAssetGateway implements ProjectIconAssetGateway {
|
||||
private readonly assets = new Map<string, CustomProjectIconAsset>();
|
||||
private readonly urls = new Map<string, string>();
|
||||
private sequence = 0;
|
||||
|
||||
async list(): Promise<CustomProjectIconAsset[]> {
|
||||
return [...this.assets.values()].map((asset) => ({ ...asset }));
|
||||
}
|
||||
|
||||
async import(file: File): Promise<CustomProjectIconAsset> {
|
||||
if (!["image/png", "image/jpeg", "image/webp"].includes(file.type)) {
|
||||
throw { code: "INVALID_ICON_MEDIA_TYPE", message: "PNG, JPEG or WebP required" } as GatewayError;
|
||||
}
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
throw { code: "ICON_TOO_LARGE", message: "Icon exceeds 2 MiB" } as GatewayError;
|
||||
}
|
||||
const id = `mock-icon-${++this.sequence}`;
|
||||
const asset: CustomProjectIconAsset = {
|
||||
id,
|
||||
mediaType: file.type as CustomProjectIconAsset["mediaType"],
|
||||
byteSize: file.size,
|
||||
width: 0,
|
||||
height: 0,
|
||||
sha256: `mock-${id}`,
|
||||
originalFilename: file.name,
|
||||
createdAt: Date.now(),
|
||||
usedByProjectCount: 0,
|
||||
};
|
||||
this.assets.set(id, asset);
|
||||
const createObjectURL = URL.createObjectURL?.bind(URL);
|
||||
this.urls.set(id, createObjectURL ? createObjectURL(file) : `data:${file.type};base64,`);
|
||||
return { ...asset };
|
||||
}
|
||||
|
||||
async delete(assetId: string): Promise<void> {
|
||||
const asset = this.assets.get(assetId);
|
||||
if (!asset) throw { code: "NOT_FOUND", message: `icon ${assetId} not found` } as GatewayError;
|
||||
if (asset.usedByProjectCount > 0) {
|
||||
throw { code: "CUSTOM_ICON_IN_USE", message: `icon is used by ${asset.usedByProjectCount} projects` } as GatewayError;
|
||||
}
|
||||
const url = this.urls.get(assetId);
|
||||
if (url?.startsWith("blob:")) URL.revokeObjectURL?.(url);
|
||||
this.assets.delete(assetId);
|
||||
this.urls.delete(assetId);
|
||||
}
|
||||
|
||||
async getUrl(assetId: string): Promise<string> {
|
||||
const url = this.urls.get(assetId);
|
||||
if (!url) throw { code: "NOT_FOUND", message: `icon ${assetId} not found` } as GatewayError;
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
export class MockProjectGateway implements ProjectGateway {
|
||||
private projects: Project[] = [];
|
||||
private contexts = new Map<string, string>();
|
||||
@ -5002,6 +5057,7 @@ export function createMockGateways(): Gateways {
|
||||
input: new MockInputGateway(),
|
||||
terminal: new MockTerminalGateway(),
|
||||
project: new MockProjectGateway(),
|
||||
projectIconAsset: new MockProjectIconAssetGateway(),
|
||||
layout: new MockLayoutGateway(),
|
||||
git: new MockGitGateway(),
|
||||
remote: new MockRemoteGateway(),
|
||||
|
||||
@ -35,6 +35,7 @@ describe("createMockGateways", () => {
|
||||
"pluginWorkspace",
|
||||
"profile",
|
||||
"project",
|
||||
"projectIconAsset",
|
||||
"remote",
|
||||
"scheduledTask",
|
||||
"skill",
|
||||
|
||||
38
frontend/src/adapters/projectIconAsset.test.ts
Normal file
38
frontend/src/adapters/projectIconAsset.test.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const invoke = vi.fn();
|
||||
vi.mock("@tauri-apps/api/core", () => ({ invoke: (...args: unknown[]) => invoke(...args) }));
|
||||
|
||||
import { TauriProjectIconAssetGateway } from "./projectIconAsset";
|
||||
|
||||
describe("TauriProjectIconAssetGateway", () => {
|
||||
beforeEach(() => invoke.mockReset());
|
||||
|
||||
it("uses the shared backend commands and base64 import DTO", async () => {
|
||||
const gateway = new TauriProjectIconAssetGateway();
|
||||
invoke.mockResolvedValueOnce([]);
|
||||
await gateway.list();
|
||||
expect(invoke).toHaveBeenCalledWith("list_custom_project_icon_assets");
|
||||
|
||||
invoke.mockResolvedValueOnce({ id: "asset-1" });
|
||||
const file = {
|
||||
name: "icon.png", type: "image/png",
|
||||
arrayBuffer: async () => new Uint8Array([0, 1, 2]).buffer,
|
||||
} as File;
|
||||
await gateway.import(file);
|
||||
expect(invoke).toHaveBeenCalledWith("import_custom_project_icon_asset", {
|
||||
request: { originalFilename: "icon.png", mediaType: "image/png", dataBase64: "AAEC" },
|
||||
});
|
||||
|
||||
invoke.mockResolvedValueOnce(undefined);
|
||||
await gateway.delete("asset-1");
|
||||
expect(invoke).toHaveBeenCalledWith("delete_custom_project_icon_asset", { assetId: "asset-1" });
|
||||
});
|
||||
|
||||
it("turns the Tauri read DTO into a browser-safe data URL", async () => {
|
||||
invoke.mockResolvedValueOnce({ mediaType: "image/webp", dataBase64: "AQID" });
|
||||
await expect(new TauriProjectIconAssetGateway().getUrl("asset-1"))
|
||||
.resolves.toBe("data:image/webp;base64,AQID");
|
||||
expect(invoke).toHaveBeenCalledWith("read_custom_project_icon_asset", { assetId: "asset-1" });
|
||||
});
|
||||
});
|
||||
41
frontend/src/adapters/projectIconAsset.ts
Normal file
41
frontend/src/adapters/projectIconAsset.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type { CustomProjectIconAsset, ProjectIconAssetGateway } from "@/ports";
|
||||
|
||||
interface CustomProjectIconAssetContent extends CustomProjectIconAsset {
|
||||
dataBase64: string;
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export class TauriProjectIconAssetGateway implements ProjectIconAssetGateway {
|
||||
list(): Promise<CustomProjectIconAsset[]> {
|
||||
return invoke("list_custom_project_icon_assets");
|
||||
}
|
||||
|
||||
async import(file: File): Promise<CustomProjectIconAsset> {
|
||||
const dataBase64 = bytesToBase64(new Uint8Array(await file.arrayBuffer()));
|
||||
return invoke("import_custom_project_icon_asset", {
|
||||
request: {
|
||||
originalFilename: file.name,
|
||||
mediaType: file.type,
|
||||
dataBase64,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async delete(assetId: string): Promise<void> {
|
||||
await invoke("delete_custom_project_icon_asset", { assetId });
|
||||
}
|
||||
|
||||
getUrl(assetId: string): Promise<string> {
|
||||
return invoke<CustomProjectIconAssetContent>("read_custom_project_icon_asset", { assetId })
|
||||
.then((content) => `data:${content.mediaType};base64,${content.dataBase64}`);
|
||||
}
|
||||
}
|
||||
@ -80,3 +80,8 @@ export function useGateways(): Gateways {
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** Optional access for leaf renderers that retain a graceful no-provider fallback. */
|
||||
export function useOptionalGateways(): Gateways | null {
|
||||
return useContext(GatewaysContext);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -495,13 +495,35 @@ export interface UserSettingsGateway {
|
||||
updateUserSettings(settings: Partial<UserSettings>): Promise<UserSettings>;
|
||||
}
|
||||
|
||||
export type ProjectIconSet = "emoji" | "devicon" | "simple-icons" | "lucide" | "idea";
|
||||
export type ProjectIconSet = "emoji" | "devicon" | "simple-icons" | "lucide" | "idea" | "custom";
|
||||
|
||||
export interface ProjectIconRef {
|
||||
set: ProjectIconSet;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** One user-imported raster icon in the instance-wide project icon library. */
|
||||
export interface CustomProjectIconAsset {
|
||||
id: string;
|
||||
mediaType: "image/png" | "image/jpeg" | "image/webp";
|
||||
byteSize: number;
|
||||
width: number;
|
||||
height: number;
|
||||
sha256: string;
|
||||
originalFilename: string;
|
||||
createdAt: number | string;
|
||||
usedByProjectCount: number;
|
||||
}
|
||||
|
||||
/** Shared custom-project-icon library, independent from any single project. */
|
||||
export interface ProjectIconAssetGateway {
|
||||
list(): Promise<CustomProjectIconAsset[]>;
|
||||
import(file: File): Promise<CustomProjectIconAsset>;
|
||||
delete(assetId: string): Promise<void>;
|
||||
/** Returns a browser-loadable URL without exposing a local filesystem path. */
|
||||
getUrl(assetId: string): Promise<string>;
|
||||
}
|
||||
|
||||
export interface ProjectSettings {
|
||||
themeColor: string | null;
|
||||
/** Absent only when talking to a pre-#325 backend. */
|
||||
@ -1892,6 +1914,7 @@ export interface Gateways {
|
||||
input: InputGateway;
|
||||
terminal: TerminalGateway;
|
||||
project: ProjectGateway;
|
||||
projectIconAsset: ProjectIconAssetGateway;
|
||||
layout: LayoutGateway;
|
||||
git: GitGateway;
|
||||
remote: RemoteGateway;
|
||||
|
||||
Reference in New Issue
Block a user