Explorateur : sélection et déplacement multiples, vignettes des schémas #2
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
- **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
|
||||
├─ projets/réseau/sdn.excalidraw ← fichiers JSON Excalidraw standards
|
||||
├─ .library.excalidrawlib ← bibliothèque partagée
|
||||
├─ .thumbs/projets/réseau/sdn.excalidraw.png ← miniatures (régénérables)
|
||||
└─ .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
|
||||
|
||||
- **+ Schéma / + Dossier** : création dans le dossier courant.
|
||||
- **⋯** sur une ligne : renommer, déplacer, dupliquer, télécharger, supprimer.
|
||||
- **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…*
|
||||
- **Vignettes / liste** : bouton ▦ / ☰ à droite de la recherche (choix mémorisé par navigateur).
|
||||
- **⋯** 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.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
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 |
|
||||
| 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-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` |
|
||||
| DELETE | `/api/entry?path=x[&recursive=true]` | | `204` (déplacé dans `.trash/`) |
|
||||
| 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
|
||||
web/embed.go embarque web/dist (produit par le build Vite)
|
||||
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é
|
||||
@@ -114,7 +132,6 @@ frontend/src/Explorer.tsx gestionnaire de fichiers
|
||||
## 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.
|
||||
- **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).
|
||||
- **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`.
|
||||
+272
-66
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { DragEvent } from "react";
|
||||
import type { DragEvent, KeyboardEvent, MouseEvent } from "react";
|
||||
import {
|
||||
api,
|
||||
ApiError,
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
withExt,
|
||||
type Entry,
|
||||
} from "./api";
|
||||
import { MoveDialog } from "./MoveDialog";
|
||||
import { Thumb } from "./Thumb";
|
||||
|
||||
export const EMPTY_SCENE = JSON.stringify({
|
||||
type: "excalidraw",
|
||||
@@ -33,24 +35,46 @@ interface Props {
|
||||
onDeleted: (path: string) => void;
|
||||
}
|
||||
|
||||
type View = "list" | "grid";
|
||||
|
||||
const fmtDate = (iso: string) =>
|
||||
new Date(iso).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" });
|
||||
|
||||
const fmtSize = (n: number) =>
|
||||
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) {
|
||||
const [dir, setDir] = useState(() => (current ? dirname(current) : ""));
|
||||
const [entries, setEntries] = useState<Entry[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
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 [moving, setMoving] = useState<string[] | null>(null); // éléments du dialogue « Déplacer vers »
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const list = results ?? entries;
|
||||
|
||||
const guard = useCallback(async (fn: () => Promise<unknown>) => {
|
||||
setError(null);
|
||||
try {
|
||||
@@ -63,8 +87,15 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
|
||||
const refresh = useCallback(
|
||||
() =>
|
||||
guard(async () => {
|
||||
setEntries(await api.list(dir));
|
||||
const next = await api.list(dir);
|
||||
setEntries(next);
|
||||
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],
|
||||
);
|
||||
@@ -73,6 +104,12 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
|
||||
refresh();
|
||||
}, [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).
|
||||
useEffect(() => {
|
||||
if (current) setDir(dirname(current));
|
||||
@@ -88,6 +125,65 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
|
||||
return () => clearTimeout(t);
|
||||
}, [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 -------------------------------------------------------------
|
||||
|
||||
const newDrawing = () =>
|
||||
@@ -108,25 +204,36 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
|
||||
await refresh();
|
||||
});
|
||||
|
||||
const move = (e: Entry, to: string) =>
|
||||
const rename = (e: Entry) =>
|
||||
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;
|
||||
await api.move(e.path, to);
|
||||
onMoved(e.path, to);
|
||||
await refresh();
|
||||
});
|
||||
|
||||
const rename = (e: Entry) => {
|
||||
const name = prompt("Nouveau nom :", e.isDir ? e.name : stripExt(e.name));
|
||||
if (!name?.trim()) return;
|
||||
move(e, join(dirname(e.path), e.isDir ? name.trim() : withExt(name.trim())));
|
||||
};
|
||||
|
||||
const moveTo = (e: Entry) => {
|
||||
const dest = prompt("Dossier de destination (vide = racine) :", dirname(e.path));
|
||||
if (dest === null) return;
|
||||
move(e, join(dest.trim().replace(/^\/+|\/+$/g, ""), e.name));
|
||||
};
|
||||
/** Déplace plusieurs éléments dans targetDir ; les échecs partiels sont listés. */
|
||||
const moveMany = (paths: string[], targetDir: string) =>
|
||||
guard(async () => {
|
||||
const todo = paths.filter((p) => canMoveInto(p, targetDir));
|
||||
if (todo.length === 0) return;
|
||||
const res = await api.moveMany(todo, targetDir);
|
||||
const failed: string[] = [];
|
||||
for (const r of res) {
|
||||
if (r.error) failed.push(`${basename(r.from)} : ${r.error}`);
|
||||
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) =>
|
||||
guard(async () => {
|
||||
@@ -143,13 +250,23 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
|
||||
await refresh();
|
||||
});
|
||||
|
||||
const remove = (e: Entry) =>
|
||||
const removeMany = (paths: string[]) =>
|
||||
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;
|
||||
await api.remove(e.path, e.isDir);
|
||||
onDeleted(e.path);
|
||||
for (const p of paths) {
|
||||
await api.remove(p, true);
|
||||
onDeleted(p);
|
||||
}
|
||||
clearSelection();
|
||||
await refresh();
|
||||
if (results) setResults(await api.search(query));
|
||||
});
|
||||
|
||||
const importFiles = (files: FileList | null) =>
|
||||
@@ -160,94 +277,140 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
|
||||
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) => {
|
||||
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";
|
||||
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) => ({
|
||||
onDragOver: (ev: DragEvent) => {
|
||||
if (!ev.dataTransfer.types.includes(DND_TYPE)) return;
|
||||
ev.preventDefault();
|
||||
ev.dataTransfer.dropEffect = "move";
|
||||
setDropTarget(targetDir);
|
||||
},
|
||||
onDragLeave: () => setDropTarget(null),
|
||||
onDrop: (ev: DragEvent) => {
|
||||
ev.preventDefault();
|
||||
setDropTarget(null);
|
||||
const from = ev.dataTransfer.getData(DND_TYPE);
|
||||
if (!from || dirname(from) === targetDir) return;
|
||||
move({ path: from, name: basename(from) } as Entry, join(targetDir, basename(from)));
|
||||
let paths: string[] = [];
|
||||
try {
|
||||
paths = JSON.parse(ev.dataTransfer.getData(DND_TYPE));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
moveMany(paths, targetDir);
|
||||
},
|
||||
});
|
||||
|
||||
// --- Rendu ---------------------------------------------------------------
|
||||
|
||||
const crumbs = dir ? dir.split("/") : [];
|
||||
const list = results ?? entries;
|
||||
|
||||
const row = (e: Entry) => (
|
||||
<li key={e.path} className={e.path === current ? "active" : ""}>
|
||||
<div
|
||||
className={`row ${dropTarget === e.path ? "drop" : ""}`}
|
||||
draggable
|
||||
onDragStart={(ev) => onDragStart(ev, e)}
|
||||
{...(e.isDir ? dropProps(e.path) : {})}
|
||||
>
|
||||
<button
|
||||
className="name"
|
||||
onClick={() => (e.isDir ? (setQuery(""), setDir(e.path)) : onOpen(e.path))}
|
||||
title={e.path}
|
||||
>
|
||||
const icon = (e: Entry) =>
|
||||
e.isDir ? (
|
||||
<span className="icon" aria-hidden>
|
||||
{e.isDir ? "📁" : "📄"}
|
||||
📁
|
||||
</span>
|
||||
<span className="label">
|
||||
{e.isDir ? e.name : stripExt(e.name)}
|
||||
{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 && (
|
||||
) : (
|
||||
<Thumb path={e.path} version={e.modTime} />
|
||||
);
|
||||
|
||||
const actions = (e: Entry) => (
|
||||
<div className="actions">
|
||||
<button onClick={() => rename(e)}>Renommer</button>
|
||||
<button onClick={() => moveTo(e)}>Déplacer…</button>
|
||||
<button onClick={() => setMoving([e.path])}>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)}>
|
||||
<button className="danger" onClick={() => removeMany([e.path])}>
|
||||
Supprimer
|
||||
</button>
|
||||
{!e.isDir && <span className="muted">{fmtSize(e.size)}</span>}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="explorer">
|
||||
<div className="explorer" onKeyDown={onKeyDown}>
|
||||
<div className="toolbar">
|
||||
<button onClick={newDrawing} title="Nouveau schéma">+ Schéma</button>
|
||||
<button onClick={newFolder} title="Nouveau dossier">+ Dossier</button>
|
||||
<button onClick={() => fileInput.current?.click()} title="Importer des fichiers .excalidraw">
|
||||
Importer
|
||||
</button>
|
||||
<button className="icon-btn" onClick={refresh} title="Rafraîchir" aria-label="Rafraîchir">
|
||||
⟳
|
||||
</button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
@@ -261,6 +424,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="searchbar">
|
||||
<input
|
||||
className="search"
|
||||
type="search"
|
||||
@@ -268,6 +432,18 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
|
||||
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 && (
|
||||
<nav className="crumbs">
|
||||
@@ -288,16 +464,37 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
|
||||
</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 && (
|
||||
<div className="error" onClick={() => setError(null)}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="entries">
|
||||
<ul className={`entries ${view} ${selected.size > 0 ? "selecting" : ""}`}>
|
||||
{!results && dir && (
|
||||
<li>
|
||||
<div className={`row ${dropTarget === dirname(dir) ? "drop" : ""}`} {...dropProps(dirname(dir))}>
|
||||
<li className={`item dir up ${dropTarget === dirname(dir) ? "drop" : ""}`}>
|
||||
<div className="row" {...dropProps(dirname(dir))}>
|
||||
<button className="name" onClick={() => setDir(dirname(dir))}>
|
||||
<span className="icon" aria-hidden>↩</span>
|
||||
<span className="label">..</span>
|
||||
@@ -305,9 +502,18 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
{list.map(row)}
|
||||
{list.flatMap(item)}
|
||||
{list.length === 0 && <li className="muted empty">{results ? "Aucun résultat" : "Dossier vide"}</li>}
|
||||
</ul>
|
||||
|
||||
{moving && (
|
||||
<MoveDialog
|
||||
paths={moving}
|
||||
startDir={results ? "" : dir}
|
||||
onCancel={() => setMoving(null)}
|
||||
onConfirm={(target) => moveMany(moving, target)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user