Browse Source

feat(sprint-5.5): store block metadata (version, changelog) in PostgreSQL

- Prisma schema: added `changelog Json @default("[]")` to Block model
- Migration: 20260324141120_add_changelog_field
- Seed: 8 blocks with actual versions (v1.0–v1.2) and changelog entries
- API: PATCH /blocks/by-path accepts changelog field
- CORS: accept any localhost port (regex pattern)
- BlockChangelog component: renders version history from API or fallback
- BlockMetaBar: loads changelog from API, passes to BlockChangelog
  - Removed "API офлайн" text, replaced with subtle gray dot
  - Added defaultChangelog prop for offline fallback
- Block pages: removed hardcoded changelog JSX, use defaultChangelog prop
- Updated SPRINTS.md with completed tasks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
main
AR 15 M4 7 days ago
parent
commit
e20d222183
  1. 2
      apps/api/prisma/migrations/20260324141120_add_changelog_field/migration.sql
  2. 1
      apps/api/prisma/schema.prisma
  3. 117
      apps/api/prisma/seed.ts
  4. 2
      apps/api/src/blocks/blocks.controller.ts
  5. 2
      apps/api/src/blocks/blocks.service.ts
  6. 2
      apps/api/src/main.ts
  7. 30
      apps/web/app/blocks/contact-forms/page.tsx
  8. 45
      apps/web/app/blocks/doctors/page.tsx
  9. 49
      apps/web/app/blocks/hero/page.tsx
  10. 28
      apps/web/app/blocks/news/page.tsx
  11. 26
      apps/web/app/blocks/reviews/page.tsx
  12. 41
      apps/web/components/ui/BlockChangelog.tsx
  13. 172
      apps/web/components/ui/BlockMetaBar.tsx
  14. 29
      docs/SPRINTS.md

2
apps/api/prisma/migrations/20260324141120_add_changelog_field/migration.sql

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Block" ADD COLUMN "changelog" JSONB NOT NULL DEFAULT '[]';

1
apps/api/prisma/schema.prisma

@ -46,6 +46,7 @@ model Block {
name String name String
version String version String
isInPreview Boolean @default(false) isInPreview Boolean @default(false)
changelog Json @default("[]")
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
createdAt DateTime @default(now()) createdAt DateTime @default(now())
} }

117
apps/api/prisma/seed.ts

