Poubelle par utilisateur (interface) : icône pleine/vide, restauration, vidage, réglage de la purge dans les paramètres
This commit is contained in:
1 parent
8a1417055b
commit
d052ac383b
7 files changed
+261
-26
No files matched your search
@@ -104,7 +104,7 @@ export default function App() {
|
|||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const [drawerWidth, setDrawerWidth] = useState(initialDrawerWidth);
|
const [drawerWidth, setDrawerWidth] = useState(initialDrawerWidth);
|
||||||
const [resizing, setResizing] = useState(false);
|
const [resizing, setResizing] = useState(false);
|
||||||
const [settings, setSettings] = useState<Settings>({ title: "", logo: "", channel: "latest" });
|
const [settings, setSettings] = useState<Settings>({ title: "", logo: "", channel: "latest", trashDays: 0 });
|
||||||
const updateStatus = useUpdateStatus();
|
const updateStatus = useUpdateStatus();
|
||||||
const pending = pendingVersion(updateStatus, settings.channel);
|
const pending = pendingVersion(updateStatus, settings.channel);
|
||||||
const [customizing, setCustomizing] = useState<false | "all" | "excalidraw">(false);
|
const [customizing, setCustomizing] = useState<false | "all" | "excalidraw">(false);
|
||||||
|
|||||||
+154
-20
@@ -8,8 +8,10 @@ import {
|
|||||||
displayPath,
|
displayPath,
|
||||||
EXT,
|
EXT,
|
||||||
inFavoris,
|
inFavoris,
|
||||||
|
inPoubelle,
|
||||||
join,
|
join,
|
||||||
label,
|
label,
|
||||||
|
POUBELLE,
|
||||||
stripExt,
|
stripExt,
|
||||||
withExt,
|
withExt,
|
||||||
type Entry,
|
type Entry,
|
||||||
@@ -23,6 +25,7 @@ import {
|
|||||||
DotsIcon,
|
DotsIcon,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
|
FileIcon,
|
||||||
FilePlusIcon,
|
FilePlusIcon,
|
||||||
FolderIcon,
|
FolderIcon,
|
||||||
FolderPlusIcon,
|
FolderPlusIcon,
|
||||||
@@ -32,12 +35,14 @@ import {
|
|||||||
ListIcon,
|
ListIcon,
|
||||||
MoveIcon,
|
MoveIcon,
|
||||||
RefreshIcon,
|
RefreshIcon,
|
||||||
|
RestoreIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
SelectAllIcon,
|
SelectAllIcon,
|
||||||
ShareIcon,
|
ShareIcon,
|
||||||
StarFilledIcon,
|
StarFilledIcon,
|
||||||
StarIcon,
|
StarIcon,
|
||||||
TagIcon,
|
TagIcon,
|
||||||
|
TrashFullIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
UpIcon,
|
UpIcon,
|
||||||
UploadIcon,
|
UploadIcon,
|
||||||
@@ -121,6 +126,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
const [tagFilter, setTagFilter] = useState<string | null>(null);
|
const [tagFilter, setTagFilter] = useState<string | null>(null);
|
||||||
const [tagging, setTagging] = useState<Entry[] | null>(null); // éléments du dialogue « Tags »
|
const [tagging, setTagging] = useState<Entry[] | null>(null); // éléments du dialogue « Tags »
|
||||||
const [iconing, setIconing] = useState<Entry[] | null>(null); // dossiers du dialogue « Icône »
|
const [iconing, setIconing] = useState<Entry[] | null>(null); // dossiers du dialogue « Icône »
|
||||||
|
const [trashDays, setTrashDays] = useState<number | null>(null); // purge automatique (0 : jamais)
|
||||||
const fileInput = useRef<HTMLInputElement>(null);
|
const fileInput = useRef<HTMLInputElement>(null);
|
||||||
const dirInput = useRef<HTMLInputElement>(null);
|
const dirInput = useRef<HTMLInputElement>(null);
|
||||||
const stay = useRef<string | null>(null); // schéma ouvert depuis Favoris : on reste dans Favoris
|
const stay = useRef<string | null>(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;
|
const filtering = query.trim() !== "" || tagFilter !== null;
|
||||||
// Dossier Favoris : des liens vers les originaux, ni création ni déplacement.
|
// Dossier Favoris : des liens vers les originaux, ni création ni déplacement.
|
||||||
const favView = !results && inFavoris(dir);
|
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), []);
|
const closeMenu = useCallback(() => setMenu(null), []);
|
||||||
// La liste change de forme : le bouton « … » d'origine disparaît.
|
// 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));
|
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(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem("explorerView", view);
|
localStorage.setItem("explorerView", view);
|
||||||
@@ -235,6 +253,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
const onItemClick = (ev: MouseEvent, e: Entry) => {
|
const onItemClick = (ev: MouseEvent, e: Entry) => {
|
||||||
if (!e.special) {
|
if (!e.special) {
|
||||||
if (ev.shiftKey) return selectRange(e.path);
|
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).
|
// 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 (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)`
|
? `${plural(paths.length, "élément")} (dossiers compris avec leur contenu)`
|
||||||
: one?.isDir
|
: one?.isDir
|
||||||
? `le dossier « ${one.name} » et tout son contenu`
|
? `le dossier « ${one.name} » et tout son contenu`
|
||||||
: `« ${basename(paths[0])} »`;
|
: `« ${stripExt(one?.name ?? basename(paths[0]))} »`;
|
||||||
if (!confirm(`Mettre ${what} à la corbeille ?`)) return;
|
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) {
|
for (const p of paths) {
|
||||||
await api.remove(p, true);
|
await api.remove(p, true);
|
||||||
onDeleted(p);
|
onDeleted(p);
|
||||||
@@ -372,6 +397,36 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
await reload();
|
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,
|
* 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.
|
* 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) => {
|
onDragOver: (ev: DragEvent) => {
|
||||||
const external = hasFiles(ev.dataTransfer);
|
const external = hasFiles(ev.dataTransfer);
|
||||||
if (!external && !ev.dataTransfer.types.includes(DND_TYPE)) return;
|
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.preventDefault();
|
||||||
ev.dataTransfer.dropEffect = external ? "copy" : inFavoris(targetDir) ? "link" : "move";
|
ev.dataTransfer.dropEffect = external ? "copy" : inFavoris(targetDir) ? "link" : "move";
|
||||||
setDropTarget(targetDir);
|
setDropTarget(targetDir);
|
||||||
@@ -461,6 +516,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (inFavoris(targetDir)) addFavorites(paths);
|
if (inFavoris(targetDir)) addFavorites(paths);
|
||||||
|
else if (inPoubelle(targetDir)) removeMany(paths);
|
||||||
else moveMany(paths, targetDir);
|
else moveMany(paths, targetDir);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -468,13 +524,15 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
// --- Rendu ---------------------------------------------------------------
|
// --- Rendu ---------------------------------------------------------------
|
||||||
|
|
||||||
const crumbs = dir ? dir.split("/") : [];
|
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 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.
|
// Dépôt n'importe où dans l'explorateur : import dans le dossier courant.
|
||||||
const zoneProps = {
|
const zoneProps = {
|
||||||
onDragOver: (ev: DragEvent) => {
|
onDragOver: (ev: DragEvent) => {
|
||||||
if (favView || !hasFiles(ev.dataTransfer)) return;
|
if (locked || !hasFiles(ev.dataTransfer)) return;
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.dataTransfer.dropEffect = "copy";
|
ev.dataTransfer.dropEffect = "copy";
|
||||||
setFileDrag(true);
|
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);
|
if (!ev.currentTarget.contains(ev.relatedTarget as Node | null)) setFileDrag(false);
|
||||||
},
|
},
|
||||||
onDrop: (ev: DragEvent) => {
|
onDrop: (ev: DragEvent) => {
|
||||||
if (favView || !hasFiles(ev.dataTransfer)) return;
|
if (locked || !hasFiles(ev.dataTransfer)) return;
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
setFileDrag(false);
|
setFileDrag(false);
|
||||||
importTree(collectDrop(ev.dataTransfer), importDir);
|
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 big = view === "grid" ? 40 : 20;
|
||||||
const icon = (e: Entry) =>
|
const icon = (e: Entry) =>
|
||||||
e.special ? (
|
e.special ? (
|
||||||
<span className={`icon special ${e.special}`} aria-hidden>
|
<span className={`icon special ${e.special} ${e.count ? "full" : ""}`} aria-hidden>
|
||||||
{e.special === "favoris" ? <StarFilledIcon size={big} /> : <UserIcon size={big} />}
|
{e.special === "favoris" ? (
|
||||||
|
<StarFilledIcon size={big} />
|
||||||
|
) : e.special === "poubelle" ? (
|
||||||
|
e.count ? (
|
||||||
|
<TrashFullIcon size={big} />
|
||||||
|
) : (
|
||||||
|
<TrashIcon size={big} />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<UserIcon size={big} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : trashView ? (
|
||||||
|
<span className="icon" aria-hidden>
|
||||||
|
{e.isDir ? <FolderIcon size={big} /> : <FileIcon size={big} />}
|
||||||
</span>
|
</span>
|
||||||
) : e.broken ? (
|
) : e.broken ? (
|
||||||
<span className="icon folder" aria-hidden>
|
<span className="icon folder" aria-hidden>
|
||||||
@@ -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) => (
|
||||||
|
<>
|
||||||
|
<button role="menuitem" onClick={() => restoreMany([e.path])}>
|
||||||
|
<RestoreIcon />
|
||||||
|
Restaurer
|
||||||
|
</button>
|
||||||
|
<hr />
|
||||||
|
<button role="menuitem" className="danger" onClick={() => removeMany([e.path])}>
|
||||||
|
<TrashIcon />
|
||||||
|
Supprimer définitivement
|
||||||
|
</button>
|
||||||
|
<span className="muted size deleted">{deletedInfo(e)}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
const actions = (e: Entry) => (
|
const actions = (e: Entry) => (
|
||||||
<>
|
<>
|
||||||
<button role="menuitem" onClick={() => rename(e)}>
|
<button role="menuitem" onClick={() => rename(e)}>
|
||||||
@@ -617,7 +705,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
<li key={e.path} className={cls}>
|
<li key={e.path} className={cls}>
|
||||||
<div
|
<div
|
||||||
className="row"
|
className="row"
|
||||||
draggable={!e.special && !favView}
|
draggable={!e.special && !locked}
|
||||||
onDragStart={(ev) => onDragStart(ev, e)}
|
onDragStart={(ev) => onDragStart(ev, e)}
|
||||||
{...(e.isDir ? dropProps(e.path) : {})}
|
{...(e.isDir ? dropProps(e.path) : {})}
|
||||||
>
|
>
|
||||||
@@ -640,7 +728,17 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
<button
|
<button
|
||||||
className="name"
|
className="name"
|
||||||
onClick={(ev) => onItemClick(ev, e)}
|
onClick={(ev) => onItemClick(ev, e)}
|
||||||
title={e.broken ? "Original introuvable : retirer ce favori" : displayPath(e.path)}
|
title={
|
||||||
|
e.broken
|
||||||
|
? "Original introuvable : retirer ce favori"
|
||||||
|
: trashView
|
||||||
|
? deletedInfo(e)
|
||||||
|
: e.special === "poubelle"
|
||||||
|
? e.count
|
||||||
|
? `Poubelle : ${plural(e.count, "élément")}`
|
||||||
|
: "Poubelle vide"
|
||||||
|
: displayPath(e.path)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{icon(e)}
|
{icon(e)}
|
||||||
<span className="label">
|
<span className="label">
|
||||||
@@ -650,7 +748,9 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{e.isDir ? e.name : stripExt(e.name)}
|
{e.isDir ? e.name : stripExt(e.name)}
|
||||||
|
{e.special === "poubelle" && !!e.count && <small className="badge">{e.count}</small>}
|
||||||
{(results || (favView && !e.broken)) && <small className="muted where"> {where(e.path)}</small>}
|
{(results || (favView && !e.broken)) && <small className="muted where"> {where(e.path)}</small>}
|
||||||
|
{trashView && <small className="muted where"> {e.origin ? where(e.origin) : ""}</small>}
|
||||||
{e.snippet && <small className="snippet">{e.snippet}</small>}
|
{e.snippet && <small className="snippet">{e.snippet}</small>}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -668,7 +768,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!e.isDir && view === "list" && <span className="meta muted">{fmtDate(e.modTime)}</span>}
|
{(!e.isDir || trashView) && view === "list" && <span className="meta muted">{fmtDate(e.modTime)}</span>}
|
||||||
{!e.special && (
|
{!e.special && (
|
||||||
<button
|
<button
|
||||||
className={`icon-btn more ${menu?.entry.path === e.path ? "open" : ""}`}
|
className={`icon-btn more ${menu?.entry.path === e.path ? "open" : ""}`}
|
||||||
@@ -692,18 +792,18 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
return (
|
return (
|
||||||
<div className={`explorer ${fileDrag ? "file-drag" : ""}`} onKeyDown={onKeyDown} {...zoneProps}>
|
<div className={`explorer ${fileDrag ? "file-drag" : ""}`} onKeyDown={onKeyDown} {...zoneProps}>
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
<button className="ex-btn" onClick={newDrawing} title="Nouveau schéma" disabled={favView}>
|
<button className="ex-btn" onClick={newDrawing} title="Nouveau schéma" disabled={locked}>
|
||||||
<FilePlusIcon />
|
<FilePlusIcon />
|
||||||
<span>Schéma</span>
|
<span>Schéma</span>
|
||||||
</button>
|
</button>
|
||||||
<button className="ex-btn" onClick={newFolder} title="Nouveau dossier" disabled={favView}>
|
<button className="ex-btn" onClick={newFolder} title="Nouveau dossier" disabled={locked}>
|
||||||
<FolderPlusIcon />
|
<FolderPlusIcon />
|
||||||
<span>Dossier</span>
|
<span>Dossier</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="ex-btn"
|
className="ex-btn"
|
||||||
onClick={() => fileInput.current?.click()}
|
onClick={() => fileInput.current?.click()}
|
||||||
disabled={favView}
|
disabled={locked}
|
||||||
title="Importer des fichiers .excalidraw (ou glisser-déposer fichiers et dossiers ici)"
|
title="Importer des fichiers .excalidraw (ou glisser-déposer fichiers et dossiers ici)"
|
||||||
>
|
>
|
||||||
<UploadIcon />
|
<UploadIcon />
|
||||||
@@ -712,7 +812,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
<button
|
<button
|
||||||
className="ex-btn square"
|
className="ex-btn square"
|
||||||
onClick={() => dirInput.current?.click()}
|
onClick={() => dirInput.current?.click()}
|
||||||
disabled={favView}
|
disabled={locked}
|
||||||
title="Importer un dossier complet (arborescence recréée, seuls les .excalidraw sont importés)"
|
title="Importer un dossier complet (arborescence recréée, seuls les .excalidraw sont importés)"
|
||||||
aria-label="Importer un dossier"
|
aria-label="Importer un dossier"
|
||||||
>
|
>
|
||||||
@@ -812,6 +912,22 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{trashView && (
|
||||||
|
<div className="trashbar">
|
||||||
|
<small className="muted">
|
||||||
|
{trashDays === null
|
||||||
|
? ""
|
||||||
|
: trashDays === 0
|
||||||
|
? "Conservés jusqu'à suppression définitive"
|
||||||
|
: `Supprimés définitivement après ${plural(trashDays, "jour")}`}
|
||||||
|
</small>
|
||||||
|
<button className="ex-btn danger" onClick={emptyTrash} disabled={list.length === 0}>
|
||||||
|
<TrashIcon />
|
||||||
|
<span>Vider la poubelle</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{selected.size > 0 && (
|
{selected.size > 0 && (
|
||||||
<div className="selbar">
|
<div className="selbar">
|
||||||
<button className="icon-btn" onClick={clearSelection} aria-label="Annuler la sélection" title="Annuler la sélection (Échap)">
|
<button className="icon-btn" onClick={clearSelection} aria-label="Annuler la sélection" title="Annuler la sélection (Échap)">
|
||||||
@@ -826,6 +942,13 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
>
|
>
|
||||||
{selected.size === selectable.length ? <DeselectIcon /> : <SelectAllIcon />}
|
{selected.size === selectable.length ? <DeselectIcon /> : <SelectAllIcon />}
|
||||||
</button>
|
</button>
|
||||||
|
{trashView && (
|
||||||
|
<button className="ex-btn" onClick={() => restoreMany([...selected])}>
|
||||||
|
<RestoreIcon />
|
||||||
|
<span>Restaurer</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!trashView && (
|
||||||
<button
|
<button
|
||||||
className="icon-btn"
|
className="icon-btn"
|
||||||
onClick={() => toggleFavorite(list.filter((e) => selected.has(e.path)))}
|
onClick={() => toggleFavorite(list.filter((e) => selected.has(e.path)))}
|
||||||
@@ -834,6 +957,8 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
>
|
>
|
||||||
<StarIcon />
|
<StarIcon />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{!trashView && (
|
||||||
<button
|
<button
|
||||||
className="icon-btn"
|
className="icon-btn"
|
||||||
onClick={() => setTagging(list.filter((e) => selected.has(e.path)))}
|
onClick={() => setTagging(list.filter((e) => selected.has(e.path)))}
|
||||||
@@ -842,7 +967,8 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
>
|
>
|
||||||
<TagIcon />
|
<TagIcon />
|
||||||
</button>
|
</button>
|
||||||
{!favView && (
|
)}
|
||||||
|
{!locked && (
|
||||||
<button className="ex-btn" onClick={() => setMoving([...selected])}>
|
<button className="ex-btn" onClick={() => setMoving([...selected])}>
|
||||||
<MoveIcon />
|
<MoveIcon />
|
||||||
<span>Déplacer…</span>
|
<span>Déplacer…</span>
|
||||||
@@ -851,8 +977,10 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
<button
|
<button
|
||||||
className="icon-btn danger"
|
className="icon-btn danger"
|
||||||
onClick={() => removeMany([...selected])}
|
onClick={() => removeMany([...selected])}
|
||||||
title={favView ? "Retirer des favoris (Suppr)" : "Mettre à la corbeille (Suppr)"}
|
title={
|
||||||
aria-label={favView ? "Retirer des favoris" : "Supprimer"}
|
favView ? "Retirer des favoris (Suppr)" : trashView ? "Supprimer définitivement (Suppr)" : "Mettre à la poubelle (Suppr)"
|
||||||
|
}
|
||||||
|
aria-label={favView ? "Retirer des favoris" : trashView ? "Supprimer définitivement" : "Supprimer"}
|
||||||
>
|
>
|
||||||
<TrashIcon />
|
<TrashIcon />
|
||||||
</button>
|
</button>
|
||||||
@@ -887,7 +1015,13 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
{list.map(item)}
|
{list.map(item)}
|
||||||
{list.length === 0 && (
|
{list.length === 0 && (
|
||||||
<li className="muted empty">
|
<li className="muted empty">
|
||||||
{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"}
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -913,7 +1047,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
|
|
||||||
{menu && (
|
{menu && (
|
||||||
<ActionMenu anchor={menu.anchor} onClose={closeMenu}>
|
<ActionMenu anchor={menu.anchor} onClose={closeMenu}>
|
||||||
{favView ? favActions(menu.entry) : actions(menu.entry)}
|
{favView ? favActions(menu.entry) : trashView ? trashActions(menu.entry) : actions(menu.entry)}
|
||||||
</ActionMenu>
|
</ActionMenu>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function MoveDialog({ paths, startDir, onCancel, onConfirm }: Props) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
api
|
api
|
||||||
.list(dir)
|
.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));
|
.catch((e) => live && setError(e.message));
|
||||||
return () => {
|
return () => {
|
||||||
live = false;
|
live = false;
|
||||||
|
|||||||
@@ -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. */
|
/** Paramètres partagés par tous les utilisateurs : titre, logo, version d'Excalidraw. */
|
||||||
export function SettingsDialog({ settings, update, focusExcalidraw, onChange, onClose }: Props) {
|
export function SettingsDialog({ settings, update, focusExcalidraw, onChange, onClose }: Props) {
|
||||||
const [title, setTitle] = useState(settings.title);
|
const [title, setTitle] = useState(settings.title);
|
||||||
@@ -214,6 +222,31 @@ export function SettingsDialog({ settings, update, focusExcalidraw, onChange, on
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="field">
|
||||||
|
<h4>Poubelle</h4>
|
||||||
|
<span>Suppression définitive automatique</span>
|
||||||
|
<div className="radio-group" role="radiogroup" aria-label="Purge de la poubelle">
|
||||||
|
{TRASH_DAYS.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d.days}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={settings.trashDays === d.days}
|
||||||
|
className={settings.trashDays === d.days ? "on" : ""}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => settings.trashDays !== d.days && run(() => api.setTrashDays(d.days))}
|
||||||
|
>
|
||||||
|
{d.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<small className="muted">
|
||||||
|
{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."}
|
||||||
|
</small>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="field">
|
<section className="field">
|
||||||
<h4>Liens de partage</h4>
|
<h4>Liens de partage</h4>
|
||||||
{shares?.length ? (
|
{shares?.length ? (
|
||||||
|
|||||||
+22
-4
@@ -10,8 +10,14 @@ export interface Entry {
|
|||||||
favorite?: boolean;
|
favorite?: boolean;
|
||||||
/** Dossier : icône choisie (voir folderIcons.tsx). */
|
/** Dossier : icône choisie (voir folderIcons.tsx). */
|
||||||
icon?: string;
|
icon?: string;
|
||||||
/** Entrée virtuelle de la racine : "favoris" ou "perso" (espace personnel). */
|
/** Entrée virtuelle de la racine : "favoris", "perso" (espace personnel) ou "poubelle". */
|
||||||
special?: "favoris" | "perso";
|
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). */
|
/** Dans le dossier Favoris : nom du lien (le chemin est celui de l'original). */
|
||||||
link?: string;
|
link?: string;
|
||||||
/** Favori dont l'original a disparu (chemin .favoris/<lien>, à retirer). */
|
/** Favori dont l'original a disparu (chemin .favoris/<lien>, à retirer). */
|
||||||
@@ -43,11 +49,12 @@ export interface MoveResult {
|
|||||||
/** Canal npm d'Excalidraw : latest (stable) ou next (préversion). */
|
/** Canal npm d'Excalidraw : latest (stable) ou next (préversion). */
|
||||||
export type Channel = "latest" | "next";
|
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 {
|
export interface Settings {
|
||||||
title: string;
|
title: string;
|
||||||
logo: string;
|
logo: string;
|
||||||
channel: Channel;
|
channel: Channel;
|
||||||
|
trashDays: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Versions d'Excalidraw publiées sur npm par canal (vide si inconnu ou désactivé). */
|
/** 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<Entry> =>
|
copy: async (from: string, to: string): Promise<Entry> =>
|
||||||
(await request("/api/copy", json({ from, to }))).json(),
|
(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<void> => {
|
remove: async (path: string, recursive: boolean): Promise<void> => {
|
||||||
await request(`/api/entry?path=${q(path)}&recursive=${recursive}`, { method: "DELETE" });
|
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<MoveResult[]> =>
|
||||||
|
(await request("/api/trash/restore", json({ paths }))).json(),
|
||||||
|
|
||||||
history: async (path: string): Promise<Version[]> =>
|
history: async (path: string): Promise<Version[]> =>
|
||||||
(await request(`/api/history?path=${q(path)}`)).json(),
|
(await request(`/api/history?path=${q(path)}`)).json(),
|
||||||
|
|
||||||
@@ -253,6 +265,10 @@ export const api = {
|
|||||||
setChannel: async (channel: Channel): Promise<Settings> =>
|
setChannel: async (channel: Channel): Promise<Settings> =>
|
||||||
(await request("/api/settings", { ...json({ channel }), method: "PUT" })).json(),
|
(await request("/api/settings", { ...json({ channel }), method: "PUT" })).json(),
|
||||||
|
|
||||||
|
/** days = 0 : la poubelle n'est jamais purgée. */
|
||||||
|
setTrashDays: async (trashDays: number): Promise<Settings> =>
|
||||||
|
(await request("/api/settings", { ...json({ trashDays }), method: "PUT" })).json(),
|
||||||
|
|
||||||
/** Le serveur recadre au centre et ramène le PNG à 64x64. */
|
/** Le serveur recadre au centre et ramène le PNG à 64x64. */
|
||||||
uploadLogo: async (png: Blob): Promise<Settings> =>
|
uploadLogo: async (png: Blob): Promise<Settings> =>
|
||||||
(
|
(
|
||||||
@@ -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).
|
// Dossiers propres à l'utilisateur connecté (voir internal/storage/spaces.go).
|
||||||
export const PERSO = ".perso";
|
export const PERSO = ".perso";
|
||||||
export const FAVORIS = ".favoris";
|
export const FAVORIS = ".favoris";
|
||||||
const SPECIAL_LABELS: Record<string, string> = { [PERSO]: "Personnel", [FAVORIS]: "Favoris" };
|
export const POUBELLE = ".poubelle";
|
||||||
|
const SPECIAL_LABELS: Record<string, string> = { [PERSO]: "Personnel", [FAVORIS]: "Favoris", [POUBELLE]: "Poubelle" };
|
||||||
/** Libellé d'un composant de chemin (".perso" → "Personnel"). */
|
/** Libellé d'un composant de chemin (".perso" → "Personnel"). */
|
||||||
export const label = (part: string) => SPECIAL_LABELS[part] ?? part;
|
export const label = (part: string) => SPECIAL_LABELS[part] ?? part;
|
||||||
/** Chemin affiché : "Personnel/projets" pour ".perso/projets". */
|
/** Chemin affiché : "Personnel/projets" pour ".perso/projets". */
|
||||||
export const displayPath = (p: string) => p.split("/").map(label).join("/");
|
export const displayPath = (p: string) => p.split("/").map(label).join("/");
|
||||||
export const inFavoris = (p: string) => p === FAVORIS || p.startsWith(FAVORIS + "/");
|
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 EXT = ".excalidraw";
|
||||||
export const withExt = (name: string) => (name.toLowerCase().endsWith(EXT) ? name : name + EXT);
|
export const withExt = (name: string) => (name.toLowerCase().endsWith(EXT) ? name : name + EXT);
|
||||||
export const stripExt = (name: string) =>
|
export const stripExt = (name: string) =>
|
||||||
|
|||||||
@@ -112,6 +112,24 @@ export const TrashIcon = icon(
|
|||||||
<path d="M4 7h16M10 11v6M14 11v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" />,
|
<path d="M4 7h16M10 11v6M14 11v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" />,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Poubelle qui contient des éléments : une feuille et une boule de papier dépassent du bord. */
|
||||||
|
export const TrashFullIcon = icon(
|
||||||
|
<>
|
||||||
|
<path d="M4 10h16M5 10l1 9a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-9M10 14v3M14 14v3" />
|
||||||
|
<path d="M7.5 10 9 3.5l4.5 1-1.2 5.5M10.2 6.5l2 .5" />
|
||||||
|
<circle cx="16" cy="7.8" r="2.2" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const RestoreIcon = icon(<path d="M9 14l-4-4 4-4M5 10h11a4 4 0 1 1 0 8h-1" />);
|
||||||
|
|
||||||
|
export const FileIcon = icon(
|
||||||
|
<>
|
||||||
|
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
|
||||||
|
<path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
export const SelectAllIcon = icon(
|
export const SelectAllIcon = icon(
|
||||||
<>
|
<>
|
||||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||||
|
|||||||
@@ -1526,3 +1526,35 @@ button.chip.on,
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 1.2;
|
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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user