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:
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
|
||||
import { BlockMetaBar, type SnapshotData } from "@/components/ui/BlockMetaBar";
|
||||
import { type ChangelogEntry } from "@/components/ui/BlockChangelog";
|
||||
import { CeoBlock, CEO_QUESTIONS } from "@/components/blocks/CeoBlock";
|
||||
|
||||
const BLOCK_PATH = "/blocks/ceo";
|
||||
|
||||
const LLM_CEO_TEXT = `
|
||||
БЛОК: Вводный текст клиники (CEO-блок)
|
||||
Источник: perm.oclinica.ru/lor — секция после баннера
|
||||
Версия: v1.1
|
||||
|
||||
НАЗНАЧЕНИЕ:
|
||||
Информационный блок под hero-баннером. Рассказывает о специализации клиники
|
||||
и вовлекает пациента через вопросы-стимулы.
|
||||
|
||||
СТРУКТУРА:
|
||||
1. Вводный абзац
|
||||
«Клиника ухо, нос специализируется на оториноларингологии – лечении взрослых и детей
|
||||
с ЛОР заболеваниями. ЛОР клиника представлена на двух адресах: ул. Цитная, 9, ул. Г. Звезда, 31а...»
|
||||
|
||||
2. Вопросы-стимулы (формат: «— Вопрос?»)
|
||||
Каждый вопрос — отдельный абзац с тире, связанный с симптомами пациентов:
|
||||
— У вас болит ухо, заложен нос, першит в горле, и вы не можете понять причину?
|
||||
— Вам срочно нужен платный ЛОР в Перми или, как ещё говорят, «ухогорлонос»?
|
||||
— Заболел ребёнок?
|
||||
— Срочно ищете частные ЛОР-клиники Перми для детей 0+ и взрослых...
|
||||
— Вам назначили проведение эндоскопической операции на ухе, горле или носе?
|
||||
|
||||
3. Заключительный абзац
|
||||
«Обращайтесь в ЛОР центр ухо, горло, нос в Перми...»
|
||||
«Будьте здоровы!»
|
||||
|
||||
ТИПОГРАФИКА:
|
||||
Шрифт: Fira Sans, 14px, line-height 1.6–1.8
|
||||
Цвет текста: #374151 (#bb-text)
|
||||
Вопросы: тот же стиль, начинаются с «—»
|
||||
Ключевые слова в тексте — обычно ссылки синего цвета (#0089c3)
|
||||
Фон блока: #ffffff, отступы 40–60px сверху и снизу
|
||||
|
||||
ПРАВИЛА:
|
||||
✓ Вопросы начинаются с «—» (тире)
|
||||
✓ Ключевые медицинские термины — ссылки #0089c3
|
||||
✓ Текст без H2 заголовка — просто связный параграф
|
||||
✕ Не добавлять маркированные списки (только тире)
|
||||
✕ Не менять стиль вопросов на другой формат
|
||||
`.trim();
|
||||
|
||||
|
||||
const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: "v1.1",
|
||||
date: "24.03.2026",
|
||||
changes: [
|
||||
"Адрес: «ул. Цитная, 9» заменён на «ул. Клары Цеткин, 9»",
|
||||
"Цвет ссылок: #52b4bd (было #0089c3)",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function CeoPageClient() {
|
||||
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)" }}>
|
||||
Вводный текст (CEO-блок)
|
||||
</h1>
|
||||
<BlockMetaBar
|
||||
path={BLOCK_PATH}
|
||||
defaultVersion="v1.1"
|
||||
defaultIsInPreview={false}
|
||||
defaultChangelog={CHANGELOG}
|
||||
onSnapshotSelect={setSnapshot}
|
||||
/>
|
||||
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
|
||||
Блок после hero-баннера на perm.oclinica.ru/lor. Описание специализации клиники
|
||||
+ вопросы-стимулы для пациентов.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Живой пример */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
|
||||
Живой пример
|
||||
</h2>
|
||||
{snapshot ? (
|
||||
<div className="rounded-xl overflow-hidden" style={{ border: "1px solid var(--bb-border)" }}>
|
||||
<style dangerouslySetInnerHTML={{ __html: snapshot.css }} />
|
||||
<div dangerouslySetInnerHTML={{ __html: snapshot.html }} />
|
||||
</div>
|
||||
) : (
|
||||
<div data-block-capture={BLOCK_PATH} className="rounded-xl overflow-hidden" style={{ border: "1px solid var(--bb-border)" }}>
|
||||
<CeoBlock />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Анатомия */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
|
||||
Структура блока
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{
|
||||
num: "1",
|
||||
title: "Вводный абзац",
|
||||
bg: "#f0f9ff",
|
||||
desc: 'Специализация клиники, адреса. Ключевые слова — ссылки синего цвета (#0089c3).',
|
||||
},
|
||||
{
|
||||
num: "2",
|
||||
title: "Вопросы-стимулы",
|
||||
bg: "#fefce8",
|
||||
desc: 'Каждый вопрос — отдельный абзац, начинается с «—». Адресован пациенту с конкретным симптомом.',
|
||||
},
|
||||
{
|
||||
num: "3",
|
||||
title: "Заключительный абзац",
|
||||
bg: "#f0fdf4",
|
||||
desc: 'Призыв обращаться. «Будьте здоровы!» — фирменная подпись клиники.',
|
||||
},
|
||||
].map((s) => (
|
||||
<div
|
||||
key={s.num}
|
||||
className="flex gap-4 p-4 rounded-xl"
|
||||
style={{ background: s.bg, border: "1px solid var(--bb-border)" }}
|
||||
>
|
||||
<div
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center font-bold text-sm shrink-0"
|
||||
style={{ background: "var(--brand-053m)", color: "#fff" }}
|
||||
>
|
||||
{s.num}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-sm mb-1" style={{ color: "var(--bb-text)" }}>
|
||||
{s.title}
|
||||
</p>
|
||||
<p className="text-xs" style={{ color: "var(--bb-text-muted)" }}>
|
||||
{s.desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Вопросы-стимулы */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
|
||||
Вопросы-стимулы (5 штук)
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{CEO_QUESTIONS.map((q, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-start gap-3 p-3 rounded-lg text-sm"
|
||||
style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }}
|
||||
>
|
||||
<span className="font-bold shrink-0 mt-0.5" style={{ color: "var(--brand-053m)" }}>
|
||||
—
|
||||
</span>
|
||||
<span style={{ color: "var(--bb-text)" }}>{q}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* LLM блок */}
|
||||
<LlmBlock path="/blocks/ceo" version="v1.1" specText={LLM_CEO_TEXT}>
|
||||
<LlmSection title="Структура блока" />
|
||||
<LlmTable
|
||||
headers={["Часть", "Содержимое", "Примечание"]}
|
||||
rows={[
|
||||
["Вводный абзац", "Специализация клиники, адреса", "Ключевые слова = ссылки #0089c3"],
|
||||
["Вопросы-стимулы", "5 вопросов от лица пациента, начинаются с «—»", "Без маркированных списков"],
|
||||
["Заключение", 'Призыв обращаться + «Будьте здоровы!»', "Фирменная подпись клиники"],
|
||||
]}
|
||||
/>
|
||||
<LlmSection title="Типографика" />
|
||||
<LlmTable
|
||||
headers={["Параметр", "Значение"]}
|
||||
rows={[
|
||||
["Шрифт", "Fira Sans"],
|
||||
["Размер", "14px"],
|
||||
["Line-height", "1.6–1.8"],
|
||||
["Цвет текста", "#374151 (--bb-text)"],
|
||||
["Цвет ссылок в тексте", "#0089c3 (--brand-053m)"],
|
||||
["Фон блока", "#ffffff"],
|
||||
]}
|
||||
/>
|
||||
<LlmSection title="Правила" />
|
||||
<LlmRules
|
||||
rules={[
|
||||
{ ok: true, text: "Вопросы начинаются с «—» (длинное тире)" },
|
||||
{ ok: true, text: "Ключевые медицинские термины — ссылки #0089c3" },
|
||||
{ ok: true, text: "Фирменная подпись «Будьте здоровы!» в конце" },
|
||||
{ ok: false, text: "Не добавлять маркированные списки (•)" },
|
||||
{ ok: false, text: "Не добавлять H2 заголовок внутри блока" },
|
||||
]}
|
||||
/>
|
||||
</LlmBlock>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,202 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
|
||||
import { BlockMetaBar } from "@/components/ui/BlockMetaBar";
|
||||
import { type ChangelogEntry } from "@/components/ui/BlockChangelog";
|
||||
import { CeoBlock, CEO_QUESTIONS } from "@/components/blocks/CeoBlock";
|
||||
import CeoPageClient from "./CeoPageClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Вводный текст (CEO-блок). Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
|
||||
};
|
||||
|
||||
const LLM_CEO_TEXT = `
|
||||
БЛОК: Вводный текст клиники (CEO-блок)
|
||||
Источник: perm.oclinica.ru/lor — секция после баннера
|
||||
Версия: v1.1
|
||||
|
||||
НАЗНАЧЕНИЕ:
|
||||
Информационный блок под hero-баннером. Рассказывает о специализации клиники
|
||||
и вовлекает пациента через вопросы-стимулы.
|
||||
|
||||
СТРУКТУРА:
|
||||
1. Вводный абзац
|
||||
«Клиника ухо, нос специализируется на оториноларингологии – лечении взрослых и детей
|
||||
с ЛОР заболеваниями. ЛОР клиника представлена на двух адресах: ул. Цитная, 9, ул. Г. Звезда, 31а...»
|
||||
|
||||
2. Вопросы-стимулы (формат: «— Вопрос?»)
|
||||
Каждый вопрос — отдельный абзац с тире, связанный с симптомами пациентов:
|
||||
— У вас болит ухо, заложен нос, першит в горле, и вы не можете понять причину?
|
||||
— Вам срочно нужен платный ЛОР в Перми или, как ещё говорят, «ухогорлонос»?
|
||||
— Заболел ребёнок?
|
||||
— Срочно ищете частные ЛОР-клиники Перми для детей 0+ и взрослых...
|
||||
— Вам назначили проведение эндоскопической операции на ухе, горле или носе?
|
||||
|
||||
3. Заключительный абзац
|
||||
«Обращайтесь в ЛОР центр ухо, горло, нос в Перми...»
|
||||
«Будьте здоровы!»
|
||||
|
||||
ТИПОГРАФИКА:
|
||||
Шрифт: Fira Sans, 14px, line-height 1.6–1.8
|
||||
Цвет текста: #374151 (#bb-text)
|
||||
Вопросы: тот же стиль, начинаются с «—»
|
||||
Ключевые слова в тексте — обычно ссылки синего цвета (#0089c3)
|
||||
Фон блока: #ffffff, отступы 40–60px сверху и снизу
|
||||
|
||||
ПРАВИЛА:
|
||||
✓ Вопросы начинаются с «—» (тире)
|
||||
✓ Ключевые медицинские термины — ссылки #0089c3
|
||||
✓ Текст без H2 заголовка — просто связный параграф
|
||||
✕ Не добавлять маркированные списки (только тире)
|
||||
✕ Не менять стиль вопросов на другой формат
|
||||
`.trim();
|
||||
|
||||
|
||||
const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: "v1.1",
|
||||
date: "24.03.2026",
|
||||
changes: [
|
||||
"Адрес: «ул. Цитная, 9» заменён на «ул. Клары Цеткин, 9»",
|
||||
"Цвет ссылок: #52b4bd (было #0089c3)",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function CeoPage() {
|
||||
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)" }}>
|
||||
Вводный текст (CEO-блок)
|
||||
</h1>
|
||||
<BlockMetaBar path="/blocks/ceo" defaultVersion="v1.1" defaultIsInPreview={false} defaultChangelog={CHANGELOG} />
|
||||
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
|
||||
Блок после hero-баннера на perm.oclinica.ru/lor. Описание специализации клиники
|
||||
+ вопросы-стимулы для пациентов.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Живой пример */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
|
||||
Живой пример
|
||||
</h2>
|
||||
<div className="rounded-xl overflow-hidden" style={{ border: "1px solid var(--bb-border)" }}>
|
||||
<CeoBlock />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Анатомия */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
|
||||
Структура блока
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{
|
||||
num: "1",
|
||||
title: "Вводный абзац",
|
||||
bg: "#f0f9ff",
|
||||
desc: 'Специализация клиники, адреса. Ключевые слова — ссылки синего цвета (#0089c3).',
|
||||
},
|
||||
{
|
||||
num: "2",
|
||||
title: "Вопросы-стимулы",
|
||||
bg: "#fefce8",
|
||||
desc: 'Каждый вопрос — отдельный абзац, начинается с «—». Адресован пациенту с конкретным симптомом.',
|
||||
},
|
||||
{
|
||||
num: "3",
|
||||
title: "Заключительный абзац",
|
||||
bg: "#f0fdf4",
|
||||
desc: 'Призыв обращаться. «Будьте здоровы!» — фирменная подпись клиники.',
|
||||
},
|
||||
].map((s) => (
|
||||
<div
|
||||
key={s.num}
|
||||
className="flex gap-4 p-4 rounded-xl"
|
||||
style={{ background: s.bg, border: "1px solid var(--bb-border)" }}
|
||||
>
|
||||
<div
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center font-bold text-sm shrink-0"
|
||||
style={{ background: "var(--brand-053m)", color: "#fff" }}
|
||||
>
|
||||
{s.num}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-sm mb-1" style={{ color: "var(--bb-text)" }}>
|
||||
{s.title}
|
||||
</p>
|
||||
<p className="text-xs" style={{ color: "var(--bb-text-muted)" }}>
|
||||
{s.desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Вопросы-стимулы */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
|
||||
Вопросы-стимулы (5 штук)
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{CEO_QUESTIONS.map((q, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-start gap-3 p-3 rounded-lg text-sm"
|
||||
style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }}
|
||||
>
|
||||
<span className="font-bold shrink-0 mt-0.5" style={{ color: "var(--brand-053m)" }}>
|
||||
—
|
||||
</span>
|
||||
<span style={{ color: "var(--bb-text)" }}>{q}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* LLM блок */}
|
||||
<LlmBlock path="/blocks/ceo" version="v1.1" specText={LLM_CEO_TEXT}>
|
||||
<LlmSection title="Структура блока" />
|
||||
<LlmTable
|
||||
headers={["Часть", "Содержимое", "Примечание"]}
|
||||
rows={[
|
||||
["Вводный абзац", "Специализация клиники, адреса", "Ключевые слова = ссылки #0089c3"],
|
||||
["Вопросы-стимулы", "5 вопросов от лица пациента, начинаются с «—»", "Без маркированных списков"],
|
||||
["Заключение", 'Призыв обращаться + «Будьте здоровы!»', "Фирменная подпись клиники"],
|
||||
]}
|
||||
/>
|
||||
<LlmSection title="Типографика" />
|
||||
<LlmTable
|
||||
headers={["Параметр", "Значение"]}
|
||||
rows={[
|
||||
["Шрифт", "Fira Sans"],
|
||||
["Размер", "14px"],
|
||||
["Line-height", "1.6–1.8"],
|
||||
["Цвет текста", "#374151 (--bb-text)"],
|
||||
["Цвет ссылок в тексте", "#0089c3 (--brand-053m)"],
|
||||
["Фон блока", "#ffffff"],
|
||||
]}
|
||||
/>
|
||||
<LlmSection title="Правила" />
|
||||
<LlmRules
|
||||
rules={[
|
||||
{ ok: true, text: "Вопросы начинаются с «—» (длинное тире)" },
|
||||
{ ok: true, text: "Ключевые медицинские термины — ссылки #0089c3" },
|
||||
{ ok: true, text: "Фирменная подпись «Будьте здоровы!» в конце" },
|
||||
{ ok: false, text: "Не добавлять маркированные списки (•)" },
|
||||
{ ok: false, text: "Не добавлять H2 заголовок внутри блока" },
|
||||
]}
|
||||
/>
|
||||
</LlmBlock>
|
||||
</div>
|
||||
);
|
||||
return <CeoPageClient />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user