@ -6,21 +6,120 @@ const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter }); const prisma = new PrismaClient({ adapter });
const BLOCKS = [ const BLOCKS = [
{ path: '/components/navigation', name: 'Шапка / Навигация', version: 'v1.0', isInPreview: true }, {
{ path: '/blocks/hero', name: 'Hero-баннер', version: 'v1.1', isInPreview: true }, path: '/components/navigation',
{ path: '/blocks/ceo', name: 'Вводный текст (CEO-блок)', version: 'v0.1', isInPreview: false }, name: 'Шапка / Навигация',
{ path: '/blocks/doctors', name: 'Наши врачи', version: 'v1.1', isInPreview: true }, version: 'v1.0',
{ path: '/blocks/reviews', name: 'Отзывы', version: 'v0.1', isInPreview: false }, isInPreview: true,
{ path: '/blocks/contact-forms', name: 'Формы записи', version: 'v0.1', isInPreview: false }, changelog: [
{ path: '/blocks/news', name: 'Новости', version: 'v0.1', isInPreview: false }, { version: 'v1.0', date: '23.03.2026', changes: ['Топ-бар, логотип, главное меню из 8 пунктов'] },
{ path: '/blocks/contact', name: 'Подвал / Контакт', version: 'v0.1', isInPreview: false }, ],
},
{
path: '/blocks/hero',
name: 'Hero-баннер',
version: 'v1.2',
isInPreview: true,
changelog: [
{ version: 'v1.2', date: '24.03.2026', changes: [
'H1: цвет #cb9768, размер 36px (было ~20px #53514e)',
'Заголовок баннера: 22px #333 (было 16px #111827)',
'CTA-кнопка: pill-стиль (было outline)',
'Дефис в H1: «–» → «-»',
]},
{ version: 'v1.1', date: '23.03.2026', changes: [
'Единый фон #f9f4e7 (ранее разбит на две зоны)',
'Реальное фото врача с пациентом',
]},
],
},
{
path: '/blocks/ceo',
name: 'Вводный текст (CEO-блок)',
version: 'v1.0',
isInPreview: false,
changelog: [
{ version: 'v1.0', date: '23.03.2026', changes: ['Текст специализации клиники, вопросы-стимулы'] },
],
},
{
path: '/blocks/doctors',
name: 'Наши врачи',
version: 'v1.2',
isInPreview: true,
changelog: [
{ version: 'v1.2', date: '24.03.2026', changes: [
'H2: 36px #000000 (было ~30px #111827)',
'line-height: 38px',
]},
{ version: 'v1.1', date: '23.03.2026', changes: [
'6 реальных фото врачей с сайта',
'Статистика без фона, только border-bottom #60959c',
]},
],
},
{
path: '/blocks/reviews',
name: 'Отзывы',
version: 'v1.1',
isInPreview: true,
changelog: [
{ version: 'v1.1', date: '24.03.2026', changes: [
'H2: 36px #000000 (было ~20px #111827)',
'line-height: 38px',
]},
{ version: 'v1.0', date: '23.03.2026', changes: [
'Карусель отзывов: кавычка, текст, стрелки',
]},
],
},
{
path: '/blocks/contact-forms',
name: 'Формы записи',
version: 'v1.1',
isInPreview: true,
changelog: [
{ version: 'v1.1', date: '24.03.2026', changes: [
'H2: 36px #000000',
'Фон формы 1: #b8e6ed → #d4f6f8',
'Фон формы 2: #ffffff → #d4f6f8 (обе формы на одном фоне)',
]},
{ version: 'v1.0', date: '23.03.2026', changes: [
'Две формы записи: «Будьте здоровы!» и «Узнайте стоимость операции»',
]},
],
},
{
path: '/blocks/news',
name: 'Новости',
version: 'v1.1',
isInPreview: true,
changelog: [
{ version: 'v1.1', date: '24.03.2026', changes: [
'H2: 36px #000000',
'Фон секции: #fff → #f2fee6 (светло-зелёный)',
]},
{ version: 'v1.0', date: '23.03.2026', changes: [
'4 карточки новостей в ряд, кнопка «Все новости»',
]},
],
},
{
path: '/blocks/contact',
name: 'Подвал / Контакт',
version: 'v1.0',
isInPreview: false,
changelog: [
{ version: 'v1.0', date: '23.03.2026', changes: ['4 колонки ссылок, адрес, часы работы'] },
],
},
]; ];
async function main() { async function main() {
for (const block of BLOCKS) { for (const block of BLOCKS) {
await prisma.block.upsert({ await prisma.block.upsert({
where: { path: block.path }, where: { path: block.path },
update: {}, update: { version: block.version, isInPreview: block.isInPreview, changelog: block.changelog },
create: block, create: block,
}); });
} }

2
apps/api/src/blocks/blocks.controller.ts

@ -18,7 +18,7 @@ export class BlocksController {
@Patch('by-path') @Patch('by-path')
update( update(
@Query('path') path: string, @Query('path') path: string,
@Body() body: { version?: string; isInPreview?: boolean }, @Body() body: { version?: string; isInPreview?: boolean; changelog?: object[] },
) { ) {
return this.blocks.update(path, body); return this.blocks.update(path, body);
} }

2
apps/api/src/blocks/blocks.service.ts

@ -17,7 +17,7 @@ export class BlocksService {
}); });
} }
update(path: string, data: { version?: string; isInPreview?: boolean }) { update(path: string, data: { version?: string; isInPreview?: boolean; changelog?: object[] }) {
return this.prisma.block.update({ where: { path }, data }); return this.prisma.block.update({ where: { path }, data });
} }
} }

2
apps/api/src/main.ts

@ -4,7 +4,7 @@ import { AppModule } from './app.module';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
app.enableCors({ origin: 'http://localhost:3001' }); app.enableCors({ origin: [/^http:\/\/localhost:\d+$/] });
await app.listen(process.env.PORT ?? 3000); await app.listen(process.env.PORT ?? 3000);
} }
bootstrap(); bootstrap();

30
apps/web/app/blocks/contact-forms/page.tsx

