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
+33
-13
@@ -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<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 [settings, setSettings] = useState<Settings>({ title: "", logo: "", channel: "latest" });
|
||||
const updateStatus = useUpdateStatus();
|
||||
const pending = pendingVersion(updateStatus, settings.channel);
|
||||
const [customizing, setCustomizing] = useState<false | "all" | "excalidraw">(false);
|
||||
|
||||
const excalidraw = useRef<ExcalidrawImperativeAPI | null>(null);
|
||||
const pathRef = useRef<string | null>(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() {
|
||||
>
|
||||
<MenuIcon />
|
||||
</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="" />}
|
||||
<span className="brand-title">{appTitle}</span>
|
||||
</button>
|
||||
@@ -384,7 +389,16 @@ export default function App() {
|
||||
</>
|
||||
)}
|
||||
<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
|
||||
className="icon-btn"
|
||||
aria-label={theme === "dark" ? "Passer en mode clair" : "Passer en mode sombre"}
|
||||
@@ -395,9 +409,9 @@ export default function App() {
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn"
|
||||
aria-label="Personnaliser le titre et le logo"
|
||||
title="Personnaliser"
|
||||
onClick={() => setCustomizing(true)}
|
||||
aria-label="Paramètres"
|
||||
title="Paramètres (titre, logo, version d'Excalidraw)"
|
||||
onClick={() => setCustomizing("all")}
|
||||
>
|
||||
<SettingsIcon />
|
||||
</button>
|
||||
@@ -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() {
|
||||
</main>
|
||||
|
||||
{customizing && (
|
||||
<CustomizeDialog settings={settings} onChange={setSettings} onClose={() => setCustomizing(false)} />
|
||||
<SettingsDialog
|
||||
settings={settings}
|
||||
update={updateStatus}
|
||||
focusExcalidraw={customizing === "excalidraw"}
|
||||
onChange={setSettings}
|
||||
onClose={() => setCustomizing(false)}
|
||||
/>
|
||||
)}
|
||||
</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;
|
||||
}
|
||||
|
||||
/** 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<Record<Channel, string>>;
|
||||
latest?: string;
|
||||
checkedAt?: string;
|
||||
}
|
||||
@@ -108,6 +113,9 @@ export const api = {
|
||||
setTitle: async (title: string): Promise<Settings> =>
|
||||
(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. */
|
||||
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(
|
||||
<>
|
||||
<path d="M4 20h4L18.5 9.5a2.83 2.83 0 0 0-4-4L4 16v4" />
|
||||
<path d="M13.5 6.5l4 4" />
|
||||
<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" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</>,
|
||||
);
|
||||
|
||||
|
||||
+58
-8
@@ -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);
|
||||
}
|
||||
@@ -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