Barre de titre : mode clair/sombre, titre et logo ; explorateur redimensionnable #3
No files matched your search
@@ -18,6 +18,7 @@ Navigateur ──HTTP──▶ conteneur excalidraw-go (:8080, uid 65532, rootfs
|
|||||||
volume /data
|
volume /data
|
||||||
├─ projets/réseau/sdn.excalidraw ← fichiers JSON Excalidraw standards
|
├─ projets/réseau/sdn.excalidraw ← fichiers JSON Excalidraw standards
|
||||||
├─ .library.excalidrawlib ← bibliothèque partagée
|
├─ .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)
|
├─ .thumbs/projets/réseau/sdn.excalidraw.png ← miniatures (régénérables)
|
||||||
└─ .trash/<horodatage>/... ← éléments supprimés
|
└─ .trash/<horodatage>/... ← éléments supprimés
|
||||||
```
|
```
|
||||||
@@ -52,6 +53,9 @@ Bind mount au lieu du volume nommé : `- ./data:/data` puis `chown -R 65532:6553
|
|||||||
- **Sauvegarde** : automatique 1,5 s après la dernière modification, immédiate avec `Ctrl/Cmd+S`. Le statut (Modifié / Enregistrement… / Enregistré) est affiché dans la barre du haut.
|
- **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.
|
- **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.
|
- **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
|
### Miniatures
|
||||||
|
|
||||||
@@ -84,6 +88,11 @@ 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) |
|
||||||
|
| PUT | `/api/settings` | `{"title":"..."}` (80 caractères max, vide = titre par défaut) | réglages |
|
||||||
|
| GET | `/api/logo?v=<version>` | | PNG 64×64 ; `404` sans logo |
|
||||||
|
| PUT | `/api/logo` | PNG (5 Mo, 8192×8192 px max) | réglages ; image recadrée et ré-encodée |
|
||||||
|
| DELETE | `/api/logo` | | réglages |
|
||||||
| GET | `/healthz` | | `ok` (hors authentification) |
|
| GET | `/healthz` | | `ok` (hors authentification) |
|
||||||
|
|
||||||
`Entry` = `{"name","path","isDir","size","modTime"}`.
|
`Entry` = `{"name","path","isDir","size","modTime"}`.
|
||||||
|
|||||||
+109
-15
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { type CSSProperties, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
hashElementsVersion,
|
hashElementsVersion,
|
||||||
@@ -14,8 +14,11 @@ import type {
|
|||||||
LibraryItems,
|
LibraryItems,
|
||||||
} 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 } 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 { MoonIcon, SettingsIcon, SunIcon } from "./icons";
|
||||||
|
import { Splitter } from "./Splitter";
|
||||||
import { storeThumb } from "./thumbs";
|
import { storeThumb } from "./thumbs";
|
||||||
|
|
||||||
type Theme = "light" | "dark";
|
type Theme = "light" | "dark";
|
||||||
@@ -31,16 +34,34 @@ const AUTOSAVE_MS = 1500;
|
|||||||
const isMobile = () => window.matchMedia("(max-width: 768px)").matches;
|
const isMobile = () => window.matchMedia("(max-width: 768px)").matches;
|
||||||
const pathFromHash = () => decodeURIComponent(location.hash.replace(/^#\/?/, "")) || null;
|
const pathFromHash = () => decodeURIComponent(location.hash.replace(/^#\/?/, "")) || null;
|
||||||
|
|
||||||
const initialTheme = (): Theme => {
|
const load = (key: string) => {
|
||||||
try {
|
try {
|
||||||
const t = localStorage.getItem("theme");
|
return localStorage.getItem(key);
|
||||||
if (t === "light" || t === "dark") return t;
|
} catch {
|
||||||
|
return null; // stockage indisponible
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const store = (key: string, value: string) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
} catch {
|
} catch {
|
||||||
/* stockage indisponible */
|
/* 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";
|
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...). */
|
/** Empreinte du contenu « persistant » (hors scroll, sélection, zoom...). */
|
||||||
const sceneVersion = (
|
const sceneVersion = (
|
||||||
elements: readonly OrderedExcalidrawElement[],
|
elements: readonly OrderedExcalidrawElement[],
|
||||||
@@ -63,6 +84,10 @@ export default function App() {
|
|||||||
const [drawerOpen, setDrawerOpen] = useState(() => !isMobile());
|
const [drawerOpen, setDrawerOpen] = useState(() => !isMobile());
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const [drawerWidth, setDrawerWidth] = useState(initialDrawerWidth);
|
||||||
|
const [resizing, setResizing] = useState(false);
|
||||||
|
const [settings, setSettings] = useState<Settings>({ title: "", logo: "" });
|
||||||
|
const [customizing, setCustomizing] = useState(false);
|
||||||
|
|
||||||
const excalidraw = useRef<ExcalidrawImperativeAPI | null>(null);
|
const 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)
|
||||||
@@ -73,6 +98,7 @@ export default function App() {
|
|||||||
const libTimer = useRef<number>(undefined);
|
const libTimer = useRef<number>(undefined);
|
||||||
const libJson = useRef(""); // dernier état connu côté serveur
|
const libJson = useRef(""); // dernier état connu côté serveur
|
||||||
const sceneSeq = useRef(0);
|
const sceneSeq = useRef(0);
|
||||||
|
const exTheme = useRef<string | null>(null); // dernier thème signalé par Excalidraw
|
||||||
const openRef = useRef<(path: string, opts?: { skipFlush?: boolean }) => Promise<void>>(async () => {});
|
const openRef = useRef<(path: string, opts?: { skipFlush?: boolean }) => Promise<void>>(async () => {});
|
||||||
|
|
||||||
// --- Sauvegarde -----------------------------------------------------------
|
// --- Sauvegarde -----------------------------------------------------------
|
||||||
@@ -128,7 +154,13 @@ export default function App() {
|
|||||||
|
|
||||||
const onChange = useCallback(
|
const onChange = useCallback(
|
||||||
(elements: readonly OrderedExcalidrawElement[], appState: AppState, files: BinaryFiles) => {
|
(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;
|
if (!pathRef.current || appState.isLoading) return;
|
||||||
const version = sceneVersion(elements, appState, files);
|
const version = sceneVersion(elements, appState, files);
|
||||||
// Premier onChange après chargement : c'est la version de référence.
|
// Premier onChange après chargement : c'est la version de référence.
|
||||||
@@ -251,13 +283,31 @@ export default function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.dataset.theme = theme;
|
document.documentElement.dataset.theme = theme;
|
||||||
try {
|
store("theme", theme);
|
||||||
localStorage.setItem("theme", theme);
|
|
||||||
} catch {
|
|
||||||
/* stockage indisponible */
|
|
||||||
}
|
|
||||||
}, [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 -----------------------------------
|
// --- Synchronisation avec l'explorateur -----------------------------------
|
||||||
|
|
||||||
const onMoved = useCallback((from: string, to: string) => {
|
const onMoved = useCallback((from: string, to: string) => {
|
||||||
@@ -282,7 +332,10 @@ export default function App() {
|
|||||||
// --- Rendu -----------------------------------------------------------------
|
// --- Rendu -----------------------------------------------------------------
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`app ${drawerOpen ? "drawer-open" : ""}`}>
|
<div
|
||||||
|
className={`app ${drawerOpen ? "drawer-open" : ""} ${resizing ? "resizing" : ""} ${scene ? "has-scene" : ""}`}
|
||||||
|
style={{ "--drawer-w": `${drawerWidth}px` } as CSSProperties}
|
||||||
|
>
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<button
|
<button
|
||||||
className="icon-btn"
|
className="icon-btn"
|
||||||
@@ -291,9 +344,20 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
☰
|
☰
|
||||||
</button>
|
</button>
|
||||||
<span className="title" title={scene?.path}>
|
<button className="brand" title="Personnaliser le titre et le logo" onClick={() => setCustomizing(true)}>
|
||||||
{scene ? stripExt(basename(scene.path)) : "Excalidraw"}
|
{settings.logo && <img className="logo" src={api.logoUrl(settings.logo)} alt="" />}
|
||||||
</span>
|
<span className="brand-title">{appTitle}</span>
|
||||||
|
</button>
|
||||||
|
{scene ? (
|
||||||
|
<span className="title" title={scene.path}>
|
||||||
|
<span className="sep muted" aria-hidden>
|
||||||
|
/
|
||||||
|
</span>
|
||||||
|
{sceneName}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="spacer" />
|
||||||
|
)}
|
||||||
{scene && (
|
{scene && (
|
||||||
<>
|
<>
|
||||||
<span className={`status ${saveState}`}>{SAVE_LABEL[saveState]}</span>
|
<span className={`status ${saveState}`}>{SAVE_LABEL[saveState]}</span>
|
||||||
@@ -305,6 +369,24 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<div className="topbar-tools">
|
||||||
|
<button
|
||||||
|
className="icon-btn"
|
||||||
|
aria-label={theme === "dark" ? "Passer en mode clair" : "Passer en mode sombre"}
|
||||||
|
title={theme === "dark" ? "Mode clair" : "Mode sombre"}
|
||||||
|
onClick={() => setTheme((t) => (t === "dark" ? "light" : "dark"))}
|
||||||
|
>
|
||||||
|
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="icon-btn"
|
||||||
|
aria-label="Personnaliser le titre et le logo"
|
||||||
|
title="Personnaliser"
|
||||||
|
onClick={() => setCustomizing(true)}
|
||||||
|
>
|
||||||
|
<SettingsIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<aside className="drawer">
|
<aside className="drawer">
|
||||||
@@ -316,6 +398,14 @@ export default function App() {
|
|||||||
onDeleted={onDeleted}
|
onDeleted={onDeleted}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
<Splitter
|
||||||
|
width={drawerWidth}
|
||||||
|
min={DRAWER_MIN}
|
||||||
|
max={drawerMax()}
|
||||||
|
onResize={(w) => setDrawerWidth(clampDrawer(w))}
|
||||||
|
onDragging={setResizing}
|
||||||
|
onReset={() => setDrawerWidth(clampDrawer(DRAWER_DEFAULT))}
|
||||||
|
/>
|
||||||
<div className="backdrop" onClick={() => setDrawerOpen(false)} />
|
<div className="backdrop" onClick={() => setDrawerOpen(false)} />
|
||||||
|
|
||||||
<main className="canvas">
|
<main className="canvas">
|
||||||
@@ -341,6 +431,10 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{customizing && (
|
||||||
|
<CustomizeDialog settings={settings} onChange={setSettings} onClose={() => setCustomizing(false)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { api, type Settings } from "./api";
|
||||||
|
|
||||||
|
export const DEFAULT_TITLE = "Excalidraw";
|
||||||
|
const MAX_TITLE = 80; // voir storage.MaxTitleLen
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: Settings;
|
||||||
|
onChange: (s: Settings) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Titre et logo de la barre du haut, partagés par tous les utilisateurs. */
|
||||||
|
export function CustomizeDialog({ settings, onChange, onClose }: Props) {
|
||||||
|
const [title, setTitle] = useState(settings.title);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const fileInput = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const run = async (op: () => Promise<Settings>, close = false) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
onChange(await op());
|
||||||
|
if (close) onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFile = (f: File | undefined) => {
|
||||||
|
if (!f) return;
|
||||||
|
if (f.type !== "image/png") {
|
||||||
|
setError("Seuls les fichiers PNG sont acceptés.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
run(() => api.uploadLogo(f));
|
||||||
|
};
|
||||||
|
|
||||||
|
const dirty = title.trim() !== settings.title;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<form
|
||||||
|
className="modal customize"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Personnaliser la barre de titre"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
run(() => api.setTitle(title), true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3>Personnaliser</h3>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>Titre affiché</span>
|
||||||
|
<input
|
||||||
|
className="search"
|
||||||
|
value={title}
|
||||||
|
maxLength={MAX_TITLE}
|
||||||
|
placeholder={DEFAULT_TITLE}
|
||||||
|
autoFocus
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<span>Logo</span>
|
||||||
|
<div className="logo-edit">
|
||||||
|
{settings.logo ? (
|
||||||
|
<img className="logo big" src={api.logoUrl(settings.logo)} alt="Logo actuel" />
|
||||||
|
) : (
|
||||||
|
<span className="logo big placeholder-logo muted" aria-hidden>
|
||||||
|
?
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="logo-actions">
|
||||||
|
<button type="button" disabled={busy} onClick={() => fileInput.current?.click()}>
|
||||||
|
{settings.logo ? "Remplacer…" : "Choisir un PNG…"}
|
||||||
|
</button>
|
||||||
|
{settings.logo && (
|
||||||
|
<button type="button" className="danger" disabled={busy} onClick={() => run(api.removeLogo)}>
|
||||||
|
Retirer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<small className="muted">PNG, recadré au centre et réduit à 64×64 px, affiché en rond.</small>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInput}
|
||||||
|
type="file"
|
||||||
|
accept="image/png"
|
||||||
|
hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
onFile(e.target.files?.[0]);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="modal-actions">
|
||||||
|
<span className="spacer" />
|
||||||
|
<button type="button" onClick={onClose}>
|
||||||
|
Fermer
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="primary" disabled={busy || !dirty}>
|
||||||
|
Enregistrer le titre
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
width: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
onResize: (width: number) => void;
|
||||||
|
/** Début / fin de glissement (désactive l'animation de la grille). */
|
||||||
|
onDragging: (dragging: boolean) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Poignée verticale entre l'explorateur et le canevas : souris, doigt ou clavier. */
|
||||||
|
export function Splitter({ width, min, max, onResize, onDragging, onReset }: Props) {
|
||||||
|
const start = useRef<{ x: number; w: number } | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="splitter"
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label="Largeur de l'explorateur"
|
||||||
|
aria-valuenow={width}
|
||||||
|
aria-valuemin={min}
|
||||||
|
aria-valuemax={max}
|
||||||
|
tabIndex={0}
|
||||||
|
title="Glisser pour redimensionner, double-clic pour la largeur par défaut"
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
start.current = { x: e.clientX, w: width };
|
||||||
|
onDragging(true);
|
||||||
|
}}
|
||||||
|
onPointerMove={(e) => {
|
||||||
|
if (start.current) onResize(start.current.w + e.clientX - start.current.x);
|
||||||
|
}}
|
||||||
|
onPointerUp={(e) => {
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||||
|
start.current = null;
|
||||||
|
onDragging(false);
|
||||||
|
}}
|
||||||
|
onLostPointerCapture={() => {
|
||||||
|
if (start.current) {
|
||||||
|
start.current = null;
|
||||||
|
onDragging(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDoubleClick={onReset}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
const step = e.shiftKey ? 64 : 16;
|
||||||
|
if (e.key === "ArrowLeft") onResize(width - step);
|
||||||
|
else if (e.key === "ArrowRight") onResize(width + step);
|
||||||
|
else if (e.key === "Home") onResize(min);
|
||||||
|
else if (e.key === "End") onResize(max);
|
||||||
|
else return;
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,12 @@ export interface MoveResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Personnalisation partagée ; logo = "" sans logo, sinon sa version. */
|
||||||
|
export interface Settings {
|
||||||
|
title: string;
|
||||||
|
logo: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(public status: number, message: string) {
|
constructor(public status: number, message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
@@ -88,6 +94,21 @@ export const api = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
settings: async (): Promise<Settings> => (await request("/api/settings")).json(),
|
||||||
|
|
||||||
|
setTitle: async (title: string): Promise<Settings> =>
|
||||||
|
(await request("/api/settings", { ...json({ title }), method: "PUT" })).json(),
|
||||||
|
|
||||||
|
/** Le serveur recadre au centre et ramène le PNG à 64x64. */
|
||||||
|
uploadLogo: async (png: Blob): Promise<Settings> =>
|
||||||
|
(
|
||||||
|
await request("/api/logo", { method: "PUT", headers: { "Content-Type": "image/png" }, body: png })
|
||||||
|
).json(),
|
||||||
|
|
||||||
|
removeLogo: async (): Promise<Settings> => (await request("/api/logo", { method: "DELETE" })).json(),
|
||||||
|
|
||||||
|
logoUrl: (v: string) => `/api/logo?v=${q(v)}`,
|
||||||
|
|
||||||
downloadUrl: (path: string) => `/api/file?path=${q(path)}`,
|
downloadUrl: (path: string) => `/api/file?path=${q(path)}`,
|
||||||
|
|
||||||
/** v = date du schéma : l'URL change à chaque nouvelle version (cache navigateur). */
|
/** v = date du schéma : l'URL change à chaque nouvelle version (cache navigateur). */
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Icônes de la barre de titre (traits, couleur héritée).
|
||||||
|
|
||||||
|
const svg = {
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
viewBox: "0 0 24 24",
|
||||||
|
fill: "none",
|
||||||
|
stroke: "currentColor",
|
||||||
|
strokeWidth: 2,
|
||||||
|
strokeLinecap: "round" as const,
|
||||||
|
strokeLinejoin: "round" as const,
|
||||||
|
"aria-hidden": true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SunIcon = () => (
|
||||||
|
<svg {...svg}>
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const MoonIcon = () => (
|
||||||
|
<svg {...svg}>
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SettingsIcon = () => (
|
||||||
|
<svg {...svg}>
|
||||||
|
<path d="M12 20h9" />
|
||||||
|
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
+131
-2
@@ -97,6 +97,42 @@ button:disabled {
|
|||||||
.app.drawer-open {
|
.app.drawer-open {
|
||||||
grid-template-columns: var(--drawer-w) 1fr;
|
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 {
|
.topbar {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
@@ -111,11 +147,68 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
.topbar .title {
|
.topbar .title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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 {
|
.status {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
@@ -147,6 +240,7 @@ button:disabled {
|
|||||||
.canvas {
|
.canvas {
|
||||||
grid-area: 2 / 2;
|
grid-area: 2 / 2;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
isolation: isolate; /* la poignée passe au-dessus de l'interface Excalidraw */
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
@@ -276,7 +370,7 @@ li.active > .row {
|
|||||||
top: var(--topbar-h);
|
top: var(--topbar-h);
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: min(var(--drawer-w), 88vw);
|
width: min(330px, 88vw); /* largeur réglable : bureau uniquement */
|
||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
box-shadow: 2px 0 12px rgb(0 0 0 / 0.2);
|
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);
|
background: rgb(0 0 0 / 0.35);
|
||||||
}
|
}
|
||||||
.row .meta,
|
.row .meta,
|
||||||
.topbar .status {
|
.topbar .status,
|
||||||
|
.has-scene .brand-title {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.splitter,
|
||||||
|
.drawer-open .splitter {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.row .name {
|
.row .name {
|
||||||
@@ -554,3 +653,33 @@ button.primary {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
align-items: center;
|
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%;
|
||||||
|
}
|
||||||
+72
-1
@@ -11,6 +11,8 @@
|
|||||||
// DELETE /api/entry?path=x[&recursive=true] mise à la corbeille
|
// DELETE /api/entry?path=x[&recursive=true] mise à la corbeille
|
||||||
// GET /api/thumb?path=x / PUT (image/png) miniature d'un schéma
|
// GET /api/thumb?path=x / PUT (image/png) miniature d'un schéma
|
||||||
// GET /api/library / PUT /api/library bibliothèque Excalidraw
|
// GET /api/library / PUT /api/library bibliothèque Excalidraw
|
||||||
|
// GET /api/settings / PUT {"title":"..."} personnalisation (titre, logo)
|
||||||
|
// GET /api/logo / PUT (image/png) / DELETE logo 64x64
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -44,6 +46,11 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("PUT /api/thumb", h.writeThumb)
|
mux.HandleFunc("PUT /api/thumb", h.writeThumb)
|
||||||
mux.HandleFunc("GET /api/library", h.readLibrary)
|
mux.HandleFunc("GET /api/library", h.readLibrary)
|
||||||
mux.HandleFunc("PUT /api/library", h.writeLibrary)
|
mux.HandleFunc("PUT /api/library", h.writeLibrary)
|
||||||
|
mux.HandleFunc("GET /api/settings", h.readSettings)
|
||||||
|
mux.HandleFunc("PUT /api/settings", h.writeSettings)
|
||||||
|
mux.HandleFunc("GET /api/logo", h.readLogo)
|
||||||
|
mux.HandleFunc("PUT /api/logo", h.writeLogo)
|
||||||
|
mux.HandleFunc("DELETE /api/logo", h.deleteLogo)
|
||||||
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
writeErr(w, http.StatusNotFound, "route inconnue")
|
writeErr(w, http.StatusNotFound, "route inconnue")
|
||||||
})
|
})
|
||||||
@@ -69,7 +76,8 @@ func fail(w http.ResponseWriter, err error) {
|
|||||||
writeErr(w, http.StatusConflict, err.Error())
|
writeErr(w, http.StatusConflict, err.Error())
|
||||||
case errors.Is(err, storage.ErrConflict):
|
case errors.Is(err, storage.ErrConflict):
|
||||||
writeErr(w, http.StatusPreconditionFailed, err.Error())
|
writeErr(w, http.StatusPreconditionFailed, err.Error())
|
||||||
case errors.Is(err, storage.ErrInvalidPath), errors.Is(err, storage.ErrInvalidJSON):
|
case errors.Is(err, storage.ErrInvalidPath), errors.Is(err, storage.ErrInvalidJSON),
|
||||||
|
errors.Is(err, storage.ErrInvalidImage):
|
||||||
writeErr(w, http.StatusBadRequest, err.Error())
|
writeErr(w, http.StatusBadRequest, err.Error())
|
||||||
case errors.As(err, &tooBig):
|
case errors.As(err, &tooBig):
|
||||||
writeErr(w, http.StatusRequestEntityTooLarge, "contenu trop volumineux")
|
writeErr(w, http.StatusRequestEntityTooLarge, "contenu trop volumineux")
|
||||||
@@ -278,3 +286,66 @@ func (h *Handler) writeLibrary(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) readSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
st, err := h.Store.ReadSettings()
|
||||||
|
if err != nil {
|
||||||
|
fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
writeJSON(w, http.StatusOK, st)
|
||||||
|
}
|
||||||
|
|
||||||
|
type settingsReq struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) writeSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req settingsReq
|
||||||
|
if !h.decode(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st, err := h.Store.WriteTitle(req.Title)
|
||||||
|
if err != nil {
|
||||||
|
fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, st)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) readLogo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data, err := h.Store.ReadLogo()
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// L'URL porte la version du logo (?v=...) : un nouveau logo change l'URL.
|
||||||
|
w.Header().Set("Cache-Control", "private, max-age=86400")
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) writeLogo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, storage.MaxLogoUpload))
|
||||||
|
if err != nil {
|
||||||
|
fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st, err := h.Store.WriteLogo(data)
|
||||||
|
if err != nil {
|
||||||
|
fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, st)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) deleteLogo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
st, err := h.Store.DeleteLogo()
|
||||||
|
if err != nil {
|
||||||
|
fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, st)
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/draw"
|
||||||
|
"image/png"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Personnalisation de l'interface (titre, logo), partagée par tous les
|
||||||
|
// navigateurs : .settings.json et .logo.png à la racine.
|
||||||
|
|
||||||
|
const (
|
||||||
|
settingsFile = ".settings.json"
|
||||||
|
logoFile = ".logo.png"
|
||||||
|
|
||||||
|
// LogoSize est le côté du logo enregistré (carré, affiché en rond).
|
||||||
|
LogoSize = 64
|
||||||
|
// MaxLogoUpload borne la taille d'un PNG envoyé avant redimensionnement.
|
||||||
|
MaxLogoUpload = 5 << 20
|
||||||
|
// MaxTitleLen borne la longueur du titre (en caractères).
|
||||||
|
MaxTitleLen = 80
|
||||||
|
|
||||||
|
maxLogoSide = 8192 // protège contre les « bombes » de décompression
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrInvalidImage signale un logo illisible ou hors limites.
|
||||||
|
var ErrInvalidImage = errors.New("image invalide")
|
||||||
|
|
||||||
|
// Settings est l'état exposé au client. Logo vaut "" sans logo, sinon une
|
||||||
|
// version (date) à ajouter à l'URL pour invalider le cache navigateur.
|
||||||
|
type Settings struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Logo string `json:"logo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type settingsDisk struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadSettings renvoie la personnalisation courante (valeurs vides par défaut).
|
||||||
|
func (s *Store) ReadSettings() (Settings, error) {
|
||||||
|
var d settingsDisk
|
||||||
|
b, err := os.ReadFile(filepath.Join(s.root, settingsFile))
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, fs.ErrNotExist):
|
||||||
|
case err != nil:
|
||||||
|
return Settings{}, err
|
||||||
|
default:
|
||||||
|
if err := json.Unmarshal(b, &d); err != nil {
|
||||||
|
return Settings{}, fmt.Errorf("%s : %w", settingsFile, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st := Settings{Title: d.Title}
|
||||||
|
if fi, err := os.Stat(filepath.Join(s.root, logoFile)); err == nil {
|
||||||
|
st.Logo = strconv.FormatInt(fi.ModTime().UnixNano(), 36)
|
||||||
|
}
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteTitle enregistre le titre affiché ("" = titre par défaut).
|
||||||
|
func (s *Store) WriteTitle(title string) (Settings, error) {
|
||||||
|
title = strings.TrimSpace(title)
|
||||||
|
if !utf8.ValidString(title) || strings.ContainsAny(title, "\r\n\x00") {
|
||||||
|
return Settings{}, fmt.Errorf("%w : titre", ErrInvalidPath)
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(title) > MaxTitleLen {
|
||||||
|
return Settings{}, fmt.Errorf("%w : titre de %d caractères max", ErrInvalidPath, MaxTitleLen)
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(settingsDisk{Title: title})
|
||||||
|
if err != nil {
|
||||||
|
return Settings{}, err
|
||||||
|
}
|
||||||
|
if err := atomicWrite(filepath.Join(s.root, settingsFile), b); err != nil {
|
||||||
|
return Settings{}, err
|
||||||
|
}
|
||||||
|
return s.ReadSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadLogo renvoie le logo PNG 64x64, ou ErrNotFound.
|
||||||
|
func (s *Store) ReadLogo() ([]byte, error) {
|
||||||
|
b, err := os.ReadFile(filepath.Join(s.root, logoFile))
|
||||||
|
return b, mapErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteLogo décode un PNG, le recadre au centre en carré, le ramène à
|
||||||
|
// LogoSize x LogoSize et l'enregistre (ré-encodé : aucune donnée annexe du
|
||||||
|
// fichier d'origine n'est conservée).
|
||||||
|
func (s *Store) WriteLogo(data []byte) (Settings, error) {
|
||||||
|
cfg, err := png.DecodeConfig(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return Settings{}, fmt.Errorf("%w : PNG attendu", ErrInvalidImage)
|
||||||
|
}
|
||||||
|
if cfg.Width < 1 || cfg.Height < 1 || cfg.Width > maxLogoSide || cfg.Height > maxLogoSide {
|
||||||
|
return Settings{}, fmt.Errorf("%w : %dx%d px max", ErrInvalidImage, maxLogoSide, maxLogoSide)
|
||||||
|
}
|
||||||
|
img, err := png.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return Settings{}, fmt.Errorf("%w : PNG illisible", ErrInvalidImage)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, squareResize(img, LogoSize)); err != nil {
|
||||||
|
return Settings{}, err
|
||||||
|
}
|
||||||
|
if err := atomicWrite(filepath.Join(s.root, logoFile), buf.Bytes()); err != nil {
|
||||||
|
return Settings{}, err
|
||||||
|
}
|
||||||
|
return s.ReadSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteLogo supprime le logo (sans erreur s'il est absent).
|
||||||
|
func (s *Store) DeleteLogo() (Settings, error) {
|
||||||
|
if err := os.Remove(filepath.Join(s.root, logoFile)); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return Settings{}, err
|
||||||
|
}
|
||||||
|
return s.ReadSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
// squareResize recadre img au centre (carré) puis le rééchantillonne en n x n
|
||||||
|
// par moyenne de surface (filtre « boîte » pondéré), en alpha prémultiplié
|
||||||
|
// pour éviter les franges sombres sur les bords transparents.
|
||||||
|
func squareResize(img image.Image, n int) *image.RGBA {
|
||||||
|
b := img.Bounds()
|
||||||
|
side := min(b.Dx(), b.Dy())
|
||||||
|
crop := image.Rect(0, 0, side, side).Add(b.Min).
|
||||||
|
Add(image.Pt((b.Dx()-side)/2, (b.Dy()-side)/2))
|
||||||
|
src := image.NewRGBA(image.Rect(0, 0, side, side))
|
||||||
|
draw.Draw(src, src.Bounds(), img, crop.Min, draw.Src)
|
||||||
|
|
||||||
|
w := boxWeights(side, n)
|
||||||
|
dst := image.NewRGBA(image.Rect(0, 0, n, n))
|
||||||
|
for dy := 0; dy < n; dy++ {
|
||||||
|
for dx := 0; dx < n; dx++ {
|
||||||
|
var acc [4]float64
|
||||||
|
var total float64
|
||||||
|
for _, wy := range w[dy] {
|
||||||
|
row := wy.i * src.Stride
|
||||||
|
for _, wx := range w[dx] {
|
||||||
|
k := wy.w * wx.w
|
||||||
|
p := src.Pix[row+wx.i*4:]
|
||||||
|
for c := range acc {
|
||||||
|
acc[c] += k * float64(p[c])
|
||||||
|
}
|
||||||
|
total += k
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o := dst.PixOffset(dx, dy)
|
||||||
|
for c := range acc {
|
||||||
|
dst.Pix[o+c] = uint8(acc[c]/total + 0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
type weight struct {
|
||||||
|
i int
|
||||||
|
w float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// boxWeights calcule, pour chaque pixel cible, les pixels source qu'il couvre
|
||||||
|
// et la fraction couverte. Fonctionne aussi en agrandissement (côté < n).
|
||||||
|
func boxWeights(side, n int) [][]weight {
|
||||||
|
scale := float64(side) / float64(n)
|
||||||
|
out := make([][]weight, n)
|
||||||
|
for d := range out {
|
||||||
|
lo, hi := float64(d)*scale, float64(d+1)*scale
|
||||||
|
for i := int(lo); i < side && float64(i) < hi; i++ {
|
||||||
|
w := min(hi, float64(i+1)) - max(lo, float64(i))
|
||||||
|
if w > 0 {
|
||||||
|
out[d] = append(out[d], weight{i, w})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func encodePNG(t *testing.T, img image.Image) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, img); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTitle(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
if st, err := s.ReadSettings(); err != nil || st != (Settings{}) {
|
||||||
|
t.Fatalf("défauts : %+v, %v", st, err)
|
||||||
|
}
|
||||||
|
st, err := s.WriteTitle(" Schémas vLab ")
|
||||||
|
if err != nil || st.Title != "Schémas vLab" {
|
||||||
|
t.Fatalf("titre : %+v, %v", st, err)
|
||||||
|
}
|
||||||
|
if st, _ := s.ReadSettings(); st.Title != "Schémas vLab" {
|
||||||
|
t.Fatalf("titre non persisté : %+v", st)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"a\nb", strings.Repeat("é", MaxTitleLen+1)} {
|
||||||
|
if _, err := s.WriteTitle(bad); !errors.Is(err, ErrInvalidPath) {
|
||||||
|
t.Errorf("titre %q accepté", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Les fichiers de réglages n'apparaissent pas dans l'explorateur.
|
||||||
|
if entries, _ := s.List(""); len(entries) != 0 {
|
||||||
|
t.Fatalf("réglages visibles : %+v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogo(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
if _, err := s.ReadLogo(); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("logo absent : %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.WriteLogo([]byte("pas une image")); !errors.Is(err, ErrInvalidImage) {
|
||||||
|
t.Fatalf("non-PNG accepté : %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 300x200 : bande rouge à gauche (hors recadrage), vert au centre.
|
||||||
|
src := image.NewNRGBA(image.Rect(0, 0, 300, 200))
|
||||||
|
for y := 0; y < 200; y++ {
|
||||||
|
for x := 0; x < 300; x++ {
|
||||||
|
c := color.NRGBA{0, 200, 0, 255}
|
||||||
|
if x < 50 {
|
||||||
|
c = color.NRGBA{255, 0, 0, 255}
|
||||||
|
}
|
||||||
|
src.Set(x, y, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st, err := s.WriteLogo(encodePNG(t, src))
|
||||||
|
if err != nil || st.Logo == "" {
|
||||||
|
t.Fatalf("écriture : %+v, %v", st, err)
|
||||||
|
}
|
||||||
|
b, err := s.ReadLogo()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
img, err := png.Decode(bytes.NewReader(b))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if sz := img.Bounds().Size(); sz != image.Pt(LogoSize, LogoSize) {
|
||||||
|
t.Fatalf("taille %v", sz)
|
||||||
|
}
|
||||||
|
if r, g, _, _ := img.At(0, 0).RGBA(); r>>8 != 0 || g>>8 != 200 {
|
||||||
|
t.Fatalf("recadrage centré attendu, coin = %v", img.At(0, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agrandissement d'une petite image.
|
||||||
|
if _, err := s.WriteLogo(encodePNG(t, image.NewNRGBA(image.Rect(0, 0, 10, 10)))); err != nil {
|
||||||
|
t.Fatalf("petite image : %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if st, err := s.DeleteLogo(); err != nil || st.Logo != "" {
|
||||||
|
t.Fatalf("suppression : %+v, %v", st, err)
|
||||||
|
}
|
||||||
|
if _, err := s.DeleteLogo(); err != nil {
|
||||||
|
t.Fatalf("double suppression : %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -145,11 +145,11 @@ func TestMoveMany(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var png = []byte("\x89PNG\r\n\x1a\nfake")
|
var fakePNG = []byte("\x89PNG\r\n\x1a\nfake")
|
||||||
|
|
||||||
func TestThumbs(t *testing.T) {
|
func TestThumbs(t *testing.T) {
|
||||||
s := newStore(t)
|
s := newStore(t)
|
||||||
if err := s.WriteThumb("d/a.excalidraw", png); !errors.Is(err, ErrNotFound) {
|
if err := s.WriteThumb("d/a.excalidraw", fakePNG); !errors.Is(err, ErrNotFound) {
|
||||||
t.Fatalf("miniature d'un schéma absent : %v", err)
|
t.Fatalf("miniature d'un schéma absent : %v", err)
|
||||||
}
|
}
|
||||||
if _, _, err := s.Write("d/a.excalidraw", []byte(doc), "", true); err != nil {
|
if _, _, err := s.Write("d/a.excalidraw", []byte(doc), "", true); err != nil {
|
||||||
@@ -161,10 +161,10 @@ func TestThumbs(t *testing.T) {
|
|||||||
if _, _, err := s.ReadThumb("d/a.excalidraw"); !errors.Is(err, ErrNotFound) {
|
if _, _, err := s.ReadThumb("d/a.excalidraw"); !errors.Is(err, ErrNotFound) {
|
||||||
t.Fatalf("miniature inexistante : %v", err)
|
t.Fatalf("miniature inexistante : %v", err)
|
||||||
}
|
}
|
||||||
if err := s.WriteThumb("d/a.excalidraw", png); err != nil {
|
if err := s.WriteThumb("d/a.excalidraw", fakePNG); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if b, _, err := s.ReadThumb("d/a.excalidraw"); err != nil || string(b) != string(png) {
|
if b, _, err := s.ReadThumb("d/a.excalidraw"); err != nil || string(b) != string(fakePNG) {
|
||||||
t.Fatalf("lecture : %q, %v", b, err)
|
t.Fatalf("lecture : %q, %v", b, err)
|
||||||
}
|
}
|
||||||
// Suit le déplacement du dossier, la copie, puis la suppression.
|
// Suit le déplacement du dossier, la copie, puis la suppression.
|
||||||
|
|||||||
Reference in new issue
Block a user