Interface alignée sur Excalidraw, import de dossiers, icône de mise à jour
- Jetons de couleur, rayons, ombres, police (Assistant) et tailles de boutons repris d'Excalidraw 0.18, en clair et en sombre. - Boutons de l'explorateur et de la barre de titre au style du bouton « Bibliothèque », icônes au trait (jeu Tabler) à la place des glyphes. - Glisser-déposer de fichiers et dossiers du système dans l'explorateur ou sur un dossier : arborescence recréée, seuls les .excalidraw importés, schémas existants conservés, bilan affiché. Bouton « Importer un dossier ». - Icône dans la barre du haut quand une version d'Excalidraw plus récente que celle embarquée (__EXCALIDRAW_VERSION__) est publiée ; un clic donne la commande de reconstruction. 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
49a816bb12
commit
6810472f09
9 files changed
+387
-35
No files matched your search
+22
-7
@@ -17,8 +17,9 @@ import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/ty
|
||||
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 { CloseIcon, MenuIcon, MoonIcon, SaveIcon, SettingsIcon, SunIcon } from "./icons";
|
||||
import { Splitter } from "./Splitter";
|
||||
import { UpdateBadge } from "./UpdateBadge";
|
||||
import { storeThumb } from "./thumbs";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
@@ -338,11 +339,13 @@ export default function App() {
|
||||
>
|
||||
<header className="topbar">
|
||||
<button
|
||||
className="icon-btn"
|
||||
className={`ex-btn square ${drawerOpen ? "active" : ""}`}
|
||||
aria-label="Afficher/masquer l'explorateur"
|
||||
aria-pressed={drawerOpen}
|
||||
title="Explorateur"
|
||||
onClick={() => setDrawerOpen((o) => !o)}
|
||||
>
|
||||
☰
|
||||
<MenuIcon />
|
||||
</button>
|
||||
<button className="brand" title="Personnaliser le titre et le logo" onClick={() => setCustomizing(true)}>
|
||||
{settings.logo && <img className="logo" src={api.logoUrl(settings.logo)} alt="" />}
|
||||
@@ -361,15 +364,27 @@ export default function App() {
|
||||
{scene && (
|
||||
<>
|
||||
<span className={`status ${saveState}`}>{SAVE_LABEL[saveState]}</span>
|
||||
<button onClick={() => save()} disabled={saveState === "saved" || saveState === "saving"}>
|
||||
Enregistrer
|
||||
<button
|
||||
className="ex-btn"
|
||||
onClick={() => save()}
|
||||
disabled={saveState === "saved" || saveState === "saving"}
|
||||
title="Enregistrer (enregistrement automatique)"
|
||||
>
|
||||
<SaveIcon />
|
||||
<span className="save-label">Enregistrer</span>
|
||||
</button>
|
||||
<button className="icon-btn" aria-label="Fermer le schéma" onClick={() => save().then(closeFile)}>
|
||||
✕
|
||||
<button
|
||||
className="icon-btn"
|
||||
aria-label="Fermer le schéma"
|
||||
title="Fermer le schéma"
|
||||
onClick={() => save().then(closeFile)}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="topbar-tools">
|
||||
<UpdateBadge />
|
||||
<button
|
||||
className="icon-btn"
|
||||
aria-label={theme === "dark" ? "Passer en mode clair" : "Passer en mode sombre"}
|
||||
|
||||
@@ -65,7 +65,7 @@ export function CustomizeDialog({ settings, onChange, onClose }: Props) {
|
||||
<label className="field">
|
||||
<span>Titre affiché</span>
|
||||
<input
|
||||
className="search"
|
||||
className="text-input"
|
||||
value={title}
|
||||
maxLength={MAX_TITLE}
|
||||
placeholder={DEFAULT_TITLE}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, basename, dirname, join, type Entry } from "./api";
|
||||
import { FolderIcon, FolderPlusIcon } from "./icons";
|
||||
|
||||
interface Props {
|
||||
/** Éléments à déplacer. */
|
||||
@@ -72,14 +73,20 @@ export function MoveDialog({ paths, startDir, onCancel, onConfirm }: Props) {
|
||||
{folders.map((f) => (
|
||||
<li key={f.path}>
|
||||
<button disabled={blocked(f.path)} onClick={() => setDir(f.path)}>
|
||||
<span aria-hidden>📁</span> {f.name}
|
||||
<span className="folder">
|
||||
<FolderIcon />
|
||||
</span>
|
||||
{f.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{folders.length === 0 && <li className="muted empty">Aucun sous-dossier</li>}
|
||||
</ul>
|
||||
<div className="modal-actions">
|
||||
<button onClick={newFolder}>+ Dossier</button>
|
||||
<button onClick={newFolder}>
|
||||
<FolderPlusIcon />
|
||||
<span>Dossier</span>
|
||||
</button>
|
||||
<span className="spacer" />
|
||||
<button onClick={onCancel}>Annuler</button>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,13 @@ export interface Settings {
|
||||
logo: string;
|
||||
}
|
||||
|
||||
/** Dernière version d'Excalidraw publiée sur npm (vide si inconnue ou désactivé). */
|
||||
export interface UpdateStatus {
|
||||
enabled: boolean;
|
||||
latest?: string;
|
||||
checkedAt?: string;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
@@ -48,6 +55,8 @@ const json = (body: unknown): RequestInit => ({
|
||||
});
|
||||
|
||||
export const api = {
|
||||
update: async (): Promise<UpdateStatus> => (await request("/api/update")).json(),
|
||||
|
||||
list: async (dir: string): Promise<Entry[]> =>
|
||||
(await request(`/api/list?path=${q(dir)}`)).json(),
|
||||
|
||||
|
||||
+142
-24
@@ -1,33 +1,151 @@
|
||||
// Icônes de la barre de titre (traits, couleur héritée).
|
||||
// Icônes au trait, dans le style d'Excalidraw (jeu Tabler, trait fin, couleur héritée).
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
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,
|
||||
};
|
||||
const icon = (paths: ReactNode) =>
|
||||
function Icon({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
{paths}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const SunIcon = () => (
|
||||
<svg {...svg}>
|
||||
export const SunIcon = icon(
|
||||
<>
|
||||
<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 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" />
|
||||
</>,
|
||||
);
|
||||
|
||||
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>
|
||||
export const MenuIcon = icon(<path d="M4 6h16M4 12h16M4 18h16" />);
|
||||
|
||||
export const CloseIcon = icon(<path d="M18 6 6 18M6 6l12 12" />);
|
||||
|
||||
export const FilePlusIcon = icon(
|
||||
<>
|
||||
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
|
||||
<path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z" />
|
||||
<path d="M12 11v6M9 14h6" />
|
||||
</>,
|
||||
);
|
||||
|
||||
export const FolderPlusIcon = icon(
|
||||
<>
|
||||
<path d="M12 19H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h4l3 3h7a2 2 0 0 1 2 2v3.5" />
|
||||
<path d="M16 19h6M19 16v6" />
|
||||
</>,
|
||||
);
|
||||
|
||||
export const UploadIcon = icon(
|
||||
<>
|
||||
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" />
|
||||
<path d="M7 9l5-5 5 5M12 4v12" />
|
||||
</>,
|
||||
);
|
||||
|
||||
export const FolderUploadIcon = icon(
|
||||
<>
|
||||
<path d="M12 19H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h4l3 3h7a2 2 0 0 1 2 2v3" />
|
||||
<path d="M19 22v-6M22 19l-3-3-3 3" />
|
||||
</>,
|
||||
);
|
||||
|
||||
export const FolderIcon = icon(
|
||||
<path d="M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2" />,
|
||||
);
|
||||
|
||||
export const UpIcon = icon(<path d="M18 18h-6a3 3 0 0 1-3-3V5M13 10 9 6 5 10" />);
|
||||
|
||||
export const SearchIcon = icon(
|
||||
<>
|
||||
<circle cx="10" cy="10" r="7" />
|
||||
<path d="m21 21-6-6" />
|
||||
</>,
|
||||
);
|
||||
|
||||
export const ListIcon = icon(<path d="M9 6h11M9 12h11M9 18h11M5 6v.01M5 12v.01M5 18v.01" />);
|
||||
|
||||
export const GridIcon = icon(
|
||||
<>
|
||||
<rect x="4" y="4" width="6" height="6" rx="1" />
|
||||
<rect x="14" y="4" width="6" height="6" rx="1" />
|
||||
<rect x="4" y="14" width="6" height="6" rx="1" />
|
||||
<rect x="14" y="14" width="6" height="6" rx="1" />
|
||||
</>,
|
||||
);
|
||||
|
||||
export const RefreshIcon = icon(
|
||||
<>
|
||||
<path d="M20 11A8.1 8.1 0 0 0 4.5 9M4 5v4h4" />
|
||||
<path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4" />
|
||||
</>,
|
||||
);
|
||||
|
||||
export const DotsIcon = icon(
|
||||
<>
|
||||
<circle cx="5" cy="12" r="1" />
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
<circle cx="19" cy="12" r="1" />
|
||||
</>,
|
||||
);
|
||||
|
||||
export const TrashIcon = icon(
|
||||
<path d="M4 7h16M10 11v6M14 11v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" />,
|
||||
);
|
||||
|
||||
export const SelectAllIcon = icon(
|
||||
<>
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</>,
|
||||
);
|
||||
|
||||
export const DeselectIcon = icon(<rect x="4" y="4" width="16" height="16" rx="2" />);
|
||||
|
||||
export const MoveIcon = icon(
|
||||
<>
|
||||
<path d="M12 19H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h4l3 3h7a2 2 0 0 1 2 2v3" />
|
||||
<path d="M16 19h6M19 16l3 3-3 3" />
|
||||
</>,
|
||||
);
|
||||
|
||||
export const SaveIcon = icon(
|
||||
<>
|
||||
<path d="M6 4h10l4 4v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2" />
|
||||
<circle cx="12" cy="14" r="2" />
|
||||
<path d="M14 4v4H8V4" />
|
||||
</>,
|
||||
);
|
||||
|
||||
export const UpdateIcon = icon(
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 16V8M8.5 11.5 12 8l3.5 3.5" />
|
||||
</>,
|
||||
);
|
||||
|
||||
export const CopyIcon = icon(
|
||||
<>
|
||||
<rect x="8" y="8" width="12" height="12" rx="2" />
|
||||
<path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" />
|
||||
</>,
|
||||
);
|
||||
@@ -0,0 +1,91 @@
|
||||
// Import de fichiers et d'arborescences (glisser-déposer ou sélecteur de dossier).
|
||||
|
||||
export interface PickedFile {
|
||||
/** Chemin relatif au point d'import, dossiers compris (« Archi/réseau/x.excalidraw »). */
|
||||
rel: string;
|
||||
file: File;
|
||||
}
|
||||
|
||||
export interface Collected {
|
||||
files: PickedFile[];
|
||||
/** Dossiers à recréer (y compris vides), parents avant enfants. */
|
||||
dirs: string[];
|
||||
/** Fichiers ignorés : pas des schémas Excalidraw. */
|
||||
ignored: string[];
|
||||
}
|
||||
|
||||
export const isScene = (name: string) => /\.excalidraw$/i.test(name);
|
||||
|
||||
// Fichiers et dossiers cachés (.DS_Store, .git…) : ignorés sans être signalés.
|
||||
const hidden = (name: string) => name.startsWith(".");
|
||||
|
||||
/** Le glisser contient-il des fichiers venant du système ? */
|
||||
export const hasFiles = (dt: DataTransfer) => dt.types.includes("Files");
|
||||
|
||||
const readAll = (reader: FileSystemDirectoryReader) =>
|
||||
new Promise<FileSystemEntry[]>((resolve, reject) => {
|
||||
const out: FileSystemEntry[] = [];
|
||||
// readEntries rend les entrées par lots (100 sous Chrome) : boucler jusqu'au lot vide.
|
||||
const next = () =>
|
||||
reader.readEntries((batch) => {
|
||||
if (batch.length === 0) return resolve(out);
|
||||
out.push(...batch);
|
||||
next();
|
||||
}, reject);
|
||||
next();
|
||||
});
|
||||
|
||||
const fileOf = (entry: FileSystemFileEntry) => new Promise<File>((resolve, reject) => entry.file(resolve, reject));
|
||||
|
||||
async function walk(entry: FileSystemEntry, prefix: string, acc: Collected) {
|
||||
if (hidden(entry.name)) return;
|
||||
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory) {
|
||||
acc.dirs.push(rel);
|
||||
for (const child of await readAll((entry as FileSystemDirectoryEntry).createReader())) {
|
||||
await walk(child, rel, acc);
|
||||
}
|
||||
} else if (isScene(entry.name)) {
|
||||
acc.files.push({ rel, file: await fileOf(entry as FileSystemFileEntry) });
|
||||
} else {
|
||||
acc.ignored.push(rel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit un dépôt (fichiers et dossiers). Les entrées doivent être capturées pendant
|
||||
* l'événement drop : le DataTransfer est vidé dès que le gestionnaire rend la main.
|
||||
*/
|
||||
export function collectDrop(dt: DataTransfer): Promise<Collected> {
|
||||
const entries = Array.from(dt.items)
|
||||
.filter((i) => i.kind === "file")
|
||||
.map((i) => i.webkitGetAsEntry?.())
|
||||
.filter((e): e is FileSystemEntry => !!e);
|
||||
const plain = entries.length ? [] : Array.from(dt.files); // navigateur sans webkitGetAsEntry
|
||||
return (async () => {
|
||||
const acc: Collected = { files: [], dirs: [], ignored: [] };
|
||||
for (const e of entries) await walk(e, "", acc);
|
||||
collectInto(plain, acc);
|
||||
return acc;
|
||||
})();
|
||||
}
|
||||
|
||||
/** Fichiers d'un <input type="file" webkitdirectory> (chemins dans webkitRelativePath). */
|
||||
export function collectFiles(files: FileList | File[]): Collected {
|
||||
const acc: Collected = { files: [], dirs: [], ignored: [] };
|
||||
collectInto(Array.from(files), acc);
|
||||
return acc;
|
||||
}
|
||||
|
||||
function collectInto(files: File[], acc: Collected) {
|
||||
const dirs = new Set(acc.dirs);
|
||||
for (const file of files) {
|
||||
const rel = file.webkitRelativePath || file.name;
|
||||
const parts = rel.split("/");
|
||||
if (parts.some(hidden)) continue;
|
||||
for (let i = 1; i < parts.length; i++) dirs.add(parts.slice(0, i).join("/"));
|
||||
if (isScene(file.name)) acc.files.push({ rel, file });
|
||||
else acc.ignored.push(rel);
|
||||
}
|
||||
acc.dirs = [...dirs].sort((a, b) => a.split("/").length - b.split("/").length);
|
||||
}
|
||||
Vendored
+3
@@ -3,3 +3,6 @@
|
||||
interface Window {
|
||||
EXCALIDRAW_ASSET_PATH?: string | string[];
|
||||
}
|
||||
|
||||
/** Version de @excalidraw/excalidraw embarquée au build (voir vite.config.ts). */
|
||||
declare const __EXCALIDRAW_VERSION__: string;
|
||||
Reference in new issue
Block a user