diff --git a/README.md b/README.md index ef0b3af..282a6dd 100644 --- a/README.md +++ b/README.md @@ -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//... ← é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":""}` (`logo` vide sans logo) | +| PUT | `/api/settings` | `{"title":"..."}` (80 caractères max, vide = titre par défaut) | réglages | +| GET | `/api/logo?v=` | | 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"}`. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3a06a35..e2559b3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(null); + const [drawerWidth, setDrawerWidth] = useState(initialDrawerWidth); + const [resizing, setResizing] = useState(false); + const [settings, setSettings] = useState({ title: "", logo: "" }); + const [customizing, setCustomizing] = useState(false); const excalidraw = useRef(null); const pathRef = useRef(null); // schéma ouvert (survit aux renommages) @@ -73,6 +98,7 @@ export default function App() { const libTimer = useRef(undefined); const libJson = useRef(""); // dernier état connu côté serveur const sceneSeq = useRef(0); + const exTheme = useRef(null); // dernier thème signalé par Excalidraw const openRef = useRef<(path: string, opts?: { skipFlush?: boolean }) => Promise>(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 ( -
+
- - {scene ? stripExt(basename(scene.path)) : "Excalidraw"} - + + {scene ? ( + + + / + + {sceneName} + + ) : ( + + )} {scene && ( <> {SAVE_LABEL[saveState]} @@ -305,6 +369,24 @@ export default function App() { )} +
+ + +
+ setDrawerWidth(clampDrawer(w))} + onDragging={setResizing} + onReset={() => setDrawerWidth(clampDrawer(DRAWER_DEFAULT))} + />
setDrawerOpen(false)} />
@@ -341,6 +431,10 @@ export default function App() {
)} + + {customizing && ( + setCustomizing(false)} /> + )}
); } diff --git a/frontend/src/CustomizeDialog.tsx b/frontend/src/CustomizeDialog.tsx new file mode 100644 index 0000000..3ac5e44 --- /dev/null +++ b/frontend/src/CustomizeDialog.tsx @@ -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(null); + const fileInput = useRef(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, 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 ( +
+
e.stopPropagation()} + onSubmit={(e) => { + e.preventDefault(); + run(() => api.setTitle(title), true); + }} + > +

Personnaliser

+ + + +
+ Logo +
+ {settings.logo ? ( + Logo actuel + ) : ( + + ? + + )} +
+ + {settings.logo && ( + + )} + PNG, recadré au centre et réduit à 64×64 px, affiché en rond. +
+ { + onFile(e.target.files?.[0]); + e.target.value = ""; + }} + /> +
+
+ + {error &&
{error}
} + +
+ + + +
+
+
+ ); +} diff --git a/frontend/src/Splitter.tsx b/frontend/src/Splitter.tsx new file mode 100644 index 0000000..cecf4ce --- /dev/null +++ b/frontend/src/Splitter.tsx @@ -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 ( +
{ + 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(); + }} + /> + ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 9a778dd..f9aa4fb 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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 => (await request("/api/settings")).json(), + + setTitle: async (title: string): Promise => + (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 => + ( + await request("/api/logo", { method: "PUT", headers: { "Content-Type": "image/png" }, body: png }) + ).json(), + + removeLogo: async (): Promise => (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). */ diff --git a/frontend/src/icons.tsx b/frontend/src/icons.tsx new file mode 100644 index 0000000..539587c --- /dev/null +++ b/frontend/src/icons.tsx @@ -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 = () => ( + + + + +); + +export const MoonIcon = () => ( + + + +); + +export const SettingsIcon = () => ( + + + + +); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 04bf79a..7a90e22 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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%; +}