From 66eb1c08de6b390baf5b1189855785ca9cce9fb5 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 7 Oct 2026 12:45:21 +0200 Subject: [PATCH 1/3] =?UTF-8?q?API=20:=20d=C3=A9placement=20multiple=20(/a?= =?UTF-8?q?pi/move-many)=20et=20miniatures=20(/api/thumb)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CZ78TqR7fgtRx3rvpNUbAL --- internal/api/api.go | 56 ++++++++++++++++ internal/storage/storage.go | 60 ++++++++++++++++- internal/storage/storage_test.go | 94 +++++++++++++++++++++++++++ internal/storage/thumbs.go | 108 +++++++++++++++++++++++++++++++ 4 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 internal/storage/thumbs.go diff --git a/internal/api/api.go b/internal/api/api.go index 5c36d83..51fd628 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -6,8 +6,10 @@ // PUT /api/file?path=a/b.excalidraw écriture (If-Match / If-None-Match: *) // POST /api/folder {"path":"a/b"} création de dossier // POST /api/move {"from":"x","to":"y"} renommage / déplacement +// POST /api/move-many {"paths":[...],"dir":"d"} déplacement multiple // POST /api/copy {"from":"x","to":"y"} duplication d'un schéma // DELETE /api/entry?path=x[&recursive=true] mise à la corbeille +// GET /api/thumb?path=x / PUT (image/png) miniature d'un schéma // GET /api/library / PUT /api/library bibliothèque Excalidraw package api @@ -35,8 +37,11 @@ func (h *Handler) Register(mux *http.ServeMux) { mux.HandleFunc("PUT /api/file", h.writeFile) mux.HandleFunc("POST /api/folder", h.mkdir) mux.HandleFunc("POST /api/move", h.move) + mux.HandleFunc("POST /api/move-many", h.moveMany) mux.HandleFunc("POST /api/copy", h.copy) mux.HandleFunc("DELETE /api/entry", h.delete) + mux.HandleFunc("GET /api/thumb", h.readThumb) + mux.HandleFunc("PUT /api/thumb", h.writeThumb) mux.HandleFunc("GET /api/library", h.readLibrary) mux.HandleFunc("PUT /api/library", h.writeLibrary) mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { @@ -175,6 +180,57 @@ func (h *Handler) move(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, entry) } +type moveManyReq struct { + Paths []string `json:"paths"` + Dir string `json:"dir"` +} + +// moveMany renvoie toujours 200 avec le détail par élément : le client +// affiche les échecs partiels (ex. nom déjà pris dans la destination). +func (h *Handler) moveMany(w http.ResponseWriter, r *http.Request) { + var req moveManyReq + if !h.decode(w, r, &req) { + return + } + if len(req.Paths) == 0 || len(req.Paths) > 1000 { + writeErr(w, http.StatusBadRequest, "entre 1 et 1000 chemins attendus") + return + } + res, err := h.Store.MoveMany(req.Paths, req.Dir) + if err != nil { + fail(w, err) + return + } + writeJSON(w, http.StatusOK, res) +} + +func (h *Handler) readThumb(w http.ResponseWriter, r *http.Request) { + data, mod, err := h.Store.ReadThumb(r.URL.Query().Get("path")) + if err != nil { + // Pas de mise en cache d'une absence : la miniature va être générée. + w.Header().Set("Cache-Control", "no-store") + fail(w, err) + return + } + // L'URL porte la date du schéma (?v=...) : une nouvelle version change l'URL. + w.Header().Set("Cache-Control", "private, max-age=86400") + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Last-Modified", mod.UTC().Format(http.TimeFormat)) + _, _ = w.Write(data) +} + +func (h *Handler) writeThumb(w http.ResponseWriter, r *http.Request) { + data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, storage.MaxThumbSize)) + if err == nil { + err = h.Store.WriteThumb(r.URL.Query().Get("path"), data) + } + if err != nil { + fail(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + func (h *Handler) copy(w http.ResponseWriter, r *http.Request) { var req moveReq if !h.decode(w, r, &req) { diff --git a/internal/storage/storage.go b/internal/storage/storage.go index f57f089..c25ed99 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -25,6 +25,7 @@ const Ext = ".excalidraw" const ( trashDir = ".trash" + thumbsDir = ".thumbs" libraryFile = ".library.excalidrawlib" ) @@ -358,6 +359,7 @@ func (s *Store) Move(from, to string) (Entry, error) { if err := os.Rename(af, at); err != nil { return Entry{}, mapErr(err) } + s.moveThumb(cf, ct, fi.IsDir()) fi, err = os.Stat(at) if err != nil { return Entry{}, err @@ -365,6 +367,55 @@ func (s *Store) Move(from, to string) (Entry, error) { return toEntry(ct, fi), nil } +// MoveResult est le résultat d'un déplacement unitaire dans MoveMany. +type MoveResult struct { + From string `json:"from"` + To string `json:"to,omitempty"` + Error string `json:"error,omitempty"` + err error +} + +// Err renvoie l'erreur du déplacement (nil si réussi). +func (r MoveResult) Err() error { return r.err } + +// MoveMany déplace plusieurs fichiers ou dossiers dans le dossier dir en +// conservant leur nom. Chaque élément est traité indépendamment : un échec +// (destination existante, dossier déplacé dans lui-même...) n'interrompt pas +// les autres. Les éléments déjà présents dans dir sont ignorés. +func (s *Store) MoveMany(paths []string, dir string) ([]MoveResult, error) { + cd, ad, err := s.resolve(dir) + if err != nil { + return nil, err + } + fi, err := os.Stat(ad) + if err != nil { + return nil, mapErr(err) + } + if !fi.IsDir() { + return nil, fmt.Errorf("%w : la destination n'est pas un dossier", ErrInvalidPath) + } + out := make([]MoveResult, 0, len(paths)) + for _, p := range paths { + r := MoveResult{From: p} + if c, err := Clean(p); err != nil || c == "" { + r.err = ErrInvalidPath + } else if strings.TrimPrefix(path.Dir("/"+c), "/") == cd { + r.From, r.To = c, c // déjà dans le dossier cible + } else { + r.From = c + var e Entry + if e, r.err = s.Move(c, path.Join(cd, path.Base(c))); r.err == nil { + r.To = e.Path + } + } + if r.err != nil { + r.Error = r.err.Error() + } + out = append(out, r) + } + return out, nil +} + // Copy duplique un schéma (les dossiers ne sont pas copiés). func (s *Store) Copy(from, to string) (Entry, error) { data, _, err := s.Read(from) @@ -372,6 +423,9 @@ func (s *Store) Copy(from, to string) (Entry, error) { return Entry{}, err } e, _, err := s.Write(to, data, "", true) + if err == nil { + s.copyThumb(from, e.Path) + } return e, err } @@ -402,7 +456,11 @@ func (s *Store) Delete(rel string, recursive bool) error { if err := os.MkdirAll(trash, 0o755); err != nil { return err } - return os.Rename(abs, filepath.Join(trash, filepath.Base(abs))) + if err := os.Rename(abs, filepath.Join(trash, filepath.Base(abs))); err != nil { + return err + } + s.removeThumb(c, fi.IsDir()) + return nil } // ReadLibrary renvoie la bibliothèque Excalidraw partagée (ou nil si absente). diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index d714424..7b119ca 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "testing" + "time" ) const doc = `{"type":"excalidraw","version":2,"elements":[]}` @@ -99,3 +100,96 @@ func TestSymlinkEscape(t *testing.T) { t.Fatalf("échappement par symlink non bloqué : %v", err) } } + +func TestMoveMany(t *testing.T) { + s := newStore(t) + for _, p := range []string{"a.excalidraw", "b.excalidraw", "src/c.excalidraw", "dst/b.excalidraw"} { + if _, _, err := s.Write(p, []byte(doc), "", true); err != nil { + t.Fatal(err) + } + } + if _, err := s.Mkdir("src/sub"); err != nil { + t.Fatal(err) + } + if _, err := s.MoveMany([]string{"a.excalidraw"}, "a.excalidraw"); !errors.Is(err, ErrInvalidPath) { + t.Fatalf("destination fichier acceptée : %v", err) + } + res, err := s.MoveMany([]string{"a.excalidraw", "b.excalidraw", "src/c.excalidraw", "src/sub", "dst/b.excalidraw", "dst", "nope.excalidraw"}, "dst") + if err != nil { + t.Fatal(err) + } + want := []struct { + to string + err error + }{ + {"dst/a.excalidraw", nil}, + {"", ErrExists}, + {"dst/c.excalidraw", nil}, + {"dst/sub", nil}, + {"dst/b.excalidraw", nil}, // déjà dans dst : ignoré + {"", ErrInvalidPath}, // dossier dans lui-même + {"", ErrNotFound}, + } + for i, w := range want { + if res[i].To != w.to || !errors.Is(res[i].Err(), w.err) { + t.Errorf("%s : to=%q err=%v ; attendu %q, %v", res[i].From, res[i].To, res[i].Err(), w.to, w.err) + } + } + if _, _, err := s.Read("b.excalidraw"); err != nil { + t.Fatalf("b déplacé malgré le conflit : %v", err) + } + // Vers la racine. + res, err = s.MoveMany([]string{"dst/sub", "b.excalidraw"}, "") + if err != nil || res[0].To != "sub" || res[0].Err() != nil || res[1].To != "b.excalidraw" { + t.Fatalf("vers la racine : %+v, %v", res, err) + } +} + +var png = []byte("\x89PNG\r\n\x1a\nfake") + +func TestThumbs(t *testing.T) { + s := newStore(t) + if err := s.WriteThumb("d/a.excalidraw", png); !errors.Is(err, ErrNotFound) { + t.Fatalf("miniature d'un schéma absent : %v", err) + } + if _, _, err := s.Write("d/a.excalidraw", []byte(doc), "", true); err != nil { + t.Fatal(err) + } + if err := s.WriteThumb("d/a.excalidraw", []byte("")); !errors.Is(err, ErrInvalidPath) { + t.Fatalf("non-PNG accepté : %v", err) + } + if _, _, err := s.ReadThumb("d/a.excalidraw"); !errors.Is(err, ErrNotFound) { + t.Fatalf("miniature inexistante : %v", err) + } + if err := s.WriteThumb("d/a.excalidraw", png); err != nil { + t.Fatal(err) + } + if b, _, err := s.ReadThumb("d/a.excalidraw"); err != nil || string(b) != string(png) { + t.Fatalf("lecture : %q, %v", b, err) + } + // Suit le déplacement du dossier, la copie, puis la suppression. + if _, err := s.Move("d", "e"); err != nil { + t.Fatal(err) + } + if _, _, err := s.ReadThumb("e/a.excalidraw"); err != nil { + t.Fatalf("après déplacement : %v", err) + } + if _, err := s.Copy("e/a.excalidraw", "e/b.excalidraw"); err != nil { + t.Fatal(err) + } + if _, _, err := s.ReadThumb("e/b.excalidraw"); err != nil { + t.Fatalf("après copie : %v", err) + } + if err := s.Delete("e/a.excalidraw", false); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(s.root, thumbsDir, "e", "a.excalidraw.png")); !os.IsNotExist(err) { + t.Fatalf("miniature non supprimée : %v", err) + } + // Périmée après modification du schéma. + old := time.Now().Add(-time.Hour) + _ = os.Chtimes(filepath.Join(s.root, thumbsDir, "e", "b.excalidraw.png"), old, old) + if _, _, err := s.ReadThumb("e/b.excalidraw"); !errors.Is(err, ErrNotFound) { + t.Fatalf("miniature périmée servie : %v", err) + } +} diff --git a/internal/storage/thumbs.go b/internal/storage/thumbs.go new file mode 100644 index 0000000..9fed564 --- /dev/null +++ b/internal/storage/thumbs.go @@ -0,0 +1,108 @@ +package storage + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "time" +) + +// Miniatures PNG des schémas, générées par le navigateur et rangées dans +// .thumbs/ en miroir de l'arborescence (a/b.excalidraw -> .thumbs/a/b.excalidraw.png). +// Une miniature plus ancienne que son schéma est considérée comme périmée. + +// MaxThumbSize borne la taille d'une miniature acceptée. +const MaxThumbSize = 1 << 20 + +var pngMagic = []byte("\x89PNG\r\n\x1a\n") + +func (s *Store) thumbAbs(c string, isDir bool) string { + p := filepath.Join(s.root, thumbsDir, filepath.FromSlash(c)) + if isDir { + return p + } + return p + ".png" +} + +// ReadThumb renvoie la miniature d'un schéma et sa date, ou ErrNotFound si +// elle est absente ou périmée. +func (s *Store) ReadThumb(rel string) ([]byte, time.Time, error) { + c, abs, err := s.resolve(rel) + if err != nil { + return nil, time.Time{}, err + } + if err := requireSchemaPath(c); err != nil { + return nil, time.Time{}, err + } + src, err := os.Stat(abs) + if err != nil { + return nil, time.Time{}, mapErr(err) + } + ta := s.thumbAbs(c, false) + ti, err := os.Stat(ta) + if err != nil { + return nil, time.Time{}, mapErr(err) + } + if ti.ModTime().Before(src.ModTime()) { + return nil, time.Time{}, fmt.Errorf("%w : miniature périmée", ErrNotFound) + } + b, err := os.ReadFile(ta) + return b, ti.ModTime(), mapErr(err) +} + +// WriteThumb enregistre la miniature PNG d'un schéma existant. +func (s *Store) WriteThumb(rel string, png []byte) error { + c, abs, err := s.resolve(rel) + if err != nil { + return err + } + if err := requireSchemaPath(c); err != nil { + return err + } + if !bytes.HasPrefix(png, pngMagic) || len(png) > MaxThumbSize { + return fmt.Errorf("%w : PNG de %d Ko max attendu", ErrInvalidPath, MaxThumbSize>>10) + } + if _, err := os.Stat(abs); err != nil { + return mapErr(err) + } + ta := s.thumbAbs(c, false) + if err := os.MkdirAll(filepath.Dir(ta), 0o755); err != nil { + return err + } + return atomicWrite(ta, png) +} + +// Les opérations suivantes accompagnent Move/Copy/Delete. Elles sont +// « au mieux » : une miniature manquante sera simplement régénérée. + +func (s *Store) moveThumb(from, to string, isDir bool) { + src, dst := s.thumbAbs(from, isDir), s.thumbAbs(to, isDir) + if _, err := os.Stat(src); err != nil { + return + } + _ = os.RemoveAll(dst) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err == nil { + _ = os.Rename(src, dst) + } +} + +func (s *Store) copyThumb(from, to string) { + cf, err1 := Clean(from) + ct, err2 := Clean(to) + if err1 != nil || err2 != nil { + return + } + b, err := os.ReadFile(s.thumbAbs(cf, false)) + if err != nil { + return + } + dst := s.thumbAbs(ct, false) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err == nil { + _ = atomicWrite(dst, b) // écrite après la copie du schéma : donc à jour + } +} + +func (s *Store) removeThumb(c string, isDir bool) { + _ = os.RemoveAll(s.thumbAbs(c, isDir)) +} -- 2.54.0 From d04350df7a20c3d8dc24539c11221b2a2eb136d5 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 7 Oct 2026 12:46:37 +0200 Subject: [PATCH 2/3] =?UTF-8?q?Explorateur=20:=20vignettes,=20s=C3=A9lecti?= =?UTF-8?q?on=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; +} -- 2.54.0 From 2da861b81dd04e15ef3856fd532823a39a739d71 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 7 Oct 2026 12:47:59 +0200 Subject: [PATCH 3/3] =?UTF-8?q?Explorateur.tsx=20(s=C3=A9lection=20multipl?= =?UTF-8?q?e,=20vignettes)=20et=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CZ78TqR7fgtRx3rvpNUbAL --- README.md | 27 ++- frontend/src/Explorer.tsx | 378 +++++++++++++++++++++++++++++--------- 2 files changed, 314 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index 3ee7610..ef0b3af 100644 --- a/README.md +++ b/README.md @@ -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//... ← é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: ` 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=` | | 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`. diff --git a/frontend/src/Explorer.tsx b/frontend/src/Explorer.tsx index 5dc6e2c..83377fd 100644 --- a/frontend/src/Explorer.tsx +++ b/frontend/src/Explorer.tsx @@ -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([]); const [query, setQuery] = useState(""); const [results, setResults] = useState(null); + const [view, setView] = useState(initialView); + const [selected, setSelected] = useState>(new Set()); + const [anchor, setAnchor] = useState(null); // origine d'une sélection Maj+clic const [menuFor, setMenuFor] = useState(null); + const [moving, setMoving] = useState(null); // éléments du dialogue « Déplacer vers » const [dropTarget, setDropTarget] = useState(null); const [error, setError] = useState(null); const fileInput = useRef(null); + const list = results ?? entries; + const guard = useCallback(async (fn: () => Promise) => { 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) => ( -
  • -
    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)}} -
    + const icon = (e: Entry) => + e.isDir ? ( + + 📁 + + ) : ( + + ); + + const actions = (e: Entry) => ( +
    + + + {!e.isDir && } + {!e.isDir && ( + + Télécharger + )} -
  • + + {!e.isDir && {fmtSize(e.size)}} + ); + 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 [ +
  • +
    onDragStart(ev, e)} + {...(e.isDir ? dropProps(e.path) : {})} + > + { + ev.stopPropagation(); + if (ev.shiftKey) { + ev.preventDefault(); + selectRange(e.path); + } + }} + onChange={() => toggle(e.path)} + /> + + {!e.isDir && view === "list" && {fmtDate(e.modTime)}} + +
    + {view === "list" && menuFor === e.path && actions(e)} +
  • , + // En grille, le menu occupe toute la largeur sous la vignette. + view === "grid" && menuFor === e.path && ( +
  • + {actions(e)} +
  • + ), + ]; + }; + return ( -
    +
    -
    - setQuery(ev.target.value)} - /> +
    + setQuery(ev.target.value)} + /> + + +
    {!results && ( )} + {selected.size > 0 && ( +
    + + {plural(selected.size, "sélectionné")} + + + +
    + )} + {error && (
    setError(null)}> {error}
    )} -
      +
        0 ? "selecting" : ""}`}> {!results && dir && ( -
      • -
        +
      • +
      • )} - {list.map(row)} + {list.flatMap(item)} {list.length === 0 &&
      • {results ? "Aucun résultat" : "Dossier vide"}
      • }
      + + {moving && ( + setMoving(null)} + onConfirm={(target) => moveMany(moving, target)} + /> + )}
    ); } -- 2.54.0