Paramètres : choix du canal d'Excalidraw (stable ou préversion)
- 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019EuvvuTv7AUc8ksDHv8VKx
This commit is contained in:
1 parent
7da52b6755
commit
88f7750577
9 files changed
+361
-255
No files matched your search
@@ -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.
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
### 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
|
```bash
|
||||||
EXCALIDRAW_VERSION=0.18.2 docker compose up -d --build # ou EXCALIDRAW_VERSION=… dans .env
|
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` |
|
| POST | `/api/copy` | `{"from":"…","to":"…"}` | `Entry` |
|
||||||
| DELETE | `/api/entry?path=x[&recursive=true]` | | `204` (déplacé dans `.trash/`) |
|
| DELETE | `/api/entry?path=x[&recursive=true]` | | `204` (déplacé dans `.trash/`) |
|
||||||
| GET/PUT | `/api/library` | bibliothèque `excalidrawlib` | |
|
| GET/PUT | `/api/library` | bibliothèque `excalidrawlib` | |
|
||||||
| GET | `/api/settings` | | `{"title":"...","logo":"<version>"}` (`logo` vide sans logo) |
|
| GET | `/api/settings` | | `{"title":"...","logo":"<version>","channel":"latest"}` (`logo` vide sans logo) |
|
||||||
| PUT | `/api/settings` | `{"title":"..."}` (80 caractères max, vide = titre par défaut) | réglages |
|
| PUT | `/api/settings` | `{"title":"..."}` (80 caractères max, vide = titre par défaut) et/ou `{"channel":"latest"\|"next"}` | réglages |
|
||||||
| GET | `/api/logo?v=<version>` | | PNG 64×64 ; `404` sans logo |
|
| 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 |
|
| PUT | `/api/logo` | PNG (5 Mo, 8192×8192 px max) | réglages ; image recadrée et ré-encodée |
|
||||||
| DELETE | `/api/logo` | | réglages |
|
| 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) |
|
| GET | `/healthz` | | `ok` (hors authentification) |
|
||||||
|
|
||||||
`Entry` = `{"name","path","isDir","size","modTime"}`.
|
`Entry` = `{"name","path","isDir","size","modTime"}`.
|
||||||
|
|||||||
+33
-13
@@ -15,11 +15,11 @@ import type {
|
|||||||
} from "@excalidraw/excalidraw/types";
|
} from "@excalidraw/excalidraw/types";
|
||||||
import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
|
import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
|
||||||
import { api, ApiError, basename, stripExt, type Settings } from "./api";
|
import { api, ApiError, basename, stripExt, type Settings } from "./api";
|
||||||
import { CustomizeDialog, DEFAULT_TITLE } from "./CustomizeDialog";
|
|
||||||
import { Explorer } from "./Explorer";
|
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 { Splitter } from "./Splitter";
|
||||||
import { UpdateBadge } from "./UpdateBadge";
|
import { SettingsDialog, DEFAULT_TITLE } from "./SettingsDialog";
|
||||||
|
import { pendingVersion, useUpdateStatus } from "./update";
|
||||||
import { storeThumb } from "./thumbs";
|
import { storeThumb } from "./thumbs";
|
||||||
|
|
||||||
type Theme = "light" | "dark";
|
type Theme = "light" | "dark";
|
||||||
@@ -87,8 +87,10 @@ export default function App() {
|
|||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const [drawerWidth, setDrawerWidth] = useState(initialDrawerWidth);
|
const [drawerWidth, setDrawerWidth] = useState(initialDrawerWidth);
|
||||||
const [resizing, setResizing] = useState(false);
|
const [resizing, setResizing] = useState(false);
|
||||||
const [settings, setSettings] = useState<Settings>({ title: "", logo: "" });
|
const [settings, setSettings] = useState<Settings>({ title: "", logo: "", channel: "latest" });
|
||||||
const [customizing, setCustomizing] = useState(false);
|
const updateStatus = useUpdateStatus();
|
||||||
|
const pending = pendingVersion(updateStatus, settings.channel);
|
||||||
|
const [customizing, setCustomizing] = useState<false | "all" | "excalidraw">(false);
|
||||||
|
|
||||||
const excalidraw = useRef<ExcalidrawImperativeAPI | null>(null);
|
const excalidraw = useRef<ExcalidrawImperativeAPI | null>(null);
|
||||||
const pathRef = useRef<string | null>(null); // schéma ouvert (survit aux renommages)
|
const pathRef = useRef<string | null>(null); // schéma ouvert (survit aux renommages)
|
||||||
@@ -219,9 +221,12 @@ export default function App() {
|
|||||||
|
|
||||||
openRef.current = openFile;
|
openRef.current = openFile;
|
||||||
|
|
||||||
const onApi = useCallback((ex: ExcalidrawImperativeAPI) => {
|
const onApi = useCallback((ex: ExcalidrawImperativeAPI | null) => {
|
||||||
excalidraw.current = ex;
|
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 --------------------------------------------------------
|
// --- Effets globaux --------------------------------------------------------
|
||||||
|
|
||||||
@@ -347,7 +352,7 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
<MenuIcon />
|
<MenuIcon />
|
||||||
</button>
|
</button>
|
||||||
<button className="brand" title="Personnaliser le titre et le logo" onClick={() => setCustomizing(true)}>
|
<button className="brand" title="Paramètres" onClick={() => setCustomizing("all")}>
|
||||||
{settings.logo && <img className="logo" src={api.logoUrl(settings.logo)} alt="" />}
|
{settings.logo && <img className="logo" src={api.logoUrl(settings.logo)} alt="" />}
|
||||||
<span className="brand-title">{appTitle}</span>
|
<span className="brand-title">{appTitle}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -384,7 +389,16 @@ export default function App() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="topbar-tools">
|
<div className="topbar-tools">
|
||||||
<UpdateBadge />
|
{pending && (
|
||||||
|
<button
|
||||||
|
className="icon-btn update-btn"
|
||||||
|
title={`Excalidraw ${pending} disponible (installée : ${__EXCALIDRAW_VERSION__}) — reconstruire l'image`}
|
||||||
|
aria-label={`Mise à jour d'Excalidraw disponible : ${pending}`}
|
||||||
|
onClick={() => setCustomizing("excalidraw")}
|
||||||
|
>
|
||||||
|
<UpdateIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="icon-btn"
|
className="icon-btn"
|
||||||
aria-label={theme === "dark" ? "Passer en mode clair" : "Passer en mode sombre"}
|
aria-label={theme === "dark" ? "Passer en mode clair" : "Passer en mode sombre"}
|
||||||
@@ -395,9 +409,9 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="icon-btn"
|
className="icon-btn"
|
||||||
aria-label="Personnaliser le titre et le logo"
|
aria-label="Paramètres"
|
||||||
title="Personnaliser"
|
title="Paramètres (titre, logo, version d'Excalidraw)"
|
||||||
onClick={() => setCustomizing(true)}
|
onClick={() => setCustomizing("all")}
|
||||||
>
|
>
|
||||||
<SettingsIcon />
|
<SettingsIcon />
|
||||||
</button>
|
</button>
|
||||||
@@ -430,7 +444,7 @@ export default function App() {
|
|||||||
// Remontage complet à chaque ouverture : historique et état propres.
|
// Remontage complet à chaque ouverture : historique et état propres.
|
||||||
key={scene.id}
|
key={scene.id}
|
||||||
initialData={{ ...scene.initialData, libraryItems: library }}
|
initialData={{ ...scene.initialData, libraryItems: library }}
|
||||||
excalidrawAPI={onApi}
|
{...apiProps}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onLibraryChange={onLibraryChange}
|
onLibraryChange={onLibraryChange}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
@@ -448,7 +462,13 @@ export default function App() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
{customizing && (
|
{customizing && (
|
||||||
<CustomizeDialog settings={settings} onChange={setSettings} onClose={() => setCustomizing(false)} />
|
<SettingsDialog
|
||||||
|
settings={settings}
|
||||||
|
update={updateStatus}
|
||||||
|
focusExcalidraw={customizing === "excalidraw"}
|
||||||
|
onChange={setSettings}
|
||||||
|
onClose={() => setCustomizing(false)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<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="text-input"
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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-<commit>) 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<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const fileInput = useRef<HTMLInputElement>(null);
|
||||||
|
const exSection = useRef<HTMLElement>(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<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;
|
||||||
|
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 (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<form
|
||||||
|
className="modal customize settings"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Paramètres"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
run(() => api.setTitle(title), true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3>Paramètres</h3>
|
||||||
|
|
||||||
|
<h4>Apparence</h4>
|
||||||
|
<label className="field">
|
||||||
|
<span>Titre affiché</span>
|
||||||
|
<input
|
||||||
|
className="text-input"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<section className="field" ref={exSection}>
|
||||||
|
<h4>Excalidraw</h4>
|
||||||
|
<span>Version à suivre</span>
|
||||||
|
<div className="radio-group" role="radiogroup" aria-label="Canal d'Excalidraw">
|
||||||
|
{CHANNELS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={settings.channel === c.id}
|
||||||
|
className={settings.channel === c.id ? "on" : ""}
|
||||||
|
disabled={busy}
|
||||||
|
title={c.hint}
|
||||||
|
onClick={() => settings.channel !== c.id && run(() => api.setChannel(c.id))}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
<small>{update?.tags?.[c.id] ?? "?"}</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<small className="muted">{CHANNELS.find((c) => c.id === settings.channel)?.hint}</small>
|
||||||
|
<p className="versions">
|
||||||
|
Version installée : <b>{INSTALLED}</b>
|
||||||
|
<br />
|
||||||
|
{!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 : <b>{pending}</b></>
|
||||||
|
: "À jour pour ce canal."}
|
||||||
|
</p>
|
||||||
|
{cmd && (
|
||||||
|
<>
|
||||||
|
<small className="muted">
|
||||||
|
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 :
|
||||||
|
</small>
|
||||||
|
<div className="cmd">
|
||||||
|
<code>{cmd}</code>
|
||||||
|
<button type="button" className="icon-btn" onClick={copy} title="Copier" aria-label="Copier la commande">
|
||||||
|
<CopyIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small className="muted">
|
||||||
|
{copied
|
||||||
|
? "Commande copiée."
|
||||||
|
: <>Pour conserver ce choix aux reconstructions suivantes, mettre <code>EXCALIDRAW_VERSION={pending}</code> dans <code>.env</code>. Tester l'image avant la mise en service : les versions 0.x peuvent changer d'API.</>}
|
||||||
|
</small>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="modal-actions">
|
||||||
|
<a className="link" href="https://github.com/excalidraw/excalidraw/releases" target="_blank" rel="noreferrer">
|
||||||
|
Notes de version
|
||||||
|
</a>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(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 (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className="icon-btn update-btn"
|
|
||||||
title={`Excalidraw ${latest} disponible (installée : ${CURRENT}) — reconstruire l'image`}
|
|
||||||
aria-label={`Mise à jour d'Excalidraw disponible : ${latest}`}
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
>
|
|
||||||
<UpdateIcon />
|
|
||||||
</button>
|
|
||||||
{open && (
|
|
||||||
<div className="modal-backdrop" onClick={() => setOpen(false)}>
|
|
||||||
<div
|
|
||||||
className="modal update"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label="Mise à jour d'Excalidraw"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<h3>Mise à jour d'Excalidraw disponible</h3>
|
|
||||||
<p>
|
|
||||||
Version installée : <b>{CURRENT}</b>
|
|
||||||
<br />
|
|
||||||
Dernière version publiée : <b>{latest}</b>
|
|
||||||
</p>
|
|
||||||
<p>Reconstruire et relancer le conteneur depuis le dossier du docker-compose :</p>
|
|
||||||
<div className="cmd">
|
|
||||||
<code>{cmd}</code>
|
|
||||||
<button className="icon-btn" onClick={copy} title="Copier" aria-label="Copier la commande">
|
|
||||||
<CopyIcon />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="muted small">
|
|
||||||
{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."}
|
|
||||||
</p>
|
|
||||||
<div className="modal-actions">
|
|
||||||
<span className="spacer" />
|
|
||||||
<a className="link" href={`https://github.com/excalidraw/excalidraw/releases`} target="_blank" rel="noreferrer">
|
|
||||||
Notes de version
|
|
||||||
</a>
|
|
||||||
<button className="primary" onClick={() => setOpen(false)}>
|
|
||||||
Fermer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+10
-2
@@ -14,15 +14,20 @@ export interface MoveResult {
|
|||||||
error?: string;
|
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 {
|
export interface Settings {
|
||||||
title: string;
|
title: string;
|
||||||
logo: 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 {
|
export interface UpdateStatus {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
tags?: Partial<Record<Channel, string>>;
|
||||||
latest?: string;
|
latest?: string;
|
||||||
checkedAt?: string;
|
checkedAt?: string;
|
||||||
}
|
}
|
||||||
@@ -108,6 +113,9 @@ export const api = {
|
|||||||
setTitle: async (title: string): Promise<Settings> =>
|
setTitle: async (title: string): Promise<Settings> =>
|
||||||
(await request("/api/settings", { ...json({ title }), method: "PUT" })).json(),
|
(await request("/api/settings", { ...json({ title }), method: "PUT" })).json(),
|
||||||
|
|
||||||
|
setChannel: async (channel: Channel): Promise<Settings> =>
|
||||||
|
(await request("/api/settings", { ...json({ channel }), method: "PUT" })).json(),
|
||||||
|
|
||||||
/** Le serveur recadre au centre et ramène le PNG à 64x64. */
|
/** Le serveur recadre au centre et ramène le PNG à 64x64. */
|
||||||
uploadLogo: async (png: Blob): Promise<Settings> =>
|
uploadLogo: async (png: Blob): Promise<Settings> =>
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ export const MoonIcon = icon(<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z" />);
|
|||||||
|
|
||||||
export const SettingsIcon = icon(
|
export const SettingsIcon = icon(
|
||||||
<>
|
<>
|
||||||
<path d="M4 20h4L18.5 9.5a2.83 2.83 0 0 0-4-4L4 16v4" />
|
<path d="M10.33 4.32c.43-1.76 2.93-1.76 3.35 0a1.72 1.72 0 0 0 2.57 1.07c1.54-.94 3.31.83 2.37 2.37a1.72 1.72 0 0 0 1.07 2.57c1.76.43 1.76 2.93 0 3.35a1.72 1.72 0 0 0-1.07 2.57c.94 1.54-.83 3.31-2.37 2.37a1.72 1.72 0 0 0-2.57 1.07c-.43 1.76-2.93 1.76-3.35 0a1.72 1.72 0 0 0-2.57-1.07c-1.54.94-3.31-.83-2.37-2.37a1.72 1.72 0 0 0-1.07-2.57c-1.76-.43-1.76-2.93 0-3.35a1.72 1.72 0 0 0 1.07-2.57c-.94-1.54.83-3.31 2.37-2.37 1 .61 2.3.07 2.57-1.07z" />
|
||||||
<path d="M13.5 6.5l4 4" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
</>,
|
</>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+58
-8
@@ -929,14 +929,6 @@ li.active > .row {
|
|||||||
background: var(--warn);
|
background: var(--warn);
|
||||||
box-shadow: 0 0 0 2px var(--ex-island);
|
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 {
|
.modal .cmd {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -955,3 +947,61 @@ li.active > .row {
|
|||||||
color: var(--ex-primary);
|
color: var(--ex-primary);
|
||||||
font-size: 13px;
|
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);
|
||||||
|
}
|
||||||
@@ -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-<commit>)
|
||||||
|
* 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<UpdateStatus | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const check = () =>
|
||||||
|
api
|
||||||
|
.update()
|
||||||
|
.then(setStatus)
|
||||||
|
.catch(() => {}); // vérification facultative
|
||||||
|
check();
|
||||||
|
const t = setInterval(check, POLL_MS);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, []);
|
||||||
|
return status;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user