From 8c29eb35e7828690ef75c9670006648b85a581fe Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 15 Oct 2026 09:14:39 +0200 Subject: [PATCH] =?UTF-8?q?Explorateur=20:=20ic=C3=B4ne=20personnalisable?= =?UTF-8?q?=20par=20dossier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Menu « … » d'un dossier > « Icône… » : grille de 27 icônes Tabler (projet, serveurs, stockage, réseau, sécurité, documents, archives…) plus « Par défaut ». L'icône est enregistrée dans .meta.json (champ icon), partagée par tous, et suit renommages et déplacements. Nouvelle route POST /api/meta/icon {"paths":[...],"icon":"server"}. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_011bGUm1KLMvT6YPbJxZgwkj --- frontend/src/Explorer.tsx | 13 +- frontend/src/IconDialog.tsx | 76 ++++++++++ frontend/src/api.ts | 7 + frontend/src/folderIcons.tsx | 251 ++++++++++++++++++++++++++++++++++ frontend/src/icons.tsx | 2 +- frontend/src/styles.css | 36 +++++ internal/api/api.go | 19 +++ internal/storage/meta.go | 40 +++++- internal/storage/meta_test.go | 32 +++++ internal/storage/spaces.go | 18 +++ internal/storage/storage.go | 1 + 11 files changed, 489 insertions(+), 6 deletions(-) create mode 100644 frontend/src/IconDialog.tsx create mode 100644 frontend/src/folderIcons.tsx diff --git a/frontend/src/Explorer.tsx b/frontend/src/Explorer.tsx index 66e1577..a58953e 100644 --- a/frontend/src/Explorer.tsx +++ b/frontend/src/Explorer.tsx @@ -44,6 +44,8 @@ import { UserIcon, } from "./icons"; import { ActionMenu } from "./ActionMenu"; +import { FolderGlyph } from "./folderIcons"; +import { IconDialog } from "./IconDialog"; import { collectDrop, collectFiles, hasFiles, type Collected } from "./importer"; import { MoveDialog } from "./MoveDialog"; import { TagsDialog } from "./TagsDialog"; @@ -118,6 +120,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi const [tags, setTags] = useState([]); const [tagFilter, setTagFilter] = useState(null); const [tagging, setTagging] = useState(null); // éléments du dialogue « Tags » + const [iconing, setIconing] = useState(null); // dossiers du dialogue « Icône » const fileInput = useRef(null); const dirInput = useRef(null); const stay = useRef(null); // schéma ouvert depuis Favoris : on reste dans Favoris @@ -499,7 +502,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi ) : e.isDir ? ( - + ) : ( @@ -578,6 +581,12 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi Tags… + {e.isDir && ( + + )} {!e.isDir && ( @@ -900,6 +909,8 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi /> )} + {iconing && setIconing(null)} />} + {menu && ( {favView ? favActions(menu.entry) : actions(menu.entry)} diff --git a/frontend/src/IconDialog.tsx b/frontend/src/IconDialog.tsx new file mode 100644 index 0000000..3174504 --- /dev/null +++ b/frontend/src/IconDialog.tsx @@ -0,0 +1,76 @@ +import { useEffect, useState } from "react"; +import { api, type Entry } from "./api"; +import { FOLDER_ICONS, FolderGlyph } from "./folderIcons"; + +interface Props { + /** Dossiers concernés. */ + items: Entry[]; + /** Appelé après le changement pour rafraîchir l'explorateur. */ + onChanged: () => Promise; + onClose: () => void; +} + +/** Choix de l'icône d'un ou plusieurs dossiers (partagée par tous les utilisateurs). */ +export function IconDialog({ items, onChanged, onClose }: Props) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + // Icône commune aux dossiers (sinon aucune n'est marquée). + const current = items.every((e) => (e.icon ?? "") === (items[0].icon ?? "")) ? (items[0].icon ?? "") : null; + + useEffect(() => { + const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + const choose = async (name: string) => { + setBusy(true); + setError(null); + try { + await api.setIcon( + items.map((e) => e.path), + name, + ); + await onChanged(); + onClose(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setBusy(false); + } + }; + + const title = items.length === 1 ? `Icône · ${items[0].name}` : `Icône · ${items.length} dossiers`; + const choice = (name: string, label: string) => ( + + ); + + return ( +
+
e.stopPropagation()}> +

{title}

