From 88f77505778cc2792c16ec5f7bd851838fe773cc Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 7 Oct 2026 16:02:09 +0200 Subject: [PATCH] =?UTF-8?q?Param=C3=A8tres=20:=20choix=20du=20canal=20d'Ex?= =?UTF-8?q?calidraw=20(stable=20ou=20pr=C3=A9version)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dialogue « Paramètres » (icône engrenage) : sections Apparence (titre, logo) et Excalidraw (canal latest/next, versions publiées, commande de reconstruction ; rappel qu'Excalidraw est compilé dans l'image). - L'icône de mise à jour suit le canal choisi et compare par égalité (les numéros next sont inférieurs à la dernière stable) ; elle ouvre les paramètres sur la section Excalidraw. - Compatibilité avec le canal next : la prop excalidrawAPI y devient onExcalidrawAPI, les deux sont passées (image next construite et testée). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019EuvvuTv7AUc8ksDHv8VKx --- README.md | 18 ++- frontend/src/App.tsx | 46 +++++-- frontend/src/CustomizeDialog.tsx | 125 ------------------ frontend/src/SettingsDialog.tsx | 210 +++++++++++++++++++++++++++++++ frontend/src/UpdateBadge.tsx | 99 --------------- frontend/src/api.ts | 12 +- frontend/src/icons.tsx | 4 +- frontend/src/styles.css | 66 ++++++++-- frontend/src/update.ts | 36 ++++++ 9 files changed, 361 insertions(+), 255 deletions(-) delete mode 100644 frontend/src/CustomizeDialog.tsx create mode 100644 frontend/src/SettingsDialog.tsx delete mode 100644 frontend/src/UpdateBadge.tsx create mode 100644 frontend/src/update.ts diff --git a/README.md b/README.md index 8b96001..05d3217 100644 --- a/README.md +++ b/README.md @@ -60,13 +60,19 @@ Bind mount au lieu du volume nommé : `- ./data:/data` puis `chown -R 65532:6553 - **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. - **Style** : l'explorateur et la barre du haut reprennent les couleurs, la police, les rayons et les tailles de boutons d'Excalidraw (jetons `--ex-*` en tête de `styles.css`), en clair comme en sombre. -- **Mise à jour d'Excalidraw** : quand une version plus récente que celle embarquée est publiée sur npm, une icône apparaît à droite de la barre du haut ; un clic affiche les deux versions et la commande de reconstruction (voir *Mettre à jour Excalidraw*). +- **Mise à jour d'Excalidraw** : quand la version publiée sur le canal choisi (voir *Paramètres*) diffère de celle embarquée, une icône apparaît à droite de la barre du haut ; un clic ouvre les paramètres sur la commande de reconstruction (voir *Mettre à jour Excalidraw*). - **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`). +- **Paramètres** : bouton ⚙ à droite de la barre (ou clic sur le titre). Section *Apparence* : titre et logo. Section *Excalidraw* : canal suivi, *Stable* (`latest`) ou *Préversion* (`next`), versions publiées et commande de reconstruction. Les paramètres sont stockés dans `DATA_DIR/.settings.json`. +- **Titre et logo** : 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`). ### Mettre à jour Excalidraw -La version d'Excalidraw est figée au build (`frontend/package-lock.json`). Le serveur interroge le registre npm (`/api/update`, toutes les 12 h par défaut) et l'interface compare la dernière version publiée à celle embarquée. Pour reconstruire avec la nouvelle version : +La version d'Excalidraw est figée au build (`frontend/package-lock.json`). Le serveur lit les dist-tags du paquet sur le registre npm (`/api/update`, toutes les 12 h par défaut) et l'interface compare la version du canal choisi dans les paramètres à celle embarquée : + +- `latest` : versions stables (0.18.1 au 2026-10-07) ; +- `next` : préversion publiée à chaque modification de la branche principale d'Excalidraw (ex. `0.18.0-4f68076`). Son numéro est inférieur à la dernière stable alors que le code est plus récent : l'icône compare donc les versions par égalité, ce qui propose aussi le retour de `next` à `latest`. + +Changer de canal dans l'interface ne change rien tant que l'image n'est pas reconstruite. Pour reconstruire avec la version indiquée : ```bash EXCALIDRAW_VERSION=0.18.2 docker compose up -d --build # ou EXCALIDRAW_VERSION=… dans .env @@ -105,12 +111,12 @@ 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/settings` | | `{"title":"...","logo":"","channel":"latest"}` (`logo` vide sans logo) | +| PUT | `/api/settings` | `{"title":"..."}` (80 caractères max, vide = titre par défaut) et/ou `{"channel":"latest"\|"next"}` | 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 | `/api/update` | | `{"enabled","latest","checkedAt"}` | +| GET | `/api/update` | | `{"enabled","tags":{"latest","next"},"latest","checkedAt"}` | | GET | `/healthz` | | `ok` (hors authentification) | `Entry` = `{"name","path","isDir","size","modTime"}`. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4076005..6324758 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,11 +15,11 @@ import type { } from "@excalidraw/excalidraw/types"; import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types"; import { api, ApiError, basename, stripExt, type Settings } from "./api"; -import { CustomizeDialog, DEFAULT_TITLE } from "./CustomizeDialog"; import { Explorer } from "./Explorer"; -import { CloseIcon, MenuIcon, MoonIcon, SaveIcon, SettingsIcon, SunIcon } from "./icons"; +import { CloseIcon, MenuIcon, MoonIcon, SaveIcon, SettingsIcon, SunIcon, UpdateIcon } from "./icons"; import { Splitter } from "./Splitter"; -import { UpdateBadge } from "./UpdateBadge"; +import { SettingsDialog, DEFAULT_TITLE } from "./SettingsDialog"; +import { pendingVersion, useUpdateStatus } from "./update"; import { storeThumb } from "./thumbs"; type Theme = "light" | "dark"; @@ -87,8 +87,10 @@ export default function App() { 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 [settings, setSettings] = useState({ title: "", logo: "", channel: "latest" }); + const updateStatus = useUpdateStatus(); + const pending = pendingVersion(updateStatus, settings.channel); + const [customizing, setCustomizing] = useState(false); const excalidraw = useRef(null); const pathRef = useRef(null); // schéma ouvert (survit aux renommages) @@ -219,9 +221,12 @@ export default function App() { openRef.current = openFile; - const onApi = useCallback((ex: ExcalidrawImperativeAPI) => { + const onApi = useCallback((ex: ExcalidrawImperativeAPI | null) => { excalidraw.current = ex; }, []); + // La prop s'appelle excalidrawAPI jusqu'à la 0.18 et onExcalidrawAPI sur le canal + // next : on passe les deux pour que l'image se construise avec l'un ou l'autre. + const apiProps = { excalidrawAPI: onApi, onExcalidrawAPI: onApi } as object; // --- Effets globaux -------------------------------------------------------- @@ -347,7 +352,7 @@ export default function App() { > - @@ -384,7 +389,16 @@ export default function App() { )}
- + {pending && ( + + )} @@ -430,7 +444,7 @@ export default function App() { // Remontage complet à chaque ouverture : historique et état propres. key={scene.id} initialData={{ ...scene.initialData, libraryItems: library }} - excalidrawAPI={onApi} + {...apiProps} onChange={onChange} onLibraryChange={onLibraryChange} theme={theme} @@ -448,7 +462,13 @@ export default function App() { {customizing && ( - setCustomizing(false)} /> + setCustomizing(false)} + /> )}
); diff --git a/frontend/src/CustomizeDialog.tsx b/frontend/src/CustomizeDialog.tsx deleted file mode 100644 index 7933d22..0000000 --- a/frontend/src/CustomizeDialog.tsx +++ /dev/null @@ -1,125 +0,0 @@ -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/SettingsDialog.tsx b/frontend/src/SettingsDialog.tsx new file mode 100644 index 0000000..d8c4b79 --- /dev/null +++ b/frontend/src/SettingsDialog.tsx @@ -0,0 +1,210 @@ +import { useEffect, useRef, useState } from "react"; +import { api, type Channel, type Settings, type UpdateStatus } from "./api"; +import { CopyIcon } from "./icons"; +import { INSTALLED, pendingVersion, rebuildCommand } from "./update"; + +export const DEFAULT_TITLE = "Excalidraw"; +const MAX_TITLE = 80; // voir storage.MaxTitleLen + +interface Props { + settings: Settings; + update: UpdateStatus | null; + /** Ouvert depuis l'icône de mise à jour : affiche directement la section Excalidraw. */ + focusExcalidraw?: boolean; + onChange: (s: Settings) => void; + onClose: () => void; +} + +const CHANNELS: { id: Channel; label: string; hint: string }[] = [ + { id: "latest", label: "Stable", hint: "latest : versions publiées officiellement (recommandé)." }, + { + id: "next", + label: "Préversion", + hint: "next : construite à chaque modification de la branche principale d'Excalidraw. Nouveautés en avance, stabilité non garantie. Son numéro (0.18.0-) est inférieur à la dernière stable alors que le code est plus récent.", + }, +]; + +/** Paramètres partagés par tous les utilisateurs : titre, logo, version d'Excalidraw. */ +export function SettingsDialog({ settings, update, focusExcalidraw, onChange, onClose }: Props) { + const [title, setTitle] = useState(settings.title); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + const fileInput = useRef(null); + const exSection = useRef(null); + + useEffect(() => { + if (focusExcalidraw) exSection.current?.scrollIntoView({ block: "start" }); + }, [focusExcalidraw]); + + 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; + const pending = pendingVersion(update, settings.channel); + const cmd = pending ? rebuildCommand(pending) : null; + const copy = () => + cmd && + navigator.clipboard?.writeText(cmd).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + + return ( +
+
e.stopPropagation()} + onSubmit={(e) => { + e.preventDefault(); + run(() => api.setTitle(title), true); + }} + > +

