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>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Block" ADD COLUMN "changelog" JSONB NOT NULL DEFAULT '[]';
|
||||
@@ -46,6 +46,7 @@ model Block {
|
||||
name String
|
||||
version String
|
||||
isInPreview Boolean @default(false)
|
||||
changelog Json @default("[]")
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
+108
-9
@@ -6,21 +6,120 @@ const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
const BLOCKS = [
|
||||
{ path: '/components/navigation', name: 'Шапка / Навигация', version: 'v1.0', isInPreview: true },
|
||||
{ path: '/blocks/hero', name: 'Hero-баннер', version: 'v1.1', isInPreview: true },
|
||||
{ path: '/blocks/ceo', name: 'Вводный текст (CEO-блок)', version: 'v0.1', isInPreview: false },
|
||||
{ path: '/blocks/doctors', name: 'Наши врачи', version: 'v1.1', isInPreview: true },
|
||||
{ path: '/blocks/reviews', name: 'Отзывы', version: 'v0.1', isInPreview: false },
|
||||
{ path: '/blocks/contact-forms', name: 'Формы записи', version: 'v0.1', isInPreview: false },
|
||||
{ path: '/blocks/news', name: 'Новости', version: 'v0.1', isInPreview: false },
|
||||
{ path: '/blocks/contact', name: 'Подвал / Контакт', version: 'v0.1', isInPreview: false },
|
||||
{
|
||||
path: '/components/navigation',
|
||||
name: 'Шапка / Навигация',
|
||||
version: 'v1.0',
|
||||
isInPreview: true,
|
||||
changelog: [
|
||||
{ version: 'v1.0', date: '23.03.2026', changes: ['Топ-бар, логотип, главное меню из 8 пунктов'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
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() {
|
||||
for (const block of BLOCKS) {
|
||||
await prisma.block.upsert({
|
||||
where: { path: block.path },
|
||||
update: {},
|
||||
update: { version: block.version, isInPreview: block.isInPreview, changelog: block.changelog },
|
||||
create: block,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export class BlocksController {
|
||||
@Patch('by-path')
|
||||
update(
|
||||
@Query('path') path: string,
|
||||
@Body() body: { version?: string; isInPreview?: boolean },
|
||||
@Body() body: { version?: string; isInPreview?: boolean; changelog?: object[] },
|
||||
) {
|
||||
return this.blocks.update(path, body);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
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);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { ContactFormsBlock } from "@/components/blocks/ContactFormsBlock";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -50,6 +51,18 @@ const LLM_FORMS_TEXT = `
|
||||
✕ Не убирать чекбокс согласия
|
||||
`.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() {
|
||||
return (
|
||||
<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>
|
||||
<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)" }}>
|
||||
Два блока форм с perm.oclinica.ru/lor — запись на приём и запрос стоимости операции.
|
||||
</p>
|
||||
@@ -112,21 +125,6 @@ export default function ContactFormsPage() {
|
||||
</div>
|
||||
</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 блок */}
|
||||
<LlmBlock path="/blocks/contact-forms" version="v1.1" specText={LLM_FORMS_TEXT}>
|
||||
<LlmSection title="Форма 1 — Запись на приём" />
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
|
||||
import { DoctorsBlock, STATS, DOCTORS } from "@/components/blocks/DoctorsBlock";
|
||||
import { BlockMetaBar } from "@/components/ui/BlockMetaBar";
|
||||
import { type ChangelogEntry } from "@/components/ui/BlockChangelog";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Блок «Наши врачи». Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
|
||||
@@ -45,6 +46,25 @@ const LLM_DOCTORS_TEXT = `
|
||||
✕ Не убирать статистику
|
||||
`.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() {
|
||||
return (
|
||||
<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>
|
||||
<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)" }}>
|
||||
Блок на странице perm.oclinica.ru/lor — заголовок, 3 стат-блока, сетка из 6 карточек врачей.
|
||||
</p>
|
||||
@@ -101,29 +121,6 @@ export default function DoctorsBlockPage() {
|
||||
</div>
|
||||
</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 блок */}
|
||||
<LlmBlock path="/blocks/doctors" version="v1.2" specText={LLM_DOCTORS_TEXT}>
|
||||
<LlmSection title="Структура блока" />
|
||||
|
||||
@@ -2,6 +2,7 @@ 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";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Hero-баннер. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
|
||||
@@ -50,6 +51,27 @@ const LLM_HERO_TEXT = `
|
||||
✕ Не убирать три пункта с галочками
|
||||
`.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() {
|
||||
return (
|
||||
<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)" }}>
|
||||
Hero-баннер
|
||||
</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)" }}>
|
||||
Главный баннер страницы раздела ЛОР — perm.oclinica.ru/lor. Двухколоночный блок, единый светло-кремовый фон{" "}
|
||||
<strong>#f9f4e7</strong>.
|
||||
@@ -138,31 +160,6 @@ export default function HeroPage() {
|
||||
</p>
|
||||
</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 блок */}
|
||||
<LlmBlock path="/blocks/hero" version="v1.2" specText={LLM_HERO_TEXT}>
|
||||
<LlmSection title="Структура баннера" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { NewsBlock } from "@/components/blocks/NewsBlock";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -67,6 +68,17 @@ const LLM_NEWS_TEXT = `
|
||||
✕ Не добавлять описание/анонс в карточку
|
||||
`.trim();
|
||||
|
||||
const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: "v1.1",
|
||||
date: "24.03.2026",
|
||||
changes: [
|
||||
"H2: размер на 36px, цвет на #000000, line-height 38px",
|
||||
"Фон секции: с белого на #f2fee6 (светло-зелёный, как на реальном сайте)",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function NewsBlockPage() {
|
||||
return (
|
||||
<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>
|
||||
<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)" }}>
|
||||
Блок новостей с perm.oclinica.ru/lor — 4 карточки в ряд (дата + заголовок-ссылка),
|
||||
кнопка «Все новости».
|
||||
@@ -132,20 +144,6 @@ export default function NewsBlockPage() {
|
||||
</p>
|
||||
</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 блок */}
|
||||
<LlmBlock path="/blocks/news" version="v1.1" specText={LLM_NEWS_TEXT}>
|
||||
<LlmSection title="Структура карточки новости" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { ReviewsBlock } from "@/components/blocks/ReviewsBlock";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -52,6 +53,16 @@ const LLM_REVIEWS_TEXT = `
|
||||
✕ Не убирать навигационные стрелки
|
||||
`.trim();
|
||||
|
||||
const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: "v1.1",
|
||||
date: "24.03.2026",
|
||||
changes: [
|
||||
"H2: размер с ~20px на 36px, цвет с #111827 на #000000, line-height 38px",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function ReviewsBlockPage() {
|
||||
return (
|
||||
<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>
|
||||
<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)" }}>
|
||||
Карусель отзывов с perm.oclinica.ru/lor — большая кавычка, текст, «Читать полностью», стрелки.
|
||||
</p>
|
||||
@@ -157,19 +168,6 @@ export default function ReviewsBlockPage() {
|
||||
</div>
|
||||
</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 блок */}
|
||||
<LlmBlock path="/blocks/reviews" version="v1.1" specText={LLM_REVIEWS_TEXT}>
|
||||
<LlmSection title="Структура блока" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { BlockChangelog, type ChangelogEntry } from "./BlockChangelog";
|
||||
|
||||
const LS_PREFIX = "bb-block-preview:";
|
||||
|
||||
@@ -21,15 +22,17 @@ interface BlockMeta {
|
||||
name: string;
|
||||
version: string;
|
||||
isInPreview: boolean;
|
||||
changelog: ChangelogEntry[];
|
||||
}
|
||||
|
||||
interface BlockMetaBarProps {
|
||||
path: string;
|
||||
defaultVersion: string;
|
||||
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 [editing, setEditing] = useState(false);
|
||||
const [versionInput, setVersionInput] = useState(defaultVersion);
|
||||
@@ -55,7 +58,7 @@ export function BlockMetaBar({ path, defaultVersion, defaultIsInPreview }: Block
|
||||
.catch(() => setApiDown(true));
|
||||
}, [apiUrl, path]);
|
||||
|
||||
async function patch(data: { version?: string; isInPreview?: boolean }) {
|
||||
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',
|
||||
@@ -78,7 +81,6 @@ export function BlockMetaBar({ path, defaultVersion, defaultIsInPreview }: Block
|
||||
|
||||
async function togglePreview() {
|
||||
if (apiDown) {
|
||||
// Fallback: toggle via localStorage
|
||||
const newVal = !localPreview;
|
||||
setLocalPreview(newVal);
|
||||
writeLocalPreview(path, newVal);
|
||||
@@ -96,92 +98,100 @@ export function BlockMetaBar({ path, defaultVersion, defaultIsInPreview }: Block
|
||||
|
||||
const version = meta?.version ?? defaultVersion;
|
||||
const isInPreview = apiDown ? localPreview : (meta?.isInPreview ?? localPreview);
|
||||
const changelog: ChangelogEntry[] = (meta?.changelog && meta.changelog.length > 0) ? meta.changelog : defaultChangelog;
|
||||
|
||||
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 badge */}
|
||||
<span className="font-semibold" style={{ color: "var(--bb-text-muted)" }}>
|
||||
Версия:
|
||||
</span>
|
||||
{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>
|
||||
<>
|
||||
<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 badge */}
|
||||
<span className="font-semibold" style={{ color: "var(--bb-text-muted)" }}>
|
||||
Версия:
|
||||
</span>
|
||||
) : (
|
||||
{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>
|
||||
)}
|
||||
|
||||
<span style={{ color: "var(--bb-border)" }}>·</span>
|
||||
|
||||
{/* Preview toggle */}
|
||||
<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',
|
||||
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)",
|
||||
}}
|
||||
>
|
||||
{version}
|
||||
<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>
|
||||
{/* Subtle offline dot instead of "API офлайн" text */}
|
||||
{apiDown && (
|
||||
<span
|
||||
className="inline-block w-1.5 h-1.5 rounded-full"
|
||||
title="API недоступен — данные из кода"
|
||||
style={{ background: "#d1d5db" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{apiDown && (
|
||||
<>
|
||||
<span style={{ color: "var(--bb-border)" }}>·</span>
|
||||
<span style={{ color: "#f59e0b" }}>API офлайн</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Changelog rendered from API or fallback */}
|
||||
{changelog.length > 0 && <BlockChangelog changelog={changelog} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user