From d052ac383bba97ebd89ee42ba6ab2abc101e2d12 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 15 Oct 2026 10:00:33 +0200 Subject: [PATCH] =?UTF-8?q?Poubelle=20par=20utilisateur=20(interface)=20:?= =?UTF-8?q?=20ic=C3=B4ne=20pleine/vide,=20restauration,=20vidage,=20r?= =?UTF-8?q?=C3=A9glage=20de=20la=20purge=20dans=20les=20param=C3=A8tres?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 2 +- frontend/src/Explorer.tsx | 206 ++++++++++++++++++++++++++------ frontend/src/MoveDialog.tsx | 2 +- frontend/src/SettingsDialog.tsx | 33 +++++ frontend/src/api.ts | 26 +++- frontend/src/icons.tsx | 18 +++ frontend/src/styles.css | 32 +++++ 7 files changed, 277 insertions(+), 42 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 92f95db..e5af936 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -104,7 +104,7 @@ export default function App() { const [loadError, setLoadError] = useState(null); const [drawerWidth, setDrawerWidth] = useState(initialDrawerWidth); const [resizing, setResizing] = useState(false); - const [settings, setSettings] = useState({ title: "", logo: "", channel: "latest" }); + const [settings, setSettings] = useState({ title: "", logo: "", channel: "latest", trashDays: 0 }); const updateStatus = useUpdateStatus(); const pending = pendingVersion(updateStatus, settings.channel); const [customizing, setCustomizing] = useState(false); diff --git a/frontend/src/Explorer.tsx b/frontend/src/Explorer.tsx index a58953e..0ef0582 100644 --- a/frontend/src/Explorer.tsx +++ b/frontend/src/Explorer.tsx @@ -8,8 +8,10 @@ import { displayPath, EXT, inFavoris, + inPoubelle, join, label, + POUBELLE, stripExt, withExt, type Entry, @@ -23,6 +25,7 @@ import { DotsIcon, DownloadIcon, EditIcon, + FileIcon, FilePlusIcon, FolderIcon, FolderPlusIcon, @@ -32,12 +35,14 @@ import { ListIcon, MoveIcon, RefreshIcon, + RestoreIcon, SearchIcon, SelectAllIcon, ShareIcon, StarFilledIcon, StarIcon, TagIcon, + TrashFullIcon, TrashIcon, UpIcon, UploadIcon, @@ -121,6 +126,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi const [tagFilter, setTagFilter] = useState(null); const [tagging, setTagging] = useState(null); // éléments du dialogue « Tags » const [iconing, setIconing] = useState(null); // dossiers du dialogue « Icône » + const [trashDays, setTrashDays] = useState(null); // purge automatique (0 : jamais) const fileInput = useRef(null); const dirInput = useRef(null); const stay = useRef(null); // schéma ouvert depuis Favoris : on reste dans Favoris @@ -130,6 +136,9 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi const filtering = query.trim() !== "" || tagFilter !== null; // Dossier Favoris : des liens vers les originaux, ni création ni déplacement. const favView = !results && inFavoris(dir); + // Poubelle : éléments à restaurer ou à supprimer définitivement, rien d'autre. + const trashView = !results && inPoubelle(dir); + const locked = favView || trashView; // ni création, ni import, ni déplacement const closeMenu = useCallback(() => setMenu(null), []); // La liste change de forme : le bouton « … » d'origine disparaît. @@ -196,6 +205,15 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi if (filtering) setResults(await api.search(query, filter)); }; + // Délai de purge, affiché dans la poubelle (réglé dans les paramètres). + useEffect(() => { + if (!trashView) return; + api + .settings() + .then((s) => setTrashDays(s.trashDays)) + .catch(() => setTrashDays(null)); + }, [trashView, refreshKey]); + useEffect(() => { try { localStorage.setItem("explorerView", view); @@ -235,6 +253,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi const onItemClick = (ev: MouseEvent, e: Entry) => { if (!e.special) { if (ev.shiftKey) return selectRange(e.path); + if (trashView) return toggle(e.path); // un élément de la poubelle ne s'ouvre pas // 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); } @@ -362,8 +381,14 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi ? `${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; + : `« ${stripExt(one?.name ?? basename(paths[0]))} »`; + if (trashView) { + if (!confirm(`Supprimer définitivement ${what} ? Cette action est irréversible.`)) return; + for (const p of paths) await api.remove(p, true); + clearSelection(); + return reload(); + } + if (!confirm(`Mettre ${what} à la poubelle ?`)) return; for (const p of paths) { await api.remove(p, true); onDeleted(p); @@ -372,6 +397,36 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi await reload(); }); + /** Remet des éléments de la poubelle à leur emplacement d'origine. */ + const restoreMany = (paths: string[]) => + guard(async () => { + const res = await api.restoreTrash(paths); + const failed = res.filter((r) => r.error); + const done = res.filter((r) => r.to); + clearSelection(); + await reload(); + if (done.length) { + setNotice({ + text: `${plural(done.length, "élément")} restauré${done.length > 1 ? "s" : ""}`, + detail: done.map((r) => displayPath(r.to!)).join("\n"), + }); + } + if (failed.length) { + const name = (p: string) => stripExt(list.find((e) => e.path === p)?.name ?? basename(p)); + setError( + `${plural(failed.length, "élément")} non restauré${failed.length > 1 ? "s" : ""} — ${failed.map((r) => `${name(r.from)} : ${r.error}`).join(" ; ")}`, + ); + } + }); + + const emptyTrash = () => + guard(async () => { + if (!confirm(`Vider la poubelle (${plural(list.length, "élément")}) ? Cette action est irréversible.`)) return; + await api.remove(POUBELLE, true); + clearSelection(); + await reload(); + }); + /** * 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. @@ -442,7 +497,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi onDragOver: (ev: DragEvent) => { const external = hasFiles(ev.dataTransfer); if (!external && !ev.dataTransfer.types.includes(DND_TYPE)) return; - if (external && inFavoris(targetDir)) return; // pas d'import dans Favoris + if (external && (inFavoris(targetDir) || inPoubelle(targetDir))) return; // pas d'import dans Favoris ni la poubelle ev.preventDefault(); ev.dataTransfer.dropEffect = external ? "copy" : inFavoris(targetDir) ? "link" : "move"; setDropTarget(targetDir); @@ -461,6 +516,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi return; } if (inFavoris(targetDir)) addFavorites(paths); + else if (inPoubelle(targetDir)) removeMany(paths); else moveMany(paths, targetDir); }, }); @@ -468,13 +524,15 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi // --- Rendu --------------------------------------------------------------- const crumbs = dir ? dir.split("/") : []; - const importDir = results || favView ? "" : dir; // pendant une recherche, le dossier courant n'est pas affiché + const importDir = results || locked ? "" : dir; // pendant une recherche, le dossier courant n'est pas affiché const where = (p: string) => displayPath(dirname(p)) || "Racine"; + const deletedInfo = (e: Entry) => + `Supprimé le ${fmtDate(e.modTime)}${e.by ? ` par ${e.by}` : ""}\nEmplacement d'origine : ${e.origin ? where(e.origin) : "inconnu"}`; // Dépôt n'importe où dans l'explorateur : import dans le dossier courant. const zoneProps = { onDragOver: (ev: DragEvent) => { - if (favView || !hasFiles(ev.dataTransfer)) return; + if (locked || !hasFiles(ev.dataTransfer)) return; ev.preventDefault(); ev.dataTransfer.dropEffect = "copy"; setFileDrag(true); @@ -483,7 +541,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi if (!ev.currentTarget.contains(ev.relatedTarget as Node | null)) setFileDrag(false); }, onDrop: (ev: DragEvent) => { - if (favView || !hasFiles(ev.dataTransfer)) return; + if (locked || !hasFiles(ev.dataTransfer)) return; ev.preventDefault(); setFileDrag(false); importTree(collectDrop(ev.dataTransfer), importDir); @@ -493,8 +551,22 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi const big = view === "grid" ? 40 : 20; const icon = (e: Entry) => e.special ? ( - - {e.special === "favoris" ? : } + + {e.special === "favoris" ? ( + + ) : e.special === "poubelle" ? ( + e.count ? ( + + ) : ( + + ) + ) : ( + + )} + + ) : trashView ? ( + + {e.isDir ? : } ) : e.broken ? ( @@ -543,6 +615,22 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi ); + // Dans la poubelle : restauration ou suppression définitive. + const trashActions = (e: Entry) => ( + <> + +
+ + {deletedInfo(e)} + + ); + const actions = (e: Entry) => ( <> @@ -668,7 +768,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi ))}
)} - {!e.isDir && view === "list" && {fmtDate(e.modTime)}} + {(!e.isDir || trashView) && view === "list" && {fmtDate(e.modTime)}} {!e.special && ( - + + )} + {selected.size > 0 && (
- - - {!favView && ( + {trashView && ( + + )} + {!trashView && ( + + )} + {!trashView && ( + + )} + {!locked && ( @@ -887,7 +1015,13 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi {list.map(item)} {list.length === 0 && (
  • - {results ? "Aucun résultat" : favView ? "Aucun favori : étoile ou glisser-déposer un schéma ici" : "Dossier vide"} + {results + ? "Aucun résultat" + : favView + ? "Aucun favori : étoile ou glisser-déposer un schéma ici" + : trashView + ? "La poubelle est vide" + : "Dossier vide"}
  • )} @@ -913,7 +1047,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi {menu && ( - {favView ? favActions(menu.entry) : actions(menu.entry)} + {favView ? favActions(menu.entry) : trashView ? trashActions(menu.entry) : actions(menu.entry)} )} diff --git a/frontend/src/MoveDialog.tsx b/frontend/src/MoveDialog.tsx index 32ffe6a..c8152b3 100644 --- a/frontend/src/MoveDialog.tsx +++ b/frontend/src/MoveDialog.tsx @@ -21,7 +21,7 @@ export function MoveDialog({ paths, startDir, onCancel, onConfirm }: Props) { setError(null); api .list(dir) - .then((es) => live && setFolders(es.filter((e) => e.isDir && e.special !== "favoris"))) + .then((es) => live && setFolders(es.filter((e) => e.isDir && e.special !== "favoris" && e.special !== "poubelle"))) .catch((e) => live && setError(e.message)); return () => { live = false; diff --git a/frontend/src/SettingsDialog.tsx b/frontend/src/SettingsDialog.tsx index 5e64479..41576b6 100644 --- a/frontend/src/SettingsDialog.tsx +++ b/frontend/src/SettingsDialog.tsx @@ -26,6 +26,14 @@ const CHANNELS: { id: Channel; label: string; hint: string }[] = [ }, ]; +/** Délais de purge de la poubelle (0 : jamais). */ +const TRASH_DAYS = [ + { days: 0, label: "Jamais" }, + { days: 1, label: "1 jour" }, + { days: 7, label: "7 jours" }, + { days: 30, label: "30 jours" }, +]; + /** Paramètres partagés par tous les utilisateurs : titre, logo, version d'Excalidraw. */ export function SettingsDialog({ settings, update, focusExcalidraw, onChange, onClose }: Props) { const [title, setTitle] = useState(settings.title); @@ -214,6 +222,31 @@ export function SettingsDialog({ settings, update, focusExcalidraw, onChange, on )} +
    +

    Poubelle

    + Suppression définitive automatique +
    + {TRASH_DAYS.map((d) => ( + + ))} +
    + + {settings.trashDays + ? `Les éléments mis à la poubelle sont supprimés définitivement ${settings.trashDays} jour${settings.trashDays > 1 ? "s" : ""} après leur suppression.` + : "La poubelle garde tout jusqu'à ce qu'on la vide ; son icône indique si elle contient des éléments."} + +
    +

    Liens de partage

    {shares?.length ? ( diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 8e875ba..fd17d9c 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -10,8 +10,14 @@ export interface Entry { favorite?: boolean; /** Dossier : icône choisie (voir folderIcons.tsx). */ icon?: string; - /** Entrée virtuelle de la racine : "favoris" ou "perso" (espace personnel). */ - special?: "favoris" | "perso"; + /** Entrée virtuelle de la racine : "favoris", "perso" (espace personnel) ou "poubelle". */ + special?: "favoris" | "perso" | "poubelle"; + /** Entrée « poubelle » de la racine : nombre d'éléments qu'elle contient. */ + count?: number; + /** Élément de la poubelle : chemin d'origine (modTime = date de suppression). */ + origin?: string; + /** Élément de la poubelle : supprimé par (vide si inconnu). */ + by?: string; /** Dans le dossier Favoris : nom du lien (le chemin est celui de l'original). */ link?: string; /** Favori dont l'original a disparu (chemin .favoris/, à retirer). */ @@ -43,11 +49,12 @@ export interface MoveResult { /** Canal npm d'Excalidraw : latest (stable) ou next (préversion). */ export type Channel = "latest" | "next"; -/** Paramètres partagés ; logo = "" sans logo, sinon sa version. */ +/** Paramètres partagés ; logo = "" sans logo, sinon sa version ; trashDays = 0 : poubelle jamais purgée. */ export interface Settings { title: string; logo: string; channel: Channel; + trashDays: number; } /** Versions d'Excalidraw publiées sur npm par canal (vide si inconnu ou désactivé). */ @@ -184,10 +191,15 @@ export const api = { copy: async (from: string, to: string): Promise => (await request("/api/copy", json({ from, to }))).json(), + /** Met à la poubelle ; dans .poubelle, supprime définitivement (path = .poubelle : la vide). */ remove: async (path: string, recursive: boolean): Promise => { await request(`/api/entry?path=${q(path)}&recursive=${recursive}`, { method: "DELETE" }); }, + /** Remet des éléments de la poubelle à leur emplacement d'origine (to : chemin restauré). */ + restoreTrash: async (paths: string[]): Promise => + (await request("/api/trash/restore", json({ paths }))).json(), + history: async (path: string): Promise => (await request(`/api/history?path=${q(path)}`)).json(), @@ -253,6 +265,10 @@ export const api = { setChannel: async (channel: Channel): Promise => (await request("/api/settings", { ...json({ channel }), method: "PUT" })).json(), + /** days = 0 : la poubelle n'est jamais purgée. */ + setTrashDays: async (trashDays: number): Promise => + (await request("/api/settings", { ...json({ trashDays }), method: "PUT" })).json(), + /** Le serveur recadre au centre et ramène le PNG à 64x64. */ uploadLogo: async (png: Blob): Promise => ( @@ -285,12 +301,14 @@ export const join = (dir: string, name: string) => (dir ? `${dir}/${name}` : nam // Dossiers propres à l'utilisateur connecté (voir internal/storage/spaces.go). export const PERSO = ".perso"; export const FAVORIS = ".favoris"; -const SPECIAL_LABELS: Record = { [PERSO]: "Personnel", [FAVORIS]: "Favoris" }; +export const POUBELLE = ".poubelle"; +const SPECIAL_LABELS: Record = { [PERSO]: "Personnel", [FAVORIS]: "Favoris", [POUBELLE]: "Poubelle" }; /** Libellé d'un composant de chemin (".perso" → "Personnel"). */ export const label = (part: string) => SPECIAL_LABELS[part] ?? part; /** Chemin affiché : "Personnel/projets" pour ".perso/projets". */ export const displayPath = (p: string) => p.split("/").map(label).join("/"); export const inFavoris = (p: string) => p === FAVORIS || p.startsWith(FAVORIS + "/"); +export const inPoubelle = (p: string) => p === POUBELLE || p.startsWith(POUBELLE + "/"); export const EXT = ".excalidraw"; export const withExt = (name: string) => (name.toLowerCase().endsWith(EXT) ? name : name + EXT); export const stripExt = (name: string) => diff --git a/frontend/src/icons.tsx b/frontend/src/icons.tsx index c5d2104..77c4e86 100644 --- a/frontend/src/icons.tsx +++ b/frontend/src/icons.tsx @@ -112,6 +112,24 @@ export const TrashIcon = icon( , ); +/** Poubelle qui contient des éléments : une feuille et une boule de papier dépassent du bord. */ +export const TrashFullIcon = icon( + <> + + + + , +); + +export const RestoreIcon = icon(); + +export const FileIcon = icon( + <> + + + , +); + export const SelectAllIcon = icon( <> diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 137a40e..dfa804f 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1526,3 +1526,35 @@ button.chip.on, text-align: center; line-height: 1.2; } + +/* Poubelle : icône pleine ou vide, nombre d'éléments, barre « Vider ». */ +.icon.special.poubelle { + color: var(--muted); +} +.icon.special.poubelle.full { + color: var(--accent); +} +.row .label .badge { + display: inline-block; + min-width: 18px; + margin-left: 6px; + padding: 0 5px; + border-radius: 9px; + background: var(--accent-soft); + color: var(--accent); + font-size: 11px; + line-height: 18px; + text-align: center; + vertical-align: 1px; +} +.trashbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 6px; +} +.menu-popup .size.deleted { + max-width: 260px; + white-space: pre-line; +}