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:
1 parent
bddbc27a1d
commit
8c29eb35e7
11 files changed
+489
-6
No files matched your search
@@ -44,6 +44,8 @@ import {
|
|||||||
UserIcon,
|
UserIcon,
|
||||||
} from "./icons";
|
} from "./icons";
|
||||||
import { ActionMenu } from "./ActionMenu";
|
import { ActionMenu } from "./ActionMenu";
|
||||||
|
import { FolderGlyph } from "./folderIcons";
|
||||||
|
import { IconDialog } from "./IconDialog";
|
||||||
import { collectDrop, collectFiles, hasFiles, type Collected } from "./importer";
|
import { collectDrop, collectFiles, hasFiles, type Collected } from "./importer";
|
||||||
import { MoveDialog } from "./MoveDialog";
|
import { MoveDialog } from "./MoveDialog";
|
||||||
import { TagsDialog } from "./TagsDialog";
|
import { TagsDialog } from "./TagsDialog";
|
||||||
@@ -118,6 +120,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
const [tags, setTags] = useState<TagCount[]>([]);
|
const [tags, setTags] = useState<TagCount[]>([]);
|
||||||
const [tagFilter, setTagFilter] = useState<string | null>(null);
|
const [tagFilter, setTagFilter] = useState<string | null>(null);
|
||||||
const [tagging, setTagging] = useState<Entry[] | null>(null); // éléments du dialogue « Tags »
|
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 fileInput = useRef<HTMLInputElement>(null);
|
||||||
const dirInput = useRef<HTMLInputElement>(null);
|
const dirInput = useRef<HTMLInputElement>(null);
|
||||||
const stay = useRef<string | null>(null); // schéma ouvert depuis Favoris : on reste dans Favoris
|
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>
|
</span>
|
||||||
) : e.isDir ? (
|
) : e.isDir ? (
|
||||||
<span className="icon folder" aria-hidden>
|
<span className="icon folder" aria-hidden>
|
||||||
<FolderIcon size={big} />
|
<FolderGlyph name={e.icon} size={big} />
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<Thumb path={e.path} version={e.modTime} />
|
<Thumb path={e.path} version={e.modTime} />
|
||||||
@@ -578,6 +581,12 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
<TagIcon />
|
<TagIcon />
|
||||||
Tags…
|
Tags…
|
||||||
</button>
|
</button>
|
||||||
|
{e.isDir && (
|
||||||
|
<button role="menuitem" onClick={() => setIconing([e])}>
|
||||||
|
<FolderGlyph name={e.icon} />
|
||||||
|
Icône…
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{!e.isDir && (
|
{!e.isDir && (
|
||||||
<a role="menuitem" href={api.downloadUrl(e.path)} download={e.name}>
|
<a role="menuitem" href={api.downloadUrl(e.path)} download={e.name}>
|
||||||
<DownloadIcon />
|
<DownloadIcon />
|
||||||
@@ -900,6 +909,8 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{iconing && <IconDialog items={iconing} onChanged={reload} onClose={() => setIconing(null)} />}
|
||||||
|
|
||||||
{menu && (
|
{menu && (
|
||||||
<ActionMenu anchor={menu.anchor} onClose={closeMenu}>
|
<ActionMenu anchor={menu.anchor} onClose={closeMenu}>
|
||||||
{favView ? favActions(menu.entry) : actions(menu.entry)}
|
{favView ? favActions(menu.entry) : actions(menu.entry)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ export interface Entry {
|
|||||||
modTime: string;
|
modTime: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
favorite?: boolean;
|
favorite?: boolean;
|
||||||
|
/** Dossier : icône choisie (voir folderIcons.tsx). */
|
||||||
|
icon?: string;
|
||||||
/** Entrée virtuelle de la racine : "favoris" ou "perso" (espace personnel). */
|
/** Entrée virtuelle de la racine : "favoris" ou "perso" (espace personnel). */
|
||||||
special?: "favoris" | "perso";
|
special?: "favoris" | "perso";
|
||||||
/** Dans le dossier Favoris : nom du lien (le chemin est celui de l'original). */
|
/** 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 }));
|
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 }> => {
|
read: async (path: string): Promise<{ text: string; etag: string }> => {
|
||||||
const res = await request(`/api/file?path=${q(path)}`);
|
const res = await request(`/api/file?path=${q(path)}`);
|
||||||
return { text: await res.text(), etag: res.headers.get("ETag") ?? "" };
|
return { text: await res.text(), etag: res.headers.get("ETag") ?? "" };
|
||||||
|
|||||||
@@ -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,7 +1,7 @@
|
|||||||
// Icônes au trait, dans le style d'Excalidraw (jeu Tabler, trait fin, couleur héritée).
|
// Icônes au trait, dans le style d'Excalidraw (jeu Tabler, trait fin, couleur héritée).
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
const icon = (paths: ReactNode) =>
|
export const icon = (paths: ReactNode) =>
|
||||||
function Icon({ size = 16 }: { size?: number }) {
|
function Icon({ size = 16 }: { size?: number }) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -1490,3 +1490,39 @@ button.chip.on,
|
|||||||
display: none;
|
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;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
// GET /api/tags tags utilisés et nombre d'éléments
|
// GET /api/tags tags utilisés et nombre d'éléments
|
||||||
// POST /api/meta/favorite {"paths":[...],"favorite":true} favoris
|
// POST /api/meta/favorite {"paths":[...],"favorite":true} favoris
|
||||||
// POST /api/meta/tags {"paths":[...],"add":[...],"remove":[...]} tags
|
// 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)
|
// 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: *)
|
// PUT /api/file?path=a/b.excalidraw écriture (If-Match / If-None-Match: *)
|
||||||
// POST /api/folder {"path":"a/b"} création de dossier
|
// 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("GET /api/tags", h.tags)
|
||||||
mux.HandleFunc("POST /api/meta/favorite", h.setFavorite)
|
mux.HandleFunc("POST /api/meta/favorite", h.setFavorite)
|
||||||
mux.HandleFunc("POST /api/meta/tags", h.editTags)
|
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("GET /api/file", h.readFile)
|
||||||
mux.HandleFunc("PUT /api/file", h.writeFile)
|
mux.HandleFunc("PUT /api/file", h.writeFile)
|
||||||
mux.HandleFunc("POST /api/folder", h.mkdir)
|
mux.HandleFunc("POST /api/folder", h.mkdir)
|
||||||
@@ -205,6 +207,7 @@ type metaReq struct {
|
|||||||
Favorite bool `json:"favorite"`
|
Favorite bool `json:"favorite"`
|
||||||
Add []string `json:"add"`
|
Add []string `json:"add"`
|
||||||
Remove []string `json:"remove"`
|
Remove []string `json:"remove"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) decodeMeta(w http.ResponseWriter, r *http.Request) (metaReq, bool) {
|
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)
|
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) {
|
func (h *Handler) readFile(w http.ResponseWriter, r *http.Request) {
|
||||||
u, ok := h.space(w, r)
|
u, ok := h.space(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -7,14 +7,16 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Tags et favoris des fichiers et dossiers, dans .meta.json à la racine :
|
// Tags et favoris des fichiers et dossiers, et icône des dossiers, dans
|
||||||
// {"items":{"a/b.excalidraw":{"tags":["NSX"],"favorite":true}}}. Ils suivent
|
// .meta.json à la racine :
|
||||||
// les renommages, déplacements et suppressions.
|
// {"items":{"a/b.excalidraw":{"tags":["NSX"],"favorite":true},"a":{"icon":"server"}}}.
|
||||||
|
// Ils suivent les renommages, déplacements et suppressions.
|
||||||
|
|
||||||
const metaFile = ".meta.json"
|
const metaFile = ".meta.json"
|
||||||
|
|
||||||
@@ -28,9 +30,14 @@ const (
|
|||||||
type itemMeta struct {
|
type itemMeta struct {
|
||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
Favorite bool `json:"favorite,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.
|
// TagCount est un tag utilisé et son nombre d'éléments.
|
||||||
type TagCount struct {
|
type TagCount struct {
|
||||||
@@ -94,6 +101,9 @@ func (s *Store) metaSnapshot() map[string]itemMeta {
|
|||||||
func decorate(e *Entry, meta map[string]itemMeta) {
|
func decorate(e *Entry, meta map[string]itemMeta) {
|
||||||
if m, ok := meta[e.Path]; ok {
|
if m, ok := meta[e.Path]; ok {
|
||||||
e.Tags, e.Favorite = m.Tags, m.Favorite
|
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
|
// 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.
|
// sans casse ni accents ; un tag déjà utilisé garde son écriture d'origine.
|
||||||
func (s *Store) EditTags(paths, add, remove []string) error {
|
func (s *Store) EditTags(paths, add, remove []string) error {
|
||||||
|
|||||||
@@ -71,3 +71,35 @@ func TestTagsFavorites(t *testing.T) {
|
|||||||
t.Fatalf("après suppression : %+v", tags)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -555,6 +555,24 @@ func (u *Space) EditTags(paths, add, remove []string) error {
|
|||||||
return nil
|
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) {
|
func (u *Space) Read(p string) ([]byte, string, error) {
|
||||||
st, rel, err := u.route(p)
|
st, rel, err := u.route(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ type Entry struct {
|
|||||||
|
|
||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
Favorite bool `json:"favorite,omitempty"`
|
Favorite bool `json:"favorite,omitempty"`
|
||||||
|
Icon string `json:"icon,omitempty"` // dossier : icône choisie (meta.go)
|
||||||
|
|
||||||
// Dossiers virtuels et favoris (spaces.go).
|
// Dossiers virtuels et favoris (spaces.go).
|
||||||
Special string `json:"special,omitempty"` // "favoris" ou "perso" : entrée de la racine
|
Special string `json:"special,omitempty"` // "favoris" ou "perso" : entrée de la racine
|
||||||
|
|||||||
Reference in new issue
Block a user