Paramètres

+ +

Apparence

+ + +
+ 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 = ""; + }} + /> +
+
+ +
+

Excalidraw

+ Version à suivre +
+ {CHANNELS.map((c) => ( + + ))} +
+ {CHANNELS.find((c) => c.id === settings.channel)?.hint} +

+ Version installée : {INSTALLED} +
+ {!update?.enabled + ? "Vérification des mises à jour désactivée (UPDATE_CHECK=false)." + : !update.tags && !update.latest + ? "Registre npm injoignable pour l'instant : versions publiées inconnues." + : pending + ? <>À installer : {pending} + : "À jour pour ce canal."} +

+ {cmd && ( + <> + + Excalidraw est compilé dans l'image : changer de canal ou de version ne prend effet + qu'après reconstruction, depuis le dossier du docker-compose : + +
+ {cmd} + +
+ + {copied + ? "Commande copiée." + : <>Pour conserver ce choix aux reconstructions suivantes, mettre EXCALIDRAW_VERSION={pending} dans .env. Tester l'image avant la mise en service : les versions 0.x peuvent changer d'API.} + + + )} +
+ + {error &&
{error}
} + +
+ + Notes de version + + + + +
+
+
+ ); +} diff --git a/frontend/src/UpdateBadge.tsx b/frontend/src/UpdateBadge.tsx deleted file mode 100644 index 4add6c7..0000000 --- a/frontend/src/UpdateBadge.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { useEffect, useState } from "react"; -import { api } from "./api"; -import { CopyIcon, UpdateIcon } from "./icons"; - -const CURRENT = __EXCALIDRAW_VERSION__; -const POLL_MS = 60 * 60 * 1000; // le serveur, lui, interroge npm toutes les 12 h par défaut - -/** Compare deux versions x.y.z (suffixe de pré-version ignoré) : > 0 si a est plus récente. */ -export function compareVersions(a: string, b: string): number { - const parse = (v: string) => v.split("-")[0].split(".").map((n) => parseInt(n, 10) || 0); - const [pa, pb] = [parse(a), parse(b)]; - for (let i = 0; i < Math.max(pa.length, pb.length); i++) { - const d = (pa[i] ?? 0) - (pb[i] ?? 0); - if (d) return d; - } - return 0; -} - -/** - * Icône affichée dans la barre du haut quand une version d'Excalidraw plus récente - * que celle embarquée est publiée ; un clic donne la commande de reconstruction. - */ -export function UpdateBadge() { - const [latest, setLatest] = useState(null); - const [open, setOpen] = useState(false); - const [copied, setCopied] = useState(false); - - useEffect(() => { - const check = () => - api - .update() - .then((s) => setLatest(s.latest && compareVersions(s.latest, CURRENT) > 0 ? s.latest : null)) - .catch(() => {}); // silencieux : vérification facultative - check(); - const t = setInterval(check, POLL_MS); - return () => clearInterval(t); - }, []); - - if (!latest) return null; - - const cmd = `EXCALIDRAW_VERSION=${latest} docker compose up -d --build`; - const copy = () => - navigator.clipboard?.writeText(cmd).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }); - - return ( - <> - - {open && ( -
setOpen(false)}> -
e.stopPropagation()} - > -

