From baa412d02ebb740cdf26172b5d0b0a14e1c643c1 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 7 Oct 2026 15:00:21 +0200 Subject: [PATCH 1/2] =?UTF-8?q?API=20:=20titre=20et=20logo=20personnalis?= =?UTF-8?q?=C3=A9s=20(/api/settings,=20/api/logo)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logo PNG recadré au centre et réduit à 64x64 côté serveur, ré-encodé. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_013WSSWgbf7gUzTzEqhjqtzN --- internal/api/api.go | 73 +++++++++++- internal/storage/settings.go | 185 ++++++++++++++++++++++++++++++ internal/storage/settings_test.go | 95 +++++++++++++++ internal/storage/storage_test.go | 8 +- 4 files changed, 356 insertions(+), 5 deletions(-) create mode 100644 internal/storage/settings.go create mode 100644 internal/storage/settings_test.go diff --git a/internal/api/api.go b/internal/api/api.go index 51fd628..2b769cf 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -11,6 +11,8 @@ // 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/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 import ( @@ -44,6 +46,11 @@ func (h *Handler) Register(mux *http.ServeMux) { mux.HandleFunc("PUT /api/thumb", h.writeThumb) mux.HandleFunc("GET /api/library", h.readLibrary) 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) { writeErr(w, http.StatusNotFound, "route inconnue") }) @@ -69,7 +76,8 @@ func fail(w http.ResponseWriter, err error) { writeErr(w, http.StatusConflict, err.Error()) case errors.Is(err, storage.ErrConflict): 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()) case errors.As(err, &tooBig): 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) } + +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) +} diff --git a/internal/storage/settings.go b/internal/storage/settings.go new file mode 100644 index 0000000..3ac0fab --- /dev/null +++ b/internal/storage/settings.go @@ -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 +} diff --git a/internal/storage/settings_test.go b/internal/storage/settings_test.go new file mode 100644 index 0000000..06ed017 --- /dev/null +++ b/internal/storage/settings_test.go @@ -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) + } +} diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index 7b119ca..66968f6 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -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) { 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) } 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) { 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) } - 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) } // Suit le déplacement du dossier, la copie, puis la suppression. -- 2.54.0 From 9ecaab550ad71ec610c46c98b9a3db2f6b84b208 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 7 Oct 2026 15:01:54 +0200 Subject: [PATCH 2/2] Interface : explorateur redimensionnable, bouton clair/sombre, titre et logo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - poignée entre l'explorateur et le canevas (souris, tactile, clavier), largeur mémorisée - bouton clair/sombre à droite de la barre, synchronisé avec le menu d'Excalidraw - dialogue Personnaliser : titre et logo PNG affiché en rond - README Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_013WSSWgbf7gUzTzEqhjqtzN --- README.md | 9 +++ frontend/src/App.tsx | 124 ++++++++++++++++++++++++---- frontend/src/CustomizeDialog.tsx | 125 +++++++++++++++++++++++++++++ frontend/src/Splitter.tsx | 61 ++++++++++++++ frontend/src/api.ts | 21 +++++ frontend/src/icons.tsx | 33 ++++++++ frontend/src/styles.css | 133 ++++++++++++++++++++++++++++++- 7 files changed, 489 insertions(+), 17 deletions(-) create mode 100644 frontend/src/CustomizeDialog.tsx create mode 100644 frontend/src/Splitter.tsx create mode 100644 frontend/src/icons.tsx diff --git a/README.md b/README.md index ef0b3af..282a6dd 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Navigateur ──HTTP──▶ conteneur excalidraw-go (:8080, uid 65532, rootfs volume /data ├─ projets/réseau/sdn.excalidraw ← fichiers JSON Excalidraw standards ├─ .library.excalidrawlib ← bibliothèque partagée + ├─ .settings.json, .logo.png ← titre et logo de la barre ├─ .thumbs/projets/réseau/sdn.excalidraw.png ← miniatures (régénérables) └─ .trash//... ← éléments supprimés ``` @@ -52,6 +53,9 @@ Bind mount au lieu du volume nommé : `- ./data:/data` puis `chown -R 65532:6553 - **Sauvegarde** : automatique 1,5 s après la dernière modification, immédiate avec `Ctrl/Cmd+S`. Le statut (Modifié / Enregistrement… / Enregistré) est affiché dans la barre du haut. - **Lien direct** : l'URL `http://hôte:8080/#/dossier/schema.excalidraw` ouvre le schéma. - **Bibliothèque** Excalidraw : persistée côté serveur, partagée entre tous les schémas. +- **Largeur de l'explorateur** (bureau) : glisser la poignée entre l'explorateur et le canevas ; flèches ←/→ au clavier quand elle a le focus (Maj : pas plus grand), double-clic pour revenir à 330 px. Mémorisée par navigateur. +- **Mode clair/sombre** : bouton ☀/☾ à droite de la barre du haut ; s'applique à l'interface et au canevas Excalidraw, reste synchronisé avec le choix du menu d'Excalidraw. Mémorisé par navigateur. +- **Titre et logo** : bouton ✎ à droite de la barre (ou clic sur le titre). Le titre et le logo sont communs à tous les utilisateurs. Le logo (PNG, 5 Mo max) est recadré au centre, réduit à 64×64 px côté serveur et affiché dans un masque rond (34 px dans la barre, net sur écran HiDPI ; taille réglable via `--logo-size` dans `styles.css`). ### Miniatures @@ -84,6 +88,11 @@ Tous les chemins sont relatifs à `DATA_DIR`, en notation `/`. Les composants co | POST | `/api/copy` | `{"from":"…","to":"…"}` | `Entry` | | DELETE | `/api/entry?path=x[&recursive=true]` | | `204` (déplacé dans `.trash/`) | | GET/PUT | `/api/library` | bibliothèque `excalidrawlib` | | +| GET | `/api/settings` | | `{"title":"...","logo":""}` (`logo` vide sans logo) | +| PUT | `/api/settings` | `{"title":"..."}` (80 caractères max, vide = titre par défaut) | réglages | +| GET | `/api/logo?v=` | | PNG 64×64 ; `404` sans logo | +| PUT | `/api/logo` | PNG (5 Mo, 8192×8192 px max) | réglages ; image recadrée et ré-encodée | +| DELETE | `/api/logo` | | réglages | | GET | `/healthz` | | `ok` (hors authentification) | `Entry` = `{"name","path","isDir","size","modTime"}`. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3a06a35..e2559b3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { type CSSProperties, useCallback, useEffect, useRef, useState } from "react"; import { Excalidraw, hashElementsVersion, @@ -14,8 +14,11 @@ import type { LibraryItems, } from "@excalidraw/excalidraw/types"; import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types"; -import { api, ApiError, basename, stripExt } from "./api"; +import { api, ApiError, basename, stripExt, type Settings } from "./api"; +import { CustomizeDialog, DEFAULT_TITLE } from "./CustomizeDialog"; import { Explorer } from "./Explorer"; +import { MoonIcon, SettingsIcon, SunIcon } from "./icons"; +import { Splitter } from "./Splitter"; import { storeThumb } from "./thumbs"; type Theme = "light" | "dark"; @@ -31,16 +34,34 @@ const AUTOSAVE_MS = 1500; const isMobile = () => window.matchMedia("(max-width: 768px)").matches; const pathFromHash = () => decodeURIComponent(location.hash.replace(/^#\/?/, "")) || null; -const initialTheme = (): Theme => { +const load = (key: string) => { try { - const t = localStorage.getItem("theme"); - if (t === "light" || t === "dark") return t; + return localStorage.getItem(key); + } catch { + return null; // stockage indisponible + } +}; +const store = (key: string, value: string) => { + try { + localStorage.setItem(key, value); } catch { /* stockage indisponible */ } +}; + +const initialTheme = (): Theme => { + const t = load("theme"); + if (t === "light" || t === "dark") return t; return matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; }; +// Largeur de l'explorateur (bureau) : réglable par la poignée, mémorisée par navigateur. +const DRAWER_DEFAULT = 330; +const DRAWER_MIN = 220; +const drawerMax = () => Math.max(DRAWER_MIN, window.innerWidth - 360); // garde un canevas utilisable +const clampDrawer = (w: number) => Math.round(Math.min(Math.max(w, DRAWER_MIN), drawerMax())); +const initialDrawerWidth = () => clampDrawer(Number(load("drawerWidth")) || DRAWER_DEFAULT); + /** Empreinte du contenu « persistant » (hors scroll, sélection, zoom...). */ const sceneVersion = ( elements: readonly OrderedExcalidrawElement[], @@ -63,6 +84,10 @@ export default function App() { const [drawerOpen, setDrawerOpen] = useState(() => !isMobile()); const [refreshKey, setRefreshKey] = useState(0); const [loadError, setLoadError] = useState(null); + const [drawerWidth, setDrawerWidth] = useState(initialDrawerWidth); + const [resizing, setResizing] = useState(false); + const [settings, setSettings] = useState({ title: "", logo: "" }); + const [customizing, setCustomizing] = useState(false); const excalidraw = useRef(null); const pathRef = useRef(null); // schéma ouvert (survit aux renommages) @@ -73,6 +98,7 @@ export default function App() { const libTimer = useRef(undefined); const libJson = useRef(""); // dernier état connu côté serveur const sceneSeq = useRef(0); + const exTheme = useRef(null); // dernier thème signalé par Excalidraw const openRef = useRef<(path: string, opts?: { skipFlush?: boolean }) => Promise>(async () => {}); // --- Sauvegarde ----------------------------------------------------------- @@ -128,7 +154,13 @@ export default function App() { const onChange = useCallback( (elements: readonly OrderedExcalidrawElement[], appState: AppState, files: BinaryFiles) => { - if (appState.theme !== theme) setTheme(appState.theme as Theme); + // Bascule depuis le menu d'Excalidraw. On ne réagit qu'à un changement + // signalé par Excalidraw : sinon, après un clic sur le bouton de la barre, + // un onChange portant encore l'ancien thème le rétablirait (boucle). + if (appState.theme !== exTheme.current) { + exTheme.current = appState.theme; + if (appState.theme !== theme) setTheme(appState.theme as Theme); + } if (!pathRef.current || appState.isLoading) return; const version = sceneVersion(elements, appState, files); // Premier onChange après chargement : c'est la version de référence. @@ -251,13 +283,31 @@ export default function App() { useEffect(() => { document.documentElement.dataset.theme = theme; - try { - localStorage.setItem("theme", theme); - } catch { - /* stockage indisponible */ - } + store("theme", theme); }, [theme]); + // Largeur mémorisée en fin de glissement ; réajustée si la fenêtre rétrécit. + useEffect(() => { + if (!resizing) store("drawerWidth", String(drawerWidth)); + }, [drawerWidth, resizing]); + + useEffect(() => { + const onResize = () => setDrawerWidth((w) => clampDrawer(w)); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, []); + + // Titre et logo personnalisés (partagés, stockés côté serveur). + useEffect(() => { + api.settings().then(setSettings).catch(console.error); + }, []); + + const appTitle = settings.title || DEFAULT_TITLE; + const sceneName = scene ? stripExt(basename(scene.path)) : null; + useEffect(() => { + document.title = sceneName ? `${sceneName} – ${appTitle}` : appTitle; + }, [appTitle, sceneName]); + // --- Synchronisation avec l'explorateur ----------------------------------- const onMoved = useCallback((from: string, to: string) => { @@ -282,7 +332,10 @@ export default function App() { // --- Rendu ----------------------------------------------------------------- return ( -
+
- - {scene ? stripExt(basename(scene.path)) : "Excalidraw"} - + + {scene ? ( + + + / + + {sceneName} + + ) : ( + + )} {scene && ( <> {SAVE_LABEL[saveState]} @@ -305,6 +369,24 @@ export default function App() { )} +
+ + +
+ setDrawerWidth(clampDrawer(w))} + onDragging={setResizing} + onReset={() => setDrawerWidth(clampDrawer(DRAWER_DEFAULT))} + />
setDrawerOpen(false)} />
@@ -341,6 +431,10 @@ export default function App() {
)} + + {customizing && ( + setCustomizing(false)} /> + )}
); } diff --git a/frontend/src/CustomizeDialog.tsx b/frontend/src/CustomizeDialog.tsx new file mode 100644 index 0000000..3ac5e44 --- /dev/null +++ b/frontend/src/CustomizeDialog.tsx @@ -0,0 +1,125 @@ +import { useEffect, useRef, useState } from "react"; +import { api, type Settings } from "./api"; + +export const DEFAULT_TITLE = "Excalidraw"; +const MAX_TITLE = 80; // voir storage.MaxTitleLen + +interface Props { + settings: Settings; + onChange: (s: Settings) => void; + onClose: () => void; +} + +/** Titre et logo de la barre du haut, partagés par tous les utilisateurs. */ +export function CustomizeDialog({ settings, onChange, onClose }: Props) { + const [title, setTitle] = useState(settings.title); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const fileInput = useRef(null); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + const run = async (op: () => Promise, close = false) => { + setBusy(true); + setError(null); + try { + onChange(await op()); + if (close) onClose(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + const onFile = (f: File | undefined) => { + if (!f) return; + if (f.type !== "image/png") { + setError("Seuls les fichiers PNG sont acceptés."); + return; + } + run(() => api.uploadLogo(f)); + }; + + const dirty = title.trim() !== settings.title; + + return ( +
+
e.stopPropagation()} + onSubmit={(e) => { + e.preventDefault(); + run(() => api.setTitle(title), true); + }} + > +