@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock"; import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
import { BlockMetaBar } from "@/components/ui/BlockMetaBar"; import { BlockMetaBar } from "@/components/ui/BlockMetaBar";
import { type ChangelogEntry } from "@/components/ui/BlockChangelog";
import { ContactFormsBlock } from "@/components/blocks/ContactFormsBlock"; import { ContactFormsBlock } from "@/components/blocks/ContactFormsBlock";
export const metadata: Metadata = { export const metadata: Metadata = {
@ -50,6 +51,18 @@ const LLM_FORMS_TEXT = `
Не убирать чекбокс согласия Не убирать чекбокс согласия
`.trim(); `.trim();
const CHANGELOG: ChangelogEntry[] = [
{
version: "v1.1",
date: "24.03.2026",
changes: [
"H2: размер на 36px, цвет на #000000, line-height 38px",
"Фон формы 1: с #b8e6ed на #d4f6f8",
"Фон формы 2: с #ffffff на #d4f6f8 (обе формы на одном фоне)",
],
},
];
export default function ContactFormsPage() { export default function ContactFormsPage() {
return ( return (
<div className="p-8 max-w-5xl mx-auto space-y-10"> <div className="p-8 max-w-5xl mx-auto space-y-10">
@ -64,7 +77,7 @@ export default function ContactFormsPage() {
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}> <h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Формы записи Формы записи
</h1> </h1>
<BlockMetaBar path="/blocks/contact-forms" defaultVersion="v1.1" defaultIsInPreview={false} /> <BlockMetaBar path="/blocks/contact-forms" defaultVersion="v1.1" defaultIsInPreview={false} defaultChangelog={CHANGELOG} />
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}> <p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Два блока форм с perm.oclinica.ru/lor запись на приём и запрос стоимости операции. Два блока форм с perm.oclinica.ru/lor запись на приём и запрос стоимости операции.
</p> </p>
@ -112,21 +125,6 @@ export default function ContactFormsPage() {
</div> </div>
</section> </section>
{/* Changelog */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
История версий
</h2>
<div className="p-3 rounded-lg" style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }}>
<p className="font-semibold text-xs mb-1" style={{ color: "var(--bb-text)" }}>v1.1 24.03.2026</p>
<ul className="list-disc list-inside space-y-0.5 text-xs" style={{ color: "var(--bb-text-muted)" }}>
<li>H2: размер на <strong>36px</strong>, цвет на <strong>#000000</strong>, line-height 38px</li>
<li>Фон формы 1: с #b8e6ed на <strong>#d4f6f8</strong></li>
<li>Фон формы 2: с #ffffff на <strong>#d4f6f8</strong> (обе формы на одном фоне)</li>
</ul>
</div>
</section>
{/* LLM блок */} {/* LLM блок */}
<LlmBlock path="/blocks/contact-forms" version="v1.1" specText={LLM_FORMS_TEXT}> <LlmBlock path="/blocks/contact-forms" version="v1.1" specText={LLM_FORMS_TEXT}>
<LlmSection title="Форма 1 — Запись на приём" /> <LlmSection title="Форма 1 — Запись на приём" />

45
apps/web/app/blocks/doctors/page.tsx

@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock"; import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
import { DoctorsBlock, STATS, DOCTORS } from "@/components/blocks/DoctorsBlock"; import { DoctorsBlock, STATS, DOCTORS } from "@/components/blocks/DoctorsBlock";
import { BlockMetaBar } from "@/components/ui/BlockMetaBar"; import { BlockMetaBar } from "@/components/ui/BlockMetaBar";
import { type ChangelogEntry } from "@/components/ui/BlockChangelog";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Блок «Наши врачи». Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой", title: "Блок «Наши врачи». Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
@ -45,6 +46,25 @@ const LLM_DOCTORS_TEXT = `
Не убирать статистику Не убирать статистику
`.trim(); `.trim();
const CHANGELOG: ChangelogEntry[] = [
{
version: "v1.2",
date: "24.03.2026",
changes: [
"H2: размер с ~30px на 36px, цвет с #111827 на #000000",
"H2 line-height: 38px",
],
},
{
version: "v1.1",
date: "23.03.2026",
changes: [
"6 реальных фото врачей с сайта",
"Статистика без фона, только border-bottom #60959c",
],
},
];
export default function DoctorsBlockPage() { export default function DoctorsBlockPage() {
return ( return (
<div className="p-8 max-w-5xl mx-auto space-y-10"> <div className="p-8 max-w-5xl mx-auto space-y-10">
@ -59,7 +79,7 @@ export default function DoctorsBlockPage() {
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}> <h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Блок «Наши врачи» Блок «Наши врачи»
</h1> </h1>
<BlockMetaBar path="/blocks/doctors" defaultVersion="v1.2" defaultIsInPreview={true} /> <BlockMetaBar path="/blocks/doctors" defaultVersion="v1.2" defaultIsInPreview={true} defaultChangelog={CHANGELOG} />
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}> <p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Блок на странице perm.oclinica.ru/lor заголовок, 3 стат-блока, сетка из 6 карточек врачей. Блок на странице perm.oclinica.ru/lor заголовок, 3 стат-блока, сетка из 6 карточек врачей.
</p> </p>
@ -101,29 +121,6 @@ export default function DoctorsBlockPage() {
</div> </div>
</section> </section>
{/* Changelog */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
История версий
</h2>
<div className="space-y-2 text-sm" style={{ color: "var(--bb-text-muted)" }}>
<div className="p-3 rounded-lg" style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }}>
<p className="font-semibold text-xs mb-1" style={{ color: "var(--bb-text)" }}>v1.2 24.03.2026</p>
<ul className="list-disc list-inside space-y-0.5 text-xs">
<li>H2: размер с ~30px на <strong>36px</strong>, цвет с #111827 на <strong>#000000</strong></li>
<li>H2 line-height: <strong>38px</strong></li>
</ul>
</div>
<div className="p-3 rounded-lg" style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }}>
<p className="font-semibold text-xs mb-1" style={{ color: "var(--bb-text)" }}>v1.1 23.03.2026</p>
<ul className="list-disc list-inside space-y-0.5 text-xs">
<li>6 реальных фото врачей с сайта</li>
<li>Статистика без фона, только border-bottom #60959c</li>
</ul>
</div>
</div>
</section>
{/* LLM блок */} {/* LLM блок */}
<LlmBlock path="/blocks/doctors" version="v1.2" specText={LLM_DOCTORS_TEXT}> <LlmBlock path="/blocks/doctors" version="v1.2" specText={LLM_DOCTORS_TEXT}>
<LlmSection title="Структура блока" /> <LlmSection title="Структура блока" />

49
apps/web/app/blocks/hero/page.tsx

@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock"; import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
import { HeroBlock, HERO_CHECKS } from "@/components/blocks/HeroBlock"; import { HeroBlock, HERO_CHECKS } from "@/components/blocks/HeroBlock";
import { BlockMetaBar } from "@/components/ui/BlockMetaBar"; import { BlockMetaBar } from "@/components/ui/BlockMetaBar";
import { type ChangelogEntry } from "@/components/ui/BlockChangelog";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Hero-баннер. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой", title: "Hero-баннер. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
@ -50,6 +51,27 @@ const LLM_HERO_TEXT = `
Не убирать три пункта с галочками Не убирать три пункта с галочками
`.trim(); `.trim();
const CHANGELOG: ChangelogEntry[] = [
{
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() { export default function HeroPage() {
return ( return (
<div className="p-8 max-w-5xl mx-auto space-y-10"> <div className="p-8 max-w-5xl mx-auto space-y-10">
@ -64,7 +86,7 @@ export default function HeroPage() {
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}> <h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Hero-баннер Hero-баннер
</h1> </h1>
<BlockMetaBar path="/blocks/hero" defaultVersion="v1.2" defaultIsInPreview={true} /> <BlockMetaBar path="/blocks/hero" defaultVersion="v1.2" defaultIsInPreview={true} defaultChangelog={CHANGELOG} />
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}> <p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Главный баннер страницы раздела ЛОР perm.oclinica.ru/lor. Двухколоночный блок, единый светло-кремовый фон{" "} Главный баннер страницы раздела ЛОР perm.oclinica.ru/lor. Двухколоночный блок, единый светло-кремовый фон{" "}
<strong>#f9f4e7</strong>. <strong>#f9f4e7</strong>.
@ -138,31 +160,6 @@ export default function HeroPage() {
</p> </p>
</section> </section>
{/* Changelog */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
История версий
</h2>
<div className="space-y-2 text-sm" style={{ color: "var(--bb-text-muted)" }}>
<div className="p-3 rounded-lg" style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }}>
<p className="font-semibold text-xs mb-1" style={{ color: "var(--bb-text)" }}>v1.2 24.03.2026</p>
<ul className="list-disc list-inside space-y-0.5 text-xs">
<li>H1: цвет исправлен с #53514e на <strong>#cb9768</strong>, размер с ~20px на <strong>36px</strong></li>
<li>Заголовок баннера: размер с ~16px на <strong>22px</strong>, цвет с #111827 на <strong>#333333</strong></li>
<li>CTA-кнопка: стиль изменён с outline на <strong>pill</strong> (фон #E9E4D4, radius 25px)</li>
<li>Дефис в H1: длинное тире «» заменено на простой дефис «-» (как на сайте)</li>
</ul>
</div>
<div className="p-3 rounded-lg" style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }}>
<p className="font-semibold text-xs mb-1" style={{ color: "var(--bb-text)" }}>v1.1 23.03.2026</p>
<ul className="list-disc list-inside space-y-0.5 text-xs">
<li>Единый фон #f9f4e7 (ранее был разбит на две зоны)</li>
<li>Реальное фото врача с пациентом</li>
</ul>
</div>
</div>
</section>
{/* LLM блок */} {/* LLM блок */}
<LlmBlock path="/blocks/hero" version="v1.2" specText={LLM_HERO_TEXT}> <LlmBlock path="/blocks/hero" version="v1.2" specText={LLM_HERO_TEXT}>
<LlmSection title="Структура баннера" /> <LlmSection title="Структура баннера" />

28
apps/web/app/blocks/news/page.tsx

@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock"; import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
import { BlockMetaBar } from "@/components/ui/BlockMetaBar"; import { BlockMetaBar } from "@/components/ui/BlockMetaBar";
import { type ChangelogEntry } from "@/components/ui/BlockChangelog";
import { NewsBlock } from "@/components/blocks/NewsBlock"; import { NewsBlock } from "@/components/blocks/NewsBlock";
export const metadata: Metadata = { export const metadata: Metadata = {
@ -67,6 +68,17 @@ const LLM_NEWS_TEXT = `
Не добавлять описание/анонс в карточку Не добавлять описание/анонс в карточку
`.trim(); `.trim();
const CHANGELOG: ChangelogEntry[] = [
{
version: "v1.1",
date: "24.03.2026",
changes: [
"H2: размер на 36px, цвет на #000000, line-height 38px",
"Фон секции: с белого на #f2fee6 (светло-зелёный, как на реальном сайте)",
],
},
];
export default function NewsBlockPage() { export default function NewsBlockPage() {
return ( return (
<div className="p-8 max-w-5xl mx-auto space-y-10"> <div className="p-8 max-w-5xl mx-auto space-y-10">
@ -81,7 +93,7 @@ export default function NewsBlockPage() {
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}> <h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Блок «Новости» Блок «Новости»
</h1> </h1>
<BlockMetaBar path="/blocks/news" defaultVersion="v1.1" defaultIsInPreview={false} /> <BlockMetaBar path="/blocks/news" defaultVersion="v1.1" defaultIsInPreview={false} defaultChangelog={CHANGELOG} />
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}> <p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Блок новостей с perm.oclinica.ru/lor 4 карточки в ряд (дата + заголовок-ссылка), Блок новостей с perm.oclinica.ru/lor 4 карточки в ряд (дата + заголовок-ссылка),
кнопка «Все новости». кнопка «Все новости».
@ -132,20 +144,6 @@ export default function NewsBlockPage() {
</p> </p>
</section> </section>
{/* Changelog */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
История версий
</h2>
<div className="p-3 rounded-lg" style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }}>
<p className="font-semibold text-xs mb-1" style={{ color: "var(--bb-text)" }}>v1.1 24.03.2026</p>
<ul className="list-disc list-inside space-y-0.5 text-xs" style={{ color: "var(--bb-text-muted)" }}>
<li>H2: размер на <strong>36px</strong>, цвет на <strong>#000000</strong>, line-height 38px</li>
<li>Фон секции: с белого на <strong>#f2fee6</strong> (светло-зелёный, как на реальном сайте)</li>
</ul>
</div>
</section>
{/* LLM блок */} {/* LLM блок */}
<LlmBlock path="/blocks/news" version="v1.1" specText={LLM_NEWS_TEXT}> <LlmBlock path="/blocks/news" version="v1.1" specText={LLM_NEWS_TEXT}>
<LlmSection title="Структура карточки новости" /> <LlmSection title="Структура карточки новости" />

26
apps/web/app/blocks/reviews/page.tsx

@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock"; import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
import { BlockMetaBar } from "@/components/ui/BlockMetaBar"; import { BlockMetaBar } from "@/components/ui/BlockMetaBar";
import { type ChangelogEntry } from "@/components/ui/BlockChangelog";
import { ReviewsBlock } from "@/components/blocks/ReviewsBlock"; import { ReviewsBlock } from "@/components/blocks/ReviewsBlock";
export const metadata: Metadata = { export const metadata: Metadata = {
@ -52,6 +53,16 @@ const LLM_REVIEWS_TEXT = `
Не убирать навигационные стрелки Не убирать навигационные стрелки
`.trim(); `.trim();
const CHANGELOG: ChangelogEntry[] = [
{
version: "v1.1",
date: "24.03.2026",
changes: [
"H2: размер с ~20px на 36px, цвет с #111827 на #000000, line-height 38px",
],
},
];
export default function ReviewsBlockPage() { export default function ReviewsBlockPage() {
return ( return (
<div className="p-8 max-w-5xl mx-auto space-y-10"> <div className="p-8 max-w-5xl mx-auto space-y-10">
@ -66,7 +77,7 @@ export default function ReviewsBlockPage() {
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}> <h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Блок «Отзывы о нас» Блок «Отзывы о нас»
</h1> </h1>
<BlockMetaBar path="/blocks/reviews" defaultVersion="v1.1" defaultIsInPreview={false} /> <BlockMetaBar path="/blocks/reviews" defaultVersion="v1.1" defaultIsInPreview={false} defaultChangelog={CHANGELOG} />
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}> <p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Карусель отзывов с perm.oclinica.ru/lor большая кавычка, текст, «Читать полностью», стрелки. Карусель отзывов с perm.oclinica.ru/lor большая кавычка, текст, «Читать полностью», стрелки.
</p> </p>
@ -157,19 +168,6 @@ export default function ReviewsBlockPage() {
</div> </div>
</section> </section>
{/* Changelog */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
История версий
</h2>
<div className="p-3 rounded-lg" style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }}>
<p className="font-semibold text-xs mb-1" style={{ color: "var(--bb-text)" }}>v1.1 24.03.2026</p>
<ul className="list-disc list-inside space-y-0.5 text-xs" style={{ color: "var(--bb-text-muted)" }}>
<li>H2: размер с ~20px на <strong>36px</strong>, цвет с #111827 на <strong>#000000</strong>, line-height 38px</li>
</ul>
</div>
</section>
{/* LLM блок */} {/* LLM блок */}
<LlmBlock path="/blocks/reviews" version="v1.1" specText={LLM_REVIEWS_TEXT}> <LlmBlock path="/blocks/reviews" version="v1.1" specText={LLM_REVIEWS_TEXT}>
<LlmSection title="Структура блока" /> <LlmSection title="Структура блока" />

41
apps/web/components/ui/BlockChangelog.tsx

@ -0,0 +1,41 @@
"use client";
export interface ChangelogEntry {
version: string;
date: string;
changes: string[];
}
interface BlockChangelogProps {
changelog: ChangelogEntry[];
}
export function BlockChangelog({ changelog }: BlockChangelogProps) {
if (!changelog || changelog.length === 0) return null;
return (
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
История версий
</h2>
<div className="space-y-2 text-sm" style={{ color: "var(--bb-text-muted)" }}>
{changelog.map((entry) => (
<div
key={entry.version}
className="p-3 rounded-lg"
style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }}
>
<p className="font-semibold text-xs mb-1" style={{ color: "var(--bb-text)" }}>
{entry.version} {entry.date}
</p>
<ul className="list-disc list-inside space-y-0.5 text-xs">
{entry.changes.map((change, i) => (
<li key={i}>{change}</li>
))}
</ul>
</div>
))}
</div>
</section>
);
}

172
apps/web/components/ui/BlockMetaBar.tsx

@ -1,6 +1,7 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { BlockChangelog, type ChangelogEntry } from "./BlockChangelog";
const LS_PREFIX = "bb-block-preview:"; const LS_PREFIX = "bb-block-preview:";
@ -21,15 +22,17 @@ interface BlockMeta {
name: string; name: string;
version: string; version: string;
isInPreview: boolean; isInPreview: boolean;
changelog: ChangelogEntry[];
} }
interface BlockMetaBarProps { interface BlockMetaBarProps {
path: string; path: string;
defaultVersion: string; defaultVersion: string;
defaultIsInPreview: boolean; defaultIsInPreview: boolean;
defaultChangelog?: ChangelogEntry[];
} }
export function BlockMetaBar({ path, defaultVersion, defaultIsInPreview }: BlockMetaBarProps) { export function BlockMetaBar({ path, defaultVersion, defaultIsInPreview, defaultChangelog = [] }: BlockMetaBarProps) {
const [meta, setMeta] = useState<BlockMeta | null>(null); const [meta, setMeta] = useState<BlockMeta | null>(null);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [versionInput, setVersionInput] = useState(defaultVersion); const [versionInput, setVersionInput] = useState(defaultVersion);
@ -55,7 +58,7 @@ export function BlockMetaBar({ path, defaultVersion, defaultIsInPreview }: Block
.catch(() => setApiDown(true)); .catch(() => setApiDown(true));
}, [apiUrl, path]); }, [apiUrl, path]);
async function patch(data: { version?: string; isInPreview?: boolean }) { async function patch(data: { version?: string; isInPreview?: boolean; changelog?: ChangelogEntry[] }) {
if (!apiUrl) return null; if (!apiUrl) return null;
const r = await fetch(`${apiUrl}/blocks/by-path?path=${encodeURIComponent(path)}`, { const r = await fetch(`${apiUrl}/blocks/by-path?path=${encodeURIComponent(path)}`, {
method: 'PATCH', method: 'PATCH',
@ -78,7 +81,6 @@ export function BlockMetaBar({ path, defaultVersion, defaultIsInPreview }: Block
async function togglePreview() { async function togglePreview() {
if (apiDown) { if (apiDown) {
// Fallback: toggle via localStorage
const newVal = !localPreview; const newVal = !localPreview;
setLocalPreview(newVal); setLocalPreview(newVal);
writeLocalPreview(path, newVal); writeLocalPreview(path, newVal);
@ -96,92 +98,100 @@ export function BlockMetaBar({ path, defaultVersion, defaultIsInPreview }: Block
const version = meta?.version ?? defaultVersion; const version = meta?.version ?? defaultVersion;
const isInPreview = apiDown ? localPreview : (meta?.isInPreview ?? localPreview); const isInPreview = apiDown ? localPreview : (meta?.isInPreview ?? localPreview);
const changelog: ChangelogEntry[] = (meta?.changelog && meta.changelog.length > 0) ? meta.changelog : defaultChangelog;
return ( return (
<div <>
className="flex items-center flex-wrap gap-3 px-4 py-2 rounded-lg text-xs mb-6" <div
style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }} 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 */} >
<span className="font-semibold" style={{ color: "var(--bb-text-muted)" }}> {/* Version badge */}
Версия: <span className="font-semibold" style={{ color: "var(--bb-text-muted)" }}>
</span> Версия:
{editing ? ( </span>
<span className="flex items-center gap-1.5"> {editing ? (
<input <span className="flex items-center gap-1.5">
className="px-2 py-0.5 rounded border text-xs font-mono w-20" <input
style={{ borderColor: "var(--bb-border)", color: "var(--bb-text)" }} className="px-2 py-0.5 rounded border text-xs font-mono w-20"
value={versionInput} style={{ borderColor: "var(--bb-border)", color: "var(--bb-text)" }}
onChange={(e) => setVersionInput(e.target.value)} value={versionInput}
onKeyDown={(e) => { if (e.key === 'Enter') saveVersion(); if (e.key === 'Escape') setEditing(false); }} onChange={(e) => setVersionInput(e.target.value)}
autoFocus onKeyDown={(e) => { if (e.key === 'Enter') saveVersion(); if (e.key === 'Escape') setEditing(false); }}
/> autoFocus
<button />
onClick={saveVersion} <button
disabled={saving} onClick={saveVersion}
className="px-2 py-0.5 rounded text-xs font-medium" disabled={saving}
style={{ background: "var(--brand-053m)", color: "#fff" }} className="px-2 py-0.5 rounded text-xs font-medium"
> style={{ background: "var(--brand-053m)", color: "#fff" }}
{saving ? '...' : 'Сохранить'} >
</button> {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 <button
onClick={() => { setEditing(false); setVersionInput(version); }} onClick={() => !apiDown && setEditing(true)}
className="px-2 py-0.5 rounded text-xs" title={apiDown ? 'Версия из кода (API недоступен)' : 'Изменить версию'}
style={{ color: "var(--bb-text-muted)" }} 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> </button>
</span> )}
) : (
<span style={{ color: "var(--bb-border)" }}>·</span>
{/* Preview toggle */}
<button <button
onClick={() => !apiDown && setEditing(true)} onClick={togglePreview}
title={apiDown ? 'API недоступен' : 'Изменить версию'} disabled={togglingPreview || (!apiDown && !meta)}
className="font-mono px-2 py-0.5 rounded" title={isInPreview ? "Убрать из превью страницы" : "Включить в превью страницы"}
style={{ className="flex items-center gap-1.5 px-2.5 py-1 rounded font-medium transition-colors cursor-pointer"
background: "var(--bb-sidebar-active-bg, #dff0fa)", style={isInPreview ? {
color: "var(--brand-053m)", background: "#dcfce7",
cursor: apiDown ? 'default' : 'pointer', color: "#16a34a",
border: "1px solid #86efac",
} : {
background: "var(--bb-sidebar-bg)",
color: "var(--bb-text-muted)",
border: "1px solid var(--bb-border)",
}} }}
> >
{version} <span
className="inline-block w-1.5 h-1.5 rounded-full"
style={{ background: isInPreview ? "#22c55e" : "#d1d5db" }}
/>
{togglingPreview
? '...'
: isInPreview
? "В превью"
: "Не в превью"}
</button> </button>
)}
{/* Subtle offline dot instead of "API офлайн" text */}
<span style={{ color: "var(--bb-border)" }}>·</span> {apiDown && (
<span
{/* Preview toggle */} className="inline-block w-1.5 h-1.5 rounded-full"
<button title="API недоступен — данные из кода"
onClick={togglePreview} style={{ background: "#d1d5db" }}
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" </div>
style={isInPreview ? {
background: "#dcfce7", {/* Changelog rendered from API or fallback */}
color: "#16a34a", {changelog.length > 0 && <BlockChangelog changelog={changelog} />}
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>
{apiDown && (
<>
<span style={{ color: "var(--bb-border)" }}>·</span>
<span style={{ color: "#f59e0b" }}>API офлайн</span>
</>
)}
</div>
); );
} }

29
docs/SPRINTS.md

@ -239,9 +239,17 @@
- Фон новостей: #f2fee6 (ранее #fff) - Фон новостей: #f2fee6 (ранее #fff)
- Типографика сайта: обновлена таблица стилей - Типографика сайта: обновлена таблица стилей
- [x] FE: Добавлена «История версий» с changelog на каждую страницу блока - [x] FE: Добавлена «История версий» с changelog на каждую страницу блока
- [x] BE: Метаданные блоков (version, isInPreview, changelog) перенесены в PostgreSQL
- Prisma schema: добавлено поле `changelog Json @default("[]")`
- Seed обновлён: 8 блоков с актуальными версиями и историей изменений
- API: PATCH /blocks/by-path принимает changelog
- CORS: открыт для любого localhost порта
- BlockMetaBar: загружает version + changelog из API, fallback на defaultVersion/defaultChangelog
- Компонент BlockChangelog: отображает историю версий из API или из кода
- Надпись «API офлайн» заменена на серую точку
- [ ] FE: Footer - [ ] FE: Footer
**Результат спринта:** Hero v1.2, Doctors v1.2, Reviews v1.1, ContactForms v1.1, News v1.1 — все стили синхронизированы с реальным сайтом. **Результат спринта:** Hero v1.2, Doctors v1.2, Reviews v1.1, ContactForms v1.1, News v1.1 — стили синхронизированы с реальным сайтом. Метаданные блоков хранятся в БД.
--- ---
@ -285,14 +293,17 @@
### Задачи ### Задачи
- [ ] FE: Страница `/pages/preview` — пустое состояние с кнопкой «Создать» - [x] FE: Страница `/pages/preview` — пустое состояние с кнопкой «Создать»
- [ ] FE: Логика `localStorage` — сохранение/сброс состояния превью - [x] FE: Логика `localStorage` — сохранение/сброс состояния превью
- [ ] FE: Рефактор `/blocks/hero/page.tsx` — вынести баннер в компонент `HeroBlock` (переиспользуемый) - [x] FE: Рефактор `/blocks/hero/page.tsx` — вынести баннер в компонент `HeroBlock` (переиспользуемый)
- [ ] FE: Рефактор `/blocks/doctors/page.tsx` — вынести в компонент `DoctorsBlock` - [x] FE: Рефактор `/blocks/doctors/page.tsx` — вынести в компонент `DoctorsBlock`
- [ ] FE: Placeholder-компонент для блоков, которые ещё не готовы (серая рамка с названием блока) - [x] FE: Placeholder-компонент для блоков, которые ещё не готовы (серая рамка с названием блока)
- [ ] FE: Сборка превью: рендер всех доступных компонентов в порядке реального сайта - [x] FE: Сборка превью: рендер всех доступных компонентов в порядке реального сайта
- [ ] FE: Sidebar — добавить «Просмотр страницы» в раздел «Страницы» - [x] FE: Sidebar — добавить «Просмотр страницы» в раздел «Страницы»
- [ ] FE: Кнопка «Пересобрать» в созданном состоянии - [x] FE: Кнопка «Пересобрать» в созданном состоянии
- [x] FE: Toggle «В превью» через localStorage (fallback при API офлайн)
- [x] BE: BlockMetaBar + PreviewClient подключены к NestJS API `/blocks`
- [x] BE: Метаданные блоков (version, changelog, isInPreview) в PostgreSQL
- [ ] Docs: Добавить `/pages/preview` v1.0 в LLM_CONTEXT.md - [ ] Docs: Добавить `/pages/preview` v1.0 в LLM_CONTEXT.md
### Зависимости ### Зависимости

Loading…
Cancel
Save