onDragStart(ev, e)}
{...(e.isDir ? dropProps(e.path) : {})}
>
+ {!e.special && (
toggle(e.path)}
/>
-
- {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 && (
-
+
Schéma
-
+
Dossier
fileInput.current?.click()}
+ disabled={favView}
title="Importer des fichiers .excalidraw (ou glisser-déposer fichiers et dossiers ici)"
>
@@ -582,6 +660,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
dirInput.current?.click()}
+ disabled={favView}
title="Importer un dossier complet (arborescence recréée, seuls les .excalidraw sont importés)"
aria-label="Importer un dossier"
>
@@ -648,15 +727,6 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
- setFavOnly((f) => !f)}
- title="Favoris de toute l'arborescence"
- >
- {favOnly ? : }
- Favoris
-
{tags.map((t) => (
/
setDir(p)} {...dropProps(p)}>
- {c}
+ {label(c)}
);
@@ -698,16 +768,16 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
{plural(selected.size, "sélectionné")}
- {selected.size === list.length ? : }
+ {selected.size === selectable.length ? : }
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"
>
@@ -720,11 +790,18 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHi
>
- setMoving([...selected])}>
-
- Déplacer…
-
- removeMany([...selected])} title="Mettre à la corbeille (Suppr)" aria-label="Supprimer">
+ {!favView && (
+ setMoving([...selected])}>
+
+ Déplacer…
+
+ )}
+ removeMany([...selected])}
+ title={favView ? "Retirer des favoris (Suppr)" : "Mettre à la corbeille (Suppr)"}
+ aria-label={favView ? "Retirer des favoris" : "Supprimer"}
+ >
@@ -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 (
/
- setDir(p)}>{c}
+ setDir(p)}>{label(c)}
);
})}
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 (
- beforeLogout().finally(() => location.assign(logout))}
- >
-
-
+ <>
+
+ {oidc ? "oidc" : "local"}
+ {id}
+
+ {logout && (
+ beforeLogout().finally(() => location.assign(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;
+ }
+}