Explorateur : icône personnalisable par dossier

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011bGUm1KLMvT6YPbJxZgwkj
This commit is contained in:
claude BotandClaude Opus 5.5 committed 2026-10-15 09:14:39 +02:00
1 parent bddbc27a1d
commit 8c29eb35e7
11 files changed
+489 -6

No files matched your search

+12 -1
View File
@@ -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<TagCount[]>([]);
const [tagFilter, setTagFilter] = useState<string | null>(null);
const [tagging, setTagging] = useState<Entry[] | null>(null); // éléments du dialogue « Tags »
const [iconing, setIconing] = useState<Entry[] | null>(null); // dossiers du dialogue « Icône »
const fileInput = useRef<HTMLInputElement>(null);
const dirInput = useRef<HTMLInputElement>(null);
const stay = useRef<string | null>(null); // schéma ouvert depuis Favoris : on reste dans Favoris
@@ -499,7 +502,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
</span>
) : e.isDir ? (
<span className="icon folder" aria-hidden>
<FolderIcon size={big} />
<FolderGlyph name={e.icon} size={big} />
</span>
) : (
<Thumb path={e.path} version={e.modTime} />
@@ -578,6 +581,12 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
<TagIcon />
Tags…
</button>
{e.isDir && (
<button role="menuitem" onClick={() => setIconing([e])}>
<FolderGlyph name={e.icon} />
Icône…
</button>
)}
{!e.isDir && (
<a role="menuitem" href={api.downloadUrl(e.path)} download={e.name}>
<DownloadIcon />
@@ -900,6 +909,8 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
/>
)}
{iconing && <IconDialog items={iconing} onChanged={reload} onClose={() => setIconing(null)} />}
{menu && (
<ActionMenu anchor={menu.anchor} onClose={closeMenu}>
{favView ? favActions(menu.entry) : actions(menu.entry)}
+76
View File
@@ -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<void>;
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<string | null>(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) => (
<button
key={name || "default"}
className={`folder-icon-choice${current === name ? " current" : ""}`}
disabled={busy}
onClick={() => choose(name)}
title={label}
aria-label={label}
aria-pressed={current === name}
>
<span className="folder">
<FolderGlyph name={name} size={24} />
</span>
<small>{label}</small>
</button>
);
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal icon-dialog" role="dialog" aria-modal="true" aria-label={title} onClick={(e) => e.stopPropagation()}>
<h3>{title}</h3>
<div className="folder-icon-grid">
{choice("", "Par défaut")}
{FOLDER_ICONS.map((i) => choice(i.name, i.label))}
</div>
{error && <div className="error">{error}</div>}
<div className="modal-actions">
<span className="spacer" />
<button onClick={onClose}>Fermer</button>
</div>
</div>
</div>
);
}
+7
View File
@@ -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<void> => {
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") ?? "" };
+251
View File
@@ -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<typeof icon>;
export const FOLDER_ICONS: { name: string; label: string; Icon: Icon }[] = [
{
name: "briefcase",
label: "Projet",
Icon: icon(
<>
<rect x="3" y="7" width="18" height="13" rx="2" />
<path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M12 12v.01M3 13a20 20 0 0 0 18 0" />
</>,
),
},
{
name: "rocket",
label: "Production",
Icon: icon(
<>
<path d="M4 13a8 8 0 0 1 7 7 6 6 0 0 0 3-5 9 9 0 0 0 6-8 3 3 0 0 0-3-3 9 9 0 0 0-8 6 6 6 0 0 0-5 3" />
<path d="M7 14a6 6 0 0 0-3 6 6 6 0 0 0 6-3" />
<circle cx="15" cy="9" r="1" />
</>,
),
},
{
name: "server",
label: "Serveurs",
Icon: icon(
<>
<rect x="3" y="4" width="18" height="8" rx="3" />
<rect x="3" y="12" width="18" height="8" rx="3" />
<path d="M7 8v.01M7 16v.01" />
</>,
),
},
{
name: "cpu",
label: "Calcul",
Icon: icon(
<>
<rect x="5" y="5" width="14" height="14" rx="1" />
<rect x="9" y="9" width="6" height="6" />
<path d="M3 10h2M3 14h2M10 3v2M14 3v2M21 10h-2M21 14h-2M14 21v-2M10 21v-2" />
</>,
),
},
{
name: "database",
label: "Stockage",
Icon: icon(
<>
<ellipse cx="12" cy="6" rx="8" ry="3" />
<path d="M4 6v6a8 3 0 0 0 16 0V6M4 12v6a8 3 0 0 0 16 0v-6" />
</>,
),
},
{
name: "box",
label: "Conteneurs",
Icon: icon(<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3M12 12l8-4.5M12 12v9M12 12 4 7.5" />),
},
{
name: "cloud",
label: "Cloud",
Icon: icon(
<path d="M6.66 18C4.08 18 2 15.99 2 13.52s2.09-4.49 4.66-4.49c.39-1.76 1.79-3.2 3.67-3.77 1.88-.57 3.96-.19 5.44 1 1.49 1.19 2.16 3.01 1.77 4.77h.99c1.91 0 3.46 1.56 3.46 3.49S20.45 18 18.54 18H6.66" />,
),
},
{
name: "network",
label: "Réseau",
Icon: icon(
<>
<circle cx="12" cy="9" r="6" />
<path d="M12 3c1.33.33 2 2.33 2 6s-.67 5.67-2 6M12 3c-1.33.33-2 2.33-2 6s.67 5.67 2 6M6 9h12M3 20h7M14 20h7M12 15v3" />
<circle cx="12" cy="20" r="2" />
</>,
),
},
{
name: "sitemap",
label: "Topologie",
Icon: icon(
<>
<rect x="3" y="15" width="6" height="6" rx="2" />
<rect x="15" y="15" width="6" height="6" rx="2" />
<rect x="9" y="3" width="6" height="6" rx="2" />
<path d="M6 15v-1a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1M12 9v3" />
</>,
),
},
{
name: "router",
label: "Routeur",
Icon: icon(
<>
<rect x="3" y="13" width="18" height="8" rx="2" />
<path d="M17 17v.01M13 17v.01M15 13v-2M11.75 8.75a4 4 0 0 1 6.5 0M8.5 6.5a8 8 0 0 1 13 0" />
</>,
),
},
{
name: "world",
label: "Internet",
Icon: icon(
<>
<circle cx="12" cy="12" r="9" />
<path d="M3.6 9h16.8M3.6 15h16.8M11.5 3a17 17 0 0 0 0 18M12.5 3a17 17 0 0 1 0 18" />
</>,
),
},
{
name: "shield",
label: "Sécurité",
Icon: icon(<path d="M12 3a12 12 0 0 0 8.5 3A12 12 0 0 1 12 21 12 12 0 0 1 3.5 6 12 12 0 0 0 12 3" />),
},
{
name: "lock",
label: "Confidentiel",
Icon: icon(
<>
<rect x="5" y="11" width="14" height="10" rx="2" />
<circle cx="12" cy="16" r="1" />
<path d="M8 11V7a4 4 0 1 1 8 0v4" />
</>,
),
},
{
name: "desktop",
label: "Postes",
Icon: icon(
<>
<rect x="3" y="4" width="18" height="12" rx="1" />
<path d="M7 20h10M9 16v4M15 16v4" />
</>,
),
},
{
name: "code",
label: "Code",
Icon: icon(<path d="M7 8l-4 4 4 4M17 8l4 4-4 4M14 4l-4 16" />),
},
{
name: "tool",
label: "Outils",
Icon: icon(<path d="M7 10h3V7L6.5 3.5a6 6 0 0 1 8 8l6 6a2 2 0 0 1-3 3l-6-6a6 6 0 0 1-8-8L7 10" />),
},
{
name: "file-text",
label: "Documents",
Icon: icon(
<>
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2zM9 9h1M9 13h6M9 17h6" />
</>,
),
},
{
name: "book",
label: "Documentation",
Icon: icon(<path d="M3 19a9 9 0 0 1 9 0 9 9 0 0 1 9 0M3 6a9 9 0 0 1 9 0 9 9 0 0 1 9 0M3 6v13M12 6v13M21 6v13" />),
},
{
name: "presentation",
label: "Présentations",
Icon: icon(<path d="M3 4h18M4 4v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4M12 16v4M9 20h6M8 12l3-3 2 2 3-3" />),
},
{
name: "chart",
label: "Indicateurs",
Icon: icon(
<>
<rect x="3" y="12" width="6" height="8" rx="1" />
<rect x="9" y="8" width="6" height="12" rx="1" />
<rect x="15" y="4" width="6" height="16" rx="1" />
<path d="M4 20h14" />
</>,
),
},
{
name: "bulb",
label: "Idées",
Icon: icon(
<path d="M3 12h1m8-9v1m8 8h1M5.6 5.6l.7.7m12.1-.7-.7.7M9 16a5 5 0 1 1 6 0 3.5 3.5 0 0 0-1 3 2 2 0 0 1-4 0 3.5 3.5 0 0 0-1-3M9.7 17h4.6" />,
),
},
{
name: "users",
label: "Équipe",
Icon: icon(
<>
<circle cx="9" cy="7" r="4" />
<path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2M16 3.13a4 4 0 0 1 0 7.75M21 21v-2a4 4 0 0 0-3-3.85" />
</>,
),
},
{
name: "building",
label: "Client / site",
Icon: icon(
<path d="M3 21h18M9 8h1M9 12h1M9 16h1M14 8h1M14 12h1M14 16h1M5 21V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16" />,
),
},
{
name: "calendar",
label: "Planning",
Icon: icon(
<>
<rect x="4" y="5" width="16" height="16" rx="2" />
<path d="M16 3v4M8 3v4M4 11h16" />
</>,
),
},
{
name: "photo",
label: "Images",
Icon: icon(
<>
<rect x="3" y="3" width="18" height="18" rx="3" />
<path d="M15 8h.01M3 16l5-5c.93-.89 2.07-.89 3 0l5 5M14 14l1-1c.93-.89 2.07-.89 3 0l3 3" />
</>,
),
},
{
name: "flag",
label: "Jalons",
Icon: icon(<path d="M5 5a5 5 0 0 1 7 0 5 5 0 0 0 7 0v9a5 5 0 0 1-7 0 5 5 0 0 0-7 0V5M5 21v-7" />),
},
{
name: "archive",
label: "Archives",
Icon: icon(
<>
<rect x="3" y="4" width="18" height="4" rx="2" />
<path d="M5 8v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8M10 12h4" />
</>,
),
},
];
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 <Icon size={size} />;
}
+1 -1
View File
@@ -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 (
<svg
+36
View File
@@ -1490,3 +1490,39 @@ button.chip.on,
display: none;
}
}
/* Choix de l'icône d'un dossier. */
.icon-dialog {
width: min(520px, calc(100vw - 32px));
}
.folder-icon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
gap: 6px;
max-height: 60vh;
overflow-y: auto;
}
.folder-icon-choice {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
height: auto;
padding: 10px 4px 8px;
border: 1px solid transparent;
border-radius: var(--ex-radius-lg);
background: transparent;
}
.folder-icon-choice:hover:not(:disabled) {
background: var(--ex-surface-mid);
}
.folder-icon-choice.current {
background: var(--accent-soft);
border-color: var(--accent);
}
.folder-icon-choice small {
font-size: 11.5px;
color: var(--muted);
text-align: center;
line-height: 1.2;
}
+19
View File
@@ -5,6 +5,7 @@
// GET /api/tags tags utilisés et nombre d'éléments
// POST /api/meta/favorite {"paths":[...],"favorite":true} favoris
// POST /api/meta/tags {"paths":[...],"add":[...],"remove":[...]} tags
// POST /api/meta/icon {"paths":[...],"icon":"server"} icône de dossiers ("" : défaut)
// GET /api/file?path=a/b.excalidraw lecture (ETag en en-tête)
// PUT /api/file?path=a/b.excalidraw écriture (If-Match / If-None-Match: *)
// POST /api/folder {"path":"a/b"} création de dossier
@@ -78,6 +79,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /api/tags", h.tags)
mux.HandleFunc("POST /api/meta/favorite", h.setFavorite)
mux.HandleFunc("POST /api/meta/tags", h.editTags)
mux.HandleFunc("POST /api/meta/icon", h.setIcon)
mux.HandleFunc("GET /api/file", h.readFile)
mux.HandleFunc("PUT /api/file", h.writeFile)
mux.HandleFunc("POST /api/folder", h.mkdir)
@@ -205,6 +207,7 @@ type metaReq struct {
Favorite bool `json:"favorite"`
Add []string `json:"add"`
Remove []string `json:"remove"`
Icon string `json:"icon"`
}
func (h *Handler) decodeMeta(w http.ResponseWriter, r *http.Request) (metaReq, bool) {
@@ -251,6 +254,22 @@ func (h *Handler) editTags(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) setIcon(w http.ResponseWriter, r *http.Request) {
u, ok := h.space(w, r)
if !ok {
return
}
req, ok := h.decodeMeta(w, r)
if !ok {
return
}
if err := u.SetIcon(req.Paths, req.Icon); err != nil {
fail(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) readFile(w http.ResponseWriter, r *http.Request) {
u, ok := h.space(w, r)
if !ok {
+36 -4
View File
@@ -7,14 +7,16 @@ import (
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"unicode/utf8"
)
// Tags et favoris des fichiers et dossiers, dans .meta.json à la racine :
// {"items":{"a/b.excalidraw":{"tags":["NSX"],"favorite":true}}}. Ils suivent
// les renommages, déplacements et suppressions.
// Tags et favoris des fichiers et dossiers, et icône des dossiers, dans
// .meta.json à la racine :
// {"items":{"a/b.excalidraw":{"tags":["NSX"],"favorite":true},"a":{"icon":"server"}}}.
// Ils suivent les renommages, déplacements et suppressions.
const metaFile = ".meta.json"
@@ -28,9 +30,14 @@ const (
type itemMeta struct {
Tags []string `json:"tags,omitempty"`
Favorite bool `json:"favorite,omitempty"`
Icon string `json:"icon,omitempty"` // dossiers : nom d'icône du frontend
}
func (m itemMeta) empty() bool { return len(m.Tags) == 0 && !m.Favorite }
func (m itemMeta) empty() bool { return len(m.Tags) == 0 && !m.Favorite && m.Icon == "" }
// iconName : nom d'icône accepté (le frontend affiche le dossier classique
// pour un nom qu'il ne connaît pas).
var iconName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
// TagCount est un tag utilisé et son nombre d'éléments.
type TagCount struct {
@@ -94,6 +101,9 @@ func (s *Store) metaSnapshot() map[string]itemMeta {
func decorate(e *Entry, meta map[string]itemMeta) {
if m, ok := meta[e.Path]; ok {
e.Tags, e.Favorite = m.Tags, m.Favorite
if e.IsDir {
e.Icon = m.Icon
}
}
}
@@ -149,6 +159,28 @@ func (s *Store) SetFavorite(paths []string, favorite bool) error {
})
}
// SetIcon choisit l'icône de dossiers ; icon = "" rétablit l'icône par défaut.
func (s *Store) SetIcon(paths []string, icon string) error {
if icon != "" && !iconName.MatchString(icon) {
return fmt.Errorf("%w : icône %q", ErrInvalidPath, icon)
}
for _, p := range paths {
_, abs, err := s.resolve(p)
if err != nil {
return err
}
if fi, err := os.Lstat(abs); err != nil {
return mapErr(err)
} else if !fi.IsDir() {
return fmt.Errorf("%w : %q n'est pas un dossier", ErrInvalidPath, p)
}
}
return s.editMeta(paths, func(m *itemMeta) error {
m.Icon = icon
return nil
})
}
// EditTags ajoute et retire des tags sur des éléments. Les tags sont comparés
// sans casse ni accents ; un tag déjà utilisé garde son écriture d'origine.
func (s *Store) EditTags(paths, add, remove []string) error {
+32
View File
@@ -71,3 +71,35 @@ func TestTagsFavorites(t *testing.T) {
t.Fatalf("après suppression : %+v", tags)
}
}
func TestFolderIcon(t *testing.T) {
s := newStore(t)
s.Write("d/a.excalidraw", []byte(doc), "", true)
if err := s.SetIcon([]string{"d"}, "server"); err != nil {
t.Fatal(err)
}
for _, bad := range []struct {
path, icon string
}{{"d", "Server"}, {"d", "../x"}, {"d/a.excalidraw", "server"}, {"absent", "server"}} {
if err := s.SetIcon([]string{bad.path}, bad.icon); err == nil {
t.Errorf("SetIcon(%q, %q) aurait dû échouer", bad.path, bad.icon)
}
}
if l, _ := s.List(""); len(l) != 1 || l[0].Icon != "server" {
t.Fatalf("List = %+v", l)
}
// L'icône suit le renommage et disparaît quand on rétablit le défaut.
s.Move("d", "e")
s2, _ := New(s.root)
if l, _ := s2.List(""); len(l) != 1 || l[0].Icon != "server" {
t.Fatalf("après renommage : %+v", l)
}
if err := s2.SetIcon([]string{"e"}, ""); err != nil {
t.Fatal(err)
}
if l, _ := s2.List(""); l[0].Icon != "" {
t.Fatalf("après retour au défaut : %+v", l)
}
}
+18
View File
@@ -555,6 +555,24 @@ func (u *Space) EditTags(paths, add, remove []string) error {
return nil
}
// SetIcon choisit l'icône de dossiers (voir Store.SetIcon).
func (u *Space) SetIcon(paths []string, icon string) error {
groups := map[*Store][]string{}
for _, p := range paths {
st, rel, err := u.route(p)
if err != nil {
return err
}
groups[st] = append(groups[st], rel)
}
for st, rels := range groups {
if err := st.SetIcon(rels, icon); err != nil {
return err
}
}
return nil
}
func (u *Space) Read(p string) ([]byte, string, error) {
st, rel, err := u.route(p)
if err != nil {
+1
View File
@@ -50,6 +50,7 @@ type Entry struct {
Tags []string `json:"tags,omitempty"`
Favorite bool `json:"favorite,omitempty"`
Icon string `json:"icon,omitempty"` // dossier : icône choisie (meta.go)
// Dossiers virtuels et favoris (spaces.go).
Special string `json:"special,omitempty"` // "favoris" ou "perso" : entrée de la racine