From ea06fdaddf6e9c2d8ca6d974162bf2b18ae6e5e1 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 8 Oct 2026 10:28:05 +0200 Subject: [PATCH] Explorateur : dossiers Favoris et Personnel, cartouche oidc/local MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) Claude-Session: https://claude.ai/code/session_0174sFqehj4uGqJHQZvWy1gn --- frontend/src/App.tsx | 4 +- frontend/src/Explorer.tsx | 191 +++++++++++++++++++++++++----------- frontend/src/MoveDialog.tsx | 6 +- frontend/src/UserMenu.tsx | 40 +++++--- frontend/src/api.ts | 19 +++- frontend/src/icons.tsx | 8 ++ frontend/src/styles.css | 63 ++++++++++++ 7 files changed, 259 insertions(+), 72 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 32e712e..92f95db 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,7 +14,7 @@ import type { LibraryItems, } from "@excalidraw/excalidraw/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 { CloseIcon, HistoryIcon, MenuIcon, MoonIcon, PinIcon, SaveIcon, SettingsIcon, ShareIcon, SunIcon, UpdateIcon } from "./icons"; import { ShareDialog } from "./ShareDialog"; @@ -539,7 +539,7 @@ export default function App() { {appTitle} {scene ? ( - + / diff --git a/frontend/src/Explorer.tsx b/frontend/src/Explorer.tsx index a31eb27..0250634 100644 --- a/frontend/src/Explorer.tsx +++ b/frontend/src/Explorer.tsx @@ -5,8 +5,11 @@ import { ApiError, basename, dirname, + displayPath, EXT, + inFavoris, join, + label, stripExt, withExt, type Entry, @@ -33,6 +36,7 @@ import { TrashIcon, UpIcon, UploadIcon, + UserIcon, } from "./icons"; import { collectDrop, collectFiles, hasFiles, type Collected } from "./importer"; 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 [tags, setTags] = useState([]); const [tagFilter, setTagFilter] = useState(null); - const [favOnly, setFavOnly] = useState(false); const [tagging, setTagging] = useState(null); // éléments du dialogue « Tags » const fileInput = useRef(null); const dirInput = useRef(null); + const stay = useRef(null); // schéma ouvert depuis Favoris : on reste dans Favoris const list = results ?? entries; - const filter = { tag: tagFilter, fav: favOnly }; - const filtering = query.trim() !== "" || tagFilter !== null || favOnly; + const filter = { tag: tagFilter }; + 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) => { 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). useEffect(() => { - if (current) setDir(dirname(current)); + if (current && current !== stay.current) setDir(dirname(current)); + stay.current = null; }, [current]); // 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); return () => clearTimeout(t); // 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. */ const reload = async () => { @@ -201,7 +208,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi }; 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 b = paths.indexOf(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)])); }; - 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) => { - if (ev.shiftKey) return selectRange(e.path); - // Ctrl/Cmd+clic, ou clic quand une sélection est en cours (mode sélection tactile). - if (ev.ctrlKey || ev.metaKey || selected.size > 0) return toggle(e.path); + if (!e.special) { + if (ev.shiftKey) return selectRange(e.path); + // Ctrl/Cmd+clic, ou clic quand une sélection est en cours (mode sélection tactile). + if (ev.ctrlKey || ev.metaKey || selected.size > 0) return toggle(e.path); + } + if (e.broken) return; if (e.isDir) { setQuery(""); setDir(e.path); - } else onOpen(e.path); + } else { + if (favView && e.path !== current) stay.current = e.path; + onOpen(e.path); + } }; 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. */ const toggleFavorite = (items: Entry[]) => 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( - items.map((e) => e.path), + files.map((e) => e.path), fav, ); setMenuFor(null); + if (favView) clearSelection(); 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) => guard(async () => { 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[]) => 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 what = 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. const paths = selected.has(e.path) ? [...selected] : [e.path]; ev.dataTransfer.setData(DND_TYPE, JSON.stringify(paths)); - ev.dataTransfer.effectAllowed = "move"; + ev.dataTransfer.effectAllowed = "linkMove"; if (paths.length > 1) { const ghost = document.createElement("div"); ghost.className = "drag-ghost"; @@ -400,8 +429,9 @@ 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 ev.preventDefault(); - ev.dataTransfer.dropEffect = external ? "copy" : "move"; + ev.dataTransfer.dropEffect = external ? "copy" : inFavoris(targetDir) ? "link" : "move"; setDropTarget(targetDir); }, onDragLeave: () => setDropTarget(null), @@ -417,19 +447,21 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi } catch { return; } - moveMany(paths, targetDir); + if (inFavoris(targetDir)) addFavorites(paths); + else moveMany(paths, targetDir); }, }); // --- Rendu --------------------------------------------------------------- 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. const zoneProps = { onDragOver: (ev: DragEvent) => { - if (!hasFiles(ev.dataTransfer)) return; + if (favView || !hasFiles(ev.dataTransfer)) return; ev.preventDefault(); ev.dataTransfer.dropEffect = "copy"; 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); }, onDrop: (ev: DragEvent) => { - if (!hasFiles(ev.dataTransfer)) return; + if (favView || !hasFiles(ev.dataTransfer)) return; ev.preventDefault(); setFileDrag(false); importTree(collectDrop(ev.dataTransfer), importDir); }, }; + const big = view === "grid" ? 40 : 20; const icon = (e: Entry) => - e.isDir ? ( + e.special ? ( + + {e.special === "favoris" ? : } + + ) : e.broken ? ( - + + + ) : e.isDir ? ( + + ) : ( ); + // Dans Favoris : actions sur l'original, retrait du lien à la place de la suppression. + const favActions = (e: Entry) => ( +
+ {!e.broken && ( + <> + + + + + + Télécharger + + + )} + +
+ ); + const actions = (e: Entry) => (
@@ -461,10 +526,12 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi {!e.isDir && } {!e.isDir && } {!e.isDir && } - + {!e.isDir && ( + + )} @@ -540,6 +615,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi )} {!e.isDir && view === "list" && {fmtDate(e.modTime)}} + {!e.special && ( + )}
- {view === "list" && menuFor === e.path && actions(e)} + {view === "list" && menuFor === e.path && (favView ? favActions(e) : actions(e))} , // En grille, le menu occupe toute la largeur sous la vignette. view === "grid" && menuFor === e.path && (
  • - {actions(e)} + {favView ? favActions(e) : actions(e)}
  • ), ]; @@ -563,17 +640,18 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi return (
    - -
    - {tags.map((t) => ( ); @@ -698,16 +768,16 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi {plural(selected.size, "sélectionné")} - - + )} +
    @@ -756,13 +833,17 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi )} {list.flatMap(item)} - {list.length === 0 &&
  • {results ? "Aucun résultat" : "Dossier vide"}
  • } + {list.length === 0 && ( +
  • + {results ? "Aucun résultat" : favView ? "Aucun favori : étoile ou glisser-déposer un schéma ici" : "Dossier vide"} +
  • + )} {fileDrag && (
    - Déposer pour importer dans « {importDir ? basename(importDir) : "Racine"} » + Déposer pour importer dans « {importDir ? label(basename(importDir)) : "Racine"} » Les dossiers sont recréés · seuls les .excalidraw sont importés
    )} @@ -779,7 +860,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi {moving && ( setMoving(null)} onConfirm={(target) => moveMany(moving, target)} /> diff --git a/frontend/src/MoveDialog.tsx b/frontend/src/MoveDialog.tsx index f7d76ab..32ffe6a 100644 --- a/frontend/src/MoveDialog.tsx +++ b/frontend/src/MoveDialog.tsx @@ -1,5 +1,5 @@ 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"; interface Props { @@ -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))) + .then((es) => live && setFolders(es.filter((e) => e.isDir && e.special !== "favoris"))) .catch((e) => live && setError(e.message)); return () => { live = false; @@ -63,7 +63,7 @@ export function MoveDialog({ paths, startDir, onCancel, onConfirm }: Props) { return ( / - + ); })} diff --git a/frontend/src/UserMenu.tsx b/frontend/src/UserMenu.tsx index 6eefbad..0651fdc 100644 --- a/frontend/src/UserMenu.tsx +++ b/frontend/src/UserMenu.tsx @@ -7,7 +7,10 @@ interface Props { beforeLogout: () => Promise; } -/** 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) { const [me, setMe] = useState(null); @@ -18,18 +21,33 @@ export function UserMenu({ beforeLogout }: Props) { .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 who = me.user.email && me.user.email !== me.user.name ? `${me.user.name} (${me.user.email})` : me.user.name; return ( - + <> + + {oidc ? "oidc" : "local"} + {id} + + {logout && ( + + )} + ); } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index bc7d7b6..ca6e25a 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -8,6 +8,12 @@ export interface Entry { modTime: string; tags?: string[]; 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/, à retirer). */ + broken?: boolean; } export interface TagCount { @@ -74,11 +80,12 @@ export interface PublicScene { scene: { elements?: unknown[]; appState?: Record; files?: Record }; } -/** 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 { mode: "none" | "local" | "oidc"; user?: { sub: string; name: string; email?: string }; logout?: string; + id: string; } 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 basename = (p: string) => p.slice(p.lastIndexOf("/") + 1); 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 = { [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 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 6dbfec0..69f3b60 100644 --- a/frontend/src/icons.tsx +++ b/frontend/src/icons.tsx @@ -203,6 +203,14 @@ export const EyeIcon = icon( /** Punaise : panneau épinglé ou flottant (comme le bouton d'ancrage des panneaux d'Excalidraw). */ export const PinIcon = icon(); +/** Espace personnel de l'utilisateur. */ +export const UserIcon = icon( + <> + + + , +); + export const LogoutIcon = icon( <> diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 49deee5..ba37f64 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1345,3 +1345,66 @@ button.chip.on, 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; + } +}