From b76c6d9069a75c614faaddce717904cf31cd3b46 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 7 Oct 2026 11:52:14 +0200 Subject: [PATCH] Add file explorer (Explorer.tsx) --- frontend/src/Explorer.tsx | 313 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 frontend/src/Explorer.tsx diff --git a/frontend/src/Explorer.tsx b/frontend/src/Explorer.tsx new file mode 100644 index 0000000..5dc6e2c --- /dev/null +++ b/frontend/src/Explorer.tsx @@ -0,0 +1,313 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { DragEvent } from "react"; +import { + api, + ApiError, + basename, + dirname, + EXT, + join, + stripExt, + withExt, + type Entry, +} from "./api"; + +export const EMPTY_SCENE = JSON.stringify({ + type: "excalidraw", + version: 2, + source: "excalidraw-go", + elements: [], + appState: {}, + files: {}, +}); + +interface Props { + /** Schéma ouvert dans l'éditeur (surligné). */ + current: string | null; + /** Incrémenté par le parent pour forcer un rafraîchissement (ex. après sauvegarde). */ + refreshKey: number; + onOpen: (path: string) => void; + /** Un fichier ou dossier a été déplacé/renommé : le parent met à jour le schéma ouvert. */ + onMoved: (from: string, to: string) => void; + /** Un fichier ou dossier a été supprimé. */ + onDeleted: (path: string) => void; +} + +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"; + +export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Props) { + const [dir, setDir] = useState(() => (current ? dirname(current) : "")); + const [entries, setEntries] = useState([]); + const [query, setQuery] = useState(""); + const [results, setResults] = useState(null); + const [menuFor, setMenuFor] = useState(null); + const [dropTarget, setDropTarget] = useState(null); + const [error, setError] = useState(null); + const fileInput = useRef(null); + + const guard = useCallback(async (fn: () => Promise) => { + setError(null); + try { + await fn(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }, []); + + const refresh = useCallback( + () => + guard(async () => { + setEntries(await api.list(dir)); + setMenuFor(null); + }), + [dir, guard], + ); + + useEffect(() => { + refresh(); + }, [refresh, refreshKey]); + + // Suit le dossier du schéma ouvert (lien direct, création, déplacement). + useEffect(() => { + if (current) setDir(dirname(current)); + }, [current]); + + // Recherche avec anti-rebond. + useEffect(() => { + if (!query.trim()) { + setResults(null); + return; + } + const t = setTimeout(() => guard(async () => setResults(await api.search(query))), 250); + return () => clearTimeout(t); + }, [query, guard, refreshKey]); + + // --- Actions ------------------------------------------------------------- + + const newDrawing = () => + guard(async () => { + const name = prompt("Nom du nouveau schéma :", "Sans titre"); + if (!name?.trim()) return; + const path = join(dir, withExt(name.trim())); + await api.write(path, EMPTY_SCENE, "*"); + await refresh(); + onOpen(path); + }); + + const newFolder = () => + guard(async () => { + const name = prompt("Nom du dossier :"); + if (!name?.trim()) return; + await api.mkdir(join(dir, name.trim())); + await refresh(); + }); + + const move = (e: Entry, to: string) => + guard(async () => { + 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)); + }; + + const duplicate = (e: Entry) => + guard(async () => { + const base = join(dirname(e.path), stripExt(e.name)); + for (let i = 1; i < 100; i++) { + const to = `${base} (copie${i > 1 ? ` ${i}` : ""})${EXT}`; + try { + await api.copy(e.path, to); + break; + } catch (err) { + if (!(err instanceof ApiError && err.status === 409)) throw err; + } + } + await refresh(); + }); + + const remove = (e: Entry) => + guard(async () => { + const what = e.isDir ? `le dossier « ${e.name} » et tout son contenu` : `« ${e.name} »`; + if (!confirm(`Mettre ${what} à la corbeille ?`)) return; + await api.remove(e.path, e.isDir); + onDeleted(e.path); + await refresh(); + }); + + const importFiles = (files: FileList | null) => + guard(async () => { + for (const f of Array.from(files ?? [])) { + await api.write(join(dir, withExt(stripExt(f.name.replace(/\.json$/i, "")))), await f.text(), "*"); + } + await refresh(); + }); + + // --- Glisser-déposer (souris) ; sur mobile : menu « Déplacer… » ---------- + + const onDragStart = (ev: DragEvent, e: Entry) => { + ev.dataTransfer.setData(DND_TYPE, e.path); + ev.dataTransfer.effectAllowed = "move"; + }; + + const dropProps = (targetDir: string) => ({ + onDragOver: (ev: DragEvent) => { + if (!ev.dataTransfer.types.includes(DND_TYPE)) return; + ev.preventDefault(); + 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))); + }, + }); + + // --- Rendu --------------------------------------------------------------- + + const crumbs = dir ? dir.split("/") : []; + const list = results ?? entries; + + const row = (e: Entry) => ( +
  • +
    onDragStart(ev, e)} + {...(e.isDir ? dropProps(e.path) : {})} + > + + {!e.isDir && {fmtDate(e.modTime)}} + +
    + {menuFor === e.path && ( +
    + + + {!e.isDir && } + {!e.isDir && ( + + Télécharger + + )} + + {!e.isDir && {fmtSize(e.size)}} +
    + )} +
  • + ); + + return ( +
    +
    + + + + + { + importFiles(ev.target.files); + ev.target.value = ""; + }} + /> +
    + + setQuery(ev.target.value)} + /> + + {!results && ( + + )} + + {error && ( +
    setError(null)}> + {error} +
    + )} + +
      + {!results && dir && ( +
    • +
      + +
      +
    • + )} + {list.map(row)} + {list.length === 0 &&
    • {results ? "Aucun résultat" : "Dossier vide"}
    • } +
    +
    + ); +}