Personnaliser

+ + + +
+ Logo +
+ {settings.logo ? ( + Logo actuel + ) : ( + + ? + + )} +
+ + {settings.logo && ( + + )} + PNG, recadré au centre et réduit à 64×64 px, affiché en rond. +
+ { + onFile(e.target.files?.[0]); + e.target.value = ""; + }} + /> +
+
+ + {error &&
{error}
} + +
+ + + +
+
+
+ ); +} diff --git a/frontend/src/Splitter.tsx b/frontend/src/Splitter.tsx new file mode 100644 index 0000000..cecf4ce --- /dev/null +++ b/frontend/src/Splitter.tsx @@ -0,0 +1,61 @@ +import { useRef } from "react"; + +interface Props { + width: number; + min: number; + max: number; + onResize: (width: number) => void; + /** Début / fin de glissement (désactive l'animation de la grille). */ + onDragging: (dragging: boolean) => void; + onReset: () => void; +} + +/** Poignée verticale entre l'explorateur et le canevas : souris, doigt ou clavier. */ +export function Splitter({ width, min, max, onResize, onDragging, onReset }: Props) { + const start = useRef<{ x: number; w: number } | null>(null); + + return ( +
{ + if (e.button !== 0) return; + e.preventDefault(); + e.currentTarget.setPointerCapture(e.pointerId); + start.current = { x: e.clientX, w: width }; + onDragging(true); + }} + onPointerMove={(e) => { + if (start.current) onResize(start.current.w + e.clientX - start.current.x); + }} + onPointerUp={(e) => { + e.currentTarget.releasePointerCapture(e.pointerId); + start.current = null; + onDragging(false); + }} + onLostPointerCapture={() => { + if (start.current) { + start.current = null; + onDragging(false); + } + }} + onDoubleClick={onReset} + onKeyDown={(e) => { + const step = e.shiftKey ? 64 : 16; + if (e.key === "ArrowLeft") onResize(width - step); + else if (e.key === "ArrowRight") onResize(width + step); + else if (e.key === "Home") onResize(min); + else if (e.key === "End") onResize(max); + else return; + e.preventDefault(); + }} + /> + ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 9a778dd..f9aa4fb 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -14,6 +14,12 @@ export interface MoveResult { error?: string; } +/** Personnalisation partagée ; logo = "" sans logo, sinon sa version. */ +export interface Settings { + title: string; + logo: string; +} + export class ApiError extends Error { constructor(public status: number, message: string) { super(message); @@ -88,6 +94,21 @@ export const api = { }); }, + settings: async (): Promise => (await request("/api/settings")).json(), + + setTitle: async (title: string): Promise => + (await request("/api/settings", { ...json({ title }), method: "PUT" })).json(), + + /** Le serveur recadre au centre et ramène le PNG à 64x64. */ + uploadLogo: async (png: Blob): Promise => + ( + await request("/api/logo", { method: "PUT", headers: { "Content-Type": "image/png" }, body: png }) + ).json(), + + removeLogo: async (): Promise => (await request("/api/logo", { method: "DELETE" })).json(), + + logoUrl: (v: string) => `/api/logo?v=${q(v)}`, + downloadUrl: (path: string) => `/api/file?path=${q(path)}`, /** v = date du schéma : l'URL change à chaque nouvelle version (cache navigateur). */ diff --git a/frontend/src/icons.tsx b/frontend/src/icons.tsx new file mode 100644 index 0000000..539587c --- /dev/null +++ b/frontend/src/icons.tsx @@ -0,0 +1,33 @@ +// Icônes de la barre de titre (traits, couleur héritée). + +const svg = { + width: 18, + height: 18, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: 2, + strokeLinecap: "round" as const, + strokeLinejoin: "round" as const, + "aria-hidden": true, +}; + +export const SunIcon = () => ( + + + + +); + +export const MoonIcon = () => ( + + + +); + +export const SettingsIcon = () => ( + + + + +); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 04bf79a..7a90e22 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -97,6 +97,42 @@ button:disabled { .app.drawer-open { grid-template-columns: var(--drawer-w) 1fr; } +.app.resizing { + transition: none; + cursor: col-resize; + user-select: none; +} + +/* Poignée de redimensionnement, à cheval sur la bordure de l'explorateur. */ +.splitter { + display: none; + grid-area: 2 / 1; + justify-self: end; + position: relative; + z-index: 5; + width: 9px; + margin-right: -5px; + cursor: col-resize; + touch-action: none; +} +.drawer-open .splitter { + display: block; +} +.splitter::after { + content: ""; + position: absolute; + inset: 0 3px; + border-radius: 2px; + transition: background 0.1s; +} +.splitter:hover::after, +.splitter:focus-visible::after, +.resizing .splitter::after { + background: var(--accent); +} +.splitter:focus-visible { + outline: none; +} .topbar { grid-column: 1 / -1; @@ -111,11 +147,68 @@ button:disabled { } .topbar .title { flex: 1; + min-width: 0; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.topbar .title .sep { + margin-right: 8px; + font-weight: 400; +} + +/* Logo + titre personnalisés (clic : dialogue « Personnaliser »). */ +.brand { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + max-width: 40%; + flex: none; + border: none; + background: transparent; + padding: 2px 4px; + font-weight: 600; +} +.brand-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +/* Logo stocké en 64x64 : affiché plus petit, il reste net sur écran HiDPI. */ +.logo { + --logo-size: 34px; + flex: none; + width: var(--logo-size); + height: var(--logo-size); + border-radius: 50%; /* masque rond */ + object-fit: cover; + background: var(--bg); + box-shadow: 0 0 0 1px var(--border); +} +.logo.big { + --logo-size: 64px; +} +.topbar-tools { + display: flex; + align-items: center; + gap: 2px; + margin-left: auto; + padding-left: 4px; + border-left: 1px solid var(--border); +} +.topbar-tools .icon-btn { + display: grid; + place-items: center; + width: 34px; + height: 34px; + padding: 0; + border-radius: 6px; +} +.topbar-tools .icon-btn:hover { + background: var(--bg); +} .status { font-size: 12px; padding: 2px 8px; @@ -147,6 +240,7 @@ button:disabled { .canvas { grid-area: 2 / 2; position: relative; + isolation: isolate; /* la poignée passe au-dessus de l'interface Excalidraw */ min-width: 0; min-height: 0; } @@ -276,7 +370,7 @@ li.active > .row { top: var(--topbar-h); bottom: 0; left: 0; - width: min(var(--drawer-w), 88vw); + width: min(330px, 88vw); /* largeur réglable : bureau uniquement */ transform: translateX(-100%); transition: transform 0.2s ease; box-shadow: 2px 0 12px rgb(0 0 0 / 0.2); @@ -296,7 +390,12 @@ li.active > .row { background: rgb(0 0 0 / 0.35); } .row .meta, - .topbar .status { + .topbar .status, + .has-scene .brand-title { + display: none; + } + .splitter, + .drawer-open .splitter { display: none; } .row .name { @@ -554,3 +653,33 @@ button.primary { gap: 6px; align-items: center; } + +/* Dialogue « Personnaliser » */ +.customize .field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 13px; +} +.customize .field > span { + font-weight: 600; +} +.logo-edit { + display: flex; + align-items: center; + gap: 12px; +} +.placeholder-logo { + display: grid; + place-items: center; + font-size: 24px; +} +.logo-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} +.logo-actions small { + flex-basis: 100%; +} -- 2.54.0