Add file explorer (Explorer.tsx)
This commit is contained in:
1 parent
9c58405011
commit
b76c6d9069
1 file changed
+313
@@ -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<Entry[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<Entry[] | null>(null);
|
||||
const [menuFor, setMenuFor] = useState<string | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const guard = useCallback(async (fn: () => Promise<unknown>) => {
|
||||
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) => (
|
||||
<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}
|
||||
>
|
||||
<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 && (
|
||||
<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>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="explorer">
|
||||
<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"
|
||||
accept=".excalidraw,application/json"
|
||||
multiple
|
||||
hidden
|
||||
onChange={(ev) => {
|
||||
importFiles(ev.target.files);
|
||||
ev.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
className="search"
|
||||
type="search"
|
||||
placeholder="Rechercher…"
|
||||
value={query}
|
||||
onChange={(ev) => setQuery(ev.target.value)}
|
||||
/>
|
||||
|
||||
{!results && (
|
||||
<nav className="crumbs">
|
||||
<button className={dropTarget === "" ? "drop" : ""} onClick={() => setDir("")} {...dropProps("")}>
|
||||
Racine
|
||||
</button>
|
||||
{crumbs.map((c, i) => {
|
||||
const p = crumbs.slice(0, i + 1).join("/");
|
||||
return (
|
||||
<span key={p}>
|
||||
<span className="muted"> / </span>
|
||||
<button className={dropTarget === p ? "drop" : ""} onClick={() => setDir(p)} {...dropProps(p)}>
|
||||
{c}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="error" onClick={() => setError(null)}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="entries">
|
||||
{!results && dir && (
|
||||
<li>
|
||||
<div className={`row ${dropTarget === dirname(dir) ? "drop" : ""}`} {...dropProps(dirname(dir))}>
|
||||
<button className="name" onClick={() => setDir(dirname(dir))}>
|
||||
<span className="icon" aria-hidden>↩</span>
|
||||
<span className="label">..</span>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
{list.map(row)}
|
||||
{list.length === 0 && <li className="muted empty">{results ? "Aucun résultat" : "Dossier vide"}</li>}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user