Explorateur : sélection et déplacement multiples, vignettes des schémas #2
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
# excalidraw-go
|
# excalidraw-go
|
||||||
|
|
||||||
Excalidraw auto-hébergé avec un **gestionnaire de fichiers intégré** : arborescence de dossiers, création, renommage, déplacement (glisser-déposer ou menu), duplication, suppression (corbeille), recherche, import/export de fichiers `.excalidraw`, sauvegarde automatique côté serveur. Interface web unique, responsive (l'explorateur devient un tiroir sur mobile).
|
Excalidraw auto-hébergé avec un **gestionnaire de fichiers intégré** : arborescence de dossiers, **vignettes des schémas**, création, renommage, **sélection et déplacement multiples** (glisser-déposer ou dialogue « Déplacer vers… »), duplication, suppression (corbeille), recherche, import/export de fichiers `.excalidraw`, sauvegarde automatique côté serveur. Interface web unique, responsive (l'explorateur devient un tiroir sur mobile).
|
||||||
|
|
||||||
- **Backend** : Go 1.24, bibliothèque standard uniquement (aucune dépendance externe), API REST/JSON, stockage en fichiers `.excalidraw` sur un volume.
|
- **Backend** : Go 1.24, bibliothèque standard uniquement (aucune dépendance externe), API REST/JSON, stockage en fichiers `.excalidraw` sur un volume.
|
||||||
- **Frontend** : React 19 + composant officiel [`@excalidraw/excalidraw`](https://www.npmjs.com/package/@excalidraw/excalidraw) 0.18.1 (licence MIT), build Vite **embarqué dans le binaire Go** (`embed`).
|
- **Frontend** : React 19 + composant officiel [`@excalidraw/excalidraw`](https://www.npmjs.com/package/@excalidraw/excalidraw) 0.18.1 (licence MIT), build Vite **embarqué dans le binaire Go** (`embed`).
|
||||||
@@ -18,6 +18,7 @@ Navigateur ──HTTP──▶ conteneur excalidraw-go (:8080, uid 65532, rootfs
|
|||||||
volume /data
|
volume /data
|
||||||
├─ projets/réseau/sdn.excalidraw ← fichiers JSON Excalidraw standards
|
├─ projets/réseau/sdn.excalidraw ← fichiers JSON Excalidraw standards
|
||||||
├─ .library.excalidrawlib ← bibliothèque partagée
|
├─ .library.excalidrawlib ← bibliothèque partagée
|
||||||
|
├─ .thumbs/projets/réseau/sdn.excalidraw.png ← miniatures (régénérables)
|
||||||
└─ .trash/<horodatage>/... ← éléments supprimés
|
└─ .trash/<horodatage>/... ← éléments supprimés
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -43,13 +44,24 @@ Bind mount au lieu du volume nommé : `- ./data:/data` puis `chown -R 65532:6553
|
|||||||
## Utilisation
|
## Utilisation
|
||||||
|
|
||||||
- **+ Schéma / + Dossier** : création dans le dossier courant.
|
- **+ Schéma / + Dossier** : création dans le dossier courant.
|
||||||
- **⋯** sur une ligne : renommer, déplacer, dupliquer, télécharger, supprimer.
|
- **Vignettes / liste** : bouton ▦ / ☰ à droite de la recherche (choix mémorisé par navigateur).
|
||||||
- **Glisser-déposer** (souris) d'un élément sur un dossier, sur `..` ou sur le fil d'Ariane pour le déplacer. Sur écran tactile : menu ⋯ → *Déplacer…*
|
- **⋯** sur un élément : renommer, déplacer, dupliquer, télécharger, supprimer.
|
||||||
|
- **Sélection multiple** : case à cocher (au survol, toujours visible sur écran tactile), `Ctrl/Cmd+clic`, `Maj+clic` pour une plage, `Ctrl+A` pour tout. Tant qu'une sélection est en cours, un simple clic/tap ajoute ou retire un élément. La barre de sélection propose *Déplacer…* et la mise à la corbeille ; `Échap` annule, `Suppr` supprime.
|
||||||
|
- **Déplacer** : glisser-déposer (souris) d'un élément — ou de toute la sélection — sur un dossier, sur `..` ou sur le fil d'Ariane ; ou *Déplacer…* qui ouvre un sélecteur de dossier (navigation, création de dossier), utilisable au doigt. En cas de conflit de nom dans la destination, les autres éléments sont tout de même déplacés et les échecs listés.
|
||||||
- **Importer** : envoie un ou plusieurs `.excalidraw` locaux dans le dossier courant.
|
- **Importer** : envoie un ou plusieurs `.excalidraw` locaux dans le dossier courant.
|
||||||
- **Sauvegarde** : automatique 1,5 s après la dernière modification, immédiate avec `Ctrl/Cmd+S`. Le statut (Modifié / Enregistrement… / Enregistré) est affiché dans la barre du haut.
|
- **Sauvegarde** : automatique 1,5 s après la dernière modification, immédiate avec `Ctrl/Cmd+S`. Le statut (Modifié / Enregistrement… / Enregistré) est affiché dans la barre du haut.
|
||||||
- **Lien direct** : l'URL `http://hôte:8080/#/dossier/schema.excalidraw` ouvre le schéma.
|
- **Lien direct** : l'URL `http://hôte:8080/#/dossier/schema.excalidraw` ouvre le schéma.
|
||||||
- **Bibliothèque** Excalidraw : persistée côté serveur, partagée entre tous les schémas.
|
- **Bibliothèque** Excalidraw : persistée côté serveur, partagée entre tous les schémas.
|
||||||
|
|
||||||
|
### Miniatures
|
||||||
|
|
||||||
|
Rendues par le navigateur avec `exportToBlob` d'Excalidraw (PNG 320 px), puis stockées dans `DATA_DIR/.thumbs/` :
|
||||||
|
|
||||||
|
- à chaque sauvegarde du schéma ouvert ;
|
||||||
|
- à la demande pour les schémas sans miniature ou modifiés hors de l'application (import, copie de fichiers sur le volume, script) : l'explorateur charge le schéma, génère la vignette (2 en parallèle, seulement pour les éléments visibles) et l'envoie au serveur, une fois pour toutes.
|
||||||
|
|
||||||
|
Une miniature plus ancienne que son schéma est ignorée (`404`) puis régénérée. Elles suivent les renommages, déplacements, copies et suppressions ; `.thumbs/` peut être supprimé sans risque. En thème sombre, elles sont inversées en CSS comme le canevas Excalidraw.
|
||||||
|
|
||||||
### Concurrence
|
### Concurrence
|
||||||
|
|
||||||
Chaque lecture renvoie un `ETag` (SHA-256 tronqué du contenu) ; chaque sauvegarde l'envoie en `If-Match`. Si le fichier a été modifié ailleurs (autre onglet, autre poste), le serveur répond `412` et l'interface propose d'écraser ou de recharger. Il n'y a pas d'édition collaborative temps réel (voir *Évolutions*).
|
Chaque lecture renvoie un `ETag` (SHA-256 tronqué du contenu) ; chaque sauvegarde l'envoie en `If-Match`. Si le fichier a été modifié ailleurs (autre onglet, autre poste), le serveur répond `412` et l'interface propose d'écraser ou de recharger. Il n'y a pas d'édition collaborative temps réel (voir *Évolutions*).
|
||||||
@@ -66,6 +78,9 @@ Tous les chemins sont relatifs à `DATA_DIR`, en notation `/`. Les composants co
|
|||||||
| PUT | `/api/file?path=x.excalidraw` | JSON ; `If-Match: <etag>` ou `If-None-Match: *` (création) | `Entry` + `ETag` ; `409` existe, `412` conflit |
|
| PUT | `/api/file?path=x.excalidraw` | JSON ; `If-Match: <etag>` ou `If-None-Match: *` (création) | `Entry` + `ETag` ; `409` existe, `412` conflit |
|
||||||
| POST | `/api/folder` | `{"path":"a/b"}` | `Entry` |
|
| POST | `/api/folder` | `{"path":"a/b"}` | `Entry` |
|
||||||
| POST | `/api/move` | `{"from":"a.excalidraw","to":"d/b.excalidraw"}` | `Entry` (renommage / déplacement, fichier ou dossier) |
|
| POST | `/api/move` | `{"from":"a.excalidraw","to":"d/b.excalidraw"}` | `Entry` (renommage / déplacement, fichier ou dossier) |
|
||||||
|
| POST | `/api/move-many` | `{"paths":["a.excalidraw","dossier"],"dir":"d"}` | `[{"from","to"}\|{"from","error"}]` : déplacement multiple, résultat par élément |
|
||||||
|
| GET | `/api/thumb?path=x.excalidraw&v=<modTime>` | | PNG ; `404` si absente ou périmée |
|
||||||
|
| PUT | `/api/thumb?path=x.excalidraw` | PNG (1 Mo max) | `204` |
|
||||||
| POST | `/api/copy` | `{"from":"…","to":"…"}` | `Entry` |
|
| POST | `/api/copy` | `{"from":"…","to":"…"}` | `Entry` |
|
||||||
| DELETE | `/api/entry?path=x[&recursive=true]` | | `204` (déplacé dans `.trash/`) |
|
| DELETE | `/api/entry?path=x[&recursive=true]` | | `204` (déplacé dans `.trash/`) |
|
||||||
| GET/PUT | `/api/library` | bibliothèque `excalidrawlib` | |
|
| GET/PUT | `/api/library` | bibliothèque `excalidrawlib` | |
|
||||||
@@ -101,7 +116,10 @@ internal/storage/ accès disque : sécurité des chemins, écriture at
|
|||||||
internal/api/ routes REST
|
internal/api/ routes REST
|
||||||
web/embed.go embarque web/dist (produit par le build Vite)
|
web/embed.go embarque web/dist (produit par le build Vite)
|
||||||
frontend/src/App.tsx éditeur Excalidraw, sauvegarde auto, conflits, bibliothèque
|
frontend/src/App.tsx éditeur Excalidraw, sauvegarde auto, conflits, bibliothèque
|
||||||
frontend/src/Explorer.tsx gestionnaire de fichiers
|
frontend/src/Explorer.tsx gestionnaire de fichiers (vignettes/liste, sélection multiple, glisser-déposer)
|
||||||
|
frontend/src/MoveDialog.tsx sélecteur de dossier « Déplacer vers… »
|
||||||
|
frontend/src/Thumb.tsx vignette chargée à l'affichage, générée si absente
|
||||||
|
frontend/src/thumbs.ts rendu PNG des miniatures et file de génération
|
||||||
```
|
```
|
||||||
|
|
||||||
## Sécurité
|
## Sécurité
|
||||||
@@ -114,7 +132,6 @@ frontend/src/Explorer.tsx gestionnaire de fichiers
|
|||||||
## Limites et évolutions possibles
|
## Limites et évolutions possibles
|
||||||
|
|
||||||
- **Collaboration temps réel** : nécessiterait le serveur [excalidraw-room](https://github.com/excalidraw/excalidraw-room) (WebSocket) et la logique de réconciliation (`reconcileElements`) côté client.
|
- **Collaboration temps réel** : nécessiterait le serveur [excalidraw-room](https://github.com/excalidraw/excalidraw-room) (WebSocket) et la logique de réconciliation (`reconcileElements`) côté client.
|
||||||
- **Miniatures** dans l'explorateur : générables côté client via `exportToSvg` et stockées à côté du fichier.
|
|
||||||
- **Historique de versions** : commit git automatique dans `DATA_DIR` à chaque sauvegarde, ou snapshots du volume (ZFS/Btrfs).
|
- **Historique de versions** : commit git automatique dans `DATA_DIR` à chaque sauvegarde, ou snapshots du volume (ZFS/Btrfs).
|
||||||
- **Multi-utilisateurs avec espaces privés** : identité transmise par le reverse proxy (`X-Forwarded-User`) et racine par utilisateur.
|
- **Multi-utilisateurs avec espaces privés** : identité transmise par le reverse proxy (`X-Forwarded-User`) et racine par utilisateur.
|
||||||
- Les schémas avec beaucoup d'images embarquées sont volumineux (base64 dans le JSON) ; ajuster `MAX_BODY_MB`.
|
- Les schémas avec beaucoup d'images embarquées sont volumineux (base64 dans le JSON) ; ajuster `MAX_BODY_MB`.
|
||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
|
import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
|
||||||
import { api, ApiError, basename, stripExt } from "./api";
|
import { api, ApiError, basename, stripExt } from "./api";
|
||||||
import { Explorer } from "./Explorer";
|
import { Explorer } from "./Explorer";
|
||||||
|
import { storeThumb } from "./thumbs";
|
||||||
|
|
||||||
type Theme = "light" | "dark";
|
type Theme = "light" | "dark";
|
||||||
type SaveState = "saved" | "dirty" | "saving" | "error";
|
type SaveState = "saved" | "dirty" | "saving" | "error";
|
||||||
@@ -91,6 +92,8 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
etagRef.current = await api.write(path, body, force ? "" : etagRef.current);
|
etagRef.current = await api.write(path, body, force ? "" : etagRef.current);
|
||||||
savedVersion.current = version;
|
savedVersion.current = version;
|
||||||
|
// Miniature pour l'explorateur ; un échec n'affecte pas la sauvegarde.
|
||||||
|
await storeThumb(path, elements, appState, files).catch((e) => console.warn("miniature", e));
|
||||||
// D'autres modifications ont pu arriver pendant l'envoi.
|
// D'autres modifications ont pu arriver pendant l'envoi.
|
||||||
const now = sceneVersion(ex.getSceneElements(), ex.getAppState(), ex.getFiles());
|
const now = sceneVersion(ex.getSceneElements(), ex.getAppState(), ex.getFiles());
|
||||||
setSaveState(now === version ? "saved" : "dirty");
|
setSaveState(now === version ? "saved" : "dirty");
|
||||||
|
|||||||
+292
-86
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { DragEvent } from "react";
|
import type { DragEvent, KeyboardEvent, MouseEvent } from "react";
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
ApiError,
|
ApiError,
|
||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
withExt,
|
withExt,
|
||||||
type Entry,
|
type Entry,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
|
import { MoveDialog } from "./MoveDialog";
|
||||||
|
import { Thumb } from "./Thumb";
|
||||||
|
|
||||||
export const EMPTY_SCENE = JSON.stringify({
|
export const EMPTY_SCENE = JSON.stringify({
|
||||||
type: "excalidraw",
|
type: "excalidraw",
|
||||||
@@ -33,24 +35,46 @@ interface Props {
|
|||||||
onDeleted: (path: string) => void;
|
onDeleted: (path: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type View = "list" | "grid";
|
||||||
|
|
||||||
const fmtDate = (iso: string) =>
|
const fmtDate = (iso: string) =>
|
||||||
new Date(iso).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" });
|
new Date(iso).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" });
|
||||||
|
|
||||||
const fmtSize = (n: number) =>
|
const fmtSize = (n: number) =>
|
||||||
n < 1024 ? `${n} o` : n < 1 << 20 ? `${(n / 1024).toFixed(1)} Ko` : `${(n / (1 << 20)).toFixed(1)} Mo`;
|
n < 1024 ? `${n} o` : n < 1 << 20 ? `${(n / 1024).toFixed(1)} Ko` : `${(n / (1 << 20)).toFixed(1)} Mo`;
|
||||||
|
|
||||||
const DND_TYPE = "application/x-excalidraw-path";
|
const plural = (n: number, word: string) => `${n} ${word}${n > 1 ? "s" : ""}`;
|
||||||
|
|
||||||
|
const DND_TYPE = "application/x-excalidraw-paths";
|
||||||
|
|
||||||
|
const initialView = (): View => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem("explorerView") === "list" ? "list" : "grid";
|
||||||
|
} catch {
|
||||||
|
return "grid";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Un élément peut-il être déplacé dans targetDir ? (pas sur place, pas dans lui-même) */
|
||||||
|
const canMoveInto = (path: string, targetDir: string) =>
|
||||||
|
dirname(path) !== targetDir && targetDir !== path && !targetDir.startsWith(path + "/");
|
||||||
|
|
||||||
export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Props) {
|
export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Props) {
|
||||||
const [dir, setDir] = useState(() => (current ? dirname(current) : ""));
|
const [dir, setDir] = useState(() => (current ? dirname(current) : ""));
|
||||||
const [entries, setEntries] = useState<Entry[]>([]);
|
const [entries, setEntries] = useState<Entry[]>([]);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [results, setResults] = useState<Entry[] | null>(null);
|
const [results, setResults] = useState<Entry[] | null>(null);
|
||||||
|
const [view, setView] = useState<View>(initialView);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [anchor, setAnchor] = useState<string | null>(null); // origine d'une sélection Maj+clic
|
||||||
const [menuFor, setMenuFor] = useState<string | null>(null);
|
const [menuFor, setMenuFor] = useState<string | null>(null);
|
||||||
|
const [moving, setMoving] = useState<string[] | null>(null); // éléments du dialogue « Déplacer vers »
|
||||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const fileInput = useRef<HTMLInputElement>(null);
|
const fileInput = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const list = results ?? entries;
|
||||||
|
|
||||||
const guard = useCallback(async (fn: () => Promise<unknown>) => {
|
const guard = useCallback(async (fn: () => Promise<unknown>) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -63,8 +87,15 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
|
|||||||
const refresh = useCallback(
|
const refresh = useCallback(
|
||||||
() =>
|
() =>
|
||||||
guard(async () => {
|
guard(async () => {
|
||||||
setEntries(await api.list(dir));
|
const next = await api.list(dir);
|
||||||
|
setEntries(next);
|
||||||
setMenuFor(null);
|
setMenuFor(null);
|
||||||
|
// Oublie les éléments sélectionnés qui ont disparu.
|
||||||
|
setSelected((s) => {
|
||||||
|
if (s.size === 0) return s;
|
||||||
|
const still = new Set(next.map((e) => e.path));
|
||||||
|
return new Set([...s].filter((p) => still.has(p)));
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
[dir, guard],
|
[dir, guard],
|
||||||
);
|
);
|
||||||
@@ -73,6 +104,12 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
|
|||||||
refresh();
|
refresh();
|
||||||
}, [refresh, refreshKey]);
|
}, [refresh, refreshKey]);
|
||||||
|
|
||||||
|
// Changer de dossier ou de recherche réinitialise la sélection.
|
||||||
|
useEffect(() => {
|
||||||
|
setSelected(new Set());
|
||||||
|
setAnchor(null);
|
||||||
|
}, [dir, results === null]);
|
||||||
|
|
||||||
// Suit le dossier du schéma ouvert (lien direct, création, déplacement).
|
// Suit le dossier du schéma ouvert (lien direct, création, déplacement).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (current) setDir(dirname(current));
|
if (current) setDir(dirname(current));
|
||||||
@@ -88,6 +125,65 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
|
|||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [query, guard, refreshKey]);
|
}, [query, guard, refreshKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem("explorerView", view);
|
||||||
|
} catch {
|
||||||
|
/* stockage indisponible */
|
||||||
|
}
|
||||||
|
}, [view]);
|
||||||
|
|
||||||
|
// --- Sélection -----------------------------------------------------------
|
||||||
|
|
||||||
|
const clearSelection = () => {
|
||||||
|
setSelected(new Set());
|
||||||
|
setAnchor(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = (path: string) => {
|
||||||
|
setSelected((s) => {
|
||||||
|
const n = new Set(s);
|
||||||
|
if (!n.delete(path)) n.add(path);
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
setAnchor(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectRange = (path: string) => {
|
||||||
|
const paths = list.map((e) => e.path);
|
||||||
|
const a = anchor ? paths.indexOf(anchor) : -1;
|
||||||
|
const b = paths.indexOf(path);
|
||||||
|
if (a < 0 || b < 0) return toggle(path);
|
||||||
|
const [lo, hi] = a < b ? [a, b] : [b, a];
|
||||||
|
setSelected((s) => new Set([...s, ...paths.slice(lo, hi + 1)]));
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAll = () => setSelected(new Set(list.map((e) => e.path)));
|
||||||
|
|
||||||
|
const onItemClick = (ev: MouseEvent, e: Entry) => {
|
||||||
|
if (ev.shiftKey) return selectRange(e.path);
|
||||||
|
// Ctrl/Cmd+clic, ou clic quand une sélection est en cours (mode sélection tactile).
|
||||||
|
if (ev.ctrlKey || ev.metaKey || selected.size > 0) return toggle(e.path);
|
||||||
|
if (e.isDir) {
|
||||||
|
setQuery("");
|
||||||
|
setDir(e.path);
|
||||||
|
} else onOpen(e.path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (ev: KeyboardEvent) => {
|
||||||
|
if ((ev.target as HTMLElement).tagName === "INPUT") return;
|
||||||
|
if (ev.key === "Escape" && selected.size > 0) {
|
||||||
|
ev.preventDefault();
|
||||||
|
clearSelection();
|
||||||
|
} else if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "a") {
|
||||||
|
ev.preventDefault();
|
||||||
|
selectAll();
|
||||||
|
} else if (ev.key === "Delete" && selected.size > 0) {
|
||||||
|
ev.preventDefault();
|
||||||
|
removeMany([...selected]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// --- Actions -------------------------------------------------------------
|
// --- Actions -------------------------------------------------------------
|
||||||
|
|
||||||
const newDrawing = () =>
|
const newDrawing = () =>
|
||||||
@@ -108,25 +204,36 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
|
|||||||
await refresh();
|
await refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
const move = (e: Entry, to: string) =>
|
const rename = (e: Entry) =>
|
||||||
guard(async () => {
|
guard(async () => {
|
||||||
|
const name = prompt("Nouveau nom :", e.isDir ? e.name : stripExt(e.name));
|
||||||
|
if (!name?.trim()) return;
|
||||||
|
const to = join(dirname(e.path), e.isDir ? name.trim() : withExt(name.trim()));
|
||||||
if (to === e.path) return;
|
if (to === e.path) return;
|
||||||
await api.move(e.path, to);
|
await api.move(e.path, to);
|
||||||
onMoved(e.path, to);
|
onMoved(e.path, to);
|
||||||
await refresh();
|
await refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
const rename = (e: Entry) => {
|
/** Déplace plusieurs éléments dans targetDir ; les échecs partiels sont listés. */
|
||||||
const name = prompt("Nouveau nom :", e.isDir ? e.name : stripExt(e.name));
|
const moveMany = (paths: string[], targetDir: string) =>
|
||||||
if (!name?.trim()) return;
|
guard(async () => {
|
||||||
move(e, join(dirname(e.path), e.isDir ? name.trim() : withExt(name.trim())));
|
const todo = paths.filter((p) => canMoveInto(p, targetDir));
|
||||||
};
|
if (todo.length === 0) return;
|
||||||
|
const res = await api.moveMany(todo, targetDir);
|
||||||
const moveTo = (e: Entry) => {
|
const failed: string[] = [];
|
||||||
const dest = prompt("Dossier de destination (vide = racine) :", dirname(e.path));
|
for (const r of res) {
|
||||||
if (dest === null) return;
|
if (r.error) failed.push(`${basename(r.from)} : ${r.error}`);
|
||||||
move(e, join(dest.trim().replace(/^\/+|\/+$/g, ""), e.name));
|
else if (r.to && r.to !== r.from) onMoved(r.from, r.to);
|
||||||
};
|
}
|
||||||
|
clearSelection();
|
||||||
|
setMoving(null);
|
||||||
|
await refresh();
|
||||||
|
if (results) setResults(await api.search(query));
|
||||||
|
if (failed.length) {
|
||||||
|
setError(`${plural(failed.length, "élément")} non déplacé${failed.length > 1 ? "s" : ""} — ${failed.join(" ; ")}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const duplicate = (e: Entry) =>
|
const duplicate = (e: Entry) =>
|
||||||
guard(async () => {
|
guard(async () => {
|
||||||
@@ -143,13 +250,23 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
|
|||||||
await refresh();
|
await refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
const remove = (e: Entry) =>
|
const removeMany = (paths: string[]) =>
|
||||||
guard(async () => {
|
guard(async () => {
|
||||||
const what = e.isDir ? `le dossier « ${e.name} » et tout son contenu` : `« ${e.name} »`;
|
const one = list.find((e) => e.path === paths[0]);
|
||||||
|
const what =
|
||||||
|
paths.length > 1
|
||||||
|
? `${plural(paths.length, "élément")} (dossiers compris avec leur contenu)`
|
||||||
|
: one?.isDir
|
||||||
|
? `le dossier « ${one.name} » et tout son contenu`
|
||||||
|
: `« ${basename(paths[0])} »`;
|
||||||
if (!confirm(`Mettre ${what} à la corbeille ?`)) return;
|
if (!confirm(`Mettre ${what} à la corbeille ?`)) return;
|
||||||
await api.remove(e.path, e.isDir);
|
for (const p of paths) {
|
||||||
onDeleted(e.path);
|
await api.remove(p, true);
|
||||||
|
onDeleted(p);
|
||||||
|
}
|
||||||
|
clearSelection();
|
||||||
await refresh();
|
await refresh();
|
||||||
|
if (results) setResults(await api.search(query));
|
||||||
});
|
});
|
||||||
|
|
||||||
const importFiles = (files: FileList | null) =>
|
const importFiles = (files: FileList | null) =>
|
||||||
@@ -160,94 +277,140 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
|
|||||||
await refresh();
|
await refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Glisser-déposer (souris) ; sur mobile : menu « Déplacer… » ----------
|
// --- Glisser-déposer (souris) ; sur mobile : sélection + « Déplacer vers… » -
|
||||||
|
|
||||||
const onDragStart = (ev: DragEvent, e: Entry) => {
|
const onDragStart = (ev: DragEvent, e: Entry) => {
|
||||||
ev.dataTransfer.setData(DND_TYPE, e.path);
|
// Glisser un élément sélectionné emporte toute la sélection.
|
||||||
|
const paths = selected.has(e.path) ? [...selected] : [e.path];
|
||||||
|
ev.dataTransfer.setData(DND_TYPE, JSON.stringify(paths));
|
||||||
ev.dataTransfer.effectAllowed = "move";
|
ev.dataTransfer.effectAllowed = "move";
|
||||||
|
if (paths.length > 1) {
|
||||||
|
const ghost = document.createElement("div");
|
||||||
|
ghost.className = "drag-ghost";
|
||||||
|
ghost.textContent = plural(paths.length, "élément");
|
||||||
|
document.body.appendChild(ghost);
|
||||||
|
ev.dataTransfer.setDragImage(ghost, 12, 12);
|
||||||
|
setTimeout(() => ghost.remove());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const dropProps = (targetDir: string) => ({
|
const dropProps = (targetDir: string) => ({
|
||||||
onDragOver: (ev: DragEvent) => {
|
onDragOver: (ev: DragEvent) => {
|
||||||
if (!ev.dataTransfer.types.includes(DND_TYPE)) return;
|
if (!ev.dataTransfer.types.includes(DND_TYPE)) return;
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
ev.dataTransfer.dropEffect = "move";
|
||||||
setDropTarget(targetDir);
|
setDropTarget(targetDir);
|
||||||
},
|
},
|
||||||
onDragLeave: () => setDropTarget(null),
|
onDragLeave: () => setDropTarget(null),
|
||||||
onDrop: (ev: DragEvent) => {
|
onDrop: (ev: DragEvent) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
setDropTarget(null);
|
setDropTarget(null);
|
||||||
const from = ev.dataTransfer.getData(DND_TYPE);
|
let paths: string[] = [];
|
||||||
if (!from || dirname(from) === targetDir) return;
|
try {
|
||||||
move({ path: from, name: basename(from) } as Entry, join(targetDir, basename(from)));
|
paths = JSON.parse(ev.dataTransfer.getData(DND_TYPE));
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
moveMany(paths, targetDir);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Rendu ---------------------------------------------------------------
|
// --- Rendu ---------------------------------------------------------------
|
||||||
|
|
||||||
const crumbs = dir ? dir.split("/") : [];
|
const crumbs = dir ? dir.split("/") : [];
|
||||||
const list = results ?? entries;
|
|
||||||
|
|
||||||
const row = (e: Entry) => (
|
const icon = (e: Entry) =>
|
||||||
<li key={e.path} className={e.path === current ? "active" : ""}>
|
e.isDir ? (
|
||||||
<div
|
<span className="icon" aria-hidden>
|
||||||
className={`row ${dropTarget === e.path ? "drop" : ""}`}
|
📁
|
||||||
draggable
|
</span>
|
||||||
onDragStart={(ev) => onDragStart(ev, e)}
|
) : (
|
||||||
{...(e.isDir ? dropProps(e.path) : {})}
|
<Thumb path={e.path} version={e.modTime} />
|
||||||
>
|
);
|
||||||
<button
|
|
||||||
className="name"
|
const actions = (e: Entry) => (
|
||||||
onClick={() => (e.isDir ? (setQuery(""), setDir(e.path)) : onOpen(e.path))}
|
<div className="actions">
|
||||||
title={e.path}
|
<button onClick={() => rename(e)}>Renommer</button>
|
||||||
>
|
<button onClick={() => setMoving([e.path])}>Déplacer…</button>
|
||||||
<span className="icon" aria-hidden>
|
{!e.isDir && <button onClick={() => duplicate(e)}>Dupliquer</button>}
|
||||||
{e.isDir ? "📁" : "📄"}
|
{!e.isDir && (
|
||||||
</span>
|
<a href={api.downloadUrl(e.path)} download={e.name}>
|
||||||
<span className="label">
|
Télécharger
|
||||||
{e.isDir ? e.name : stripExt(e.name)}
|
</a>
|
||||||
{results && <small className="muted"> {dirname(e.path) || "/"}</small>}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{!e.isDir && <span className="meta muted">{fmtDate(e.modTime)}</span>}
|
|
||||||
<button
|
|
||||||
className="icon-btn"
|
|
||||||
aria-label="Actions"
|
|
||||||
onClick={() => setMenuFor(menuFor === e.path ? null : e.path)}
|
|
||||||
>
|
|
||||||
⋯
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{menuFor === e.path && (
|
|
||||||
<div className="actions">
|
|
||||||
<button onClick={() => rename(e)}>Renommer</button>
|
|
||||||
<button onClick={() => moveTo(e)}>Déplacer…</button>
|
|
||||||
{!e.isDir && <button onClick={() => duplicate(e)}>Dupliquer</button>}
|
|
||||||
{!e.isDir && (
|
|
||||||
<a href={api.downloadUrl(e.path)} download={e.name}>
|
|
||||||
Télécharger
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
<button className="danger" onClick={() => remove(e)}>
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
{!e.isDir && <span className="muted">{fmtSize(e.size)}</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</li>
|
<button className="danger" onClick={() => removeMany([e.path])}>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
{!e.isDir && <span className="muted">{fmtSize(e.size)}</span>}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const item = (e: Entry) => {
|
||||||
|
const isSel = selected.has(e.path);
|
||||||
|
const cls = [
|
||||||
|
"item",
|
||||||
|
e.isDir ? "dir" : "file",
|
||||||
|
e.path === current ? "active" : "",
|
||||||
|
isSel ? "selected" : "",
|
||||||
|
dropTarget === e.path ? "drop" : "",
|
||||||
|
].join(" ");
|
||||||
|
return [
|
||||||
|
<li key={e.path} className={cls}>
|
||||||
|
<div
|
||||||
|
className="row"
|
||||||
|
draggable
|
||||||
|
onDragStart={(ev) => onDragStart(ev, e)}
|
||||||
|
{...(e.isDir ? dropProps(e.path) : {})}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="check"
|
||||||
|
checked={isSel}
|
||||||
|
aria-label={`Sélectionner ${e.name}`}
|
||||||
|
onClick={(ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
if (ev.shiftKey) {
|
||||||
|
ev.preventDefault();
|
||||||
|
selectRange(e.path);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onChange={() => toggle(e.path)}
|
||||||
|
/>
|
||||||
|
<button className="name" onClick={(ev) => onItemClick(ev, e)} title={e.path}>
|
||||||
|
{icon(e)}
|
||||||
|
<span className="label">
|
||||||
|
{e.isDir ? e.name : stripExt(e.name)}
|
||||||
|
{results && <small className="muted where"> {dirname(e.path) || "/"}</small>}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{!e.isDir && view === "list" && <span className="meta muted">{fmtDate(e.modTime)}</span>}
|
||||||
|
<button
|
||||||
|
className="icon-btn more"
|
||||||
|
aria-label="Actions"
|
||||||
|
onClick={() => setMenuFor(menuFor === e.path ? null : e.path)}
|
||||||
|
>
|
||||||
|
⋯
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{view === "list" && menuFor === e.path && actions(e)}
|
||||||
|
</li>,
|
||||||
|
// En grille, le menu occupe toute la largeur sous la vignette.
|
||||||
|
view === "grid" && menuFor === e.path && (
|
||||||
|
<li key={`${e.path}#menu`} className="menu-row">
|
||||||
|
{actions(e)}
|
||||||
|
</li>
|
||||||
|
),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="explorer">
|
<div className="explorer" onKeyDown={onKeyDown}>
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
<button onClick={newDrawing} title="Nouveau schéma">+ Schéma</button>
|
<button onClick={newDrawing} title="Nouveau schéma">+ Schéma</button>
|
||||||
<button onClick={newFolder} title="Nouveau dossier">+ Dossier</button>
|
<button onClick={newFolder} title="Nouveau dossier">+ Dossier</button>
|
||||||
<button onClick={() => fileInput.current?.click()} title="Importer des fichiers .excalidraw">
|
<button onClick={() => fileInput.current?.click()} title="Importer des fichiers .excalidraw">
|
||||||
Importer
|
Importer
|
||||||
</button>
|
</button>
|
||||||
<button className="icon-btn" onClick={refresh} title="Rafraîchir" aria-label="Rafraîchir">
|
|
||||||
⟳
|
|
||||||
</button>
|
|
||||||
<input
|
<input
|
||||||
ref={fileInput}
|
ref={fileInput}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -261,13 +424,26 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<div className="searchbar">
|
||||||
className="search"
|
<input
|
||||||
type="search"
|
className="search"
|
||||||
placeholder="Rechercher…"
|
type="search"
|
||||||
value={query}
|
placeholder="Rechercher…"
|
||||||
onChange={(ev) => setQuery(ev.target.value)}
|
value={query}
|
||||||
/>
|
onChange={(ev) => setQuery(ev.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="icon-btn"
|
||||||
|
onClick={() => setView(view === "grid" ? "list" : "grid")}
|
||||||
|
title={view === "grid" ? "Affichage en liste" : "Affichage en vignettes"}
|
||||||
|
aria-label={view === "grid" ? "Affichage en liste" : "Affichage en vignettes"}
|
||||||
|
>
|
||||||
|
{view === "grid" ? "☰" : "▦"}
|
||||||
|
</button>
|
||||||
|
<button className="icon-btn" onClick={refresh} title="Rafraîchir" aria-label="Rafraîchir">
|
||||||
|
⟳
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{!results && (
|
{!results && (
|
||||||
<nav className="crumbs">
|
<nav className="crumbs">
|
||||||
@@ -288,16 +464,37 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
|
|||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<div className="selbar">
|
||||||
|
<button className="icon-btn" onClick={clearSelection} aria-label="Annuler la sélection" title="Annuler la sélection (Échap)">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
<span className="count">{plural(selected.size, "sélectionné")}</span>
|
||||||
|
<button
|
||||||
|
className="icon-btn"
|
||||||
|
onClick={selected.size === list.length ? clearSelection : selectAll}
|
||||||
|
title={selected.size === list.length ? "Tout désélectionner" : "Tout sélectionner (Ctrl+A)"}
|
||||||
|
aria-label={selected.size === list.length ? "Tout désélectionner" : "Tout sélectionner"}
|
||||||
|
>
|
||||||
|
{selected.size === list.length ? "☐" : "☑"}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setMoving([...selected])}>Déplacer…</button>
|
||||||
|
<button className="danger" onClick={() => removeMany([...selected])} title="Mettre à la corbeille (Suppr)" aria-label="Supprimer">
|
||||||
|
🗑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="error" onClick={() => setError(null)}>
|
<div className="error" onClick={() => setError(null)}>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ul className="entries">
|
<ul className={`entries ${view} ${selected.size > 0 ? "selecting" : ""}`}>
|
||||||
{!results && dir && (
|
{!results && dir && (
|
||||||
<li>
|
<li className={`item dir up ${dropTarget === dirname(dir) ? "drop" : ""}`}>
|
||||||
<div className={`row ${dropTarget === dirname(dir) ? "drop" : ""}`} {...dropProps(dirname(dir))}>
|
<div className="row" {...dropProps(dirname(dir))}>
|
||||||
<button className="name" onClick={() => setDir(dirname(dir))}>
|
<button className="name" onClick={() => setDir(dirname(dir))}>
|
||||||
<span className="icon" aria-hidden>↩</span>
|
<span className="icon" aria-hidden>↩</span>
|
||||||
<span className="label">..</span>
|
<span className="label">..</span>
|
||||||
@@ -305,9 +502,18 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
{list.map(row)}
|
{list.flatMap(item)}
|
||||||
{list.length === 0 && <li className="muted empty">{results ? "Aucun résultat" : "Dossier vide"}</li>}
|
{list.length === 0 && <li className="muted empty">{results ? "Aucun résultat" : "Dossier vide"}</li>}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
{moving && (
|
||||||
|
<MoveDialog
|
||||||
|
paths={moving}
|
||||||
|
startDir={results ? "" : dir}
|
||||||
|
onCancel={() => setMoving(null)}
|
||||||
|
onConfirm={(target) => moveMany(moving, target)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api, basename, dirname, join, type Entry } from "./api";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Éléments à déplacer. */
|
||||||
|
paths: string[];
|
||||||
|
startDir: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: (dir: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sélecteur de dossier de destination (utilisable au doigt comme à la souris). */
|
||||||
|
export function MoveDialog({ paths, startDir, onCancel, onConfirm }: Props) {
|
||||||
|
const [dir, setDir] = useState(startDir);
|
||||||
|
const [folders, setFolders] = useState<Entry[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
setError(null);
|
||||||
|
api
|
||||||
|
.list(dir)
|
||||||
|
.then((es) => live && setFolders(es.filter((e) => e.isDir)))
|
||||||
|
.catch((e) => live && setError(e.message));
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [dir]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onCancel();
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
|
// Un dossier déplacé ne peut pas recevoir son propre contenu.
|
||||||
|
const blocked = (p: string) => paths.some((m) => p === m || p.startsWith(m + "/"));
|
||||||
|
const crumbs = dir ? dir.split("/") : [];
|
||||||
|
|
||||||
|
const newFolder = async () => {
|
||||||
|
const name = prompt("Nom du dossier :");
|
||||||
|
if (!name?.trim()) return;
|
||||||
|
try {
|
||||||
|
const e = await api.mkdir(join(dir, name.trim()));
|
||||||
|
setDir(e.path);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const title =
|
||||||
|
paths.length === 1 ? `Déplacer « ${basename(paths[0])} »` : `Déplacer ${paths.length} éléments`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onCancel}>
|
||||||
|
<div className="modal" role="dialog" aria-modal="true" aria-label={title} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<nav className="crumbs">
|
||||||
|
<button onClick={() => setDir("")}>Racine</button>
|
||||||
|
{crumbs.map((c, i) => {
|
||||||
|
const p = crumbs.slice(0, i + 1).join("/");
|
||||||
|
return (
|
||||||
|
<span key={p}>
|
||||||
|
<span className="muted"> / </span>
|
||||||
|
<button onClick={() => setDir(p)}>{c}</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
<ul className="folders">
|
||||||
|
{folders.map((f) => (
|
||||||
|
<li key={f.path}>
|
||||||
|
<button disabled={blocked(f.path)} onClick={() => setDir(f.path)}>
|
||||||
|
<span aria-hidden>📁</span> {f.name}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{folders.length === 0 && <li className="muted empty">Aucun sous-dossier</li>}
|
||||||
|
</ul>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button onClick={newFolder}>+ Dossier</button>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button onClick={onCancel}>Annuler</button>
|
||||||
|
<button
|
||||||
|
className="primary"
|
||||||
|
disabled={blocked(dir) || paths.every((p) => dirname(p) === dir)}
|
||||||
|
onClick={() => onConfirm(dir)}
|
||||||
|
>
|
||||||
|
Déplacer ici
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { api } from "./api";
|
||||||
|
import { ensureThumb, knownThumb } from "./thumbs";
|
||||||
|
|
||||||
|
type State = "idle" | "server" | "generating" | "retry" | "empty" | "error";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Miniature d'un schéma, chargée quand elle devient visible. Si le serveur
|
||||||
|
* n'en a pas (ou qu'elle est périmée), elle est générée dans le navigateur
|
||||||
|
* puis envoyée au serveur pour les visites suivantes.
|
||||||
|
*/
|
||||||
|
export function Thumb({ path, version }: { path: string; version: string }) {
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [state, setState] = useState<State>("idle");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const k = knownThumb(path, version);
|
||||||
|
setState(k === "empty" || k === "error" ? k : "idle");
|
||||||
|
}, [path, version]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el || visible) return;
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
(es) => {
|
||||||
|
if (es.some((e) => e.isIntersecting)) {
|
||||||
|
setVisible(true);
|
||||||
|
io.disconnect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: "200px" },
|
||||||
|
);
|
||||||
|
io.observe(el);
|
||||||
|
return () => io.disconnect();
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && state === "idle") setState("server");
|
||||||
|
}, [visible, state]);
|
||||||
|
|
||||||
|
const onError = () => {
|
||||||
|
if (state !== "server") return setState("error");
|
||||||
|
setState("generating");
|
||||||
|
ensureThumb(path, version).then((r) => setState(r === "ok" ? "retry" : r));
|
||||||
|
};
|
||||||
|
|
||||||
|
const src =
|
||||||
|
state === "server"
|
||||||
|
? api.thumbUrl(path, version)
|
||||||
|
: state === "retry"
|
||||||
|
? `${api.thumbUrl(path, version)}&r=1` // la réponse 404 précédente n'est pas réutilisée
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span ref={ref} className={`thumb ${state}`} aria-hidden>
|
||||||
|
{src ? (
|
||||||
|
<img src={src} alt="" loading="lazy" decoding="async" draggable={false} onError={onError} />
|
||||||
|
) : (
|
||||||
|
<span className="ph">{state === "empty" ? "∅" : state === "error" ? "📄" : ""}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,12 @@ export interface Entry {
|
|||||||
modTime: string;
|
modTime: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MoveResult {
|
||||||
|
from: string;
|
||||||
|
to?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(public status: number, message: string) {
|
constructor(public status: number, message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
@@ -61,6 +67,10 @@ export const api = {
|
|||||||
move: async (from: string, to: string): Promise<Entry> =>
|
move: async (from: string, to: string): Promise<Entry> =>
|
||||||
(await request("/api/move", json({ from, to }))).json(),
|
(await request("/api/move", json({ from, to }))).json(),
|
||||||
|
|
||||||
|
/** Déplace plusieurs éléments dans dir ; le détail par élément indique les échecs. */
|
||||||
|
moveMany: async (paths: string[], dir: string): Promise<MoveResult[]> =>
|
||||||
|
(await request("/api/move-many", json({ paths, dir }))).json(),
|
||||||
|
|
||||||
copy: async (from: string, to: string): Promise<Entry> =>
|
copy: async (from: string, to: string): Promise<Entry> =>
|
||||||
(await request("/api/copy", json({ from, to }))).json(),
|
(await request("/api/copy", json({ from, to }))).json(),
|
||||||
|
|
||||||
@@ -79,6 +89,17 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
downloadUrl: (path: string) => `/api/file?path=${q(path)}`,
|
downloadUrl: (path: string) => `/api/file?path=${q(path)}`,
|
||||||
|
|
||||||
|
/** v = date du schéma : l'URL change à chaque nouvelle version (cache navigateur). */
|
||||||
|
thumbUrl: (path: string, v: string) => `/api/thumb?path=${q(path)}&v=${q(v)}`,
|
||||||
|
|
||||||
|
writeThumb: async (path: string, png: Blob): Promise<void> => {
|
||||||
|
await request(`/api/thumb?path=${q(path)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "image/png" },
|
||||||
|
body: png,
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helpers de chemins (format "slash", relatifs à la racine).
|
// Helpers de chemins (format "slash", relatifs à la racine).
|
||||||
|
|||||||
+261
-3
@@ -10,7 +10,7 @@
|
|||||||
--ok: #2b8a3e;
|
--ok: #2b8a3e;
|
||||||
--warn: #e67700;
|
--warn: #e67700;
|
||||||
--topbar-h: 44px;
|
--topbar-h: 44px;
|
||||||
--drawer-w: 300px;
|
--drawer-w: 330px;
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +45,7 @@ body,
|
|||||||
|
|
||||||
button,
|
button,
|
||||||
.actions a {
|
.actions a {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
@@ -179,8 +180,14 @@ button:disabled {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
.searchbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
.search {
|
.search {
|
||||||
width: 100%;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -222,7 +229,7 @@ button:disabled {
|
|||||||
li.active > .row {
|
li.active > .row {
|
||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
}
|
}
|
||||||
.row.drop,
|
.item.drop > .row,
|
||||||
.crumbs button.drop {
|
.crumbs button.drop {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
@@ -296,3 +303,254 @@ li.active > .row {
|
|||||||
padding: 10px 6px; /* cibles tactiles plus grandes */
|
padding: 10px 6px; /* cibles tactiles plus grandes */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Explorateur : sélection, vignettes, déplacement ----------------------- */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--thumb-bg: #ffffff;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--thumb-bg: #121212;
|
||||||
|
}
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] button.primary {
|
||||||
|
color: #121212;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.selbar .count {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.selbar button:not(.icon-btn) {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.selbar .icon-btn {
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Case à cocher : au survol (souris), toujours visible pendant une sélection
|
||||||
|
et sur écran tactile. */
|
||||||
|
.item .check {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin: 0 0 0 4px;
|
||||||
|
flex: none;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.item:hover .check,
|
||||||
|
.item .check:focus-visible,
|
||||||
|
.selecting .item .check {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
@media (hover: none) {
|
||||||
|
.item .check {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.item.selected > .row {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
outline: 1px solid var(--accent);
|
||||||
|
}
|
||||||
|
.entries .item.up .row {
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Miniatures */
|
||||||
|
.thumb {
|
||||||
|
flex: none;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 33px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--thumb-bg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.thumb img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .thumb img {
|
||||||
|
filter: invert(93%) hue-rotate(180deg); /* même rendu que le mode sombre d'Excalidraw */
|
||||||
|
}
|
||||||
|
.thumb .ph {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.thumb.server,
|
||||||
|
.thumb.generating {
|
||||||
|
background: linear-gradient(90deg, var(--thumb-bg), var(--panel), var(--thumb-bg));
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.2s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes shimmer {
|
||||||
|
to {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vue grille */
|
||||||
|
.entries.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
grid-auto-flow: row dense;
|
||||||
|
gap: 6px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
.entries.grid > .empty,
|
||||||
|
.entries.grid > .menu-row {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.entries.grid .row {
|
||||||
|
position: relative;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0;
|
||||||
|
height: 100%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.entries.grid .row .name {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
.entries.grid .thumb,
|
||||||
|
.entries.grid .icon {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
}
|
||||||
|
.entries.grid .icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 42px;
|
||||||
|
}
|
||||||
|
.entries.grid .label {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.entries.grid .where {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.entries.grid .check {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 6px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.entries.grid .more {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
z-index: 1;
|
||||||
|
background: color-mix(in srgb, var(--bg) 80%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.entries.grid .item:hover .more,
|
||||||
|
.entries.grid .more:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
@media (hover: none) {
|
||||||
|
.entries.grid .more {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.entries.grid .actions {
|
||||||
|
padding: 4px 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-ghost {
|
||||||
|
position: fixed;
|
||||||
|
top: -100px;
|
||||||
|
left: -100px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font: 600 13px system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dialogue « Déplacer vers… » */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgb(0 0 0 / 0.4);
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
width: min(420px, 100%);
|
||||||
|
max-height: min(560px, 90dvh);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: 0 8px 32px rgb(0 0 0 / 0.3);
|
||||||
|
}
|
||||||
|
.modal h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.modal .folders {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 120px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.modal .folders button {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 9px 10px;
|
||||||
|
}
|
||||||
|
.modal .folders button:hover:not(:disabled) {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
.modal .folders .empty {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Miniatures PNG des schémas : rendues dans le navigateur par Excalidraw
|
||||||
|
// (exportToBlob), stockées côté serveur dans .thumbs/ (voir storage/thumbs.go).
|
||||||
|
|
||||||
|
import { exportToBlob, restoreElements } from "@excalidraw/excalidraw";
|
||||||
|
import type { AppState, BinaryFiles } from "@excalidraw/excalidraw/types";
|
||||||
|
import type { ExcalidrawElement } from "@excalidraw/excalidraw/element/types";
|
||||||
|
import { api } from "./api";
|
||||||
|
|
||||||
|
const THUMB_PX = 320;
|
||||||
|
|
||||||
|
/** Rend la miniature d'une scène ; null si la scène est vide. */
|
||||||
|
export async function renderThumb(
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: Partial<AppState>,
|
||||||
|
files: BinaryFiles,
|
||||||
|
): Promise<Blob | null> {
|
||||||
|
const live = elements.filter((e) => !e.isDeleted);
|
||||||
|
if (live.length === 0) return null;
|
||||||
|
return exportToBlob({
|
||||||
|
elements: live,
|
||||||
|
appState: {
|
||||||
|
viewBackgroundColor: appState.viewBackgroundColor ?? "#ffffff",
|
||||||
|
exportBackground: true,
|
||||||
|
exportWithDarkMode: false, // le thème sombre est appliqué en CSS
|
||||||
|
exportEmbedScene: false,
|
||||||
|
},
|
||||||
|
files,
|
||||||
|
maxWidthOrHeight: THUMB_PX,
|
||||||
|
exportPadding: 16,
|
||||||
|
mimeType: "image/png",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rend puis envoie la miniature ; renvoie false si la scène est vide. */
|
||||||
|
export async function storeThumb(
|
||||||
|
path: string,
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: Partial<AppState>,
|
||||||
|
files: BinaryFiles,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const png = await renderThumb(elements, appState, files);
|
||||||
|
if (png) await api.writeThumb(path, png);
|
||||||
|
return png !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Génération à la demande (schémas importés, anciens, modifiés ailleurs) ---
|
||||||
|
|
||||||
|
export type ThumbResult = "ok" | "empty" | "error";
|
||||||
|
|
||||||
|
const MAX_PARALLEL = 2;
|
||||||
|
const results = new Map<string, ThumbResult>(); // "chemin@date" -> résultat
|
||||||
|
const pending = new Map<string, Promise<ThumbResult>>();
|
||||||
|
const queue: (() => void)[] = [];
|
||||||
|
let running = 0;
|
||||||
|
|
||||||
|
const next = () => {
|
||||||
|
while (running < MAX_PARALLEL && queue.length) {
|
||||||
|
running++;
|
||||||
|
queue.shift()!();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function generate(path: string): Promise<ThumbResult> {
|
||||||
|
try {
|
||||||
|
const { text } = await api.read(path);
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
const elements = restoreElements(data.elements ?? [], null);
|
||||||
|
return (await storeThumb(path, elements, data.appState ?? {}, data.files ?? {})) ? "ok" : "empty";
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("miniature", path, e);
|
||||||
|
return "error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Résultat déjà connu pour cette version du schéma (évite de régénérer en boucle). */
|
||||||
|
export const knownThumb = (path: string, version: string) => results.get(`${path}@${version}`);
|
||||||
|
|
||||||
|
/** Génère la miniature manquante d'un schéma (file d'attente, 2 en parallèle). */
|
||||||
|
export function ensureThumb(path: string, version: string): Promise<ThumbResult> {
|
||||||
|
const key = `${path}@${version}`;
|
||||||
|
const known = results.get(key);
|
||||||
|
if (known) return Promise.resolve(known);
|
||||||
|
let p = pending.get(key);
|
||||||
|
if (!p) {
|
||||||
|
p = new Promise<ThumbResult>((resolve) => {
|
||||||
|
queue.push(() =>
|
||||||
|
generate(path)
|
||||||
|
.then((r) => {
|
||||||
|
results.set(key, r);
|
||||||
|
resolve(r);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
pending.delete(key);
|
||||||
|
running--;
|
||||||
|
next();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
pending.set(key, p);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
@@ -6,8 +6,10 @@
|
|||||||
// 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
|
||||||
// POST /api/move {"from":"x","to":"y"} renommage / déplacement
|
// POST /api/move {"from":"x","to":"y"} renommage / déplacement
|
||||||
|
// POST /api/move-many {"paths":[...],"dir":"d"} déplacement multiple
|
||||||
// POST /api/copy {"from":"x","to":"y"} duplication d'un schéma
|
// POST /api/copy {"from":"x","to":"y"} duplication d'un schéma
|
||||||
// DELETE /api/entry?path=x[&recursive=true] mise à la corbeille
|
// DELETE /api/entry?path=x[&recursive=true] mise à la corbeille
|
||||||
|
// GET /api/thumb?path=x / PUT (image/png) miniature d'un schéma
|
||||||
// GET /api/library / PUT /api/library bibliothèque Excalidraw
|
// GET /api/library / PUT /api/library bibliothèque Excalidraw
|
||||||
package api
|
package api
|
||||||
|
|
||||||
@@ -35,8 +37,11 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
|||||||
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)
|
||||||
mux.HandleFunc("POST /api/move", h.move)
|
mux.HandleFunc("POST /api/move", h.move)
|
||||||
|
mux.HandleFunc("POST /api/move-many", h.moveMany)
|
||||||
mux.HandleFunc("POST /api/copy", h.copy)
|
mux.HandleFunc("POST /api/copy", h.copy)
|
||||||
mux.HandleFunc("DELETE /api/entry", h.delete)
|
mux.HandleFunc("DELETE /api/entry", h.delete)
|
||||||
|
mux.HandleFunc("GET /api/thumb", h.readThumb)
|
||||||
|
mux.HandleFunc("PUT /api/thumb", h.writeThumb)
|
||||||
mux.HandleFunc("GET /api/library", h.readLibrary)
|
mux.HandleFunc("GET /api/library", h.readLibrary)
|
||||||
mux.HandleFunc("PUT /api/library", h.writeLibrary)
|
mux.HandleFunc("PUT /api/library", h.writeLibrary)
|
||||||
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -175,6 +180,57 @@ func (h *Handler) move(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, entry)
|
writeJSON(w, http.StatusOK, entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type moveManyReq struct {
|
||||||
|
Paths []string `json:"paths"`
|
||||||
|
Dir string `json:"dir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// moveMany renvoie toujours 200 avec le détail par élément : le client
|
||||||
|
// affiche les échecs partiels (ex. nom déjà pris dans la destination).
|
||||||
|
func (h *Handler) moveMany(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req moveManyReq
|
||||||
|
if !h.decode(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Paths) == 0 || len(req.Paths) > 1000 {
|
||||||
|
writeErr(w, http.StatusBadRequest, "entre 1 et 1000 chemins attendus")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := h.Store.MoveMany(req.Paths, req.Dir)
|
||||||
|
if err != nil {
|
||||||
|
fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) readThumb(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data, mod, err := h.Store.ReadThumb(r.URL.Query().Get("path"))
|
||||||
|
if err != nil {
|
||||||
|
// Pas de mise en cache d'une absence : la miniature va être générée.
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// L'URL porte la date du schéma (?v=...) : une nouvelle version change l'URL.
|
||||||
|
w.Header().Set("Cache-Control", "private, max-age=86400")
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
w.Header().Set("Last-Modified", mod.UTC().Format(http.TimeFormat))
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) writeThumb(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, storage.MaxThumbSize))
|
||||||
|
if err == nil {
|
||||||
|
err = h.Store.WriteThumb(r.URL.Query().Get("path"), data)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) copy(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) copy(w http.ResponseWriter, r *http.Request) {
|
||||||
var req moveReq
|
var req moveReq
|
||||||
if !h.decode(w, r, &req) {
|
if !h.decode(w, r, &req) {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const Ext = ".excalidraw"
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
trashDir = ".trash"
|
trashDir = ".trash"
|
||||||
|
thumbsDir = ".thumbs"
|
||||||
libraryFile = ".library.excalidrawlib"
|
libraryFile = ".library.excalidrawlib"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -358,6 +359,7 @@ func (s *Store) Move(from, to string) (Entry, error) {
|
|||||||
if err := os.Rename(af, at); err != nil {
|
if err := os.Rename(af, at); err != nil {
|
||||||
return Entry{}, mapErr(err)
|
return Entry{}, mapErr(err)
|
||||||
}
|
}
|
||||||
|
s.moveThumb(cf, ct, fi.IsDir())
|
||||||
fi, err = os.Stat(at)
|
fi, err = os.Stat(at)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Entry{}, err
|
return Entry{}, err
|
||||||
@@ -365,6 +367,55 @@ func (s *Store) Move(from, to string) (Entry, error) {
|
|||||||
return toEntry(ct, fi), nil
|
return toEntry(ct, fi), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MoveResult est le résultat d'un déplacement unitaire dans MoveMany.
|
||||||
|
type MoveResult struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Err renvoie l'erreur du déplacement (nil si réussi).
|
||||||
|
func (r MoveResult) Err() error { return r.err }
|
||||||
|
|
||||||
|
// MoveMany déplace plusieurs fichiers ou dossiers dans le dossier dir en
|
||||||
|
// conservant leur nom. Chaque élément est traité indépendamment : un échec
|
||||||
|
// (destination existante, dossier déplacé dans lui-même...) n'interrompt pas
|
||||||
|
// les autres. Les éléments déjà présents dans dir sont ignorés.
|
||||||
|
func (s *Store) MoveMany(paths []string, dir string) ([]MoveResult, error) {
|
||||||
|
cd, ad, err := s.resolve(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fi, err := os.Stat(ad)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapErr(err)
|
||||||
|
}
|
||||||
|
if !fi.IsDir() {
|
||||||
|
return nil, fmt.Errorf("%w : la destination n'est pas un dossier", ErrInvalidPath)
|
||||||
|
}
|
||||||
|
out := make([]MoveResult, 0, len(paths))
|
||||||
|
for _, p := range paths {
|
||||||
|
r := MoveResult{From: p}
|
||||||
|
if c, err := Clean(p); err != nil || c == "" {
|
||||||
|
r.err = ErrInvalidPath
|
||||||
|
} else if strings.TrimPrefix(path.Dir("/"+c), "/") == cd {
|
||||||
|
r.From, r.To = c, c // déjà dans le dossier cible
|
||||||
|
} else {
|
||||||
|
r.From = c
|
||||||
|
var e Entry
|
||||||
|
if e, r.err = s.Move(c, path.Join(cd, path.Base(c))); r.err == nil {
|
||||||
|
r.To = e.Path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.err != nil {
|
||||||
|
r.Error = r.err.Error()
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Copy duplique un schéma (les dossiers ne sont pas copiés).
|
// Copy duplique un schéma (les dossiers ne sont pas copiés).
|
||||||
func (s *Store) Copy(from, to string) (Entry, error) {
|
func (s *Store) Copy(from, to string) (Entry, error) {
|
||||||
data, _, err := s.Read(from)
|
data, _, err := s.Read(from)
|
||||||
@@ -372,6 +423,9 @@ func (s *Store) Copy(from, to string) (Entry, error) {
|
|||||||
return Entry{}, err
|
return Entry{}, err
|
||||||
}
|
}
|
||||||
e, _, err := s.Write(to, data, "", true)
|
e, _, err := s.Write(to, data, "", true)
|
||||||
|
if err == nil {
|
||||||
|
s.copyThumb(from, e.Path)
|
||||||
|
}
|
||||||
return e, err
|
return e, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,7 +456,11 @@ func (s *Store) Delete(rel string, recursive bool) error {
|
|||||||
if err := os.MkdirAll(trash, 0o755); err != nil {
|
if err := os.MkdirAll(trash, 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return os.Rename(abs, filepath.Join(trash, filepath.Base(abs)))
|
if err := os.Rename(abs, filepath.Join(trash, filepath.Base(abs))); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.removeThumb(c, fi.IsDir())
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadLibrary renvoie la bibliothèque Excalidraw partagée (ou nil si absente).
|
// ReadLibrary renvoie la bibliothèque Excalidraw partagée (ou nil si absente).
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const doc = `{"type":"excalidraw","version":2,"elements":[]}`
|
const doc = `{"type":"excalidraw","version":2,"elements":[]}`
|
||||||
@@ -99,3 +100,96 @@ func TestSymlinkEscape(t *testing.T) {
|
|||||||
t.Fatalf("échappement par symlink non bloqué : %v", err)
|
t.Fatalf("échappement par symlink non bloqué : %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMoveMany(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
for _, p := range []string{"a.excalidraw", "b.excalidraw", "src/c.excalidraw", "dst/b.excalidraw"} {
|
||||||
|
if _, _, err := s.Write(p, []byte(doc), "", true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := s.Mkdir("src/sub"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := s.MoveMany([]string{"a.excalidraw"}, "a.excalidraw"); !errors.Is(err, ErrInvalidPath) {
|
||||||
|
t.Fatalf("destination fichier acceptée : %v", err)
|
||||||
|
}
|
||||||
|
res, err := s.MoveMany([]string{"a.excalidraw", "b.excalidraw", "src/c.excalidraw", "src/sub", "dst/b.excalidraw", "dst", "nope.excalidraw"}, "dst")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []struct {
|
||||||
|
to string
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{"dst/a.excalidraw", nil},
|
||||||
|
{"", ErrExists},
|
||||||
|
{"dst/c.excalidraw", nil},
|
||||||
|
{"dst/sub", nil},
|
||||||
|
{"dst/b.excalidraw", nil}, // déjà dans dst : ignoré
|
||||||
|
{"", ErrInvalidPath}, // dossier dans lui-même
|
||||||
|
{"", ErrNotFound},
|
||||||
|
}
|
||||||
|
for i, w := range want {
|
||||||
|
if res[i].To != w.to || !errors.Is(res[i].Err(), w.err) {
|
||||||
|
t.Errorf("%s : to=%q err=%v ; attendu %q, %v", res[i].From, res[i].To, res[i].Err(), w.to, w.err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, _, err := s.Read("b.excalidraw"); err != nil {
|
||||||
|
t.Fatalf("b déplacé malgré le conflit : %v", err)
|
||||||
|
}
|
||||||
|
// Vers la racine.
|
||||||
|
res, err = s.MoveMany([]string{"dst/sub", "b.excalidraw"}, "")
|
||||||
|
if err != nil || res[0].To != "sub" || res[0].Err() != nil || res[1].To != "b.excalidraw" {
|
||||||
|
t.Fatalf("vers la racine : %+v, %v", res, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var png = []byte("\x89PNG\r\n\x1a\nfake")
|
||||||
|
|
||||||
|
func TestThumbs(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
if err := s.WriteThumb("d/a.excalidraw", png); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("miniature d'un schéma absent : %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := s.Write("d/a.excalidraw", []byte(doc), "", true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.WriteThumb("d/a.excalidraw", []byte("<svg/>")); !errors.Is(err, ErrInvalidPath) {
|
||||||
|
t.Fatalf("non-PNG accepté : %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := s.ReadThumb("d/a.excalidraw"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("miniature inexistante : %v", err)
|
||||||
|
}
|
||||||
|
if err := s.WriteThumb("d/a.excalidraw", png); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if b, _, err := s.ReadThumb("d/a.excalidraw"); err != nil || string(b) != string(png) {
|
||||||
|
t.Fatalf("lecture : %q, %v", b, err)
|
||||||
|
}
|
||||||
|
// Suit le déplacement du dossier, la copie, puis la suppression.
|
||||||
|
if _, err := s.Move("d", "e"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := s.ReadThumb("e/a.excalidraw"); err != nil {
|
||||||
|
t.Fatalf("après déplacement : %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.Copy("e/a.excalidraw", "e/b.excalidraw"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := s.ReadThumb("e/b.excalidraw"); err != nil {
|
||||||
|
t.Fatalf("après copie : %v", err)
|
||||||
|
}
|
||||||
|
if err := s.Delete("e/a.excalidraw", false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(s.root, thumbsDir, "e", "a.excalidraw.png")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("miniature non supprimée : %v", err)
|
||||||
|
}
|
||||||
|
// Périmée après modification du schéma.
|
||||||
|
old := time.Now().Add(-time.Hour)
|
||||||
|
_ = os.Chtimes(filepath.Join(s.root, thumbsDir, "e", "b.excalidraw.png"), old, old)
|
||||||
|
if _, _, err := s.ReadThumb("e/b.excalidraw"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("miniature périmée servie : %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Miniatures PNG des schémas, générées par le navigateur et rangées dans
|
||||||
|
// .thumbs/ en miroir de l'arborescence (a/b.excalidraw -> .thumbs/a/b.excalidraw.png).
|
||||||
|
// Une miniature plus ancienne que son schéma est considérée comme périmée.
|
||||||
|
|
||||||
|
// MaxThumbSize borne la taille d'une miniature acceptée.
|
||||||
|
const MaxThumbSize = 1 << 20
|
||||||
|
|
||||||
|
var pngMagic = []byte("\x89PNG\r\n\x1a\n")
|
||||||
|
|
||||||
|
func (s *Store) thumbAbs(c string, isDir bool) string {
|
||||||
|
p := filepath.Join(s.root, thumbsDir, filepath.FromSlash(c))
|
||||||
|
if isDir {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return p + ".png"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadThumb renvoie la miniature d'un schéma et sa date, ou ErrNotFound si
|
||||||
|
// elle est absente ou périmée.
|
||||||
|
func (s *Store) ReadThumb(rel string) ([]byte, time.Time, error) {
|
||||||
|
c, abs, err := s.resolve(rel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, time.Time{}, err
|
||||||
|
}
|
||||||
|
if err := requireSchemaPath(c); err != nil {
|
||||||
|
return nil, time.Time{}, err
|
||||||
|
}
|
||||||
|
src, err := os.Stat(abs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, time.Time{}, mapErr(err)
|
||||||
|
}
|
||||||
|
ta := s.thumbAbs(c, false)
|
||||||
|
ti, err := os.Stat(ta)
|
||||||
|
if err != nil {
|
||||||
|
return nil, time.Time{}, mapErr(err)
|
||||||
|
}
|
||||||
|
if ti.ModTime().Before(src.ModTime()) {
|
||||||
|
return nil, time.Time{}, fmt.Errorf("%w : miniature périmée", ErrNotFound)
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(ta)
|
||||||
|
return b, ti.ModTime(), mapErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteThumb enregistre la miniature PNG d'un schéma existant.
|
||||||
|
func (s *Store) WriteThumb(rel string, png []byte) error {
|
||||||
|
c, abs, err := s.resolve(rel)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := requireSchemaPath(c); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !bytes.HasPrefix(png, pngMagic) || len(png) > MaxThumbSize {
|
||||||
|
return fmt.Errorf("%w : PNG de %d Ko max attendu", ErrInvalidPath, MaxThumbSize>>10)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(abs); err != nil {
|
||||||
|
return mapErr(err)
|
||||||
|
}
|
||||||
|
ta := s.thumbAbs(c, false)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(ta), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return atomicWrite(ta, png)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les opérations suivantes accompagnent Move/Copy/Delete. Elles sont
|
||||||
|
// « au mieux » : une miniature manquante sera simplement régénérée.
|
||||||
|
|
||||||
|
func (s *Store) moveThumb(from, to string, isDir bool) {
|
||||||
|
src, dst := s.thumbAbs(from, isDir), s.thumbAbs(to, isDir)
|
||||||
|
if _, err := os.Stat(src); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.RemoveAll(dst)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err == nil {
|
||||||
|
_ = os.Rename(src, dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) copyThumb(from, to string) {
|
||||||
|
cf, err1 := Clean(from)
|
||||||
|
ct, err2 := Clean(to)
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(s.thumbAbs(cf, false))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dst := s.thumbAbs(ct, false)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err == nil {
|
||||||
|
_ = atomicWrite(dst, b) // écrite après la copie du schéma : donc à jour
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) removeThumb(c string, isDir bool) {
|
||||||
|
_ = os.RemoveAll(s.thumbAbs(c, isDir))
|
||||||
|
}
|
||||||
Reference in new issue
Block a user