Historique des versions : snapshots horodatés, différences, restauration
- Avant d'écraser un schéma, le contenu précédent est copié dans DATA_DIR/.history/<chemin>/<date>.excalidraw, au plus une version par HISTORY_INTERVAL (10 min) ; rétention HISTORY_KEEP (50) et HISTORY_MAX_AGE. Pas de git : l'image reste distroless. - API : GET /api/history, GET /api/history/version, POST /api/history/snapshot, POST /api/history/restore (le contenu remplacé devient une version). - L'historique suit renommages et déplacements, part à la corbeille avec le schéma supprimé. - Dialogue Historique (barre du haut, menu de l'explorateur) : aperçu, différences colorées avec l'état actuel, restaurer, restaurer en copie, créer une version. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H6J7vKwXx7uopxKhWfFJpm
This commit is contained in:
1 parent
88f7750577
commit
9f9ec3b3a4
14 files changed
+1005
-7
No files matched your search
+47
-1
@@ -16,7 +16,8 @@ import type {
|
||||
import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
|
||||
import { api, ApiError, basename, stripExt, type Settings } from "./api";
|
||||
import { Explorer } from "./Explorer";
|
||||
import { CloseIcon, MenuIcon, MoonIcon, SaveIcon, SettingsIcon, SunIcon, UpdateIcon } from "./icons";
|
||||
import { CloseIcon, HistoryIcon, MenuIcon, MoonIcon, SaveIcon, SettingsIcon, SunIcon, UpdateIcon } from "./icons";
|
||||
import { HistoryDialog } from "./HistoryDialog";
|
||||
import { Splitter } from "./Splitter";
|
||||
import { SettingsDialog, DEFAULT_TITLE } from "./SettingsDialog";
|
||||
import { pendingVersion, useUpdateStatus } from "./update";
|
||||
@@ -91,6 +92,7 @@ export default function App() {
|
||||
const updateStatus = useUpdateStatus();
|
||||
const pending = pendingVersion(updateStatus, settings.channel);
|
||||
const [customizing, setCustomizing] = useState<false | "all" | "excalidraw">(false);
|
||||
const [historyFor, setHistoryFor] = useState<string | null>(null);
|
||||
|
||||
const excalidraw = useRef<ExcalidrawImperativeAPI | null>(null);
|
||||
const pathRef = useRef<string | null>(null); // schéma ouvert (survit aux renommages)
|
||||
@@ -335,6 +337,28 @@ export default function App() {
|
||||
[closeFile],
|
||||
);
|
||||
|
||||
// --- Historique -------------------------------------------------------------
|
||||
|
||||
/** Les modifications en cours sont enregistrées avant d'afficher l'historique. */
|
||||
const showHistory = useCallback(
|
||||
async (path: string) => {
|
||||
if (path === pathRef.current) await save();
|
||||
setHistoryFor(path);
|
||||
},
|
||||
[save],
|
||||
);
|
||||
|
||||
const restoreVersion = useCallback(
|
||||
async (path: string, id: string) => {
|
||||
const open = path === pathRef.current;
|
||||
if (open) await save();
|
||||
await api.restore(path, id);
|
||||
if (open) await openFile(path, { skipFlush: true });
|
||||
setRefreshKey((k) => k + 1);
|
||||
},
|
||||
[save, openFile],
|
||||
);
|
||||
|
||||
// --- Rendu -----------------------------------------------------------------
|
||||
|
||||
return (
|
||||
@@ -378,6 +402,14 @@ export default function App() {
|
||||
<SaveIcon />
|
||||
<span className="save-label">Enregistrer</span>
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn"
|
||||
aria-label="Historique des versions"
|
||||
title="Historique des versions"
|
||||
onClick={() => showHistory(scene.path)}
|
||||
>
|
||||
<HistoryIcon />
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn"
|
||||
aria-label="Fermer le schéma"
|
||||
@@ -425,6 +457,7 @@ export default function App() {
|
||||
onOpen={(p) => openFile(p)}
|
||||
onMoved={onMoved}
|
||||
onDeleted={onDeleted}
|
||||
onHistory={showHistory}
|
||||
/>
|
||||
</aside>
|
||||
<Splitter
|
||||
@@ -461,6 +494,19 @@ export default function App() {
|
||||
)}
|
||||
</main>
|
||||
|
||||
{historyFor && (
|
||||
<HistoryDialog
|
||||
path={historyFor}
|
||||
theme={theme}
|
||||
onRestore={(id) => restoreVersion(historyFor, id)}
|
||||
onCopied={(p) => {
|
||||
setRefreshKey((k) => k + 1);
|
||||
openFile(p);
|
||||
}}
|
||||
onClose={() => setHistoryFor(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{customizing && (
|
||||
<SettingsDialog
|
||||
settings={settings}
|
||||
|
||||
@@ -52,6 +52,8 @@ interface Props {
|
||||
onMoved: (from: string, to: string) => void;
|
||||
/** Un fichier ou dossier a été supprimé. */
|
||||
onDeleted: (path: string) => void;
|
||||
/** Affiche l'historique des versions d'un schéma. */
|
||||
onHistory: (path: string) => void;
|
||||
}
|
||||
|
||||
type View = "list" | "grid";
|
||||
@@ -78,7 +80,7 @@ const initialView = (): View => {
|
||||
const canMoveInto = (path: string, targetDir: string) =>
|
||||
dirname(path) !== targetDir && targetDir !== path && !targetDir.startsWith(path + "/");
|
||||
|
||||
export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Props) {
|
||||
export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHistory }: Props) {
|
||||
const [dir, setDir] = useState(() => (current ? dirname(current) : ""));
|
||||
const [entries, setEntries] = useState<Entry[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -421,6 +423,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
|
||||
<button onClick={() => rename(e)}>Renommer</button>
|
||||
<button onClick={() => setMoving([e.path])}>Déplacer…</button>
|
||||
{!e.isDir && <button onClick={() => duplicate(e)}>Dupliquer</button>}
|
||||
{!e.isDir && <button onClick={() => onHistory(e.path)}>Historique…</button>}
|
||||
{!e.isDir && (
|
||||
<a href={api.downloadUrl(e.path)} download={e.name}>
|
||||
Télécharger
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { exportToSvg, restoreElements } from "@excalidraw/excalidraw";
|
||||
import type { BinaryFiles } from "@excalidraw/excalidraw/types";
|
||||
import type { ExcalidrawElement } from "@excalidraw/excalidraw/element/types";
|
||||
import { api, basename, dirname, join, stripExt, withExt, type Version } from "./api";
|
||||
import { DownloadIcon, HistoryIcon } from "./icons";
|
||||
|
||||
interface Props {
|
||||
path: string;
|
||||
theme: "light" | "dark";
|
||||
/** Restaure la version (le parent enregistre et recharge le schéma ouvert). */
|
||||
onRestore: (id: string) => Promise<void>;
|
||||
/** Une copie a été créée à partir d'une version. */
|
||||
onCopied: (path: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface Scene {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
files: BinaryFiles;
|
||||
background: string;
|
||||
}
|
||||
|
||||
type View = "version" | "current" | "diff";
|
||||
|
||||
const CURRENT = "";
|
||||
|
||||
const parse = (text: string): Scene => {
|
||||
const data = JSON.parse(text);
|
||||
return {
|
||||
elements: restoreElements(data.elements ?? [], null).filter((e) => !e.isDeleted),
|
||||
files: data.files ?? {},
|
||||
background: data.appState?.viewBackgroundColor ?? "#ffffff",
|
||||
};
|
||||
};
|
||||
|
||||
const fmtDate = (iso: string) =>
|
||||
new Date(iso).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
|
||||
|
||||
const fmtAge = (iso: string) => {
|
||||
const s = (Date.now() - new Date(iso).getTime()) / 1000;
|
||||
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
|
||||
if (s < 3600) return rtf.format(-Math.max(1, Math.round(s / 60)), "minute");
|
||||
if (s < 86400) return rtf.format(-Math.round(s / 3600), "hour");
|
||||
return rtf.format(-Math.round(s / 86400), "day");
|
||||
};
|
||||
|
||||
const fmtSize = (n: number) => (n < 1 << 20 ? `${Math.max(1, Math.round(n / 1024))} Ko` : `${(n / (1 << 20)).toFixed(1)} Mo`);
|
||||
|
||||
// Différences élément par élément (identifiants stables dans Excalidraw).
|
||||
const DIFF = {
|
||||
added: { stroke: "#2f9e44", fill: "#b2f2bb", label: "ajouté" },
|
||||
removed: { stroke: "#e03131", fill: "#ffc9c9", label: "supprimé" },
|
||||
changed: { stroke: "#f08c00", fill: "#ffec99", label: "modifié" },
|
||||
} as const;
|
||||
type Change = keyof typeof DIFF;
|
||||
|
||||
function diff(older: Scene, now: Scene) {
|
||||
const before = new Map(older.elements.map((e) => [e.id, e]));
|
||||
const after = new Map(now.elements.map((e) => [e.id, e]));
|
||||
const kind = new Map<string, Change>();
|
||||
for (const [id, e] of after) {
|
||||
const o = before.get(id);
|
||||
if (!o) kind.set(id, "added");
|
||||
else if (o.version !== e.version || o.versionNonce !== e.versionNonce) kind.set(id, "changed");
|
||||
}
|
||||
for (const id of before.keys()) if (!after.has(id)) kind.set(id, "removed");
|
||||
const count = { added: 0, removed: 0, changed: 0 };
|
||||
for (const k of kind.values()) count[k]++;
|
||||
|
||||
// Scène de comparaison : l'état actuel plus les éléments disparus, colorés
|
||||
// selon le changement ; ce qui n'a pas bougé est estompé.
|
||||
const paint = (e: ExcalidrawElement): ExcalidrawElement => {
|
||||
const container = "containerId" in e ? e.containerId : null; // texte d'une forme
|
||||
const k = kind.get(e.id) ?? (container ? kind.get(container) : undefined);
|
||||
if (!k) return { ...e, opacity: 20 };
|
||||
const c = DIFF[k];
|
||||
return {
|
||||
...e,
|
||||
opacity: 100,
|
||||
strokeColor: c.stroke,
|
||||
backgroundColor: e.backgroundColor === "transparent" ? "transparent" : c.fill,
|
||||
...(k === "removed" ? { strokeStyle: "dashed" as const } : {}),
|
||||
};
|
||||
};
|
||||
const elements = [...now.elements, ...older.elements.filter((e) => !after.has(e.id))].map(paint);
|
||||
return { count, scene: { elements, files: { ...older.files, ...now.files }, background: now.background } };
|
||||
}
|
||||
|
||||
/** Historique d'un schéma : aperçu, comparaison avec l'état actuel, restauration. */
|
||||
export function HistoryDialog({ path, theme, onRestore, onCopied, onClose }: Props) {
|
||||
const [versions, setVersions] = useState<Version[] | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [view, setView] = useState<View>("diff");
|
||||
const [scenes, setScenes] = useState<Record<string, Scene>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const preview = useRef<HTMLDivElement>(null);
|
||||
const name = stripExt(basename(path));
|
||||
|
||||
const run = async (op: () => Promise<unknown>) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await op();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadList = () =>
|
||||
run(async () => {
|
||||
const [vs, cur] = await Promise.all([api.history(path), api.read(path)]);
|
||||
setScenes((s) => ({ ...s, [CURRENT]: parse(cur.text) }));
|
||||
setVersions(vs);
|
||||
setSelected((sel) => sel ?? vs[0]?.id ?? null);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadList();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [path]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
// Contenu de la version choisie (mis en cache : une version ne change pas).
|
||||
useEffect(() => {
|
||||
if (!selected || scenes[selected]) return;
|
||||
let live = true;
|
||||
api
|
||||
.readVersion(path, selected)
|
||||
.then((t) => live && setScenes((s) => ({ ...s, [selected]: parse(t) })))
|
||||
.catch((e) => live && setError(e.message));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [path, selected, scenes]);
|
||||
|
||||
const older = selected ? scenes[selected] : undefined;
|
||||
const now = scenes[CURRENT];
|
||||
const comparison = useMemo(() => (older && now ? diff(older, now) : null), [older, now]);
|
||||
const shown = view === "current" ? now : view === "version" ? older : comparison?.scene;
|
||||
|
||||
// Aperçu SVG rendu par Excalidraw.
|
||||
useEffect(() => {
|
||||
const box = preview.current;
|
||||
if (!box) return;
|
||||
box.replaceChildren();
|
||||
if (!shown) return;
|
||||
if (shown.elements.length === 0) {
|
||||
box.textContent = "Schéma vide";
|
||||
return;
|
||||
}
|
||||
let live = true;
|
||||
exportToSvg({
|
||||
elements: shown.elements,
|
||||
appState: {
|
||||
viewBackgroundColor: shown.background,
|
||||
exportBackground: true,
|
||||
exportWithDarkMode: theme === "dark",
|
||||
},
|
||||
files: shown.files,
|
||||
exportPadding: 16,
|
||||
})
|
||||
.then((svg: SVGSVGElement) => {
|
||||
if (!live) return;
|
||||
svg.removeAttribute("width");
|
||||
svg.removeAttribute("height");
|
||||
box.replaceChildren(svg);
|
||||
})
|
||||
.catch((e: Error) => live && setError(`Aperçu impossible : ${e.message}`));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [shown, theme]);
|
||||
|
||||
const current = versions?.find((v) => v.id === selected);
|
||||
|
||||
const restore = () =>
|
||||
current &&
|
||||
confirm(`Restaurer la version du ${fmtDate(current.time)} ?\n\nL'état actuel est conservé dans l'historique.`) &&
|
||||
run(async () => {
|
||||
await onRestore(current.id);
|
||||
onClose();
|
||||
});
|
||||
|
||||
const copy = () =>
|
||||
current &&
|
||||
run(async () => {
|
||||
const d = new Date(current.time);
|
||||
const stamp = `${d.toLocaleDateString().replace(/\//g, "-")} ${String(d.getHours()).padStart(2, "0")}h${String(d.getMinutes()).padStart(2, "0")}`;
|
||||
const to = join(dirname(path), withExt(`${name} (version du ${stamp})`));
|
||||
await api.write(to, await api.readVersion(path, current.id), "*");
|
||||
onCopied(to);
|
||||
onClose();
|
||||
});
|
||||
|
||||
const snapshot = () =>
|
||||
run(async () => {
|
||||
const v = await api.snapshot(path);
|
||||
setSelected(v.id);
|
||||
await loadList();
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div
|
||||
className="modal history"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Historique de ${name}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="history-head">
|
||||
<h3>Historique · {name}</h3>
|
||||
<button disabled={busy} onClick={snapshot} title="Enregistrer l'état actuel comme version">
|
||||
<HistoryIcon />
|
||||
<span>Créer une version</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="history-body">
|
||||
<ul className="versions-list">
|
||||
{versions?.map((v) => (
|
||||
<li key={v.id}>
|
||||
<button className={v.id === selected ? "on" : ""} onClick={() => setSelected(v.id)}>
|
||||
<span>{fmtDate(v.time)}</span>
|
||||
<small className="muted">
|
||||
{fmtAge(v.time)} · {fmtSize(v.size)}
|
||||
</small>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{versions?.length === 0 && (
|
||||
<li className="muted empty">
|
||||
Aucune version pour l'instant. Une version est conservée automatiquement quand le schéma est
|
||||
modifié après une pause ; « Créer une version » en fixe une tout de suite.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<div className="history-preview">
|
||||
<div className="segmented" role="group" aria-label="Aperçu">
|
||||
{(
|
||||
[
|
||||
["diff", "Différences"],
|
||||
["version", "Cette version"],
|
||||
["current", "Actuelle"],
|
||||
] as [View, string][]
|
||||
).map(([id, label]) => (
|
||||
<button key={id} className={view === id ? "on" : ""} disabled={!selected} onClick={() => setView(id)}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="svg-box" ref={preview} />
|
||||
{view === "diff" && comparison && (
|
||||
<p className="legend">
|
||||
<span className="muted">Depuis cette version :</span>
|
||||
{(Object.keys(DIFF) as Change[]).map((k) => (
|
||||
<span key={k}>
|
||||
<i style={{ background: DIFF[k].stroke }} />
|
||||
{comparison.count[k]} {DIFF[k].label}
|
||||
{comparison.count[k] > 1 ? "s" : ""}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
<div className="modal-actions">
|
||||
{current && (
|
||||
<a className="link" href={api.versionUrl(path, current.id)} download={`${name}.excalidraw`} title="Télécharger cette version">
|
||||
<DownloadIcon />
|
||||
</a>
|
||||
)}
|
||||
<span className="spacer" />
|
||||
<button onClick={onClose}>Fermer</button>
|
||||
<button disabled={busy || !current} onClick={copy} title="Créer un nouveau schéma à partir de cette version">
|
||||
Restaurer en copie
|
||||
</button>
|
||||
<button className="primary" disabled={busy || !current} onClick={restore}>
|
||||
Restaurer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,13 @@ export interface UpdateStatus {
|
||||
checkedAt?: string;
|
||||
}
|
||||
|
||||
/** Version enregistrée d'un schéma (time : date de ce contenu). */
|
||||
export interface Version {
|
||||
id: string;
|
||||
time: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
@@ -98,6 +105,22 @@ export const api = {
|
||||
await request(`/api/entry?path=${q(path)}&recursive=${recursive}`, { method: "DELETE" });
|
||||
},
|
||||
|
||||
history: async (path: string): Promise<Version[]> =>
|
||||
(await request(`/api/history?path=${q(path)}`)).json(),
|
||||
|
||||
readVersion: async (path: string, id: string): Promise<string> =>
|
||||
(await request(`/api/history/version?path=${q(path)}&id=${q(id)}`)).text(),
|
||||
|
||||
/** Crée une version du contenu courant (sans attendre l'intervalle automatique). */
|
||||
snapshot: async (path: string): Promise<Version> =>
|
||||
(await request("/api/history/snapshot", json({ path }))).json(),
|
||||
|
||||
/** Restaure une version ; le contenu remplacé devient lui-même une version. */
|
||||
restore: async (path: string, id: string): Promise<Entry> =>
|
||||
(await request("/api/history/restore", json({ path, id }))).json(),
|
||||
|
||||
versionUrl: (path: string, id: string) => `/api/history/version?path=${q(path)}&id=${q(id)}`,
|
||||
|
||||
readLibrary: async (): Promise<unknown> => (await request("/api/library")).json(),
|
||||
|
||||
writeLibrary: async (body: string): Promise<void> => {
|
||||
|
||||
@@ -149,3 +149,17 @@ export const CopyIcon = icon(
|
||||
<path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" />
|
||||
</>,
|
||||
);
|
||||
|
||||
export const HistoryIcon = icon(
|
||||
<>
|
||||
<path d="M12 8v4l2 2" />
|
||||
<path d="M3.05 11a9 9 0 1 1 .5 4m-.5 5v-5h5" />
|
||||
</>,
|
||||
);
|
||||
|
||||
export const DownloadIcon = icon(
|
||||
<>
|
||||
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" />
|
||||
<path d="M7 11l5 5 5-5M12 4v12" />
|
||||
</>,
|
||||
);
|
||||
@@ -1005,3 +1005,125 @@ li.active > .row {
|
||||
background: var(--ex-primary);
|
||||
color: var(--ex-surface-lowest);
|
||||
}
|
||||
|
||||
/* --- Historique des versions ----------------------------------------------- */
|
||||
|
||||
.modal.history {
|
||||
width: min(920px, 100%);
|
||||
height: min(640px, 92dvh);
|
||||
max-height: none;
|
||||
}
|
||||
.history-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.history-head h3 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.history-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.versions-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--ex-radius-lg);
|
||||
background: var(--ex-surface-lowest);
|
||||
}
|
||||
.versions-list button {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
font-size: 13px;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
.versions-list button.on {
|
||||
background: var(--accent-soft);
|
||||
color: var(--ex-on-primary-container);
|
||||
}
|
||||
.versions-list small {
|
||||
font-size: 11px;
|
||||
}
|
||||
.versions-list .empty {
|
||||
padding: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.history-preview {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.history-preview .segmented {
|
||||
align-self: flex-start;
|
||||
}
|
||||
.history-preview .segmented button {
|
||||
height: calc(var(--ex-btn-lg) - 6px);
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.history-preview .segmented button.on {
|
||||
background: var(--ex-primary-container);
|
||||
color: var(--ex-on-primary-container);
|
||||
}
|
||||
.svg-box {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--ex-radius-lg);
|
||||
background: var(--ex-surface-lowest);
|
||||
color: var(--muted);
|
||||
}
|
||||
.svg-box svg {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 14px;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.legend i {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-right: 5px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.modal-actions a.link {
|
||||
display: inline-flex;
|
||||
padding: 6px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.modal.history {
|
||||
height: 92dvh;
|
||||
}
|
||||
.history-body {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(90px, 30%) 1fr;
|
||||
}
|
||||
.history-head button span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user