+
+ {choice("", "Par défaut")} + {FOLDER_ICONS.map((i) => choice(i.name, i.label))} +
+ {error &&
{error}
} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index dc73d7c..8e875ba 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -8,6 +8,8 @@ export interface Entry { modTime: string; tags?: string[]; favorite?: boolean; + /** Dossier : icône choisie (voir folderIcons.tsx). */ + icon?: string; /** Entrée virtuelle de la racine : "favoris" ou "perso" (espace personnel). */ special?: "favoris" | "perso"; /** Dans le dossier Favoris : nom du lien (le chemin est celui de l'original). */ @@ -151,6 +153,11 @@ export const api = { await request("/api/meta/tags", json({ paths, add, remove })); }, + /** icon = "" : dossier classique. */ + setIcon: async (paths: string[], icon: string): Promise => { + await request("/api/meta/icon", json({ paths, icon })); + }, + read: async (path: string): Promise<{ text: string; etag: string }> => { const res = await request(`/api/file?path=${q(path)}`); return { text: await res.text(), etag: res.headers.get("ETag") ?? "" }; diff --git a/frontend/src/folderIcons.tsx b/frontend/src/folderIcons.tsx new file mode 100644 index 0000000..ea4c282 --- /dev/null +++ b/frontend/src/folderIcons.tsx @@ -0,0 +1,251 @@ +// Icônes proposées pour les dossiers (jeu Tabler, comme icons.tsx). Le nom est +// enregistré côté serveur (.meta.json) : ne pas renommer une clé existante. +import { FolderIcon, icon } from "./icons"; + +type Icon = ReturnType; + +export const FOLDER_ICONS: { name: string; label: string; Icon: Icon }[] = [ + { + name: "briefcase", + label: "Projet", + Icon: icon( + <> + + + , + ), + }, + { + name: "rocket", + label: "Production", + Icon: icon( + <> + + + + , + ), + }, + { + name: "server", + label: "Serveurs", + Icon: icon( + <> + + + + , + ), + }, + { + name: "cpu", + label: "Calcul", + Icon: icon( + <> + + + + , + ), + }, + { + name: "database", + label: "Stockage", + Icon: icon( + <> + + + , + ), + }, + { + name: "box", + label: "Conteneurs", + Icon: icon(), + }, + { + name: "cloud", + label: "Cloud", + Icon: icon( + , + ), + }, + { + name: "network", + label: "Réseau", + Icon: icon( + <> + + + + , + ), + }, + { + name: "sitemap", + label: "Topologie", + Icon: icon( + <> + + + + + , + ), + }, + { + name: "router", + label: "Routeur", + Icon: icon( + <> + + + , + ), + }, + { + name: "world", + label: "Internet", + Icon: icon( + <> + + + , + ), + }, + { + name: "shield", + label: "Sécurité", + Icon: icon(), + }, + { + name: "lock", + label: "Confidentiel", + Icon: icon( + <> + + + + , + ), + }, + { + name: "desktop", + label: "Postes", + Icon: icon( + <> + + + , + ), + }, + { + name: "code", + label: "Code", + Icon: icon(), + }, + { + name: "tool", + label: "Outils", + Icon: icon(), + }, + { + name: "file-text", + label: "Documents", + Icon: icon( + <> + + + , + ), + }, + { + name: "book", + label: "Documentation", + Icon: icon(), + }, + { + name: "presentation", + label: "Présentations", + Icon: icon(), + }, + { + name: "chart", + label: "Indicateurs", + Icon: icon( + <> + + + + + , + ), + }, + { + name: "bulb", + label: "Idées", + Icon: icon( + , + ), + }, + { + name: "users", + label: "Équipe", + Icon: icon( + <> + + + , + ), + }, + { + name: "building", + label: "Client / site", + Icon: icon( + , + ), + }, + { + name: "calendar", + label: "Planning", + Icon: icon( + <> + + + , + ), + }, + { + name: "photo", + label: "Images", + Icon: icon( + <> + + + , + ), + }, + { + name: "flag", + label: "Jalons", + Icon: icon(), + }, + { + name: "archive", + label: "Archives", + Icon: icon( + <> + + + , + ), + }, +]; + +const BY_NAME = new Map(FOLDER_ICONS.map((i) => [i.name, i.Icon])); + +/** Icône d'un dossier : celle choisie, ou le dossier classique (nom absent ou inconnu). */ +export function FolderGlyph({ name, size }: { name?: string; size?: number }) { + const Icon = (name && BY_NAME.get(name)) || FolderIcon; + return ; +} diff --git a/frontend/src/icons.tsx b/frontend/src/icons.tsx index 6662c41..c5d2104 100644 --- a/frontend/src/icons.tsx +++ b/frontend/src/icons.tsx @@ -1,7 +1,7 @@ // Icônes au trait, dans le style d'Excalidraw (jeu Tabler, trait fin, couleur héritée). import type { ReactNode } from "react"; -const icon = (paths: ReactNode) => +export const icon = (paths: ReactNode) => function Icon({ size = 16 }: { size?: number }) { return (