Refine admin editor around static content

This commit is contained in:
dcconstructions 2026-06-25 23:10:48 +03:00
parent 69678a441e
commit d0020a48e3
9 changed files with 1487 additions and 265 deletions

View File

@ -1,23 +1,38 @@
:root {
color-scheme: dark;
--bg: #08090b;
--panel: #121417;
--panel-2: #181b20;
--line: #2a2f36;
--text: #f4f5f6;
--muted: #9097a1;
--accent: #1976ff;
--danger: #cf3b3b;
--bg: #07080a;
--shell: rgba(10, 10, 14, 0.96);
--sidebar: rgba(7, 8, 11, 0.9);
--surface: rgba(255, 255, 255, 0.034);
--surface-strong: rgba(255, 255, 255, 0.062);
--field: rgba(255, 255, 255, 0.045);
--text: rgba(255, 255, 255, 0.93);
--muted: rgba(255, 255, 255, 0.58);
--faint: rgba(255, 255, 255, 0.36);
--accent: #b9ff4a;
--accent-text: #071005;
--danger: #ff6f6f;
--radius-lg: 1.35rem;
--radius-md: 1.1rem;
}
* {
box-sizing: border-box;
}
html {
min-height: 100%;
background: var(--bg);
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
overflow: hidden;
background:
radial-gradient(circle at 14% 78%, rgba(185, 255, 74, 0.22), transparent 24rem),
radial-gradient(circle at 86% 18%, rgba(255, 255, 255, 0.08), transparent 18rem),
linear-gradient(145deg, #090a0c 0%, #050608 60%, #0b0c0f 100%);
color: var(--text);
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
@ -29,263 +44,602 @@ textarea {
font: inherit;
}
button {
appearance: none;
}
.admin-backdrop {
position: fixed;
inset: 0;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 32%),
rgba(0, 0, 0, 0.58);
backdrop-filter: blur(26px);
}
.admin-shell {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 360px minmax(0, 1fr);
min-height: 100vh;
grid-template-columns: 296px minmax(0, 1fr);
width: min(88rem, calc(100vw - 7.5rem));
height: min(88vh, 920px);
margin: 6vh auto;
overflow: hidden;
border-radius: 1.65rem;
background: var(--shell);
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.42);
}
.sidebar {
border-right: 1px solid var(--line);
background: #0d0f12;
padding: 20px;
min-height: 0;
overflow: auto;
padding: 1rem 0.75rem 1.25rem;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 26rem),
var(--sidebar);
box-shadow: inset -1px 0 rgba(255, 255, 255, 0.035);
}
.sidebar-head,
.editor-head,
.actions,
.button-row,
.toggle-row {
display: flex;
.sidebar-head {
display: grid;
gap: 1rem;
padding: 0.25rem 0 1.15rem;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.75rem;
width: max-content;
padding: 0.45rem 0.65rem;
color: var(--text);
font-size: 0.98rem;
text-decoration: none;
}
.sidebar-head,
.editor-head {
justify-content: space-between;
gap: 16px;
.workspace-card {
display: grid;
grid-template-columns: 2.9rem minmax(0, 1fr);
align-items: center;
gap: 0.85rem;
min-height: 4rem;
padding: 0.75rem;
border-radius: 1.35rem;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.028)),
rgba(255, 255, 255, 0.035);
}
.eyebrow {
.workspace-logo,
.empty-icon {
display: grid;
place-items: center;
width: 2.9rem;
aspect-ratio: 1;
border-radius: 1rem;
background: #050608;
color: var(--accent);
font-size: 0.78rem;
font-weight: 800;
}
.workspace-name {
overflow: hidden;
color: var(--text);
font-size: 0.98rem;
font-weight: 800;
line-height: 1.15;
text-overflow: ellipsis;
white-space: nowrap;
}
.workspace-role,
.breadcrumb,
.panel-note,
.field-path,
.block-meta,
.group-desc,
.status,
.empty-state p {
color: var(--muted);
font-size: 12px;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
h1,
h2 {
margin: 4px 0 0;
font-size: 24px;
line-height: 1.05;
letter-spacing: 0;
.workspace-role {
margin-top: 0.12rem;
font-size: 0.82rem;
}
.regions {
display: grid;
gap: 18px;
margin-top: 24px;
gap: 1.45rem;
padding-bottom: 2rem;
}
.region-title {
margin: 0 0 8px;
color: var(--muted);
font-size: 12px;
font-weight: 700;
letter-spacing: 0;
margin: 0 0 0.55rem;
padding: 0 0.65rem;
color: rgba(255, 255, 255, 0.64);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.22em;
text-transform: uppercase;
}
.block-list {
display: grid;
gap: 8px;
gap: 0.45rem;
}
.block-btn {
display: grid;
width: 100%;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
color: var(--text);
min-height: 3rem;
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: rgba(255, 255, 255, 0.74);
cursor: pointer;
padding: 11px 12px;
gap: 0.22rem;
padding: 0.72rem 0.85rem;
text-align: left;
}
.block-btn:hover,
.block-btn:hover {
background: rgba(255, 255, 255, 0.04);
color: var(--text);
}
.block-btn.active {
border-color: var(--accent);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.025)),
rgba(255, 255, 255, 0.05);
color: var(--accent);
box-shadow: inset 2px 0 var(--accent);
}
.block-btn.disabled {
color: #5f6670;
color: rgba(255, 255, 255, 0.3);
}
.block-name {
overflow: hidden;
font-size: 0.94rem;
font-weight: 780;
line-height: 1.18;
text-overflow: ellipsis;
white-space: nowrap;
}
.block-meta {
display: block;
margin-top: 4px;
color: var(--muted);
font-size: 12px;
overflow: hidden;
font-size: 0.74rem;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.editor {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
min-width: 0;
padding: 24px;
min-height: 0;
padding: 1.6rem 1.9rem 1.5rem;
}
.editor-head,
.actions,
.button-row,
.toggle-row,
.card-head,
.panel-head,
.group-head {
display: flex;
align-items: center;
}
.editor-head {
border-bottom: 1px solid var(--line);
padding-bottom: 20px;
justify-content: space-between;
gap: 1rem;
padding: 0 0 1.25rem;
}
h1,
h2,
h3 {
margin: 0;
letter-spacing: 0;
}
.editor-head h1 {
color: rgba(255, 255, 255, 0.86);
font-size: 1.2rem;
font-weight: 820;
line-height: 1.2;
}
.breadcrumb {
margin-top: 0.4rem;
font-size: 0.75rem;
line-height: 1.3;
}
.editor-head h2 {
margin-top: 1.35rem;
font-size: clamp(1.8rem, 3vw, 2.45rem);
font-weight: 740;
line-height: 1.02;
}
.actions,
.button-row {
gap: 8px;
gap: 0.55rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.primary-btn,
.ghost-btn,
.danger-btn,
.icon-btn {
border: 1px solid var(--line);
border-radius: 8px;
color: var(--text);
min-height: 2.75rem;
border: 0;
border-radius: 1.25rem;
cursor: pointer;
min-height: 36px;
padding: 8px 12px;
padding: 0.72rem 1.08rem;
text-decoration: none;
transition:
opacity 0.15s ease,
transform 0.15s ease,
background 0.15s ease;
}
.primary-btn {
border-color: var(--accent);
min-width: 12rem;
background: var(--accent);
color: var(--accent-text);
font-weight: 760;
}
.ghost-btn,
.icon-btn {
background: var(--panel-2);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.025)),
rgba(255, 255, 255, 0.045);
color: var(--text);
font-weight: 700;
}
.danger-btn {
border-color: #5d2525;
background: #241010;
color: #ffb9b9;
background: rgba(255, 111, 111, 0.12);
color: #ffd2d2;
font-weight: 740;
}
.icon-btn {
width: 36px;
width: 2.75rem;
padding: 0;
}
.primary-btn:hover,
.ghost-btn:hover,
.danger-btn:hover,
.icon-btn:hover {
transform: translateY(-1px);
}
.primary-btn:disabled,
.ghost-btn:disabled,
.danger-btn:disabled,
.icon-btn:disabled,
input:disabled,
textarea:disabled {
cursor: not-allowed;
opacity: 0.42;
transform: none;
}
.empty-state {
margin-top: 24px;
max-width: 680px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
color: var(--muted);
display: grid;
grid-template-columns: 3rem minmax(0, 1fr);
align-self: start;
gap: 1rem;
max-width: 44rem;
margin-top: 1.4rem;
padding: 1.25rem;
border-radius: var(--radius-lg);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.065), rgba(255, 255, 255, 0.026)),
var(--surface);
backdrop-filter: blur(18px);
}
.empty-state h3 {
font-size: 1.05rem;
line-height: 1.25;
}
.empty-state p {
margin: 0.35rem 0 0;
font-size: 0.9rem;
line-height: 1.45;
padding: 16px;
}
.block-form {
min-height: 0;
overflow: auto;
display: grid;
gap: 18px;
margin-top: 24px;
gap: 1.15rem;
padding: 0 0.1rem 5rem;
scrollbar-color: rgba(255, 255, 255, 0.25) transparent;
}
.hidden {
display: none;
display: none !important;
}
.settings-card,
.content-panel,
.json-details {
border-radius: var(--radius-lg);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.058), rgba(255, 255, 255, 0.018)),
var(--surface);
backdrop-filter: blur(18px);
}
.settings-card {
padding: 1.25rem;
}
.block-meta-card {
margin-top: 0.1rem;
}
.card-head,
.panel-head,
.group-head {
justify-content: space-between;
gap: 1rem;
}
.card-head h3,
.panel-head h3,
.group-head h3 {
color: rgba(255, 255, 255, 0.9);
font-size: 1rem;
font-weight: 820;
line-height: 1.2;
}
.section-kicker {
margin-bottom: 0.35rem;
color: rgba(255, 255, 255, 0.58);
font-size: 0.72rem;
font-weight: 850;
letter-spacing: 0.22em;
text-transform: uppercase;
}
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
gap: 0.85rem;
margin-top: 1.2rem;
}
.content-panel {
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(255, 255, 255, 0.02);
padding: 16px;
display: grid;
gap: 1rem;
padding: 1.2rem;
}
.section-title {
margin-bottom: 14px;
color: var(--text);
font-size: 14px;
font-weight: 800;
.panel-note {
max-width: 22rem;
font-size: 0.8rem;
line-height: 1.35;
text-align: right;
}
.content-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
gap: 1rem;
}
.content-fields .wide {
grid-column: 1 / -1;
}
.content-fields .empty-note {
grid-column: 1 / -1;
color: var(--muted);
font-size: 14px;
line-height: 1.4;
}
label {
.content-group {
display: grid;
gap: 8px;
color: var(--muted);
font-size: 13px;
font-weight: 700;
gap: 1rem;
padding: 1.1rem;
border-radius: 1.25rem;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.018)),
rgba(255, 255, 255, 0.026);
}
.group-desc {
margin: 0.2rem 0 0;
font-size: 0.78rem;
line-height: 1.35;
}
.group-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.field-control {
display: grid;
gap: 0.45rem;
min-width: 0;
color: rgba(255, 255, 255, 0.72);
font-size: 0.82rem;
font-weight: 730;
}
.field-control.wide {
grid-column: 1 / -1;
}
.field-label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
min-width: 0;
}
.field-label-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.field-kind {
flex: 0 0 auto;
border-radius: 999px;
background: rgba(255, 255, 255, 0.07);
color: rgba(255, 255, 255, 0.52);
font-size: 0.68rem;
font-weight: 820;
letter-spacing: 0.04em;
padding: 0.2rem 0.45rem;
text-transform: uppercase;
}
.field-path {
overflow: hidden;
font-size: 0.68rem;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
input,
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
border: 0;
outline: 0;
border-radius: 1.18rem;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.058), rgba(255, 255, 255, 0.018)),
var(--field);
color: var(--text);
outline: none;
padding: 10px 12px;
padding: 0.88rem 0.95rem;
}
input:focus,
textarea:focus {
border-color: var(--accent);
box-shadow:
inset 0 0 0 1px rgba(185, 255, 74, 0.3),
0 0 0 4px rgba(185, 255, 74, 0.055);
}
textarea {
min-height: 8rem;
resize: vertical;
}
.toggle-row {
justify-content: flex-start;
gap: 10px;
gap: 0.65rem;
color: rgba(255, 255, 255, 0.78);
font-size: 0.86rem;
font-weight: 780;
}
.toggle-row input {
width: 18px;
height: 18px;
width: 2.7rem;
height: 1.45rem;
margin: 0;
appearance: none;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
padding: 0.18rem;
}
.toggle-row input::before {
display: block;
width: 1.08rem;
aspect-ratio: 1;
border-radius: 50%;
background: rgba(255, 255, 255, 0.82);
content: "";
transition: transform 0.16s ease;
}
.toggle-row input:checked {
background: var(--accent);
}
.toggle-row input:checked::before {
background: #050608;
transform: translateX(1.22rem);
}
.empty-note {
color: var(--muted);
font-size: 0.9rem;
line-height: 1.42;
}
.json-details {
padding: 1rem 1.2rem;
}
.json-details summary {
cursor: pointer;
color: rgba(255, 255, 255, 0.74);
font-size: 0.9rem;
font-weight: 800;
}
.json-field {
display: grid;
gap: 0.7rem;
margin-top: 1rem;
color: var(--muted);
font-size: 0.82rem;
font-weight: 720;
}
.json-field textarea {
min-height: 360px;
resize: vertical;
min-height: 24rem;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 13px;
font-size: 0.8rem;
line-height: 1.45;
}
.status {
min-height: 24px;
margin: 18px 0 0;
color: var(--muted);
min-height: 1.25rem;
margin: 1rem 0 0;
overflow: hidden;
font-size: 0.78rem;
line-height: 1.35;
text-overflow: ellipsis;
white-space: pre-wrap;
}
@media (max-width: 900px) {
@media (max-width: 1100px) {
body {
overflow: auto;
}
.admin-shell {
grid-template-columns: 1fr;
width: min(100vw - 1.5rem, 58rem);
height: auto;
min-height: calc(100vh - 1.5rem);
margin: 0.75rem auto;
}
.sidebar {
border-right: 0;
border-bottom: 1px solid var(--line);
max-height: 48vh;
max-height: 38vh;
box-shadow: inset 0 -1px rgba(255, 255, 255, 0.035);
}
.editor {
min-height: 62vh;
}
.editor-head {
@ -293,11 +647,35 @@ textarea:focus {
flex-direction: column;
}
.field-grid {
.actions {
justify-content: flex-start;
}
}
@media (max-width: 720px) {
.admin-shell {
width: 100vw;
min-height: 100vh;
margin: 0;
border-radius: 0;
}
.editor {
padding: 1.1rem;
}
.field-grid,
.group-grid {
grid-template-columns: 1fr;
}
.content-fields {
grid-template-columns: 1fr;
.panel-head,
.card-head {
align-items: flex-start;
flex-direction: column;
}
.panel-note {
text-align: left;
}
}

View File

@ -29,6 +29,107 @@ const el = {
deleteBlock: document.querySelector("#delete-block"),
};
const STATIC_FALLBACK = {
id: "static-elements",
type: "staticElements",
adminLabel: "Статичные элементы",
enabled: true,
content: {
navigation: {
logoAlt: "главная",
brandLabel: "NODE.DC",
links: [
{ label: "О продукте", href: "index.html#features", target: "" },
{ label: "Лист ожидания", href: "index.html#hero", target: "" },
{ label: "Документация", href: "https://docs.ssscript.app/", target: "_blank" },
{ label: "Тарифы", href: "index.html#pricing-wrap", target: "" },
],
},
notifications: [
{
eyebrowHtml: '<span class="darker-70">ДАЛЕЕ</span> — ИЮЛЬ 2026',
title: "Открытая бета",
body: "Открываем доступ всем. Будем собирать и отлаживать вместе.",
},
{
eyebrowHtml: '<span class="darker-70">ДАЛЕЕ</span> —<span class="darker-70"> </span>ИЮЛЬ 2026',
title: "Альфа для Founder-доступа",
body: "На второй фазе тестирования бета открыта для всех подписчиков Founder-тарифа.",
},
{
eyebrowHtml: "СЕЙЧАС",
title: "Инвайты в закрытую бету уже отправлены.",
body: "Сейчас тестируем базовую функциональность и ИИ-интеграцию. ",
},
],
dock: {
appTarget: "app-data",
trashAlt: "Белая иконка корзины.",
demoFileLabel: "демо-видео.txt",
},
assets: {
webglModelSrc: "./assets/custom/icon.glb",
logoPng: "./assets/custom/logo.png",
logoPngAlt: "Логотип NODE.DC",
appIconSrc: "./assets/webflow/images/69ad964d951f9d17cc5a919e_logo-app.svg",
appIconAlt: "Иконка NODE.DC",
appWordmarkSrc: "./assets/webflow/images/69ad96a28b06b7552a339137_logtype.svg?v=node-dc",
appWordmarkAlt: "Логотип NODE.DC",
},
},
editableFields: [
field("content.navigation.logoAlt", "Логотип в шапке: alt", "text", "text", "Шапка"),
field("content.navigation.brandLabel", "Бренд в шапке", "text", "text", "Шапка"),
field("content.navigation.links.0.label", "Ссылка 1: текст", "text", "text", "Шапка"),
field("content.navigation.links.0.href", "Ссылка 1: адрес", "url", "attr", "Шапка"),
field("content.navigation.links.1.label", "Ссылка 2: текст", "text", "text", "Шапка"),
field("content.navigation.links.1.href", "Ссылка 2: адрес", "url", "attr", "Шапка"),
field("content.navigation.links.2.label", "Ссылка 3: текст", "text", "text", "Шапка"),
field("content.navigation.links.2.href", "Ссылка 3: адрес", "url", "attr", "Шапка"),
field("content.navigation.links.2.target", "Ссылка 3: target", "text", "attr", "Шапка"),
field("content.navigation.links.3.label", "Ссылка 4: текст", "text", "text", "Шапка"),
field("content.navigation.links.3.href", "Ссылка 4: адрес", "url", "attr", "Шапка"),
field("content.notifications.0.eyebrowHtml", "Уведомление 1: дата/статус", "html", "html", "Уведомления"),
field("content.notifications.0.title", "Уведомление 1: заголовок", "text", "text", "Уведомления"),
field("content.notifications.0.body", "Уведомление 1: текст", "textarea", "text", "Уведомления"),
field("content.notifications.1.eyebrowHtml", "Уведомление 2: дата/статус", "html", "html", "Уведомления"),
field("content.notifications.1.title", "Уведомление 2: заголовок", "text", "text", "Уведомления"),
field("content.notifications.1.body", "Уведомление 2: текст", "textarea", "text", "Уведомления"),
field("content.notifications.2.eyebrowHtml", "Уведомление 3: дата/статус", "html", "html", "Уведомления"),
field("content.notifications.2.title", "Уведомление 3: заголовок", "text", "text", "Уведомления"),
field("content.notifications.2.body", "Уведомление 3: текст", "textarea", "text", "Уведомления"),
field("content.dock.appTarget", "Dock: ID приложения", "text", "text", "Dock"),
field("content.dock.trashAlt", "Dock: alt корзины", "text", "text", "Dock"),
field("content.dock.demoFileLabel", "Dock: подпись демо-файла", "text", "text", "Dock"),
field("content.assets.webglModelSrc", "WebGL-модель логотипа", "media", "attr", "Модель и логотипы"),
field("content.assets.logoPng", "PNG-логотип", "media", "attr", "Модель и логотипы"),
field("content.assets.logoPngAlt", "PNG-логотип: alt", "text", "text", "Модель и логотипы"),
field("content.assets.appIconSrc", "Иконка приложения", "media", "attr", "Модель и логотипы"),
field("content.assets.appIconAlt", "Иконка приложения: alt", "text", "text", "Модель и логотипы"),
field("content.assets.appWordmarkSrc", "Wordmark приложения", "media", "attr", "Модель и логотипы"),
field("content.assets.appWordmarkAlt", "Wordmark приложения: alt", "text", "text", "Модель и логотипы"),
],
};
const GROUP_COPY = {
Шапка: "Навигация верхней mac-панели: бренд, ссылки и целевые адреса.",
Уведомления: "Карточки уведомлений справа сверху. Порядок соответствует текущему stack в shell.",
Dock: "Нижняя системная панель: связь кнопки приложения, корзина и подписи файлов.",
"Модель и логотипы": "Базовые ассеты статичного слоя: GLB-модель, PNG/SVG-логотипы и alt-тексты.",
Заголовок: "Главный заголовок и вводный текст выбранной секции.",
"Тезисы первого экрана": "Четыре смысловых тезиса вокруг формы раннего доступа.",
"Карточка раннего доступа": "Текст внутри центрального окна приложения.",
Форма: "Плейсхолдеры, кнопки, success/failure состояния формы.",
"Юридический блок": "Политики, права и нижний текст формы.",
"Видео-окно": "Окно демо-видео и подключенный медиа-файл.",
Пункты: "Текстовые пункты внутри секции.",
Контент: "Остальные typed-поля выбранного блока.",
};
function field(path, label, kind = "text", renderAs = kind === "html" ? "html" : "text", group = null) {
return { path, label, kind, renderAs, group: group || inferFieldGroup(path) };
}
function setStatus(message) {
el.status.textContent = message;
}
@ -46,29 +147,83 @@ function setByPath(source, path, nextValue) {
const lastKey = keys.pop();
let target = source;
for (const key of keys) {
if (target[key] == null) {
target[key] = /^\d+$/.test(keys[0]) ? [] : {};
keys.forEach((key, index) => {
const nextKey = keys[index + 1] ?? lastKey;
if (target[key] == null || typeof target[key] !== "object") {
target[key] = /^\d+$/.test(nextKey) ? [] : {};
}
target = target[key];
}
});
target[lastKey] = nextValue;
}
function inferFieldGroup(path) {
if (path.startsWith("content.navigation.")) return "Шапка";
if (path.startsWith("content.notifications.")) return "Уведомления";
if (path.startsWith("content.dock.")) return "Dock";
if (path.startsWith("content.assets.")) return "Модель и логотипы";
if (path.startsWith("content.cards.")) return "Тезисы первого экрана";
if (path.startsWith("content.app.")) return "Карточка раннего доступа";
if (path.startsWith("content.form.")) return "Форма";
if (path === "content.legalHtml") return "Юридический блок";
if (path === "content.windowTitle" || path === "content.videoSrc") return "Видео-окно";
if (path.startsWith("content.features.")) return "Пункты";
if (path.startsWith("content.heading.") || path === "content.introHtml") return "Заголовок";
return "Контент";
}
function ensureStaticElements() {
if (!state.page.staticElements) {
state.page.staticElements = clone(STATIC_FALLBACK);
return;
}
state.page.staticElements.id ||= STATIC_FALLBACK.id;
state.page.staticElements.type ||= STATIC_FALLBACK.type;
state.page.staticElements.adminLabel ||= STATIC_FALLBACK.adminLabel;
state.page.staticElements.enabled = true;
state.page.staticElements.content ||= clone(STATIC_FALLBACK.content);
state.page.staticElements.editableFields ||= clone(STATIC_FALLBACK.editableFields);
}
function isStaticSelection() {
return state.selected?.kind === "static";
}
function activeRegion() {
if (!state.selected) return null;
if (isStaticSelection()) {
return {
id: "static",
label: "Статичные элементы",
description: "Shell-слой страницы: шапка, уведомления, dock и базовые ассеты.",
};
}
return state.page.regions[state.selected.regionIndex];
}
function activeBlock() {
if (!state.selected) return null;
if (isStaticSelection()) {
return state.page.staticElements;
}
const region = activeRegion();
if (!region || state.selected.blockIndex == null) return null;
return region.blocks[state.selected.blockIndex];
}
function uniqueId(base) {
const ids = new Set(state.page.regions.flatMap((region) => region.blocks.map((block) => block.id)));
const ids = new Set([
state.page.staticElements?.id,
...state.page.regions.flatMap((region) => region.blocks.map((block) => block.id)),
]);
let candidate = `${base}-copy`;
let index = 2;
@ -80,36 +235,78 @@ function uniqueId(base) {
return candidate;
}
function labelForRegion(region) {
if (region.id === "beforeCViz") return "Главная";
if (region.id === "cViz") return "Секции";
return region.label || region.id;
}
function renderBlockButton({ list, block, meta, active, disabled, onClick }) {
const button = document.createElement("button");
const name = document.createElement("span");
const description = document.createElement("span");
button.type = "button";
button.className = `block-btn${active ? " active" : ""}${disabled ? " disabled" : ""}`;
name.className = "block-name";
name.textContent = block.adminLabel || block.id;
description.className = "block-meta";
description.textContent = meta;
button.append(name, description);
button.addEventListener("click", onClick);
list.append(button);
}
function renderRegions() {
el.regions.innerHTML = "";
const staticNode = document.createElement("section");
const staticTitle = document.createElement("h3");
const staticList = document.createElement("div");
staticTitle.className = "region-title";
staticTitle.textContent = "Static";
staticList.className = "block-list";
renderBlockButton({
list: staticList,
block: state.page.staticElements,
meta: "shell · nav · notifications · dock",
active: isStaticSelection(),
disabled: false,
onClick: () => {
state.selected = { kind: "static" };
renderAll();
},
});
staticNode.append(staticTitle, staticList);
el.regions.append(staticNode);
state.page.regions.forEach((region, regionIndex) => {
const regionNode = document.createElement("section");
const title = document.createElement("h3");
const list = document.createElement("div");
title.className = "region-title";
title.textContent = region.label || region.id;
title.textContent = labelForRegion(region);
list.className = "block-list";
region.blocks.forEach((block, blockIndex) => {
const button = document.createElement("button");
const meta = document.createElement("span");
const isActive =
state.selected?.regionIndex === regionIndex && state.selected?.blockIndex === blockIndex;
state.selected?.kind === "block" &&
state.selected?.regionIndex === regionIndex &&
state.selected?.blockIndex === blockIndex;
button.type = "button";
button.className = `block-btn${isActive ? " active" : ""}${block.enabled === false ? " disabled" : ""}`;
button.textContent = block.adminLabel || block.id;
meta.className = "block-meta";
meta.textContent = `${block.id} · ${block.template}`;
button.append(meta);
button.addEventListener("click", () => {
state.selected = { regionIndex, blockIndex };
renderBlockButton({
list,
block,
meta: `${block.id} · ${block.template || block.type}`,
active: isActive,
disabled: block.enabled === false,
onClick: () => {
state.selected = { kind: "block", regionIndex, blockIndex };
renderAll();
},
});
list.append(button);
});
regionNode.append(title, list);
@ -117,27 +314,38 @@ function renderRegions() {
});
}
function setStructuralControlsDisabled(disabled) {
for (const control of [el.moveUp, el.moveDown, el.duplicate, el.copy, el.pasteAfter, el.deleteBlock]) {
control.disabled = disabled;
}
}
function renderEditor() {
const region = activeRegion();
const block = activeBlock();
const isStatic = isStaticSelection();
if (!block) {
el.empty.classList.remove("hidden");
el.form.classList.add("hidden");
el.selectedRegion.textContent = "Выберите блок";
el.selectedRegion.textContent = "NODE.DC / Выберите блок";
el.selectedTitle.textContent = "Контентный слой";
return;
}
el.empty.classList.add("hidden");
el.form.classList.remove("hidden");
el.selectedRegion.textContent = region.label || region.id;
el.selectedRegion.textContent = `NODE.DC / ${region.label || region.id}`;
el.selectedTitle.textContent = block.adminLabel || block.id;
el.id.value = block.id || "";
el.label.value = block.adminLabel || "";
el.template.value = block.template || "";
el.template.value = block.template || block.type || "";
el.anchor.value = block.anchor || "";
el.enabled.checked = block.enabled !== false;
el.id.readOnly = isStatic;
el.anchor.disabled = isStatic;
el.enabled.disabled = isStatic;
setStructuralControlsDisabled(isStatic);
renderContentFields(block);
el.json.value = JSON.stringify(block, null, 2);
}
@ -152,14 +360,30 @@ function syncBlockFromFields() {
if (!block) return;
syncContentFields();
block.adminLabel = el.label.value.trim() || block.adminLabel || block.id;
if (!isStaticSelection()) {
block.id = el.id.value.trim();
block.adminLabel = el.label.value.trim();
block.anchor = el.anchor.value.trim() || null;
block.enabled = el.enabled.checked;
}
el.json.value = JSON.stringify(block, null, 2);
renderRegions();
}
function groupEditableFields(block) {
const groups = new Map();
for (const editableField of block.editableFields ?? []) {
const groupName = editableField.group || inferFieldGroup(editableField.path);
if (!groups.has(groupName)) groups.set(groupName, []);
groups.get(groupName).push(editableField);
}
return groups;
}
function renderContentFields(block) {
el.contentFields.innerHTML = "";
@ -171,19 +395,48 @@ function renderContentFields(block) {
return;
}
for (const editableField of block.editableFields) {
for (const [groupName, fields] of groupEditableFields(block)) {
const group = document.createElement("section");
const head = document.createElement("div");
const titleWrap = document.createElement("div");
const title = document.createElement("h3");
const desc = document.createElement("p");
const grid = document.createElement("div");
group.className = "content-group";
head.className = "group-head";
title.textContent = groupName;
desc.className = "group-desc";
desc.textContent = GROUP_COPY[groupName] || GROUP_COPY["Контент"];
titleWrap.append(title, desc);
head.append(titleWrap);
grid.className = "group-grid";
for (const editableField of fields) {
const label = document.createElement("label");
const labelRow = document.createElement("span");
const labelText = document.createElement("span");
const kind = document.createElement("span");
const path = document.createElement("span");
const value = getByPath(block, editableField.path) ?? "";
const isLong = editableField.kind === "html" || editableField.kind === "textarea";
const input = isLong ? document.createElement("textarea") : document.createElement("input");
label.className = isLong ? "wide" : "";
label.textContent = editableField.label || editableField.path;
label.className = `field-control${isLong ? " wide" : ""}`;
labelRow.className = "field-label-row";
labelText.className = "field-label-text";
labelText.textContent = editableField.label || editableField.path;
kind.className = "field-kind";
kind.textContent = editableField.kind || "text";
path.className = "field-path";
path.textContent = editableField.path;
labelRow.append(labelText, kind);
input.dataset.path = editableField.path;
input.value = value;
if (!isLong) {
input.type = editableField.kind === "media" || editableField.kind === "url" ? "text" : "text";
input.type = "text";
input.autocomplete = "off";
}
@ -194,8 +447,12 @@ function renderContentFields(block) {
el.json.value = JSON.stringify(active, null, 2);
});
label.append(input);
el.contentFields.append(label);
label.append(labelRow, input, path);
grid.append(label);
}
group.append(head, grid);
el.contentFields.append(group);
}
}
@ -209,12 +466,17 @@ function syncContentFields() {
}
function syncBlockFromJson() {
const region = activeRegion();
if (!region) return false;
if (!state.selected) return false;
try {
const parsed = JSON.parse(el.json.value);
if (isStaticSelection()) {
state.page.staticElements = parsed;
} else {
const region = activeRegion();
if (!region) return false;
region.blocks[state.selected.blockIndex] = parsed;
}
renderAll();
setStatus("JSON блока применён локально. Нажмите «Сохранить JSON», чтобы записать файл.");
return true;
@ -228,7 +490,8 @@ async function loadPage() {
const response = await fetch("/api/page/home");
if (!response.ok) throw new Error(await response.text());
state.page = await response.json();
state.selected = null;
ensureStaticElements();
state.selected = { kind: "static" };
renderAll();
setStatus("Модель home.json загружена.");
}
@ -258,6 +521,7 @@ async function renderHome() {
}
function moveSelected(delta) {
if (isStaticSelection()) return;
const region = activeRegion();
const index = state.selected?.blockIndex;
if (!region || index == null) return;
@ -272,6 +536,7 @@ function moveSelected(delta) {
}
function duplicateSelected() {
if (isStaticSelection()) return;
const region = activeRegion();
const block = activeBlock();
if (!region || !block) return;
@ -286,6 +551,7 @@ function duplicateSelected() {
}
function copySelected() {
if (isStaticSelection()) return;
const block = activeBlock();
if (!block) return;
@ -294,6 +560,7 @@ function copySelected() {
}
function pasteAfterSelected() {
if (isStaticSelection()) return;
const region = activeRegion();
if (!region || !state.clipboard) return;
@ -307,12 +574,13 @@ function pasteAfterSelected() {
}
function deleteSelected() {
if (isStaticSelection()) return;
const region = activeRegion();
if (!region || state.selected.blockIndex == null) return;
region.blocks.splice(state.selected.blockIndex, 1);
state.selected.blockIndex = Math.min(state.selected.blockIndex, region.blocks.length - 1);
if (state.selected.blockIndex < 0) state.selected = null;
if (state.selected.blockIndex < 0) state.selected = { kind: "static" };
renderAll();
}

View File

@ -7,14 +7,21 @@
<link rel="stylesheet" href="./admin.css" />
</head>
<body>
<div class="admin-backdrop" aria-hidden="true"></div>
<main class="admin-shell">
<aside class="sidebar">
<div class="sidebar-head">
<a class="back-link" href="/" target="_blank" aria-label="Открыть сайт">
<span></span>
<span>Открыть сайт</span>
</a>
<div class="workspace-card">
<div class="workspace-logo">ND</div>
<div>
<div class="eyebrow">NODE.DC</div>
<h1>Блоки страницы</h1>
<div class="workspace-name">NODE.DC</div>
<div class="workspace-role">Администратор сайта</div>
</div>
</div>
<button id="reload" class="icon-btn" type="button" title="Перезагрузить данные"></button>
</div>
<div id="regions" class="regions"></div>
</aside>
@ -22,46 +29,64 @@
<section class="editor">
<header class="editor-head">
<div>
<div id="selected-region" class="eyebrow">Выберите блок</div>
<h1>Настройки сайта</h1>
<div id="selected-region" class="breadcrumb">NODE.DC / Выберите блок</div>
<h2 id="selected-title">Контентный слой</h2>
</div>
<div class="actions">
<a class="ghost-btn" href="/" target="_blank">Открыть сайт</a>
<button id="render" class="ghost-btn" type="button">Собрать index.html</button>
<button id="reload" class="icon-btn" type="button" title="Перезагрузить данные"></button>
<button id="save" class="primary-btn" type="button">Сохранить JSON</button>
<button id="render" class="ghost-btn" type="button">Собрать index.html</button>
</div>
</header>
<div id="empty-state" class="empty-state">
Слева выберите блок. Сейчас это первый этап: блоки можно включать, выключать,
дублировать и менять порядок внутри своего DOM-региона.
<div class="empty-icon">ND</div>
<div>
<h3>Выберите блок слева</h3>
<p>Статика, hero, форма и секции страницы разделены на рабочие группы. Сначала редактируем typed-поля, сырой JSON оставлен как технический fallback.</p>
</div>
</div>
<form id="block-form" class="block-form hidden">
<section class="settings-card block-meta-card">
<div class="card-head">
<div>
<div class="section-kicker">Идентификатор</div>
<h3>Служебные параметры</h3>
</div>
<label class="toggle-row">
<input id="block-enabled" name="enabled" type="checkbox" />
<span>В рендере</span>
</label>
</div>
<div class="field-grid">
<label>
ID блока
<label class="field-control">
<span>ID блока</span>
<input id="block-id" name="id" type="text" autocomplete="off" />
</label>
<label>
Название в админке
<label class="field-control">
<span>Название в админке</span>
<input id="block-label" name="adminLabel" type="text" autocomplete="off" />
</label>
<label>
Шаблон
<label class="field-control">
<span>Шаблон / тип</span>
<input id="block-template" name="template" type="text" autocomplete="off" readonly />
</label>
<label>
Anchor
<label class="field-control">
<span>Anchor</span>
<input id="block-anchor" name="anchor" type="text" autocomplete="off" />
</label>
</div>
</section>
<label class="toggle-row">
<input id="block-enabled" name="enabled" type="checkbox" />
<span>Блок включён в рендер</span>
</label>
<section class="settings-card structure-card">
<div class="card-head">
<div>
<div class="section-kicker">Порядок</div>
<h3>Операции с блоком</h3>
</div>
</div>
<div class="button-row">
<button id="move-up" class="ghost-btn" type="button">Вверх</button>
<button id="move-down" class="ghost-btn" type="button">Вниз</button>
@ -70,16 +95,26 @@
<button id="paste-after" class="ghost-btn" type="button">Вставить после</button>
<button id="delete-block" class="danger-btn" type="button">Удалить</button>
</div>
</section>
<section class="content-panel">
<div class="section-title">Контент блока</div>
<div class="panel-head">
<div>
<div class="section-kicker">Контент</div>
<h3>Редактируемые поля</h3>
</div>
<span class="panel-note">Поля сгруппированы по смысловым подблокам верстки.</span>
</div>
<div id="content-fields" class="content-fields"></div>
</section>
<details class="json-details">
<summary>JSON выбранного блока</summary>
<label class="json-field">
JSON выбранного блока
<span>Техническое представление</span>
<textarea id="block-json" spellcheck="false"></textarea>
</label>
</details>
</form>
<pre id="status" class="status"></pre>

File diff suppressed because one or more lines are too long

View File

@ -3,6 +3,283 @@
"slug": "home",
"output": "index.html",
"title": "NODE.DC",
"staticElements": {
"id": "static-elements",
"type": "staticElements",
"adminLabel": "Статичные элементы",
"enabled": true,
"content": {
"navigation": {
"logoAlt": "главная",
"brandLabel": "NODE.DC",
"links": [
{
"label": "О продукте",
"href": "index.html#features",
"target": ""
},
{
"label": "Лист ожидания",
"href": "index.html#hero",
"target": ""
},
{
"label": "Документация",
"href": "https://docs.ssscript.app/",
"target": "_blank"
},
{
"label": "Тарифы",
"href": "index.html#pricing-wrap",
"target": ""
}
]
},
"notifications": [
{
"eyebrowHtml": "<span class=\"darker-70\">ДАЛЕЕ</span> — ИЮЛЬ 2026",
"title": "Открытая бета",
"body": "Открываем доступ всем. Будем собирать и отлаживать вместе."
},
{
"eyebrowHtml": "<span class=\"darker-70\">ДАЛЕЕ</span> —<span class=\"darker-70\"> </span>ИЮЛЬ 2026",
"title": "Альфа для Founder-доступа",
"body": "На второй фазе тестирования бета открыта для всех подписчиков Founder-тарифа."
},
{
"eyebrowHtml": "СЕЙЧАС",
"title": "Инвайты в закрытую бету уже отправлены.",
"body": "Сейчас тестируем базовую функциональность и ИИ-интеграцию. "
}
],
"dock": {
"appTarget": "app-data",
"trashAlt": "Белая иконка корзины.",
"demoFileLabel": "демо-видео.txt"
},
"assets": {
"webglModelSrc": "./assets/custom/icon.glb",
"logoPng": "./assets/custom/logo.png",
"logoPngAlt": "Логотип NODE.DC",
"appIconSrc": "./assets/webflow/images/69ad964d951f9d17cc5a919e_logo-app.svg",
"appIconAlt": "Иконка NODE.DC",
"appWordmarkSrc": "./assets/webflow/images/69ad96a28b06b7552a339137_logtype.svg?v=node-dc",
"appWordmarkAlt": "Логотип NODE.DC"
}
},
"editableFields": [
{
"path": "content.navigation.logoAlt",
"label": "Логотип в шапке: alt",
"kind": "text",
"renderAs": "text",
"group": "Шапка"
},
{
"path": "content.navigation.brandLabel",
"label": "Бренд в шапке",
"kind": "text",
"renderAs": "text",
"group": "Шапка"
},
{
"path": "content.navigation.links.0.label",
"label": "Ссылка 1: текст",
"kind": "text",
"renderAs": "text",
"group": "Шапка"
},
{
"path": "content.navigation.links.0.href",
"label": "Ссылка 1: адрес",
"kind": "url",
"renderAs": "attr",
"group": "Шапка"
},
{
"path": "content.navigation.links.1.label",
"label": "Ссылка 2: текст",
"kind": "text",
"renderAs": "text",
"group": "Шапка"
},
{
"path": "content.navigation.links.1.href",
"label": "Ссылка 2: адрес",
"kind": "url",
"renderAs": "attr",
"group": "Шапка"
},
{
"path": "content.navigation.links.2.label",
"label": "Ссылка 3: текст",
"kind": "text",
"renderAs": "text",
"group": "Шапка"
},
{
"path": "content.navigation.links.2.href",
"label": "Ссылка 3: адрес",
"kind": "url",
"renderAs": "attr",
"group": "Шапка"
},
{
"path": "content.navigation.links.2.target",
"label": "Ссылка 3: target",
"kind": "text",
"renderAs": "attr",
"group": "Шапка"
},
{
"path": "content.navigation.links.3.label",
"label": "Ссылка 4: текст",
"kind": "text",
"renderAs": "text",
"group": "Шапка"
},
{
"path": "content.navigation.links.3.href",
"label": "Ссылка 4: адрес",
"kind": "url",
"renderAs": "attr",
"group": "Шапка"
},
{
"path": "content.notifications.0.eyebrowHtml",
"label": "Уведомление 1: дата/статус",
"kind": "html",
"renderAs": "html",
"group": "Уведомления"
},
{
"path": "content.notifications.0.title",
"label": "Уведомление 1: заголовок",
"kind": "text",
"renderAs": "text",
"group": "Уведомления"
},
{
"path": "content.notifications.0.body",
"label": "Уведомление 1: текст",
"kind": "textarea",
"renderAs": "text",
"group": "Уведомления"
},
{
"path": "content.notifications.1.eyebrowHtml",
"label": "Уведомление 2: дата/статус",
"kind": "html",
"renderAs": "html",
"group": "Уведомления"
},
{
"path": "content.notifications.1.title",
"label": "Уведомление 2: заголовок",
"kind": "text",
"renderAs": "text",
"group": "Уведомления"
},
{
"path": "content.notifications.1.body",
"label": "Уведомление 2: текст",
"kind": "textarea",
"renderAs": "text",
"group": "Уведомления"
},
{
"path": "content.notifications.2.eyebrowHtml",
"label": "Уведомление 3: дата/статус",
"kind": "html",
"renderAs": "html",
"group": "Уведомления"
},
{
"path": "content.notifications.2.title",
"label": "Уведомление 3: заголовок",
"kind": "text",
"renderAs": "text",
"group": "Уведомления"
},
{
"path": "content.notifications.2.body",
"label": "Уведомление 3: текст",
"kind": "textarea",
"renderAs": "text",
"group": "Уведомления"
},
{
"path": "content.dock.appTarget",
"label": "Dock: ID приложения",
"kind": "text",
"renderAs": "text",
"group": "Dock"
},
{
"path": "content.dock.trashAlt",
"label": "Dock: alt корзины",
"kind": "text",
"renderAs": "text",
"group": "Dock"
},
{
"path": "content.dock.demoFileLabel",
"label": "Dock: подпись демо-файла",
"kind": "text",
"renderAs": "text",
"group": "Dock"
},
{
"path": "content.assets.webglModelSrc",
"label": "WebGL-модель логотипа",
"kind": "media",
"renderAs": "attr",
"group": "Модель и логотипы"
},
{
"path": "content.assets.logoPng",
"label": "PNG-логотип",
"kind": "media",
"renderAs": "attr",
"group": "Модель и логотипы"
},
{
"path": "content.assets.logoPngAlt",
"label": "PNG-логотип: alt",
"kind": "text",
"renderAs": "text",
"group": "Модель и логотипы"
},
{
"path": "content.assets.appIconSrc",
"label": "Иконка приложения",
"kind": "media",
"renderAs": "attr",
"group": "Модель и логотипы"
},
{
"path": "content.assets.appIconAlt",
"label": "Иконка приложения: alt",
"kind": "text",
"renderAs": "text",
"group": "Модель и логотипы"
},
{
"path": "content.assets.appWordmarkSrc",
"label": "Wordmark приложения",
"kind": "media",
"renderAs": "attr",
"group": "Модель и логотипы"
},
{
"path": "content.assets.appWordmarkAlt",
"label": "Wordmark приложения: alt",
"kind": "text",
"renderAs": "text",
"group": "Модель и логотипы"
}
]
},
"seo": {
"title": "NODE.DC — ИИ-платформа для Webflow",
"description": "Единая платформа для кастомного кода, MCP и ИИ-агентов в Webflow: контекст проекта, автоматизация, деплой и управляемая разработка."
@ -71,181 +348,211 @@
"path": "content.heading.muted",
"label": "Hero: приглушённая строка",
"kind": "text",
"renderAs": "text"
"renderAs": "text",
"group": "Заголовок"
},
{
"path": "content.heading.main",
"label": "Hero: основная строка",
"kind": "text",
"renderAs": "text"
"renderAs": "text",
"group": "Заголовок"
},
{
"path": "content.introHtml",
"label": "Hero: вводный текст",
"kind": "html",
"renderAs": "html"
"renderAs": "html",
"group": "Заголовок"
},
{
"path": "content.cards.0.muted",
"label": "Карточка 1: приглушённая строка",
"kind": "text",
"renderAs": "text"
"renderAs": "text",
"group": "Тезисы первого экрана"
},
{
"path": "content.cards.0.main",
"label": "Карточка 1: основная строка",
"kind": "text",
"renderAs": "text"
"renderAs": "text",
"group": "Тезисы первого экрана"
},
{
"path": "content.cards.0.bodyHtml",
"label": "Карточка 1: текст",
"kind": "html",
"renderAs": "html"
"renderAs": "html",
"group": "Тезисы первого экрана"
},
{
"path": "content.cards.1.muted",
"label": "Карточка 2: приглушённая строка",
"kind": "text",
"renderAs": "text"
"renderAs": "text",
"group": "Тезисы первого экрана"
},
{
"path": "content.cards.1.main",
"label": "Карточка 2: основная строка",
"kind": "text",
"renderAs": "text"
"renderAs": "text",
"group": "Тезисы первого экрана"
},
{
"path": "content.cards.1.bodyHtml",
"label": "Карточка 2: текст",
"kind": "html",
"renderAs": "html"
"renderAs": "html",
"group": "Тезисы первого экрана"
},
{
"path": "content.cards.2.muted",
"label": "Карточка 3: приглушённая строка",
"kind": "text",
"renderAs": "text"
"renderAs": "text",
"group": "Тезисы первого экрана"
},
{
"path": "content.cards.2.main",
"label": "Карточка 3: основная строка",
"kind": "text",
"renderAs": "text"
"renderAs": "text",
"group": "Тезисы первого экрана"
},
{
"path": "content.cards.2.bodyHtml",
"label": "Карточка 3: текст",
"kind": "html",
"renderAs": "html"
"renderAs": "html",
"group": "Тезисы первого экрана"
},
{
"path": "content.app.iconAlt",
"label": "App: alt иконки",
"kind": "text",
"renderAs": "attr"
"renderAs": "attr",
"group": "Карточка раннего доступа"
},
{
"path": "content.app.logoAlt",
"label": "App: alt логотипа",
"kind": "text",
"renderAs": "attr"
"renderAs": "attr",
"group": "Карточка раннего доступа"
},
{
"path": "content.app.release",
"label": "App: релиз",
"kind": "text",
"renderAs": "text"
"renderAs": "text",
"group": "Карточка раннего доступа"
},
{
"path": "content.app.heading",
"label": "App: заголовок",
"kind": "text",
"renderAs": "text"
"renderAs": "text",
"group": "Карточка раннего доступа"
},
{
"path": "content.app.description",
"label": "App: описание",
"kind": "text",
"renderAs": "text"
"renderAs": "text",
"group": "Карточка раннего доступа"
},
{
"path": "content.form.messagePlaceholder",
"label": "Форма: placeholder сообщения",
"kind": "text",
"renderAs": "attr"
"renderAs": "attr",
"group": "Форма"
},
{
"path": "content.form.namePlaceholder",
"label": "Форма: placeholder имени",
"kind": "text",
"renderAs": "attr"
"renderAs": "attr",
"group": "Форма"
},
{
"path": "content.form.emailPlaceholder",
"label": "Форма: placeholder email",
"kind": "text",
"renderAs": "attr"
"renderAs": "attr",
"group": "Форма"
},
{
"path": "content.form.submitLabel",
"label": "Форма: кнопка",
"kind": "text",
"renderAs": "text"
"renderAs": "text",
"group": "Форма"
},
{
"path": "content.form.successHeading",
"label": "Форма: успех, заголовок",
"kind": "text",
"renderAs": "text"
"renderAs": "text",
"group": "Форма"
},
{
"path": "content.form.successTextHtml",
"label": "Форма: успех, текст",
"kind": "html",
"renderAs": "html"
"renderAs": "html",
"group": "Форма"
},
{
"path": "content.form.betaCtaLabel",
"label": "Форма: CTA беты",
"kind": "text",
"renderAs": "text"
"renderAs": "text",
"group": "Форма"
},
{
"path": "content.form.founderIntroHtml",
"label": "Форма: Founder intro",
"kind": "html",
"renderAs": "html"
"renderAs": "html",
"group": "Форма"
},
{
"path": "content.form.founderCtaHtml",
"label": "Форма: Founder CTA",
"kind": "html",
"renderAs": "html"
"renderAs": "html",
"group": "Форма"
},
{
"path": "content.form.spotsLabel",
"label": "Форма: лимит мест",
"kind": "text",
"renderAs": "text"
"renderAs": "text",
"group": "Форма"
},
{
"path": "content.form.failureHeading",
"label": "Форма: ошибка, заголовок",
"kind": "text",
"renderAs": "text"
"renderAs": "text",
"group": "Форма"
},
{
"path": "content.form.failureTextHtml",
"label": "Форма: ошибка, текст",
"kind": "html",
"renderAs": "html"
"renderAs": "html",
"group": "Форма"
},
{
"path": "content.legalHtml",
"label": "Юридический текст",
"kind": "html",
"renderAs": "html"
"renderAs": "html",
"group": "Юридический блок"
}
],
"enabled": true
@ -289,79 +596,92 @@
"path": "content.windowTitle",
"label": "Окно: название файла",
"kind": "text",
"renderAs": "text"
"renderAs": "text",
"group": "Видео-окно"
},
{
"path": "content.videoSrc",
"label": "Окно: видео",
"kind": "media",
"renderAs": "attr"
"renderAs": "attr",
"group": "Видео-окно"
},
{
"path": "content.heading.muted",
"label": "Заголовок: приглушённая строка",
"kind": "text",
"renderAs": "text"
"renderAs": "text",
"group": "Заголовок"
},
{
"path": "content.heading.main",
"label": "Заголовок: основная строка",
"kind": "text",
"renderAs": "text"
"renderAs": "text",
"group": "Заголовок"
},
{
"path": "content.introHtml",
"label": "Вводный текст",
"kind": "html",
"renderAs": "html"
"renderAs": "html",
"group": "Заголовок"
},
{
"path": "content.features.0.eyebrow",
"label": "Пункт 1: подпись",
"kind": "text",
"renderAs": "text"
"renderAs": "text",
"group": "Пункты"
},
{
"path": "content.features.0.bodyHtml",
"label": "Пункт 1: текст",
"kind": "html",
"renderAs": "html"
"renderAs": "html",
"group": "Пункты"
},
{
"path": "content.features.1.eyebrow",
"label": "Пункт 2: подпись",
"kind": "text",
"renderAs": "text"
"renderAs": "text",
"group": "Пункты"
},
{
"path": "content.features.1.bodyHtml",
"label": "Пункт 2: текст",
"kind": "html",
"renderAs": "html"
"renderAs": "html",
"group": "Пункты"
},
{
"path": "content.features.2.eyebrow",
"label": "Пункт 3: подпись",
"kind": "text",
"renderAs": "text"
"renderAs": "text",
"group": "Пункты"
},
{
"path": "content.features.2.bodyHtml",
"label": "Пункт 3: текст",
"kind": "html",
"renderAs": "html"
"renderAs": "html",
"group": "Пункты"
},
{
"path": "content.features.3.eyebrow",
"label": "Пункт 4: подпись",
"kind": "text",
"renderAs": "text"
"renderAs": "text",
"group": "Пункты"
},
{
"path": "content.features.3.bodyHtml",
"label": "Пункт 4: текст",
"kind": "html",
"renderAs": "html"
"renderAs": "html",
"group": "Пункты"
}
],
"enabled": true

View File

@ -67,7 +67,7 @@
</script><link rel="stylesheet" href="./assets/custom/css/out.css">
<script defer src="./assets/vendor/umami-script.js" data-website-id="4f6678ae-c91d-431b-92aa-2d822e34f910"></script>
<script id="node-dc-static-config" type="application/json">{"assets":{"icon":"./assets/custom/icon.glb","logo":"./assets/custom/logo.png"}}</script>
<script>
window.__API_ORIGIN__="https://ssscript-website.vercel.app";
(function(d,h,host){

File diff suppressed because one or more lines are too long

View File

@ -8,7 +8,8 @@ const templateRoot = join(repoRoot, "templates", "pages", "home");
const blockRoot = join(templateRoot, "blocks");
const contentPath = join(repoRoot, "content", "pages", "home.json");
const html = await readFile(sourcePath, "utf8");
let html = await readFile(sourcePath, "utf8");
html = html.replace(/\n*<script id="node-dc-static-config"(?: type="application\/json")?>[\s\S]*?<\/script>\n*/g, "\n");
const marker = {
hero: '<header id="hero" class="s h-screen">',
@ -38,7 +39,7 @@ const demoVideoFolderStart = findRequired(marker.demoVideoFolder, waitlistFilesS
const footerStart = findRequired(marker.footer, demoVideoFolderStart);
const footerEnd = findRequired("</footer>", footerStart) + "</footer>".length;
const shell = [
let shell = [
html.slice(0, heroStart),
"{{NODEDC_BLOCKS_BEFORE_CVIZ}}",
html.slice(cVizStart, cVizOpenEnd),
@ -116,8 +117,23 @@ function getByPath(source, path) {
return path.split(".").reduce((value, key) => value?.[key], source);
}
function field(path, label, kind = "text", renderAs = kind === "html" ? "html" : "text") {
return { path, label, kind, renderAs };
function inferFieldGroup(path) {
if (path.startsWith("content.navigation.")) return "Шапка";
if (path.startsWith("content.notifications.")) return "Уведомления";
if (path.startsWith("content.dock.")) return "Dock";
if (path.startsWith("content.assets.")) return "Модель и логотипы";
if (path.startsWith("content.cards.")) return "Тезисы первого экрана";
if (path.startsWith("content.app.")) return "Карточка раннего доступа";
if (path.startsWith("content.form.")) return "Форма";
if (path === "content.legalHtml") return "Юридический блок";
if (path === "content.windowTitle" || path === "content.videoSrc") return "Видео-окно";
if (path.startsWith("content.features.")) return "Пункты";
if (path.startsWith("content.heading.") || path === "content.introHtml") return "Заголовок";
return "Контент";
}
function field(path, label, kind = "text", renderAs = kind === "html" ? "html" : "text", group = null) {
return { path, label, kind, renderAs, group: group || inferFieldGroup(path) };
}
function tokenizeBlock(block, definition) {
@ -286,6 +302,195 @@ const typedBlockDefinitions = {
},
};
const staticElements = {
id: "static-elements",
type: "staticElements",
adminLabel: "Статичные элементы",
enabled: true,
content: {
navigation: {
logoAlt: "главная",
brandLabel: "NODE.DC",
links: [
{
label: "О продукте",
href: "index.html#features",
target: "",
},
{
label: "Лист ожидания",
href: "index.html#hero",
target: "",
},
{
label: "Документация",
href: "https://docs.ssscript.app/",
target: "_blank",
},
{
label: "Тарифы",
href: "index.html#pricing-wrap",
target: "",
},
],
},
notifications: [
{
eyebrowHtml: '<span class="darker-70">ДАЛЕЕ</span> — ИЮЛЬ 2026',
title: "Открытая бета",
body: "Открываем доступ всем. Будем собирать и отлаживать вместе.",
},
{
eyebrowHtml: '<span class="darker-70">ДАЛЕЕ</span> —<span class="darker-70"> </span>ИЮЛЬ 2026',
title: "Альфа для Founder-доступа",
body: "На второй фазе тестирования бета открыта для всех подписчиков Founder-тарифа.",
},
{
eyebrowHtml: "СЕЙЧАС",
title: "Инвайты в закрытую бету уже отправлены.",
body: "Сейчас тестируем базовую функциональность и ИИ-интеграцию. ",
},
],
dock: {
appTarget: "app-data",
trashAlt: "Белая иконка корзины.",
demoFileLabel: "демо-видео.txt",
},
assets: {
webglModelSrc: "./assets/custom/icon.glb",
logoPng: "./assets/custom/logo.png",
logoPngAlt: "Логотип NODE.DC",
appIconSrc: "./assets/webflow/images/69ad964d951f9d17cc5a919e_logo-app.svg",
appIconAlt: "Иконка NODE.DC",
appWordmarkSrc: "./assets/webflow/images/69ad96a28b06b7552a339137_logtype.svg?v=node-dc",
appWordmarkAlt: "Логотип NODE.DC",
},
},
editableFields: [
field("content.navigation.logoAlt", "Логотип в шапке: alt"),
field("content.navigation.brandLabel", "Бренд в шапке"),
field("content.navigation.links.0.label", "Ссылка 1: текст"),
field("content.navigation.links.0.href", "Ссылка 1: адрес", "url", "attr"),
field("content.navigation.links.1.label", "Ссылка 2: текст"),
field("content.navigation.links.1.href", "Ссылка 2: адрес", "url", "attr"),
field("content.navigation.links.2.label", "Ссылка 3: текст"),
field("content.navigation.links.2.href", "Ссылка 3: адрес", "url", "attr"),
field("content.navigation.links.2.target", "Ссылка 3: target", "text", "attr"),
field("content.navigation.links.3.label", "Ссылка 4: текст"),
field("content.navigation.links.3.href", "Ссылка 4: адрес", "url", "attr"),
field("content.notifications.0.eyebrowHtml", "Уведомление 1: дата/статус", "html"),
field("content.notifications.0.title", "Уведомление 1: заголовок"),
field("content.notifications.0.body", "Уведомление 1: текст", "textarea"),
field("content.notifications.1.eyebrowHtml", "Уведомление 2: дата/статус", "html"),
field("content.notifications.1.title", "Уведомление 2: заголовок"),
field("content.notifications.1.body", "Уведомление 2: текст", "textarea"),
field("content.notifications.2.eyebrowHtml", "Уведомление 3: дата/статус", "html"),
field("content.notifications.2.title", "Уведомление 3: заголовок"),
field("content.notifications.2.body", "Уведомление 3: текст", "textarea"),
field("content.dock.appTarget", "Dock: ID приложения"),
field("content.dock.trashAlt", "Dock: alt корзины"),
field("content.dock.demoFileLabel", "Dock: подпись демо-файла"),
field("content.assets.webglModelSrc", "WebGL-модель логотипа", "media", "attr"),
field("content.assets.logoPng", "PNG-логотип", "media", "attr"),
field("content.assets.logoPngAlt", "PNG-логотип: alt"),
field("content.assets.appIconSrc", "Иконка приложения", "media", "attr"),
field("content.assets.appIconAlt", "Иконка приложения: alt"),
field("content.assets.appWordmarkSrc", "Wordmark приложения", "media", "attr"),
field("content.assets.appWordmarkAlt", "Wordmark приложения: alt"),
],
};
function shellToken(mode, path) {
return `{{${mode}:staticElements.${path}}}`;
}
function replaceShellLiteral(source, literal, replacement, label) {
if (!source.includes(literal)) {
throw new Error(`Could not find shell fragment while tokenizing static elements: ${label}`);
}
return source.replace(literal, replacement);
}
function tokenizeStaticShell(source, definition) {
const { navigation, notifications, dock } = definition.content;
let tokenized = source;
tokenized = replaceShellLiteral(
tokenized,
`aria-label="${navigation.logoAlt}" aria-current`,
`aria-label="${shellToken("attr", "content.navigation.logoAlt")}" aria-current`,
"navigation.logoAlt",
);
tokenized = replaceShellLiteral(
tokenized,
`class="bar-txt medium w--current">${navigation.brandLabel}</a>`,
`class="bar-txt medium w--current">${shellToken("text", "content.navigation.brandLabel")}</a>`,
"navigation.brandLabel",
);
for (const [index, link] of navigation.links.entries()) {
const targetPart = link.target ? ` target="${link.target}"` : "";
tokenized = replaceShellLiteral(
tokenized,
`<a href="${link.href}"${targetPart} class="bar-txt">${link.label}</a>`,
`<a href="${shellToken("attr", `content.navigation.links.${index}.href`)}"${
link.target ? ` target="${shellToken("attr", `content.navigation.links.${index}.target`)}"` : ""
} class="bar-txt">${shellToken("text", `content.navigation.links.${index}.label`)}</a>`,
`navigation.links.${index}`,
);
}
notifications.forEach((notification, index) => {
tokenized = replaceShellLiteral(
tokenized,
notification.eyebrowHtml,
shellToken("html", `content.notifications.${index}.eyebrowHtml`),
`notifications.${index}.eyebrowHtml`,
);
tokenized = replaceShellLiteral(
tokenized,
`<div>${notification.title}</div>`,
`<div>${shellToken("text", `content.notifications.${index}.title`)}</div>`,
`notifications.${index}.title`,
);
tokenized = replaceShellLiteral(
tokenized,
`<div>${notification.body}</div>`,
`<div>${shellToken("text", `content.notifications.${index}.body`)}</div>`,
`notifications.${index}.body`,
);
});
tokenized = replaceShellLiteral(
tokenized,
`data-target="${dock.appTarget}" class="dock-icon ssscript"`,
`data-target="${shellToken("attr", "content.dock.appTarget")}" class="dock-icon ssscript"`,
"dock.appTarget",
);
tokenized = replaceShellLiteral(
tokenized,
`alt="${dock.trashAlt}" class="dock-app-img trash"`,
`alt="${shellToken("attr", "content.dock.trashAlt")}" class="dock-app-img trash"`,
"dock.trashAlt",
);
tokenized = replaceShellLiteral(
tokenized,
"<script>\nwindow.__API_ORIGIN__=",
`<script id="node-dc-static-config" type="application/json">{"assets":{"icon":"${shellToken(
"js",
"content.assets.webglModelSrc",
)}","logo":"${shellToken("js", "content.assets.logoPng")}"}}</script>\n<script>\nwindow.__API_ORIGIN__=`,
"static asset config",
);
return tokenized;
}
shell = tokenizeStaticShell(shell, staticElements);
const preparedBlocks = blocks.map((block) => {
const definition = typedBlockDefinitions[block.id];
return definition ? tokenizeBlock(block, definition) : block;
@ -296,6 +501,7 @@ const page = {
slug: "home",
output: "index.html",
title: "NODE.DC",
staticElements,
seo: {
title: "NODE.DC — ИИ-платформа для Webflow",
description:

View File

@ -48,8 +48,19 @@ function escapeAttr(value) {
return escapeHtml(value).replaceAll('"', "&quot;").replaceAll("'", "&#39;");
}
function escapeJsString(value) {
return String(value)
.replaceAll("\\", "\\\\")
.replaceAll('"', '\\"')
.replaceAll("\n", "\\n")
.replaceAll("\r", "\\r")
.replaceAll("\u2028", "\\u2028")
.replaceAll("\u2029", "\\u2029")
.replaceAll("</script", "<\\/script");
}
function renderTemplateTokens(html, block) {
return html.replace(/\{\{\s*(text|html|attr):([a-zA-Z0-9_.-]+)\s*\}\}/g, (_match, mode, path) => {
return html.replace(/\{\{\s*(text|html|attr|js):([a-zA-Z0-9_.-]+)\s*\}\}/g, (_match, mode, path) => {
const value = getByPath(block, path);
if (value === undefined) {
@ -64,6 +75,10 @@ function renderTemplateTokens(html, block) {
return value == null ? "" : escapeAttr(value);
}
if (mode === "js") {
return value == null ? "" : escapeJsString(value);
}
return value == null ? "" : escapeHtml(value);
});
}
@ -117,7 +132,7 @@ async function renderRegion(region) {
return blocks.join("");
}
let output = shell;
let output = renderTemplateTokens(shell, { id: page.slug, ...page });
for (const region of page.regions ?? []) {
const token = REGION_TOKEN[region.id];
if (!token) {