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:
@@ -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} />}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user