From d04350df7a20c3d8dc24539c11221b2a2eb136d5 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 7 Oct 2026 12:46:37 +0200 Subject: [PATCH] =?UTF-8?q?Explorateur=20:=20vignettes,=20s=C3=A9lection?= =?UTF-8?q?=20et=20d=C3=A9placement=20multiples?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vue vignettes/liste, miniatures générées à la sauvegarde et à la demande - multi-sélection (cases, Ctrl/Maj+clic, Ctrl+A, Échap, Suppr) - glisser-déposer de la sélection, dialogue « Déplacer vers… » tactile Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CZ78TqR7fgtRx3rvpNUbAL --- frontend/src/App.tsx | 3 + frontend/src/MoveDialog.tsx | 96 +++++++++++++ frontend/src/Thumb.tsx | 64 +++++++++ frontend/src/api.ts | 21 +++ frontend/src/styles.css | 264 +++++++++++++++++++++++++++++++++++- frontend/src/thumbs.ts | 103 ++++++++++++++ 6 files changed, 548 insertions(+), 3 deletions(-) create mode 100644 frontend/src/MoveDialog.tsx create mode 100644 frontend/src/Thumb.tsx create mode 100644 frontend/src/thumbs.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b9f9ec0..3a06a35 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ import type { import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types"; import { api, ApiError, basename, stripExt } from "./api"; import { Explorer } from "./Explorer"; +import { storeThumb } from "./thumbs"; type Theme = "light" | "dark"; type SaveState = "saved" | "dirty" | "saving" | "error"; @@ -91,6 +92,8 @@ export default function App() { try { etagRef.current = await api.write(path, body, force ? "" : etagRef.current); 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. const now = sceneVersion(ex.getSceneElements(), ex.getAppState(), ex.getFiles()); setSaveState(now === version ? "saved" : "dirty"); diff --git a/frontend/src/MoveDialog.tsx b/frontend/src/MoveDialog.tsx new file mode 100644 index 0000000..f27e29b --- /dev/null +++ b/frontend/src/MoveDialog.tsx @@ -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([]); + const [error, setError] = useState(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 ( +
+
e.stopPropagation()}> +

{title}

+ + {error &&
{error}
} +
    + {folders.map((f) => ( +
  • + +
  • + ))} + {folders.length === 0 &&
  • Aucun sous-dossier
  • } +
+
+ + + + +
+
+
+ ); +} diff --git a/frontend/src/Thumb.tsx b/frontend/src/Thumb.tsx new file mode 100644 index 0000000..1acb13f --- /dev/null +++ b/frontend/src/Thumb.tsx @@ -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(null); + const [visible, setVisible] = useState(false); + const [state, setState] = useState("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 ( + + {src ? ( + + ) : ( + {state === "empty" ? "∅" : state === "error" ? "📄" : ""} + )} + + ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 6f94e79..9a778dd 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -8,6 +8,12 @@ export interface Entry { modTime: string; } +export interface MoveResult { + from: string; + to?: string; + error?: string; +} + export class ApiError extends Error { constructor(public status: number, message: string) { super(message); @@ -61,6 +67,10 @@ export const api = { move: async (from: string, to: string): Promise => (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 => + (await request("/api/move-many", json({ paths, dir }))).json(), + copy: async (from: string, to: string): Promise => (await request("/api/copy", json({ from, to }))).json(), @@ -79,6 +89,17 @@ export const api = { }, 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 => { + await request(`/api/thumb?path=${q(path)}`, { + method: "PUT", + headers: { "Content-Type": "image/png" }, + body: png, + }); + }, }; // Helpers de chemins (format "slash", relatifs à la racine). diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 3b241fc..04bf79a 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -10,7 +10,7 @@ --ok: #2b8a3e; --warn: #e67700; --topbar-h: 44px; - --drawer-w: 300px; + --drawer-w: 330px; color-scheme: light; } @@ -45,6 +45,7 @@ body, button, .actions a { + -webkit-tap-highlight-color: transparent; font: inherit; color: inherit; background: var(--panel); @@ -179,8 +180,14 @@ button:disabled { flex-wrap: wrap; gap: 4px; } +.searchbar { + display: flex; + align-items: center; + gap: 2px; +} .search { - width: 100%; + flex: 1; + min-width: 0; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; @@ -222,7 +229,7 @@ button:disabled { li.active > .row { background: var(--accent-soft); } -.row.drop, +.item.drop > .row, .crumbs button.drop { border-color: var(--accent); background: var(--accent-soft); @@ -296,3 +303,254 @@ li.active > .row { 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; +} diff --git a/frontend/src/thumbs.ts b/frontend/src/thumbs.ts new file mode 100644 index 0000000..bf4fdc9 --- /dev/null +++ b/frontend/src/thumbs.ts @@ -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, + files: BinaryFiles, +): Promise { + 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, + files: BinaryFiles, +): Promise { + 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(); // "chemin@date" -> résultat +const pending = new Map>(); +const queue: (() => void)[] = []; +let running = 0; + +const next = () => { + while (running < MAX_PARALLEL && queue.length) { + running++; + queue.shift()!(); + } +}; + +async function generate(path: string): Promise { + 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 { + 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((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; +}