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
+231
View File
@@ -0,0 +1,231 @@
"use client";
import { useState } from "react";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
import { HeroBlock, HERO_CHECKS } from "@/components/blocks/HeroBlock";
import { BlockMetaBar, type SnapshotData } from "@/components/ui/BlockMetaBar";
import { type ChangelogEntry } from "@/components/ui/BlockChangelog";
const BLOCK_PATH = "/blocks/hero";
const LLM_HERO_TEXT = `
БЛОК: Hero-баннер (главный баннер страницы)
Источник: perm.oclinica.ru/lor — реальный баннер раздела ЛОР
Версия: v1.3
ЗАГОЛОВОК СТРАНИЦЫ (H1, над баннером):
«ЛОР Клиника ухо, горло, нос - медицинский центр лечения ЛОР заболеваний у детей и взрослых»
Шрифт: Fira Sans, 36px, weight 700, цвет: #cb9768
СТРУКТУРА БАННЕРА (двухколоночная, единый фон #f9f4e7):
Левая колонка (~50%):
— Заголовок: «ЭНДОСКОПИЧЕСКОЕ ХИРУРГИЧЕСКОЕ ЛЕЧЕНИЕ ЛОР ОРГАНОВ»
Шрифт: Fira Sans, 22px, weight 700, uppercase, цвет #333333
— 3 пункта с галочками (✓ бежевый #bf9975):
1. «БЕЗОПАСНО – оперируют хирурги с 15-летним опытом работы»
2. «БЕЗ ВНЕШНИХ РАЗРЕЗОВ – хирургия сверхмалых размеров»
3. «БЫСТРО – под наблюдением врача пациент находится 1 сутки»
Ключевое слово: uppercase bold; описание: обычный текст, ~13px
— Кнопка «Узнать стоимость операции» — стиль: bb-btn-pill (кремовый фон #E9E4D4, radius 25px)
Правая колонка (~50%):
— Фото врача с пациентом
— Изображение занимает всю высоту блока
ПОД БАННЕРОМ:
— Кнопки соцсетей (VK, FB, TW), цвет #9ca3af
— Счётчик просмотров
ЦВЕТА:
Фон баннера: #f9f4e7 (светло-кремовый, единый для всего блока)
Кнопка CTA: pill-стиль (кремовый #E9E4D4, border #D5CFBD, radius 25px)
Заголовок блока: #333333
Пункты: ключевое слово #111827 bold, описание #374151
Галочка: #bf9975 (бежевый)
ПРАВИЛА:
✓ Фон баннера всегда #f9f4e7 (светло-кремовый) — единый, без разделения на зоны
✓ Заголовок блока uppercase, жирный
✓ Три пункта с галочками обязательны
✕ Не менять фон баннера на другой цвет
✕ Не разбивать фон на два разных цвета
✕ Не убирать три пункта с галочками
`.trim();
const CHANGELOG: ChangelogEntry[] = [
{
version: "v1.3",
date: "24.03.2026",
changes: [
"Счётчик: «98 573 просмотра» заменён на «Поделиться / 98572» (как на реальном сайте)",
"Убраны кнопки VK/FB/TW",
],
},
{
version: "v1.2",
date: "24.03.2026",
changes: [
"H1: цвет исправлен с #53514e на #cb9768, размер с ~20px на 36px",
"Заголовок баннера: размер с ~16px на 22px, цвет с #111827 на #333333",
"CTA-кнопка: стиль изменён с outline на pill (фон #E9E4D4, radius 25px)",
"Дефис в H1: длинное тире «–» заменено на простой дефис «-» (как на сайте)",
],
},
{
version: "v1.1",
date: "23.03.2026",
changes: [
"Единый фон #f9f4e7 (ранее был разбит на две зоны)",
"Реальное фото врача с пациентом",
],
},
];
export default function HeroPageClient() {
const [snapshot, setSnapshot] = useState<SnapshotData | null>(null);
return (
<div className="p-8 max-w-5xl mx-auto space-y-10">
{/* Заголовок страницы */}
<div>
<p
className="text-xs font-semibold uppercase tracking-widest mb-1"
style={{ color: "var(--brand-053m)" }}
>
Блоки
</p>
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Hero-баннер
</h1>
<BlockMetaBar
path={BLOCK_PATH}
defaultVersion="v1.3"
defaultIsInPreview={true}
defaultChangelog={CHANGELOG}
onSnapshotSelect={setSnapshot}
/>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Главный баннер страницы раздела ЛОР perm.oclinica.ru/lor. Двухколоночный блок, единый светло-кремовый фон{" "}
<strong>#f9f4e7</strong>.
</p>
</div>
{/* Живой пример */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Живой пример
</h2>
{snapshot ? (
<>
<style dangerouslySetInnerHTML={{ __html: snapshot.css }} />
<div dangerouslySetInnerHTML={{ __html: snapshot.html }} />
</>
) : (
<div data-block-capture={BLOCK_PATH}>
<HeroBlock />
</div>
)}
</section>
{/* Анатомия */}
<section className="space-y-4">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Анатомия баннера
</h2>
<div
className="rounded-xl p-5 space-y-2"
style={{ background: "#f5f0e8", border: "1px solid #d5cfbd" }}
>
<p className="font-semibold text-sm" style={{ color: "var(--bb-text)" }}>
Весь баннер единый фон <span className="font-mono">#f9f4e7</span>
</p>
<ul className="space-y-1">
{[
"Фон: #f9f4e7 (светло-кремовый) — одинаковый для всего блока",
"Левая половина (~50%): заголовок uppercase bold + 3 галочки + кнопка outline",
"Правая половина (~50%): фото врача с пациентом",
"Минимальная высота: ~280px",
].map((item) => (
<li key={item} className="text-xs flex items-start gap-1.5">
<span style={{ color: "var(--brand-053m)" }}>·</span>
<span style={{ color: "var(--bb-text-muted)" }}>{item}</span>
</li>
))}
</ul>
</div>
</section>
{/* Три пункта с галочками */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Три пункта баннера
</h2>
<div className="space-y-2">
{HERO_CHECKS.map((c) => (
<div
key={c.key}
className="flex items-start gap-3 p-3 rounded-lg"
style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }}
>
<span className="font-bold text-lg shrink-0" style={{ color: "#bf9975" }}>
</span>
<div>
<span className="text-sm font-bold uppercase" style={{ color: "#111827" }}>
{c.key}
</span>
<span className="text-sm" style={{ color: "#374151" }}>
{" "} {c.desc}
</span>
</div>
</div>
))}
</div>
<p className="text-xs" style={{ color: "var(--bb-text-muted)" }}>
Ключевое слово: uppercase + bold. Описание: обычный текст. Галочка: #bf9975 (бежевый).
</p>
</section>
{/* LLM блок */}
<LlmBlock path="/blocks/hero" version="v1.3" specText={LLM_HERO_TEXT}>
<LlmSection title="Структура баннера" />
<LlmTable
headers={["Зона", "Ширина", "Фон", "Содержимое"]}
rows={[
["Весь баннер", "100%", "#f9f4e7", "Единый светло-кремовый фон"],
["Левый блок", "~50%", "#f9f4e7 (общий)", "Заголовок uppercase + 3 галочки + кнопка pill"],
["Правый блок", "~50%", "#f9f4e7 (общий)", "Фото врача с пациентом"],
["Под баннером", "100%", "#fff", "Кнопки соцсетей + счётчик просмотров"],
]}
/>
<LlmSection title="Три пункта баннера" />
<LlmTable
headers={["Ключевое слово", "Описание"]}
rows={HERO_CHECKS.map((c) => [c.key, c.desc])}
/>
<LlmSection title="Цвета" />
<LlmTable
headers={["Элемент", "Цвет", "Токен"]}
rows={[
["Фон баннера (единый)", "#f9f4e7", "Светло-кремовый фон"],
["Кнопка CTA", "pill-стиль (#E9E4D4, 25px)", "bb-btn-pill"],
["Заголовок блока", "#333333", "—"],
["H1 страницы", "#cb9768", "36px, bold"],
["Галочка ✓", "#bf9975", "Бежевый"],
]}
/>
<LlmSection title="Правила" />
<LlmRules
rules={[
{ ok: true, text: "Фон баннера: #f9f4e7 (светло-кремовый) — единый для всего блока" },
{ ok: true, text: "Кнопка CTA: bb-btn-pill (кремовый #E9E4D4, radius 25px)" },
{ ok: true, text: "Заголовок: uppercase, bold" },
{ ok: true, text: "Три пункта с галочками ✓ (#bf9975)" },
{ ok: false, text: "Не менять фон баннера на другой цвет" },
{ ok: false, text: "Не разбивать баннер на два разных цвета фона" },
{ ok: false, text: "Не убирать три пункта с галочками" },
]}
/>
</LlmBlock>
</div>
);
}
+2 -206
View File
@@ -1,214 +1,10 @@
import type { Metadata } from "next";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
import { HeroBlock, HERO_CHECKS } from "@/components/blocks/HeroBlock";
import { BlockMetaBar } from "@/components/ui/BlockMetaBar";
import { type ChangelogEntry } from "@/components/ui/BlockChangelog";
import HeroPageClient from "./HeroPageClient";
export const metadata: Metadata = {
title: "Hero-баннер. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
};
const LLM_HERO_TEXT = `
БЛОК: Hero-баннер (главный баннер страницы)
Источник: perm.oclinica.ru/lor — реальный баннер раздела ЛОР
Версия: v1.3
ЗАГОЛОВОК СТРАНИЦЫ (H1, над баннером):
«ЛОР Клиника ухо, горло, нос - медицинский центр лечения ЛОР заболеваний у детей и взрослых»
Шрифт: Fira Sans, 36px, weight 700, цвет: #cb9768
СТРУКТУРА БАННЕРА (двухколоночная, единый фон #f9f4e7):
Левая колонка (~50%):
— Заголовок: «ЭНДОСКОПИЧЕСКОЕ ХИРУРГИЧЕСКОЕ ЛЕЧЕНИЕ ЛОР ОРГАНОВ»
Шрифт: Fira Sans, 22px, weight 700, uppercase, цвет #333333
— 3 пункта с галочками (✓ бежевый #bf9975):
1. «БЕЗОПАСНО – оперируют хирурги с 15-летним опытом работы»
2. «БЕЗ ВНЕШНИХ РАЗРЕЗОВ – хирургия сверхмалых размеров»
3. «БЫСТРО – под наблюдением врача пациент находится 1 сутки»
Ключевое слово: uppercase bold; описание: обычный текст, ~13px
— Кнопка «Узнать стоимость операции» — стиль: bb-btn-pill (кремовый фон #E9E4D4, radius 25px)
Правая колонка (~50%):
— Фото врача с пациентом
— Изображение занимает всю высоту блока
ПОД БАННЕРОМ:
— Кнопки соцсетей (VK, FB, TW), цвет #9ca3af
— Счётчик просмотров
ЦВЕТА:
Фон баннера: #f9f4e7 (светло-кремовый, единый для всего блока)
Кнопка CTA: pill-стиль (кремовый #E9E4D4, border #D5CFBD, radius 25px)
Заголовок блока: #333333
Пункты: ключевое слово #111827 bold, описание #374151
Галочка: #bf9975 (бежевый)
ПРАВИЛА:
✓ Фон баннера всегда #f9f4e7 (светло-кремовый) — единый, без разделения на зоны
✓ Заголовок блока uppercase, жирный
✓ Три пункта с галочками обязательны
✕ Не менять фон баннера на другой цвет
✕ Не разбивать фон на два разных цвета
✕ Не убирать три пункта с галочками
`.trim();
const CHANGELOG: ChangelogEntry[] = [
{
version: "v1.3",
date: "24.03.2026",
changes: [
"Счётчик: «98 573 просмотра» заменён на «Поделиться / 98572» (как на реальном сайте)",
"Убраны кнопки VK/FB/TW",
],
},
{
version: "v1.2",
date: "24.03.2026",
changes: [
"H1: цвет исправлен с #53514e на #cb9768, размер с ~20px на 36px",
"Заголовок баннера: размер с ~16px на 22px, цвет с #111827 на #333333",
"CTA-кнопка: стиль изменён с outline на pill (фон #E9E4D4, radius 25px)",
"Дефис в H1: длинное тире «–» заменено на простой дефис «-» (как на сайте)",
],
},
{
version: "v1.1",
date: "23.03.2026",
changes: [
"Единый фон #f9f4e7 (ранее был разбит на две зоны)",
"Реальное фото врача с пациентом",
],
},
];
export default function HeroPage() {
return (
<div className="p-8 max-w-5xl mx-auto space-y-10">
{/* Заголовок страницы */}
<div>
<p
className="text-xs font-semibold uppercase tracking-widest mb-1"
style={{ color: "var(--brand-053m)" }}
>
Блоки
</p>
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Hero-баннер
</h1>
<BlockMetaBar path="/blocks/hero" defaultVersion="v1.3" defaultIsInPreview={true} defaultChangelog={CHANGELOG} />
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Главный баннер страницы раздела ЛОР perm.oclinica.ru/lor. Двухколоночный блок, единый светло-кремовый фон{" "}
<strong>#f9f4e7</strong>.
</p>
</div>
{/* Живой пример */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Живой пример
</h2>
<HeroBlock />
</section>
{/* Анатомия */}
<section className="space-y-4">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Анатомия баннера
</h2>
<div
className="rounded-xl p-5 space-y-2"
style={{ background: "#f5f0e8", border: "1px solid #d5cfbd" }}
>
<p className="font-semibold text-sm" style={{ color: "var(--bb-text)" }}>
Весь баннер единый фон <span className="font-mono">#f9f4e7</span>
</p>
<ul className="space-y-1">
{[
"Фон: #f9f4e7 (светло-кремовый) — одинаковый для всего блока",
"Левая половина (~50%): заголовок uppercase bold + 3 галочки + кнопка outline",
"Правая половина (~50%): фото врача с пациентом",
"Минимальная высота: ~280px",
].map((item) => (
<li key={item} className="text-xs flex items-start gap-1.5">
<span style={{ color: "var(--brand-053m)" }}>·</span>
<span style={{ color: "var(--bb-text-muted)" }}>{item}</span>
</li>
))}
</ul>
</div>
</section>
{/* Три пункта с галочками */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Три пункта баннера
</h2>
<div className="space-y-2">
{HERO_CHECKS.map((c) => (
<div
key={c.key}
className="flex items-start gap-3 p-3 rounded-lg"
style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }}
>
<span className="font-bold text-lg shrink-0" style={{ color: "#bf9975" }}>
</span>
<div>
<span className="text-sm font-bold uppercase" style={{ color: "#111827" }}>
{c.key}
</span>
<span className="text-sm" style={{ color: "#374151" }}>
{" "} {c.desc}
</span>
</div>
</div>
))}
</div>
<p className="text-xs" style={{ color: "var(--bb-text-muted)" }}>
Ключевое слово: uppercase + bold. Описание: обычный текст. Галочка: #bf9975 (бежевый).
</p>
</section>
{/* LLM блок */}
<LlmBlock path="/blocks/hero" version="v1.3" specText={LLM_HERO_TEXT}>
<LlmSection title="Структура баннера" />
<LlmTable
headers={["Зона", "Ширина", "Фон", "Содержимое"]}
rows={[
["Весь баннер", "100%", "#f9f4e7", "Единый светло-кремовый фон"],
["Левый блок", "~50%", "#f9f4e7 (общий)", "Заголовок uppercase + 3 галочки + кнопка pill"],
["Правый блок", "~50%", "#f9f4e7 (общий)", "Фото врача с пациентом"],
["Под баннером", "100%", "#fff", "Кнопки соцсетей + счётчик просмотров"],
]}
/>
<LlmSection title="Три пункта баннера" />
<LlmTable
headers={["Ключевое слово", "Описание"]}
rows={HERO_CHECKS.map((c) => [c.key, c.desc])}
/>
<LlmSection title="Цвета" />
<LlmTable
headers={["Элемент", "Цвет", "Токен"]}
rows={[
["Фон баннера (единый)", "#f9f4e7", "Светло-кремовый фон"],
["Кнопка CTA", "pill-стиль (#E9E4D4, 25px)", "bb-btn-pill"],
["Заголовок блока", "#333333", "—"],
["H1 страницы", "#cb9768", "36px, bold"],
["Галочка ✓", "#bf9975", "Бежевый"],
]}
/>
<LlmSection title="Правила" />
<LlmRules
rules={[
{ ok: true, text: "Фон баннера: #f9f4e7 (светло-кремовый) — единый для всего блока" },
{ ok: true, text: "Кнопка CTA: bb-btn-pill (кремовый #E9E4D4, radius 25px)" },
{ ok: true, text: "Заголовок: uppercase, bold" },
{ ok: true, text: "Три пункта с галочками ✓ (#bf9975)" },
{ ok: false, text: "Не менять фон баннера на другой цвет" },
{ ok: false, text: "Не разбивать баннер на два разных цвета фона" },
{ ok: false, text: "Не убирать три пункта с галочками" },
]}
/>
</LlmBlock>
</div>
);
return <HeroPageClient />;
}