diff --git a/frontend/src/Explorer.tsx b/frontend/src/Explorer.tsx index e57333b..0488243 100644 --- a/frontend/src/Explorer.tsx +++ b/frontend/src/Explorer.tsx @@ -11,6 +11,25 @@ import { withExt, type Entry, } from "./api"; +import { + CloseIcon, + DeselectIcon, + DotsIcon, + FilePlusIcon, + FolderIcon, + FolderPlusIcon, + FolderUploadIcon, + GridIcon, + ListIcon, + MoveIcon, + RefreshIcon, + SearchIcon, + SelectAllIcon, + TrashIcon, + UpIcon, + UploadIcon, +} from "./icons"; +import { collectDrop, collectFiles, hasFiles, type Collected } from "./importer"; import { MoveDialog } from "./MoveDialog"; import { Thumb } from "./Thumb"; @@ -71,7 +90,11 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr const [moving, setMoving] = useState(null); // éléments du dialogue « Déplacer vers » const [dropTarget, setDropTarget] = useState(null); const [error, setError] = useState(null); + const [notice, setNotice] = useState<{ text: string; detail?: string } | null>(null); + const [busy, setBusy] = useState(null); // progression d'un import + const [fileDrag, setFileDrag] = useState(false); // fichiers du système survolant l'explorateur const fileInput = useRef(null); + const dirInput = useRef(null); const list = results ?? entries; @@ -269,12 +292,51 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr if (results) setResults(await api.search(query)); }); - const importFiles = (files: FileList | null) => + /** + * Importe fichiers et arborescences dans targetDir : les dossiers sont recréés, + * seuls les .excalidraw sont importés, les schémas déjà présents sont conservés. + */ + const importTree = (pending: Collected | Promise, targetDir: string) => guard(async () => { - for (const f of Array.from(files ?? [])) { - await api.write(join(dir, withExt(stripExt(f.name.replace(/\.json$/i, "")))), await f.text(), "*"); + setNotice(null); + setBusy("Lecture…"); + try { + const { files, dirs, ignored } = await pending; + for (const d of dirs) { + try { + await api.mkdir(join(targetDir, d)); + } catch (err) { + if (!(err instanceof ApiError && err.status === 409)) throw err; // déjà présent + } + } + let done = 0; + const existing: string[] = []; + const failed: string[] = []; + for (const { rel, file } of files) { + setBusy(`Import ${done + existing.length + failed.length + 1}/${files.length}…`); + try { + await api.write(join(targetDir, rel), await file.text(), "*"); + done++; + } catch (err) { + if (err instanceof ApiError && err.status === 409) existing.push(rel); + else failed.push(`${rel} : ${err instanceof Error ? err.message : err}`); + } + } + await refresh(); + const parts = [`${plural(done, "schéma")} importé${done > 1 ? "s" : ""}`]; + if (dirs.length) parts.push(plural(dirs.length, "dossier")); + if (existing.length) parts.push(`${existing.length} déjà présent${existing.length > 1 ? "s" : ""} (conservé${existing.length > 1 ? "s" : ""})`); + if (ignored.length) parts.push(`${plural(ignored.length, "fichier")} ignoré${ignored.length > 1 ? "s" : ""} (pas .excalidraw)`); + const detail = [ + ...failed.map((f) => `Échec — ${f}`), + ...existing.map((f) => `Déjà présent — ${f}`), + ...ignored.map((f) => `Ignoré — ${f}`), + ].join("\n"); + setNotice({ text: parts.join(" · "), detail: detail || undefined }); + if (failed.length) setError(`${plural(failed.length, "fichier")} en échec : ${failed.join(" ; ")}`); + } finally { + setBusy(null); } - await refresh(); }); // --- Glisser-déposer (souris) ; sur mobile : sélection + « Déplacer vers… » - @@ -294,17 +356,23 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr } }; + // Cibles de dépôt : dossiers, fil d'Ariane et « .. ». Accepte les éléments de + // l'explorateur (déplacement) et les fichiers/dossiers du système (import). const dropProps = (targetDir: string) => ({ onDragOver: (ev: DragEvent) => { - if (!ev.dataTransfer.types.includes(DND_TYPE)) return; + const external = hasFiles(ev.dataTransfer); + if (!external && !ev.dataTransfer.types.includes(DND_TYPE)) return; ev.preventDefault(); - ev.dataTransfer.dropEffect = "move"; + ev.dataTransfer.dropEffect = external ? "copy" : "move"; setDropTarget(targetDir); }, onDragLeave: () => setDropTarget(null), onDrop: (ev: DragEvent) => { ev.preventDefault(); + ev.stopPropagation(); // l'explorateur n'importe pas une seconde fois setDropTarget(null); + setFileDrag(false); + if (hasFiles(ev.dataTransfer)) return importTree(collectDrop(ev.dataTransfer), targetDir); let paths: string[] = []; try { paths = JSON.parse(ev.dataTransfer.getData(DND_TYPE)); @@ -318,11 +386,31 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr // --- Rendu --------------------------------------------------------------- const crumbs = dir ? dir.split("/") : []; + const importDir = results ? "" : dir; // pendant une recherche, le dossier courant n'est pas affiché + + // Dépôt n'importe où dans l'explorateur : import dans le dossier courant. + const zoneProps = { + onDragOver: (ev: DragEvent) => { + if (!hasFiles(ev.dataTransfer)) return; + ev.preventDefault(); + ev.dataTransfer.dropEffect = "copy"; + setFileDrag(true); + }, + onDragLeave: (ev: DragEvent) => { + if (!ev.currentTarget.contains(ev.relatedTarget as Node | null)) setFileDrag(false); + }, + onDrop: (ev: DragEvent) => { + if (!hasFiles(ev.dataTransfer)) return; + ev.preventDefault(); + setFileDrag(false); + importTree(collectDrop(ev.dataTransfer), importDir); + }, + }; const icon = (e: Entry) => e.isDir ? ( - - 📁 + + ) : ( @@ -387,9 +475,10 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr {view === "list" && menuFor === e.path && actions(e)} @@ -404,44 +493,88 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr }; return ( -
+
- - - + + + { - importFiles(ev.target.files); + if (ev.target.files) importTree(collectFiles(ev.target.files), importDir); + ev.target.value = ""; + }} + /> + { + if (ev.target.files) importTree(collectFiles(ev.target.files), importDir); ev.target.value = ""; }} />
- setQuery(ev.target.value)} - /> - + +
+ + +
@@ -493,13 +626,21 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr {error}
)} + {busy &&
{busy}
} + {notice && !busy && ( +
setNotice(null)} title={notice.detail ? `${notice.detail}\n\n(clic pour masquer)` : "Clic pour masquer"}> + {notice.text} +
+ )}
    0 ? "selecting" : ""}`}> {!results && dir && (
  • @@ -509,6 +650,14 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr {list.length === 0 &&
  • {results ? "Aucun résultat" : "Dossier vide"}
  • }
+ {fileDrag && ( +
+ + Déposer pour importer dans « {importDir ? basename(importDir) : "Racine"} » + Les dossiers sont recréés · seuls les .excalidraw sont importés +
+ )} + {moving && (