Barre de titre : mode clair/sombre, titre et logo ; explorateur redimensionnable #3

Merged
claude Bot merged 2 commits from feat/barre-titre-theme into main 2026-10-07 15:03:33 +02:00
7 changed files with 489 additions and 17 deletions
Showing only changes of commit 9ecaab550a - Show all commits

No files matched your search

+9
View File
@@ -18,6 +18,7 @@ Navigateur ──HTTP──▶ conteneur excalidraw-go (:8080, uid 65532, rootfs
volume /data
├─ projets/réseau/sdn.excalidraw ← fichiers JSON Excalidraw standards
├─ .library.excalidrawlib ← bibliothèque partagée
├─ .settings.json, .logo.png ← titre et logo de la barre
├─ .thumbs/projets/réseau/sdn.excalidraw.png ← miniatures (régénérables)
└─ .trash/<horodatage>/... ← éléments supprimés
```
@@ -52,6 +53,9 @@ Bind mount au lieu du volume nommé : `- ./data:/data` puis `chown -R 65532:6553
- **Sauvegarde** : automatique 1,5 s après la dernière modification, immédiate avec `Ctrl/Cmd+S`. Le statut (Modifié / Enregistrement… / Enregistré) est affiché dans la barre du haut.
- **Lien direct** : l'URL `http://hôte:8080/#/dossier/schema.excalidraw` ouvre le schéma.
- **Bibliothèque** Excalidraw : persistée côté serveur, partagée entre tous les schémas.
- **Largeur de l'explorateur** (bureau) : glisser la poignée entre l'explorateur et le canevas ; flèches ←/→ au clavier quand elle a le focus (Maj : pas plus grand), double-clic pour revenir à 330 px. Mémorisée par navigateur.
- **Mode clair/sombre** : bouton ☀/☾ à droite de la barre du haut ; s'applique à l'interface et au canevas Excalidraw, reste synchronisé avec le choix du menu d'Excalidraw. Mémorisé par navigateur.
- **Titre et logo** : bouton ✎ à droite de la barre (ou clic sur le titre). Le titre et le logo sont communs à tous les utilisateurs. Le logo (PNG, 5 Mo max) est recadré au centre, réduit à 64×64 px côté serveur et affiché dans un masque rond (34 px dans la barre, net sur écran HiDPI ; taille réglable via `--logo-size` dans `styles.css`).
### Miniatures
@@ -84,6 +88,11 @@ Tous les chemins sont relatifs à `DATA_DIR`, en notation `/`. Les composants co
| POST | `/api/copy` | `{"from":"…","to":"…"}` | `Entry` |
| DELETE | `/api/entry?path=x[&recursive=true]` | | `204` (déplacé dans `.trash/`) |
| GET/PUT | `/api/library` | bibliothèque `excalidrawlib` | |
| GET | `/api/settings` | | `{"title":"...","logo":"<version>"}` (`logo` vide sans logo) |
| PUT | `/api/settings` | `{"title":"..."}` (80 caractères max, vide = titre par défaut) | réglages |
| GET | `/api/logo?v=<version>` | | PNG 64×64 ; `404` sans logo |
| PUT | `/api/logo` | PNG (5 Mo, 8192×8192 px max) | réglages ; image recadrée et ré-encodée |
| DELETE | `/api/logo` | | réglages |
| GET | `/healthz` | | `ok` (hors authentification) |
`Entry` = `{"name","path","isDir","size","modTime"}`.
+109 -15
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { type CSSProperties, useCallback, useEffect, useRef, useState } from "react";
import {
Excalidraw,
hashElementsVersion,
@@ -14,8 +14,11 @@ import type {
LibraryItems,
} from "@excalidraw/excalidraw/types";
import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
import { api, ApiError, basename, stripExt } from "./api";
import { api, ApiError, basename, stripExt, type Settings } from "./api";
import { CustomizeDialog, DEFAULT_TITLE } from "./CustomizeDialog";
import { Explorer } from "./Explorer";
import { MoonIcon, SettingsIcon, SunIcon } from "./icons";
import { Splitter } from "./Splitter";
import { storeThumb } from "./thumbs";
type Theme = "light" | "dark";
@@ -31,16 +34,34 @@ const AUTOSAVE_MS = 1500;
const isMobile = () => window.matchMedia("(max-width: 768px)").matches;
const pathFromHash = () => decodeURIComponent(location.hash.replace(/^#\/?/, "")) || null;
const initialTheme = (): Theme => {
const load = (key: string) => {
try {
const t = localStorage.getItem("theme");
if (t === "light" || t === "dark") return t;
return localStorage.getItem(key);
} catch {
return null; // stockage indisponible
}
};
const store = (key: string, value: string) => {
try {
localStorage.setItem(key, value);
} catch {
/* stockage indisponible */
}
};
const initialTheme = (): Theme => {
const t = load("theme");
if (t === "light" || t === "dark") return t;
return matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
};
// Largeur de l'explorateur (bureau) : réglable par la poignée, mémorisée par navigateur.
const DRAWER_DEFAULT = 330;
const DRAWER_MIN = 220;
const drawerMax = () => Math.max(DRAWER_MIN, window.innerWidth - 360); // garde un canevas utilisable
const clampDrawer = (w: number) => Math.round(Math.min(Math.max(w, DRAWER_MIN), drawerMax()));
const initialDrawerWidth = () => clampDrawer(Number(load("drawerWidth")) || DRAWER_DEFAULT);
/** Empreinte du contenu « persistant » (hors scroll, sélection, zoom...). */
const sceneVersion = (
elements: readonly OrderedExcalidrawElement[],
@@ -63,6 +84,10 @@ export default function App() {
const [drawerOpen, setDrawerOpen] = useState(() => !isMobile());
const [refreshKey, setRefreshKey] = useState(0);
const [loadError, setLoadError] = useState<string | null>(null);
const [drawerWidth, setDrawerWidth] = useState(initialDrawerWidth);
const [resizing, setResizing] = useState(false);
const [settings, setSettings] = useState<Settings>({ title: "", logo: "" });
const [customizing, setCustomizing] = useState(false);
const excalidraw = useRef<ExcalidrawImperativeAPI | null>(null);
const pathRef = useRef<string | null>(null); // schéma ouvert (survit aux renommages)
@@ -73,6 +98,7 @@ export default function App() {
const libTimer = useRef<number>(undefined);
const libJson = useRef(""); // dernier état connu côté serveur
const sceneSeq = useRef(0);
const exTheme = useRef<string | null>(null); // dernier thème signalé par Excalidraw
const openRef = useRef<(path: string, opts?: { skipFlush?: boolean }) => Promise<void>>(async () => {});
// --- Sauvegarde -----------------------------------------------------------
@@ -128,7 +154,13 @@ export default function App() {
const onChange = useCallback(
(elements: readonly OrderedExcalidrawElement[], appState: AppState, files: BinaryFiles) => {
if (appState.theme !== theme) setTheme(appState.theme as Theme);
// Bascule depuis le menu d'Excalidraw. On ne réagit qu'à un changement
// signalé par Excalidraw : sinon, après un clic sur le bouton de la barre,
// un onChange portant encore l'ancien thème le rétablirait (boucle).
if (appState.theme !== exTheme.current) {
exTheme.current = appState.theme;
if (appState.theme !== theme) setTheme(appState.theme as Theme);
}
if (!pathRef.current || appState.isLoading) return;
const version = sceneVersion(elements, appState, files);
// Premier onChange après chargement : c'est la version de référence.
@@ -251,13 +283,31 @@ export default function App() {
useEffect(() => {
document.documentElement.dataset.theme = theme;
try {
localStorage.setItem("theme", theme);
} catch {
/* stockage indisponible */
}
store("theme", theme);
}, [theme]);
// Largeur mémorisée en fin de glissement ; réajustée si la fenêtre rétrécit.
useEffect(() => {
if (!resizing) store("drawerWidth", String(drawerWidth));
}, [drawerWidth, resizing]);
useEffect(() => {
const onResize = () => setDrawerWidth((w) => clampDrawer(w));
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
// Titre et logo personnalisés (partagés, stockés côté serveur).
useEffect(() => {
api.settings().then(setSettings).catch(console.error);
}, []);
const appTitle = settings.title || DEFAULT_TITLE;
const sceneName = scene ? stripExt(basename(scene.path)) : null;
useEffect(() => {
document.title = sceneName ? `${sceneName} – ${appTitle}` : appTitle;
}, [appTitle, sceneName]);
// --- Synchronisation avec l'explorateur -----------------------------------
const onMoved = useCallback((from: string, to: string) => {
@@ -282,7 +332,10 @@ export default function App() {
// --- Rendu -----------------------------------------------------------------
return (
<div className={`app ${drawerOpen ? "drawer-open" : ""}`}>
<div
className={`app ${drawerOpen ? "drawer-open" : ""} ${resizing ? "resizing" : ""} ${scene ? "has-scene" : ""}`}
style={{ "--drawer-w": `${drawerWidth}px` } as CSSProperties}
>
<header className="topbar">
<button
className="icon-btn"
@@ -291,9 +344,20 @@ export default function App() {
>
☰
</button>
<span className="title" title={scene?.path}>
{scene ? stripExt(basename(scene.path)) : "Excalidraw"}
</span>
<button className="brand" title="Personnaliser le titre et le logo" onClick={() => setCustomizing(true)}>
{settings.logo && <img className="logo" src={api.logoUrl(settings.logo)} alt="" />}
<span className="brand-title">{appTitle}</span>
</button>
{scene ? (
<span className="title" title={scene.path}>
<span className="sep muted" aria-hidden>
/
</span>
{sceneName}
</span>
) : (
<span className="spacer" />
)}
{scene && (
<>
<span className={`status ${saveState}`}>{SAVE_LABEL[saveState]}</span>
@@ -305,6 +369,24 @@ export default function App() {
</button>
</>
)}
<div className="topbar-tools">
<button
className="icon-btn"
aria-label={theme === "dark" ? "Passer en mode clair" : "Passer en mode sombre"}
title={theme === "dark" ? "Mode clair" : "Mode sombre"}
onClick={() => setTheme((t) => (t === "dark" ? "light" : "dark"))}
>
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
</button>
<button
className="icon-btn"
aria-label="Personnaliser le titre et le logo"
title="Personnaliser"
onClick={() => setCustomizing(true)}
>
<SettingsIcon />
</button>
</div>
</header>
<aside className="drawer">
@@ -316,6 +398,14 @@ export default function App() {
onDeleted={onDeleted}
/>
</aside>
<Splitter
width={drawerWidth}
min={DRAWER_MIN}
max={drawerMax()}
onResize={(w) => setDrawerWidth(clampDrawer(w))}
onDragging={setResizing}
onReset={() => setDrawerWidth(clampDrawer(DRAWER_DEFAULT))}
/>
<div className="backdrop" onClick={() => setDrawerOpen(false)} />
<main className="canvas">
@@ -341,6 +431,10 @@ export default function App() {
</div>
)}
</main>
{customizing && (
<CustomizeDialog settings={settings} onChange={setSettings} onClose={() => setCustomizing(false)} />
)}
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { useEffect, useRef, useState } from "react";
import { api, type Settings } from "./api";
export const DEFAULT_TITLE = "Excalidraw";
const MAX_TITLE = 80; // voir storage.MaxTitleLen
interface Props {
settings: Settings;
onChange: (s: Settings) => void;
onClose: () => void;
}
/** Titre et logo de la barre du haut, partagés par tous les utilisateurs. */
export function CustomizeDialog({ settings, onChange, onClose }: Props) {
const [title, setTitle] = useState(settings.title);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const run = async (op: () => Promise<Settings>, close = false) => {
setBusy(true);
setError(null);
try {
onChange(await op());
if (close) onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const onFile = (f: File | undefined) => {
if (!f) return;
if (f.type !== "image/png") {
setError("Seuls les fichiers PNG sont acceptés.");
return;
}
run(() => api.uploadLogo(f));
};
const dirty = title.trim() !== settings.title;
return (
<div className="modal-backdrop" onClick={onClose}>
<form
className="modal customize"
role="dialog"
aria-modal="true"
aria-label="Personnaliser la barre de titre"
onClick={(e) => e.stopPropagation()}
onSubmit={(e) => {
e.preventDefault();
run(() => api.setTitle(title), true);
}}
>
<h3>Personnaliser</h3>
<label className="field">
<span>Titre affiché</span>
<input
className="search"
value={title}
maxLength={MAX_TITLE}
placeholder={DEFAULT_TITLE}
autoFocus
onChange={(e) => setTitle(e.target.value)}
/>
</label>
<div className="field">
<span>Logo</span>
<div className="logo-edit">
{settings.logo ? (
<img className="logo big" src={api.logoUrl(settings.logo)} alt="Logo actuel" />
) : (
<span className="logo big placeholder-logo muted" aria-hidden>
?
</span>
)}
<div className="logo-actions">
<button type="button" disabled={busy} onClick={() => fileInput.current?.click()}>
{settings.logo ? "Remplacer…" : "Choisir un PNG…"}
</button>
{settings.logo && (
<button type="button" className="danger" disabled={busy} onClick={() => run(api.removeLogo)}>
Retirer
</button>
)}
<small className="muted">PNG, recadré au centre et réduit à 64×64 px, affiché en rond.</small>
</div>
<input
ref={fileInput}
type="file"
accept="image/png"
hidden
onChange={(e) => {
onFile(e.target.files?.[0]);
e.target.value = "";
}}
/>
</div>
</div>
{error && <div className="error">{error}</div>}
<div className="modal-actions">
<span className="spacer" />
<button type="button" onClick={onClose}>
Fermer
</button>
<button type="submit" className="primary" disabled={busy || !dirty}>
Enregistrer le titre
</button>
</div>
</form>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { useRef } from "react";
interface Props {
width: number;
min: number;
max: number;
onResize: (width: number) => void;
/** Début / fin de glissement (désactive l'animation de la grille). */
onDragging: (dragging: boolean) => void;
onReset: () => void;
}
/** Poignée verticale entre l'explorateur et le canevas : souris, doigt ou clavier. */
export function Splitter({ width, min, max, onResize, onDragging, onReset }: Props) {
const start = useRef<{ x: number; w: number } | null>(null);
return (
<div
className="splitter"
role="separator"
aria-orientation="vertical"
aria-label="Largeur de l'explorateur"
aria-valuenow={width}
aria-valuemin={min}
aria-valuemax={max}
tabIndex={0}
title="Glisser pour redimensionner, double-clic pour la largeur par défaut"
onPointerDown={(e) => {
if (e.button !== 0) return;
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
start.current = { x: e.clientX, w: width };
onDragging(true);
}}
onPointerMove={(e) => {
if (start.current) onResize(start.current.w + e.clientX - start.current.x);
}}
onPointerUp={(e) => {
e.currentTarget.releasePointerCapture(e.pointerId);
start.current = null;
onDragging(false);
}}
onLostPointerCapture={() => {
if (start.current) {
start.current = null;
onDragging(false);
}
}}
onDoubleClick={onReset}
onKeyDown={(e) => {
const step = e.shiftKey ? 64 : 16;
if (e.key === "ArrowLeft") onResize(width - step);
else if (e.key === "ArrowRight") onResize(width + step);
else if (e.key === "Home") onResize(min);
else if (e.key === "End") onResize(max);
else return;
e.preventDefault();
}}
/>
);
}
+21
View File
@@ -14,6 +14,12 @@ export interface MoveResult {
error?: string;
}
/** Personnalisation partagée ; logo = "" sans logo, sinon sa version. */
export interface Settings {
title: string;
logo: string;
}
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
@@ -88,6 +94,21 @@ export const api = {
});
},
settings: async (): Promise<Settings> => (await request("/api/settings")).json(),
setTitle: async (title: string): Promise<Settings> =>
(await request("/api/settings", { ...json({ title }), method: "PUT" })).json(),
/** Le serveur recadre au centre et ramène le PNG à 64x64. */
uploadLogo: async (png: Blob): Promise<Settings> =>
(
await request("/api/logo", { method: "PUT", headers: { "Content-Type": "image/png" }, body: png })
).json(),
removeLogo: async (): Promise<Settings> => (await request("/api/logo", { method: "DELETE" })).json(),
logoUrl: (v: string) => `/api/logo?v=${q(v)}`,
downloadUrl: (path: string) => `/api/file?path=${q(path)}`,
/** v = date du schéma : l'URL change à chaque nouvelle version (cache navigateur). */
+33
View File
@@ -0,0 +1,33 @@
// Icônes de la barre de titre (traits, couleur héritée).
const svg = {
width: 18,
height: 18,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 2,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
};
export const SunIcon = () => (
<svg {...svg}>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
);
export const MoonIcon = () => (
<svg {...svg}>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
);
export const SettingsIcon = () => (
<svg {...svg}>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
);
+131 -2
View File
@@ -97,6 +97,42 @@ button:disabled {
.app.drawer-open {
grid-template-columns: var(--drawer-w) 1fr;
}
.app.resizing {
transition: none;
cursor: col-resize;
user-select: none;
}
/* Poignée de redimensionnement, à cheval sur la bordure de l'explorateur. */
.splitter {
display: none;
grid-area: 2 / 1;
justify-self: end;
position: relative;
z-index: 5;
width: 9px;
margin-right: -5px;
cursor: col-resize;
touch-action: none;
}
.drawer-open .splitter {
display: block;
}
.splitter::after {
content: "";
position: absolute;
inset: 0 3px;
border-radius: 2px;
transition: background 0.1s;
}
.splitter:hover::after,
.splitter:focus-visible::after,
.resizing .splitter::after {
background: var(--accent);
}
.splitter:focus-visible {
outline: none;
}
.topbar {
grid-column: 1 / -1;
@@ -111,11 +147,68 @@ button:disabled {
}
.topbar .title {
flex: 1;
min-width: 0;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topbar .title .sep {
margin-right: 8px;
font-weight: 400;
}
/* Logo + titre personnalisés (clic : dialogue « Personnaliser »). */
.brand {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
max-width: 40%;
flex: none;
border: none;
background: transparent;
padding: 2px 4px;
font-weight: 600;
}
.brand-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Logo stocké en 64x64 : affiché plus petit, il reste net sur écran HiDPI. */
.logo {
--logo-size: 34px;
flex: none;
width: var(--logo-size);
height: var(--logo-size);
border-radius: 50%; /* masque rond */
object-fit: cover;
background: var(--bg);
box-shadow: 0 0 0 1px var(--border);
}
.logo.big {
--logo-size: 64px;
}
.topbar-tools {
display: flex;
align-items: center;
gap: 2px;
margin-left: auto;
padding-left: 4px;
border-left: 1px solid var(--border);
}
.topbar-tools .icon-btn {
display: grid;
place-items: center;
width: 34px;
height: 34px;
padding: 0;
border-radius: 6px;
}
.topbar-tools .icon-btn:hover {
background: var(--bg);
}
.status {
font-size: 12px;
padding: 2px 8px;
@@ -147,6 +240,7 @@ button:disabled {
.canvas {
grid-area: 2 / 2;
position: relative;
isolation: isolate; /* la poignée passe au-dessus de l'interface Excalidraw */
min-width: 0;
min-height: 0;
}
@@ -276,7 +370,7 @@ li.active > .row {
top: var(--topbar-h);
bottom: 0;
left: 0;
width: min(var(--drawer-w), 88vw);
width: min(330px, 88vw); /* largeur réglable : bureau uniquement */
transform: translateX(-100%);
transition: transform 0.2s ease;
box-shadow: 2px 0 12px rgb(0 0 0 / 0.2);
@@ -296,7 +390,12 @@ li.active > .row {
background: rgb(0 0 0 / 0.35);
}
.row .meta,
.topbar .status {
.topbar .status,
.has-scene .brand-title {
display: none;
}
.splitter,
.drawer-open .splitter {
display: none;
}
.row .name {
@@ -554,3 +653,33 @@ button.primary {
gap: 6px;
align-items: center;
}
/* Dialogue « Personnaliser » */
.customize .field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
}
.customize .field > span {
font-weight: 600;
}
.logo-edit {
display: flex;
align-items: center;
gap: 12px;
}
.placeholder-logo {
display: grid;
place-items: center;
font-size: 24px;
}
.logo-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.logo-actions small {
flex-basis: 100%;
}