Explorateur : dossiers Favoris et Personnel, cartouche oidc/local
- Racine : entrées « Favoris » (étoile) et « Personnel » en tête, ni sélectionnables ni déplaçables ; fil d'Ariane et sélecteur « Déplacer vers… » affichent Personnel/Favoris à la place de .perso/.favoris. - Favoris : glisser un schéma sur Favoris (ou l'étoile) crée un lien, sans déplacer l'original ; ouvrir un favori ouvre l'original sans quitter Favoris ; Supprimer y retire seulement le lien ; emplacement de l'original affiché ; favori cassé barré. Plus de favoris sur les dossiers. Le filtre « Favoris » de la ligne de tags est remplacé par le dossier. - Barre du haut : cartouche encadré « OIDC » + e-mail, ou « LOCAL » + nom du compte, avant le bouton de déconnexion. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0174sFqehj4uGqJHQZvWy1gn
This commit is contained in:
1 parent
824871bd24
commit
ea06fdaddf
7 files changed
+240
-53
No files matched your search
@@ -14,7 +14,7 @@ import type {
|
|||||||
LibraryItems,
|
LibraryItems,
|
||||||
} from "@excalidraw/excalidraw/types";
|
} from "@excalidraw/excalidraw/types";
|
||||||
import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
|
import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
|
||||||
import { api, ApiError, basename, stripExt, type Settings } from "./api";
|
import { api, ApiError, basename, displayPath, stripExt, type Settings } from "./api";
|
||||||
import { Explorer } from "./Explorer";
|
import { Explorer } from "./Explorer";
|
||||||
import { CloseIcon, HistoryIcon, MenuIcon, MoonIcon, PinIcon, SaveIcon, SettingsIcon, ShareIcon, SunIcon, UpdateIcon } from "./icons";
|
import { CloseIcon, HistoryIcon, MenuIcon, MoonIcon, PinIcon, SaveIcon, SettingsIcon, ShareIcon, SunIcon, UpdateIcon } from "./icons";
|
||||||
import { ShareDialog } from "./ShareDialog";
|
import { ShareDialog } from "./ShareDialog";
|
||||||
@@ -539,7 +539,7 @@ export default function App() {
|
|||||||
<span className="brand-title">{appTitle}</span>
|
<span className="brand-title">{appTitle}</span>
|
||||||
</button>
|
</button>
|
||||||
{scene ? (
|
{scene ? (
|
||||||
<span className="title" title={scene.path}>
|
<span className="title" title={displayPath(scene.path)}>
|
||||||
<span className="sep muted" aria-hidden>
|
<span className="sep muted" aria-hidden>
|
||||||
/
|
/
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
+125
-44
@@ -5,8 +5,11 @@ import {
|
|||||||
ApiError,
|
ApiError,
|
||||||
basename,
|
basename,
|
||||||
dirname,
|
dirname,
|
||||||
|
displayPath,
|
||||||
EXT,
|
EXT,
|
||||||
|
inFavoris,
|
||||||
join,
|
join,
|
||||||
|
label,
|
||||||
stripExt,
|
stripExt,
|
||||||
withExt,
|
withExt,
|
||||||
type Entry,
|
type Entry,
|
||||||
@@ -33,6 +36,7 @@ import {
|
|||||||
TrashIcon,
|
TrashIcon,
|
||||||
UpIcon,
|
UpIcon,
|
||||||
UploadIcon,
|
UploadIcon,
|
||||||
|
UserIcon,
|
||||||
} from "./icons";
|
} from "./icons";
|
||||||
import { collectDrop, collectFiles, hasFiles, type Collected } from "./importer";
|
import { collectDrop, collectFiles, hasFiles, type Collected } from "./importer";
|
||||||
import { MoveDialog } from "./MoveDialog";
|
import { MoveDialog } from "./MoveDialog";
|
||||||
@@ -107,14 +111,16 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
const [fileDrag, setFileDrag] = useState(false); // fichiers du système survolant l'explorateur
|
const [fileDrag, setFileDrag] = useState(false); // fichiers du système survolant l'explorateur
|
||||||
const [tags, setTags] = useState<TagCount[]>([]);
|
const [tags, setTags] = useState<TagCount[]>([]);
|
||||||
const [tagFilter, setTagFilter] = useState<string | null>(null);
|
const [tagFilter, setTagFilter] = useState<string | null>(null);
|
||||||
const [favOnly, setFavOnly] = useState(false);
|
|
||||||
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 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 list = results ?? entries;
|
const list = results ?? entries;
|
||||||
const filter = { tag: tagFilter, fav: favOnly };
|
const filter = { tag: tagFilter };
|
||||||
const filtering = query.trim() !== "" || tagFilter !== null || favOnly;
|
const filtering = query.trim() !== "" || tagFilter !== null;
|
||||||
|
// Dossier Favoris : des liens vers les originaux, ni création ni déplacement.
|
||||||
|
const favView = !results && inFavoris(dir);
|
||||||
|
|
||||||
const guard = useCallback(async (fn: () => Promise<unknown>) => {
|
const guard = useCallback(async (fn: () => Promise<unknown>) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -156,7 +162,8 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
|
|
||||||
// Suit le dossier du schéma ouvert (lien direct, création, déplacement).
|
// Suit le dossier du schéma ouvert (lien direct, création, déplacement).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (current) setDir(dirname(current));
|
if (current && current !== stay.current) setDir(dirname(current));
|
||||||
|
stay.current = null;
|
||||||
}, [current]);
|
}, [current]);
|
||||||
|
|
||||||
// Recherche (texte, tag, favoris) avec anti-rebond.
|
// Recherche (texte, tag, favoris) avec anti-rebond.
|
||||||
@@ -168,7 +175,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
const t = setTimeout(() => guard(async () => setResults(await api.search(query, filter))), 250);
|
const t = setTimeout(() => guard(async () => setResults(await api.search(query, filter))), 250);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [query, tagFilter, favOnly, guard, refreshKey]);
|
}, [query, tagFilter, guard, refreshKey]);
|
||||||
|
|
||||||
/** Recharge le dossier et, si une recherche est affichée, ses résultats. */
|
/** Recharge le dossier et, si une recherche est affichée, ses résultats. */
|
||||||
const reload = async () => {
|
const reload = async () => {
|
||||||
@@ -201,7 +208,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectRange = (path: string) => {
|
const selectRange = (path: string) => {
|
||||||
const paths = list.map((e) => e.path);
|
const paths = selectable.map((e) => e.path);
|
||||||
const a = anchor ? paths.indexOf(anchor) : -1;
|
const a = anchor ? paths.indexOf(anchor) : -1;
|
||||||
const b = paths.indexOf(path);
|
const b = paths.indexOf(path);
|
||||||
if (a < 0 || b < 0) return toggle(path);
|
if (a < 0 || b < 0) return toggle(path);
|
||||||
@@ -209,16 +216,23 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
setSelected((s) => new Set([...s, ...paths.slice(lo, hi + 1)]));
|
setSelected((s) => new Set([...s, ...paths.slice(lo, hi + 1)]));
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectAll = () => setSelected(new Set(list.map((e) => e.path)));
|
const selectable = list.filter((e) => !e.special);
|
||||||
|
const selectAll = () => setSelected(new Set(selectable.map((e) => e.path)));
|
||||||
|
|
||||||
const onItemClick = (ev: MouseEvent, e: Entry) => {
|
const onItemClick = (ev: MouseEvent, e: Entry) => {
|
||||||
|
if (!e.special) {
|
||||||
if (ev.shiftKey) return selectRange(e.path);
|
if (ev.shiftKey) return selectRange(e.path);
|
||||||
// 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);
|
||||||
|
}
|
||||||
|
if (e.broken) return;
|
||||||
if (e.isDir) {
|
if (e.isDir) {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setDir(e.path);
|
setDir(e.path);
|
||||||
} else onOpen(e.path);
|
} else {
|
||||||
|
if (favView && e.path !== current) stay.current = e.path;
|
||||||
|
onOpen(e.path);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onKeyDown = (ev: KeyboardEvent) => {
|
const onKeyDown = (ev: KeyboardEvent) => {
|
||||||
@@ -288,15 +302,24 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
/** Favoris : la sélection devient favorite, sauf si elle l'est déjà entièrement. */
|
/** Favoris : la sélection devient favorite, sauf si elle l'est déjà entièrement. */
|
||||||
const toggleFavorite = (items: Entry[]) =>
|
const toggleFavorite = (items: Entry[]) =>
|
||||||
guard(async () => {
|
guard(async () => {
|
||||||
const fav = !items.every((e) => e.favorite);
|
const files = items.filter((e) => !e.isDir);
|
||||||
|
if (files.length === 0) return;
|
||||||
|
const fav = favView ? false : !files.every((e) => e.favorite);
|
||||||
await api.setFavorite(
|
await api.setFavorite(
|
||||||
items.map((e) => e.path),
|
files.map((e) => e.path),
|
||||||
fav,
|
fav,
|
||||||
);
|
);
|
||||||
setMenuFor(null);
|
setMenuFor(null);
|
||||||
|
if (favView) clearSelection();
|
||||||
await reload();
|
await reload();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Dépôt sur Favoris : les schémas déposés y sont ajoutés (lien, pas déplacement). */
|
||||||
|
const addFavorites = (paths: string[]) =>
|
||||||
|
toggleFavorite(
|
||||||
|
paths.map((p) => list.find((e) => e.path === p) ?? { name: basename(p), path: p, isDir: false, size: 0, modTime: "" }),
|
||||||
|
);
|
||||||
|
|
||||||
const duplicate = (e: Entry) =>
|
const duplicate = (e: Entry) =>
|
||||||
guard(async () => {
|
guard(async () => {
|
||||||
const base = join(dirname(e.path), stripExt(e.name));
|
const base = join(dirname(e.path), stripExt(e.name));
|
||||||
@@ -314,6 +337,12 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
|
|
||||||
const removeMany = (paths: string[]) =>
|
const removeMany = (paths: string[]) =>
|
||||||
guard(async () => {
|
guard(async () => {
|
||||||
|
// Dans Favoris, « Supprimer » retire le lien ; l'original reste en place.
|
||||||
|
if (favView) {
|
||||||
|
await api.setFavorite(paths, false);
|
||||||
|
clearSelection();
|
||||||
|
return reload();
|
||||||
|
}
|
||||||
const one = list.find((e) => e.path === paths[0]);
|
const one = list.find((e) => e.path === paths[0]);
|
||||||
const what =
|
const what =
|
||||||
paths.length > 1
|
paths.length > 1
|
||||||
@@ -383,7 +412,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
// Glisser un élément sélectionné emporte toute la sélection.
|
// Glisser un élément sélectionné emporte toute la sélection.
|
||||||
const paths = selected.has(e.path) ? [...selected] : [e.path];
|
const paths = selected.has(e.path) ? [...selected] : [e.path];
|
||||||
ev.dataTransfer.setData(DND_TYPE, JSON.stringify(paths));
|
ev.dataTransfer.setData(DND_TYPE, JSON.stringify(paths));
|
||||||
ev.dataTransfer.effectAllowed = "move";
|
ev.dataTransfer.effectAllowed = "linkMove";
|
||||||
if (paths.length > 1) {
|
if (paths.length > 1) {
|
||||||
const ghost = document.createElement("div");
|
const ghost = document.createElement("div");
|
||||||
ghost.className = "drag-ghost";
|
ghost.className = "drag-ghost";
|
||||||
@@ -400,8 +429,9 @@ 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
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.dataTransfer.dropEffect = external ? "copy" : "move";
|
ev.dataTransfer.dropEffect = external ? "copy" : inFavoris(targetDir) ? "link" : "move";
|
||||||
setDropTarget(targetDir);
|
setDropTarget(targetDir);
|
||||||
},
|
},
|
||||||
onDragLeave: () => setDropTarget(null),
|
onDragLeave: () => setDropTarget(null),
|
||||||
@@ -417,19 +447,21 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
moveMany(paths, targetDir);
|
if (inFavoris(targetDir)) addFavorites(paths);
|
||||||
|
else moveMany(paths, targetDir);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Rendu ---------------------------------------------------------------
|
// --- Rendu ---------------------------------------------------------------
|
||||||
|
|
||||||
const crumbs = dir ? dir.split("/") : [];
|
const crumbs = dir ? dir.split("/") : [];
|
||||||
const importDir = results ? "" : dir; // pendant une recherche, le dossier courant n'est pas affiché
|
const importDir = results || favView ? "" : dir; // pendant une recherche, le dossier courant n'est pas affiché
|
||||||
|
const where = (p: string) => displayPath(dirname(p)) || "Racine";
|
||||||
|
|
||||||
// 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 (!hasFiles(ev.dataTransfer)) return;
|
if (favView || !hasFiles(ev.dataTransfer)) return;
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.dataTransfer.dropEffect = "copy";
|
ev.dataTransfer.dropEffect = "copy";
|
||||||
setFileDrag(true);
|
setFileDrag(true);
|
||||||
@@ -438,22 +470,55 @@ 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 (!hasFiles(ev.dataTransfer)) return;
|
if (favView || !hasFiles(ev.dataTransfer)) return;
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
setFileDrag(false);
|
setFileDrag(false);
|
||||||
importTree(collectDrop(ev.dataTransfer), importDir);
|
importTree(collectDrop(ev.dataTransfer), importDir);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const big = view === "grid" ? 40 : 20;
|
||||||
const icon = (e: Entry) =>
|
const icon = (e: Entry) =>
|
||||||
e.isDir ? (
|
e.special ? (
|
||||||
|
<span className={`icon special ${e.special}`} aria-hidden>
|
||||||
|
{e.special === "favoris" ? <StarFilledIcon size={big} /> : <UserIcon size={big} />}
|
||||||
|
</span>
|
||||||
|
) : e.broken ? (
|
||||||
<span className="icon folder" aria-hidden>
|
<span className="icon folder" aria-hidden>
|
||||||
<FolderIcon size={view === "grid" ? 40 : 20} />
|
<StarIcon size={big} />
|
||||||
|
</span>
|
||||||
|
) : e.isDir ? (
|
||||||
|
<span className="icon folder" aria-hidden>
|
||||||
|
<FolderIcon size={big} />
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<Thumb path={e.path} version={e.modTime} />
|
<Thumb path={e.path} version={e.modTime} />
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Dans Favoris : actions sur l'original, retrait du lien à la place de la suppression.
|
||||||
|
const favActions = (e: Entry) => (
|
||||||
|
<div className="actions">
|
||||||
|
{!e.broken && (
|
||||||
|
<>
|
||||||
|
<button onClick={() => setDir(dirname(e.path))}>Ouvrir l'emplacement</button>
|
||||||
|
<button onClick={() => onHistory(e.path)}>Historique…</button>
|
||||||
|
<button onClick={() => onShare(e.path)}>Partager…</button>
|
||||||
|
<button onClick={() => setTagging([e])}>
|
||||||
|
<TagIcon />
|
||||||
|
Tags…
|
||||||
|
</button>
|
||||||
|
<a href={api.downloadUrl(e.path)} download={e.name}>
|
||||||
|
Télécharger
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button className="danger" onClick={() => removeMany([e.path])}>
|
||||||
|
<StarIcon />
|
||||||
|
Retirer des favoris
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const actions = (e: Entry) => (
|
const actions = (e: Entry) => (
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button onClick={() => rename(e)}>Renommer</button>
|
<button onClick={() => rename(e)}>Renommer</button>
|
||||||
@@ -461,10 +526,12 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
{!e.isDir && <button onClick={() => duplicate(e)}>Dupliquer</button>}
|
{!e.isDir && <button onClick={() => duplicate(e)}>Dupliquer</button>}
|
||||||
{!e.isDir && <button onClick={() => onHistory(e.path)}>Historique…</button>}
|
{!e.isDir && <button onClick={() => onHistory(e.path)}>Historique…</button>}
|
||||||
{!e.isDir && <button onClick={() => onShare(e.path)}>Partager…</button>}
|
{!e.isDir && <button onClick={() => onShare(e.path)}>Partager…</button>}
|
||||||
|
{!e.isDir && (
|
||||||
<button onClick={() => toggleFavorite([e])}>
|
<button onClick={() => toggleFavorite([e])}>
|
||||||
{e.favorite ? <StarFilledIcon /> : <StarIcon />}
|
{e.favorite ? <StarFilledIcon /> : <StarIcon />}
|
||||||
{e.favorite ? "Retirer des favoris" : "Favori"}
|
{e.favorite ? "Retirer des favoris" : "Favori"}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<button onClick={() => setTagging([e])}>
|
<button onClick={() => setTagging([e])}>
|
||||||
<TagIcon />
|
<TagIcon />
|
||||||
Tags…
|
Tags…
|
||||||
@@ -488,16 +555,19 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
e.isDir ? "dir" : "file",
|
e.isDir ? "dir" : "file",
|
||||||
e.path === current ? "active" : "",
|
e.path === current ? "active" : "",
|
||||||
isSel ? "selected" : "",
|
isSel ? "selected" : "",
|
||||||
|
e.special ? "special" : "",
|
||||||
|
e.broken ? "broken" : "",
|
||||||
dropTarget === e.path ? "drop" : "",
|
dropTarget === e.path ? "drop" : "",
|
||||||
].join(" ");
|
].join(" ");
|
||||||
return [
|
return [
|
||||||
<li key={e.path} className={cls}>
|
<li key={e.path} className={cls}>
|
||||||
<div
|
<div
|
||||||
className="row"
|
className="row"
|
||||||
draggable
|
draggable={!e.special && !favView}
|
||||||
onDragStart={(ev) => onDragStart(ev, e)}
|
onDragStart={(ev) => onDragStart(ev, e)}
|
||||||
{...(e.isDir ? dropProps(e.path) : {})}
|
{...(e.isDir ? dropProps(e.path) : {})}
|
||||||
>
|
>
|
||||||
|
{!e.special && (
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="check"
|
className="check"
|
||||||
@@ -512,7 +582,12 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
}}
|
}}
|
||||||
onChange={() => toggle(e.path)}
|
onChange={() => toggle(e.path)}
|
||||||
/>
|
/>
|
||||||
<button className="name" onClick={(ev) => onItemClick(ev, e)} title={e.path}>
|
)}
|
||||||
|
<button
|
||||||
|
className="name"
|
||||||
|
onClick={(ev) => onItemClick(ev, e)}
|
||||||
|
title={e.broken ? "Original introuvable : retirer ce favori" : displayPath(e.path)}
|
||||||
|
>
|
||||||
{icon(e)}
|
{icon(e)}
|
||||||
<span className="label">
|
<span className="label">
|
||||||
{e.favorite && (
|
{e.favorite && (
|
||||||
@@ -521,7 +596,7 @@ 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)}
|
||||||
{results && <small className="muted where"> {dirname(e.path) || "/"}</small>}
|
{(results || (favView && !e.broken)) && <small className="muted where"> {where(e.path)}</small>}
|
||||||
{e.snippet && <small className="snippet">{e.snippet}</small>}
|
{e.snippet && <small className="snippet">{e.snippet}</small>}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -540,6 +615,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 && view === "list" && <span className="meta muted">{fmtDate(e.modTime)}</span>}
|
||||||
|
{!e.special && (
|
||||||
<button
|
<button
|
||||||
className="icon-btn more"
|
className="icon-btn more"
|
||||||
aria-label="Actions"
|
aria-label="Actions"
|
||||||
@@ -548,13 +624,14 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
>
|
>
|
||||||
<DotsIcon />
|
<DotsIcon />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{view === "list" && menuFor === e.path && actions(e)}
|
{view === "list" && menuFor === e.path && (favView ? favActions(e) : actions(e))}
|
||||||
</li>,
|
</li>,
|
||||||
// En grille, le menu occupe toute la largeur sous la vignette.
|
// En grille, le menu occupe toute la largeur sous la vignette.
|
||||||
view === "grid" && menuFor === e.path && (
|
view === "grid" && menuFor === e.path && (
|
||||||
<li key={`${e.path}#menu`} className="menu-row">
|
<li key={`${e.path}#menu`} className="menu-row">
|
||||||
{actions(e)}
|
{favView ? favActions(e) : actions(e)}
|
||||||
</li>
|
</li>
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@@ -563,17 +640,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">
|
<button className="ex-btn" onClick={newDrawing} title="Nouveau schéma" disabled={favView}>
|
||||||
<FilePlusIcon />
|
<FilePlusIcon />
|
||||||
<span>Schéma</span>
|
<span>Schéma</span>
|
||||||
</button>
|
</button>
|
||||||
<button className="ex-btn" onClick={newFolder} title="Nouveau dossier">
|
<button className="ex-btn" onClick={newFolder} title="Nouveau dossier" disabled={favView}>
|
||||||
<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}
|
||||||
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 />
|
||||||
@@ -582,6 +660,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}
|
||||||
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"
|
||||||
>
|
>
|
||||||
@@ -648,15 +727,6 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="filters" role="group" aria-label="Filtres">
|
<div className="filters" role="group" aria-label="Filtres">
|
||||||
<button
|
|
||||||
className={`chip ${favOnly ? "on" : ""}`}
|
|
||||||
aria-pressed={favOnly}
|
|
||||||
onClick={() => setFavOnly((f) => !f)}
|
|
||||||
title="Favoris de toute l'arborescence"
|
|
||||||
>
|
|
||||||
{favOnly ? <StarFilledIcon size={12} /> : <StarIcon size={12} />}
|
|
||||||
Favoris
|
|
||||||
</button>
|
|
||||||
{tags.map((t) => (
|
{tags.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.name}
|
key={t.name}
|
||||||
@@ -682,7 +752,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
<span key={p}>
|
<span key={p}>
|
||||||
<span className="muted"> / </span>
|
<span className="muted"> / </span>
|
||||||
<button className={dropTarget === p ? "drop" : ""} onClick={() => setDir(p)} {...dropProps(p)}>
|
<button className={dropTarget === p ? "drop" : ""} onClick={() => setDir(p)} {...dropProps(p)}>
|
||||||
{c}
|
{label(c)}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -698,16 +768,16 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
<span className="count">{plural(selected.size, "sélectionné")}</span>
|
<span className="count">{plural(selected.size, "sélectionné")}</span>
|
||||||
<button
|
<button
|
||||||
className="icon-btn"
|
className="icon-btn"
|
||||||
onClick={selected.size === list.length ? clearSelection : selectAll}
|
onClick={selected.size === selectable.length ? clearSelection : selectAll}
|
||||||
title={selected.size === list.length ? "Tout désélectionner" : "Tout sélectionner (Ctrl+A)"}
|
title={selected.size === selectable.length ? "Tout désélectionner" : "Tout sélectionner (Ctrl+A)"}
|
||||||
aria-label={selected.size === list.length ? "Tout désélectionner" : "Tout sélectionner"}
|
aria-label={selected.size === selectable.length ? "Tout désélectionner" : "Tout sélectionner"}
|
||||||
>
|
>
|
||||||
{selected.size === list.length ? <DeselectIcon /> : <SelectAllIcon />}
|
{selected.size === selectable.length ? <DeselectIcon /> : <SelectAllIcon />}
|
||||||
</button>
|
</button>
|
||||||
<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)))}
|
||||||
title="Favoris (ajouter ou retirer)"
|
title={favView ? "Retirer des favoris" : "Favoris (ajouter ou retirer)"}
|
||||||
aria-label="Favoris"
|
aria-label="Favoris"
|
||||||
>
|
>
|
||||||
<StarIcon />
|
<StarIcon />
|
||||||
@@ -720,11 +790,18 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
>
|
>
|
||||||
<TagIcon />
|
<TagIcon />
|
||||||
</button>
|
</button>
|
||||||
|
{!favView && (
|
||||||
<button className="ex-btn" onClick={() => setMoving([...selected])}>
|
<button className="ex-btn" onClick={() => setMoving([...selected])}>
|
||||||
<MoveIcon />
|
<MoveIcon />
|
||||||
<span>Déplacer…</span>
|
<span>Déplacer…</span>
|
||||||
</button>
|
</button>
|
||||||
<button className="icon-btn danger" onClick={() => removeMany([...selected])} title="Mettre à la corbeille (Suppr)" aria-label="Supprimer">
|
)}
|
||||||
|
<button
|
||||||
|
className="icon-btn danger"
|
||||||
|
onClick={() => removeMany([...selected])}
|
||||||
|
title={favView ? "Retirer des favoris (Suppr)" : "Mettre à la corbeille (Suppr)"}
|
||||||
|
aria-label={favView ? "Retirer des favoris" : "Supprimer"}
|
||||||
|
>
|
||||||
<TrashIcon />
|
<TrashIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -756,13 +833,17 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
{list.flatMap(item)}
|
{list.flatMap(item)}
|
||||||
{list.length === 0 && <li className="muted empty">{results ? "Aucun résultat" : "Dossier vide"}</li>}
|
{list.length === 0 && (
|
||||||
|
<li className="muted empty">
|
||||||
|
{results ? "Aucun résultat" : favView ? "Aucun favori : étoile ou glisser-déposer un schéma ici" : "Dossier vide"}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{fileDrag && (
|
{fileDrag && (
|
||||||
<div className="drop-hint" aria-hidden>
|
<div className="drop-hint" aria-hidden>
|
||||||
<FolderUploadIcon size={28} />
|
<FolderUploadIcon size={28} />
|
||||||
<span>Déposer pour importer dans « {importDir ? basename(importDir) : "Racine"} »</span>
|
<span>Déposer pour importer dans « {importDir ? label(basename(importDir)) : "Racine"} »</span>
|
||||||
<small>Les dossiers sont recréés · seuls les .excalidraw sont importés</small>
|
<small>Les dossiers sont recréés · seuls les .excalidraw sont importés</small>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -779,7 +860,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
|
|||||||
{moving && (
|
{moving && (
|
||||||
<MoveDialog
|
<MoveDialog
|
||||||
paths={moving}
|
paths={moving}
|
||||||
startDir={results ? "" : dir}
|
startDir={importDir}
|
||||||
onCancel={() => setMoving(null)}
|
onCancel={() => setMoving(null)}
|
||||||
onConfirm={(target) => moveMany(moving, target)}
|
onConfirm={(target) => moveMany(moving, target)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api, basename, dirname, join, type Entry } from "./api";
|
import { api, basename, dirname, join, label, type Entry } from "./api";
|
||||||
import { FolderIcon, FolderPlusIcon } from "./icons";
|
import { FolderIcon, FolderPlusIcon } from "./icons";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -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)))
|
.then((es) => live && setFolders(es.filter((e) => e.isDir && e.special !== "favoris")))
|
||||||
.catch((e) => live && setError(e.message));
|
.catch((e) => live && setError(e.message));
|
||||||
return () => {
|
return () => {
|
||||||
live = false;
|
live = false;
|
||||||
@@ -63,7 +63,7 @@ export function MoveDialog({ paths, startDir, onCancel, onConfirm }: Props) {
|
|||||||
return (
|
return (
|
||||||
<span key={p}>
|
<span key={p}>
|
||||||
<span className="muted"> / </span>
|
<span className="muted"> / </span>
|
||||||
<button onClick={() => setDir(p)}>{c}</button>
|
<button onClick={() => setDir(p)}>{label(c)}</button>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ interface Props {
|
|||||||
beforeLogout: () => Promise<unknown>;
|
beforeLogout: () => Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Utilisateur connecté et bouton de déconnexion (mode OIDC uniquement). */
|
/**
|
||||||
|
* Utilisateur connecté : cartouche du mode (« oidc » ou « local ») suivi de
|
||||||
|
* l'identifiant, puis bouton de déconnexion (mode OIDC uniquement).
|
||||||
|
*/
|
||||||
export function UserMenu({ beforeLogout }: Props) {
|
export function UserMenu({ beforeLogout }: Props) {
|
||||||
const [me, setMe] = useState<Me | null>(null);
|
const [me, setMe] = useState<Me | null>(null);
|
||||||
|
|
||||||
@@ -18,11 +21,24 @@ export function UserMenu({ beforeLogout }: Props) {
|
|||||||
.catch(() => setMe(null));
|
.catch(() => setMe(null));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!me?.user || !me.logout) return null;
|
if (!me) return null;
|
||||||
|
const oidc = me.mode === "oidc";
|
||||||
|
// OIDC : l'identifiant de l'espace personnel (e-mail) ; local : le nom du compte.
|
||||||
|
const id = oidc ? me.id : (me.user?.name ?? me.id);
|
||||||
|
const who = me.user
|
||||||
|
? me.user.email && me.user.email !== me.user.name
|
||||||
|
? `${me.user.name} (${me.user.email})`
|
||||||
|
: me.user.name
|
||||||
|
: id;
|
||||||
const logout = me.logout;
|
const logout = me.logout;
|
||||||
const who = me.user.email && me.user.email !== me.user.name ? `${me.user.name} (${me.user.email})` : me.user.name;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<span className="whoami" title={`Connecté : ${who}\nEspace personnel : ${me.id}`}>
|
||||||
|
<span className={`auth-tag ${oidc ? "oidc" : "local"}`}>{oidc ? "oidc" : "local"}</span>
|
||||||
|
<span className="user-id">{id}</span>
|
||||||
|
</span>
|
||||||
|
{logout && (
|
||||||
<button
|
<button
|
||||||
className="icon-btn"
|
className="icon-btn"
|
||||||
aria-label={`Se déconnecter (${who})`}
|
aria-label={`Se déconnecter (${who})`}
|
||||||
@@ -31,5 +47,7 @@ export function UserMenu({ beforeLogout }: Props) {
|
|||||||
>
|
>
|
||||||
<LogoutIcon />
|
<LogoutIcon />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+18
-1
@@ -8,6 +8,12 @@ export interface Entry {
|
|||||||
modTime: string;
|
modTime: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
favorite?: boolean;
|
favorite?: boolean;
|
||||||
|
/** Entrée virtuelle de la racine : "favoris" ou "perso" (espace personnel). */
|
||||||
|
special?: "favoris" | "perso";
|
||||||
|
/** Dans le dossier Favoris : nom du lien (le chemin est celui de l'original). */
|
||||||
|
link?: string;
|
||||||
|
/** Favori dont l'original a disparu (chemin .favoris/<lien>, à retirer). */
|
||||||
|
broken?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TagCount {
|
export interface TagCount {
|
||||||
@@ -74,11 +80,12 @@ export interface PublicScene {
|
|||||||
scene: { elements?: unknown[]; appState?: Record<string, unknown>; files?: Record<string, unknown> };
|
scene: { elements?: unknown[]; appState?: Record<string, unknown>; files?: Record<string, unknown> };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Session courante (GET /api/me) ; logout présent en mode OIDC. */
|
/** Session courante (GET /api/me) ; logout présent en mode OIDC, id = dossier de l'utilisateur. */
|
||||||
export interface Me {
|
export interface Me {
|
||||||
mode: "none" | "local" | "oidc";
|
mode: "none" | "local" | "oidc";
|
||||||
user?: { sub: string; name: string; email?: string };
|
user?: { sub: string; name: string; email?: string };
|
||||||
logout?: string;
|
logout?: string;
|
||||||
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@@ -247,6 +254,16 @@ export const api = {
|
|||||||
export const dirname = (p: string) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
|
export const dirname = (p: string) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
|
||||||
export const basename = (p: string) => p.slice(p.lastIndexOf("/") + 1);
|
export const basename = (p: string) => p.slice(p.lastIndexOf("/") + 1);
|
||||||
export const join = (dir: string, name: string) => (dir ? `${dir}/${name}` : name);
|
export const join = (dir: string, name: string) => (dir ? `${dir}/${name}` : name);
|
||||||
|
|
||||||
|
// Dossiers propres à l'utilisateur connecté (voir internal/storage/spaces.go).
|
||||||
|
export const PERSO = ".perso";
|
||||||
|
export const FAVORIS = ".favoris";
|
||||||
|
const SPECIAL_LABELS: Record<string, string> = { [PERSO]: "Personnel", [FAVORIS]: "Favoris" };
|
||||||
|
/** 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 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) =>
|
||||||
|
|||||||
@@ -203,6 +203,14 @@ export const EyeIcon = icon(
|
|||||||
/** Punaise : panneau épinglé ou flottant (comme le bouton d'ancrage des panneaux d'Excalidraw). */
|
/** Punaise : panneau épinglé ou flottant (comme le bouton d'ancrage des panneaux d'Excalidraw). */
|
||||||
export const PinIcon = icon(<path d="M15 4.5l-4 4-4 1.5-1.5 1.5 7 7 1.5-1.5 1.5-4 4-4M9 15l-4.5 4.5M14.5 4 20 9.5" />);
|
export const PinIcon = icon(<path d="M15 4.5l-4 4-4 1.5-1.5 1.5 7 7 1.5-1.5 1.5-4 4-4M9 15l-4.5 4.5M14.5 4 20 9.5" />);
|
||||||
|
|
||||||
|
/** Espace personnel de l'utilisateur. */
|
||||||
|
export const UserIcon = icon(
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="8" r="4" />
|
||||||
|
<path d="M6 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
export const LogoutIcon = icon(
|
export const LogoutIcon = icon(
|
||||||
<>
|
<>
|
||||||
<path d="M14 8V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2v-2" />
|
<path d="M14 8V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2v-2" />
|
||||||
|
|||||||
@@ -1345,3 +1345,66 @@ button.chip.on,
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Entrées propres à l'utilisateur en tête de la racine : Favoris et Personnel. */
|
||||||
|
.icon.special.favoris {
|
||||||
|
color: #f59f00;
|
||||||
|
}
|
||||||
|
.icon.special.perso {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.item.special > .row {
|
||||||
|
background: var(--ex-surface-mid);
|
||||||
|
border-radius: var(--ex-radius-lg);
|
||||||
|
}
|
||||||
|
/* Favori dont l'original a disparu : barré, seul « Retirer » reste possible. */
|
||||||
|
.item.broken .label {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utilisateur connecté (barre de titre) : cartouche du mode + identifiant. */
|
||||||
|
.whoami {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 260px;
|
||||||
|
margin: 0 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.auth-tag {
|
||||||
|
flex: none;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: 4px;
|
||||||
|
font: 600 11px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.auth-tag.oidc {
|
||||||
|
color: #0b7285;
|
||||||
|
background: #e3fafc;
|
||||||
|
}
|
||||||
|
.auth-tag.local {
|
||||||
|
color: #e67700;
|
||||||
|
background: #fff4e6;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .auth-tag.oidc {
|
||||||
|
color: #66d9e8;
|
||||||
|
background: rgb(102 217 232 / 0.12);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .auth-tag.local {
|
||||||
|
color: #ffc078;
|
||||||
|
background: rgb(255 192 120 / 0.12);
|
||||||
|
}
|
||||||
|
.user-id {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.user-id {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user