Mise à jour d'Excalidraw disponible

-

- Version installée : {CURRENT} -
- Dernière version publiée : {latest} -

-

Reconstruire et relancer le conteneur depuis le dossier du docker-compose :

-
- {cmd} - -
-

- {copied - ? "Commande copiée." - : "Les versions 0.x d'Excalidraw peuvent changer d'API : tester l'image avant la mise en service. Pour figer la version dans le dépôt, voir « Mettre à jour Excalidraw » dans le README."} -

-
- - - Notes de version - - -
-
-
- )} - - ); -} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 4c3ef6b..7c1f76d 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -14,15 +14,20 @@ export interface MoveResult { error?: string; } -/** Personnalisation partagée ; logo = "" sans logo, sinon sa version. */ +/** Canal npm d'Excalidraw : latest (stable) ou next (préversion). */ +export type Channel = "latest" | "next"; + +/** Paramètres partagés ; logo = "" sans logo, sinon sa version. */ export interface Settings { title: string; logo: string; + channel: Channel; } -/** Dernière version d'Excalidraw publiée sur npm (vide si inconnue ou désactivé). */ +/** Versions d'Excalidraw publiées sur npm par canal (vide si inconnu ou désactivé). */ export interface UpdateStatus { enabled: boolean; + tags?: Partial>; latest?: string; checkedAt?: string; } @@ -108,6 +113,9 @@ export const api = { setTitle: async (title: string): Promise => (await request("/api/settings", { ...json({ title }), method: "PUT" })).json(), + setChannel: async (channel: Channel): Promise => + (await request("/api/settings", { ...json({ channel }), method: "PUT" })).json(), + /** Le serveur recadre au centre et ramène le PNG à 64x64. */ uploadLogo: async (png: Blob): Promise => ( diff --git a/frontend/src/icons.tsx b/frontend/src/icons.tsx index ad6a1ac..df79f7f 100644 --- a/frontend/src/icons.tsx +++ b/frontend/src/icons.tsx @@ -31,8 +31,8 @@ export const MoonIcon = icon(); export const SettingsIcon = icon( <> - - + + , ); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 98cd71d..24b67a8 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -929,14 +929,6 @@ li.active > .row { background: var(--warn); box-shadow: 0 0 0 2px var(--ex-island); } -.modal.update p { - margin: 0; - font-size: 13px; - line-height: 1.5; -} -.modal.update .small { - font-size: 12px; -} .modal .cmd { display: flex; align-items: center; @@ -955,3 +947,61 @@ li.active > .row { color: var(--ex-primary); font-size: 13px; } + +/* Dialogue « Paramètres » */ +.modal.settings { + width: min(480px, 100%); + max-height: min(720px, 92dvh); + overflow-y: auto; +} +.modal h4 { + margin: 6px 0 0; + padding-top: 8px; + border-top: 1px solid var(--border); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.4px; + text-transform: uppercase; + color: var(--muted); +} +.modal h3 + h4 { + border-top: none; + padding-top: 0; +} +.modal .versions { + margin: 0; + font-size: 13px; + line-height: 1.5; +} +.modal small code { + font-size: 11px; +} +/* Choix du canal : RadioGroup d'Excalidraw. */ +.radio-group { + display: flex; + gap: 2px; + padding: 2px; + border: 1px solid var(--ex-surface-high); + border-radius: 0.625rem; + background: var(--ex-island); + align-self: flex-start; +} +.radio-group button { + flex-direction: column; + gap: 2px; + height: auto; + min-width: 110px; + padding: 6px 12px; + background: transparent; + box-shadow: none; + font-size: 13px; +} +.radio-group button small { + font-size: 11px; + letter-spacing: normal; + opacity: 0.75; +} +.radio-group button.on { + background: var(--ex-primary); + color: var(--ex-surface-lowest); +} diff --git a/frontend/src/update.ts b/frontend/src/update.ts new file mode 100644 index 0000000..fd8dbc9 --- /dev/null +++ b/frontend/src/update.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from "react"; +import { api, type Channel, type UpdateStatus } from "./api"; + +/** Version d'Excalidraw compilée dans cette image. */ +export const INSTALLED = __EXCALIDRAW_VERSION__; + +const POLL_MS = 60 * 60 * 1000; // le serveur, lui, interroge npm toutes les 12 h par défaut + +export const rebuildCommand = (version: string) => `EXCALIDRAW_VERSION=${version} docker compose up -d --build`; + +/** + * Version publiée sur le canal choisi, si elle diffère de celle installée. + * Simple comparaison d'égalité : sur le canal next, les numéros (0.18.0-) + * sont inférieurs à la dernière stable alors que le code est plus récent, et + * revenir de next à latest doit aussi proposer une reconstruction. + */ +export const pendingVersion = (status: UpdateStatus | null, channel: Channel) => { + const v = status?.tags?.[channel] ?? (channel === "latest" ? status?.latest : undefined); + return v && v !== INSTALLED ? v : null; +}; + +/** Versions publiées sur npm, relues périodiquement (silencieux en cas d'erreur). */ +export function useUpdateStatus() { + const [status, setStatus] = useState(null); + useEffect(() => { + const check = () => + api + .update() + .then(setStatus) + .catch(() => {}); // vérification facultative + check(); + const t = setInterval(check, POLL_MS); + return () => clearInterval(t); + }, []); + return status; +}