Цифровой бренд бук клиники. Описание элементов сайта: цвета, шрифты, блоки, формы, страницы
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.
 
 
 

364 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 (prefer current API version over hardcoded default)
const currentVersion = meta?.version ?? defaultVersion;
const updated = await patch({ version: currentVersion, 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: currentVersion, 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} />}
</>
);
}