feat(sprint-5.5): add block version snapshots with switching between versions

- Add BlockSnapshot Prisma model (html, css, version, changelog) + migration
- Add API endpoints: POST/GET /blocks/snapshots, GET /blocks/snapshots/:id
- BlockMetaBar: version dropdown, HTML capture on save, onSnapshotSelect prop
- "Сохранить версию" now captures innerHTML snapshot + CSS and stores in DB
- Selecting archived version shows stored HTML snapshot instead of live component
- Yellow banner "Архивная версия" with link to return to current
- Split all 8 block pages into Server Component (metadata) + Client Component
- Add data-block-capture attribute for snapshot capture targeting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AR 15 M4
2026-03-25 00:17:25 +05:00
parent 196526ffc4
commit 5b54ad5c23
21 changed files with 1962 additions and 1554 deletions
+135 -10
View File
@@ -25,14 +25,47 @@ interface BlockMeta {
changelog: ChangelogEntry[];
}
interface SnapshotListItem {
id: string;
version: string;
createdAt: string;
}
export interface SnapshotData {
html: string;
css: string;
}
interface BlockMetaBarProps {
path: string;
defaultVersion: string;
defaultIsInPreview: boolean;
defaultChangelog?: ChangelogEntry[];
onSnapshotSelect?: (snapshot: SnapshotData | null) => void;
}
export function BlockMetaBar({ path, defaultVersion, defaultIsInPreview, defaultChangelog = [] }: BlockMetaBarProps) {
function captureBlockHtml(path: string): { html: string; css: string } {
const container = document.querySelector(`[data-block-capture="${path}"]`);
if (!container) throw new Error("Block container not found");
const html = container.innerHTML;
const cssRules: string[] = [];
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
const text = rule.cssText;
if (text.includes("bb-") || text.includes("--brand") || text.includes("--bb-")) {
cssRules.push(text);
}
}
} catch { /* cross-origin sheets */ }
}
return { html, css: cssRules.join("\n") };
}
export function BlockMetaBar({ path, defaultVersion, defaultIsInPreview, defaultChangelog = [], onSnapshotSelect }: BlockMetaBarProps) {
const [meta, setMeta] = useState<BlockMeta | null>(null);
const [editing, setEditing] = useState(false);
const [versionInput, setVersionInput] = useState(defaultVersion);
@@ -41,6 +74,9 @@ export function BlockMetaBar({ path, defaultVersion, defaultIsInPreview, default
const [togglingPreview, setTogglingPreview] = useState(false);
const [apiDown, setApiDown] = useState(false);
const [localPreview, setLocalPreview] = useState(defaultIsInPreview);
const [snapshots, setSnapshots] = useState<SnapshotListItem[]>([]);
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string>("current");
const [loadingSnapshot, setLoadingSnapshot] = useState(false);
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
@@ -59,6 +95,20 @@ export function BlockMetaBar({ path, defaultVersion, defaultIsInPreview, default
.catch(() => setApiDown(true));
}, [apiUrl, path]);
useEffect(() => {
if (!apiUrl || apiDown) return;
fetchSnapshots();
}, [apiUrl, apiDown, path]);
async function fetchSnapshots() {
if (!apiUrl) return;
try {
const r = await fetch(`${apiUrl}/blocks/snapshots?path=${encodeURIComponent(path)}`);
const data: SnapshotListItem[] = await r.json();
setSnapshots(data);
} catch { /* ignore */ }
}
async function patch(data: { version?: string; isInPreview?: boolean; changelog?: ChangelogEntry[] }) {
if (!apiUrl) return null;
const r = await fetch(`${apiUrl}/blocks/by-path?path=${encodeURIComponent(path)}`, {
@@ -81,14 +131,27 @@ export function BlockMetaBar({ path, defaultVersion, defaultIsInPreview, default
}
async function saveFullVersion() {
if (apiDown) return;
if (apiDown || !apiUrl) return;
setSavingVersion('saving');
try {
// 1. Update block metadata
const updated = await patch({ version: defaultVersion, changelog: defaultChangelog });
if (updated) {
setMeta(updated);
setVersionInput(updated.version);
}
// 2. Capture and save HTML snapshot
try {
const { html, css } = captureBlockHtml(path);
await fetch(`${apiUrl}/blocks/snapshots?path=${encodeURIComponent(path)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ version: defaultVersion, changelog: defaultChangelog, html, css }),
});
await fetchSnapshots();
} catch { /* snapshot capture may fail if container not found */ }
setSavingVersion('done');
setTimeout(() => setSavingVersion(false), 1500);
} catch {
@@ -113,9 +176,30 @@ export function BlockMetaBar({ path, defaultVersion, defaultIsInPreview, default
}
}
async function handleVersionSelect(value: string) {
setSelectedSnapshotId(value);
if (value === "current") {
onSnapshotSelect?.(null);
return;
}
if (!apiUrl) return;
setLoadingSnapshot(true);
try {
const r = await fetch(`${apiUrl}/blocks/snapshots/${value}`);
const data = await r.json();
onSnapshotSelect?.({ html: data.html, css: data.css });
} catch {
onSnapshotSelect?.(null);
setSelectedSnapshotId("current");
} finally {
setLoadingSnapshot(false);
}
}
const version = meta?.version ?? defaultVersion;
const isInPreview = apiDown ? localPreview : (meta?.isInPreview ?? localPreview);
const changelog: ChangelogEntry[] = (meta?.changelog && meta.changelog.length > 0) ? meta.changelog : defaultChangelog;
const isViewingSnapshot = selectedSnapshotId !== "current";
return (
<>
@@ -123,11 +207,31 @@ export function BlockMetaBar({ path, defaultVersion, defaultIsInPreview, default
className="flex items-center flex-wrap gap-3 px-4 py-2 rounded-lg text-xs mb-6"
style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }}
>
{/* Version badge */}
{/* Version selector */}
<span className="font-semibold" style={{ color: "var(--bb-text-muted)" }}>
Версия:
</span>
{editing ? (
{snapshots.length > 0 ? (
<select
value={selectedSnapshotId}
onChange={(e) => handleVersionSelect(e.target.value)}
disabled={loadingSnapshot}
className="font-mono px-2 py-0.5 rounded text-xs"
style={{
background: "var(--bb-sidebar-active-bg, #dff0fa)",
color: "var(--brand-053m)",
border: "1px solid var(--bb-border)",
cursor: "pointer",
}}
>
<option value="current">{version} (текущая)</option>
{snapshots.map((s) => (
<option key={s.id} value={s.id}>
{s.version} {new Date(s.createdAt).toLocaleDateString("ru")}
</option>
))}
</select>
) : editing ? (
<span className="flex items-center gap-1.5">
<input
className="px-2 py-0.5 rounded border text-xs font-mono w-20"
@@ -168,6 +272,10 @@ export function BlockMetaBar({ path, defaultVersion, defaultIsInPreview, default
</button>
)}
{loadingSnapshot && (
<span className="text-xs" style={{ color: "var(--bb-text-muted)" }}>загрузка...</span>
)}
<span style={{ color: "var(--bb-border)" }}>·</span>
{/* Preview toggle */}
@@ -202,8 +310,8 @@ export function BlockMetaBar({ path, defaultVersion, defaultIsInPreview, default
{/* Save version button */}
<button
onClick={saveFullVersion}
disabled={apiDown || savingVersion === 'saving'}
title={apiDown ? 'API недоступен' : 'Сохранить текущую версию и changelog в БД'}
disabled={apiDown || savingVersion === 'saving' || isViewingSnapshot}
title={apiDown ? 'API недоступен' : isViewingSnapshot ? 'Переключитесь на текущую версию' : 'Сохранить текущую версию и changelog в БД'}
className="px-2.5 py-1 rounded font-medium transition-colors"
style={savingVersion === 'done' ? {
background: "#dcfce7",
@@ -212,16 +320,16 @@ export function BlockMetaBar({ path, defaultVersion, defaultIsInPreview, default
cursor: "default",
} : {
background: "var(--bb-sidebar-bg)",
color: apiDown ? "var(--bb-text-muted)" : "var(--brand-053m)",
color: (apiDown || isViewingSnapshot) ? "var(--bb-text-muted)" : "var(--brand-053m)",
border: "1px solid var(--bb-border)",
cursor: apiDown ? "default" : "pointer",
opacity: apiDown ? 0.5 : 1,
cursor: (apiDown || isViewingSnapshot) ? "default" : "pointer",
opacity: (apiDown || isViewingSnapshot) ? 0.5 : 1,
}}
>
{savingVersion === 'saving' ? '...' : savingVersion === 'done' ? 'Сохранено' : 'Сохранить версию'}
</button>
{/* Subtle offline dot instead of "API офлайн" text */}
{/* Subtle offline dot */}
{apiDown && (
<span
className="inline-block w-1.5 h-1.5 rounded-full"
@@ -231,6 +339,23 @@ export function BlockMetaBar({ path, defaultVersion, defaultIsInPreview, default
)}
</div>
{/* Archive notice */}
{isViewingSnapshot && (
<div
className="flex items-center gap-2 px-4 py-2 rounded-lg text-xs mb-4"
style={{ background: "#fef3c7", border: "1px solid #fcd34d", color: "#92400e" }}
>
Архивная версия (только просмотр).
<button
onClick={() => handleVersionSelect("current")}
className="underline font-medium"
style={{ color: "#92400e" }}
>
Вернуться к текущей
</button>
</div>
)}
{/* Changelog rendered from API or fallback */}
{changelog.length > 0 && <BlockChangelog changelog={changelog} />}
</>