You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
363 lines
12 KiB
363 lines
12 KiB
"use client"; |
|
|
|
import { useState, useEffect } from "react"; |
|
import { BlockChangelog, type ChangelogEntry } from "./BlockChangelog"; |
|
|
|
const LS_PREFIX = "bb-block-preview:"; |
|
|
|
function readLocalPreview(path: string, fallback: boolean): boolean { |
|
if (typeof window === "undefined") return fallback; |
|
const v = localStorage.getItem(LS_PREFIX + path); |
|
if (v === null) return fallback; |
|
return v === "true"; |
|
} |
|
|
|
function writeLocalPreview(path: string, value: boolean) { |
|
localStorage.setItem(LS_PREFIX + path, String(value)); |
|
window.dispatchEvent(new Event("bb-preview-change")); |
|
} |
|
|
|
interface BlockMeta { |
|
path: string; |
|
name: string; |
|
version: string; |
|
isInPreview: boolean; |
|
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; |
|
} |
|
|
|
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); |
|
const [saving, setSaving] = useState(false); |
|
const [savingVersion, setSavingVersion] = useState<false | 'saving' | 'done'>(false); |
|
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; |
|
|
|
useEffect(() => { |
|
setLocalPreview(readLocalPreview(path, defaultIsInPreview)); |
|
}, [path, defaultIsInPreview]); |
|
|
|
useEffect(() => { |
|
if (!apiUrl) { setApiDown(true); return; } |
|
fetch(`${apiUrl}/blocks/by-path?path=${encodeURIComponent(path)}`) |
|
.then((r) => r.json()) |
|
.then((data: BlockMeta) => { |
|
setMeta(data); |
|
setVersionInput(data.version); |
|
}) |
|
.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)}`, { |
|
method: 'PATCH', |
|
headers: { 'Content-Type': 'application/json' }, |
|
body: JSON.stringify(data), |
|
}); |
|
return r.json() as Promise<BlockMeta>; |
|
} |
|
|
|
async function saveVersion() { |
|
if (!meta) return; |
|
setSaving(true); |
|
try { |
|
const updated = await patch({ version: versionInput }); |
|
if (updated) { setMeta(updated); setEditing(false); } |
|
} finally { |
|
setSaving(false); |
|
} |
|
} |
|
|
|
async function saveFullVersion() { |
|
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 { |
|
setSavingVersion(false); |
|
} |
|
} |
|
|
|
async function togglePreview() { |
|
if (apiDown) { |
|
const newVal = !localPreview; |
|
setLocalPreview(newVal); |
|
writeLocalPreview(path, newVal); |
|
return; |
|
} |
|
if (!meta) return; |
|
setTogglingPreview(true); |
|
try { |
|
const updated = await patch({ isInPreview: !meta.isInPreview }); |
|
if (updated) setMeta(updated); |
|
} finally { |
|
setTogglingPreview(false); |
|
} |
|
} |
|
|
|
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 ( |
|
<> |
|
<div |
|
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 selector */} |
|
<span className="font-semibold" style={{ color: "var(--bb-text-muted)" }}> |
|
Версия: |
|
</span> |
|
{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" |
|
style={{ borderColor: "var(--bb-border)", color: "var(--bb-text)" }} |
|
value={versionInput} |
|
onChange={(e) => setVersionInput(e.target.value)} |
|
onKeyDown={(e) => { if (e.key === 'Enter') saveVersion(); if (e.key === 'Escape') setEditing(false); }} |
|
autoFocus |
|
/> |
|
<button |
|
onClick={saveVersion} |
|
disabled={saving} |
|
className="px-2 py-0.5 rounded text-xs font-medium" |
|
style={{ background: "var(--brand-053m)", color: "#fff" }} |
|
> |
|
{saving ? '...' : 'Сохранить'} |
|
</button> |
|
<button |
|
onClick={() => { setEditing(false); setVersionInput(version); }} |
|
className="px-2 py-0.5 rounded text-xs" |
|
style={{ color: "var(--bb-text-muted)" }} |
|
> |
|
Отмена |
|
</button> |
|
</span> |
|
) : ( |
|
<button |
|
onClick={() => !apiDown && setEditing(true)} |
|
title={apiDown ? 'Версия из кода (API недоступен)' : 'Изменить версию'} |
|
className="font-mono px-2 py-0.5 rounded" |
|
style={{ |
|
background: "var(--bb-sidebar-active-bg, #dff0fa)", |
|
color: "var(--brand-053m)", |
|
cursor: apiDown ? 'default' : 'pointer', |
|
}} |
|
> |
|
{version} |
|
</button> |
|
)} |
|
|
|
{loadingSnapshot && ( |
|
<span className="text-xs" style={{ color: "var(--bb-text-muted)" }}>загрузка...</span> |
|
)} |
|
|
|
<span style={{ color: "var(--bb-border)" }}>·</span> |
|
|
|
{/* Preview toggle */} |
|
<button |
|
onClick={togglePreview} |
|
disabled={togglingPreview || (!apiDown && !meta)} |
|
title={isInPreview ? "Убрать из превью страницы" : "Включить в превью страницы"} |
|
className="flex items-center gap-1.5 px-2.5 py-1 rounded font-medium transition-colors cursor-pointer" |
|
style={isInPreview ? { |
|
background: "#dcfce7", |
|
color: "#16a34a", |
|
border: "1px solid #86efac", |
|
} : { |
|
background: "var(--bb-sidebar-bg)", |
|
color: "var(--bb-text-muted)", |
|
border: "1px solid var(--bb-border)", |
|
}} |
|
> |
|
<span |
|
className="inline-block w-1.5 h-1.5 rounded-full" |
|
style={{ background: isInPreview ? "#22c55e" : "#d1d5db" }} |
|
/> |
|
{togglingPreview |
|
? '...' |
|
: isInPreview |
|
? "В превью" |
|
: "Не в превью"} |
|
</button> |
|
|
|
<span style={{ color: "var(--bb-border)" }}>·</span> |
|
|
|
{/* Save version button */} |
|
<button |
|
onClick={saveFullVersion} |
|
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", |
|
color: "#16a34a", |
|
border: "1px solid #86efac", |
|
cursor: "default", |
|
} : { |
|
background: "var(--bb-sidebar-bg)", |
|
color: (apiDown || isViewingSnapshot) ? "var(--bb-text-muted)" : "var(--brand-053m)", |
|
border: "1px solid var(--bb-border)", |
|
cursor: (apiDown || isViewingSnapshot) ? "default" : "pointer", |
|
opacity: (apiDown || isViewingSnapshot) ? 0.5 : 1, |
|
}} |
|
> |
|
{savingVersion === 'saving' ? '...' : savingVersion === 'done' ? 'Сохранено' : 'Сохранить версию'} |
|
</button> |
|
|
|
{/* Subtle offline dot */} |
|
{apiDown && ( |
|
<span |
|
className="inline-block w-1.5 h-1.5 rounded-full" |
|
title="API недоступен — данные из кода" |
|
style={{ background: "#d1d5db" }} |
|
/> |
|
)} |
|
</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} />} |
|
</> |
|
); |
|
}
|
|
|