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 { :root {
color-scheme: dark; color-scheme: dark;
--bg: #08090b; --bg: #07080a;
--panel: #121417; --shell: rgba(10, 10, 14, 0.96);
--panel-2: #181b20; --sidebar: rgba(7, 8, 11, 0.9);
--line: #2a2f36; --surface: rgba(255, 255, 255, 0.034);
--text: #f4f5f6; --surface-strong: rgba(255, 255, 255, 0.062);
--muted: #9097a1; --field: rgba(255, 255, 255, 0.045);
--accent: #1976ff; --text: rgba(255, 255, 255, 0.93);
--danger: #cf3b3b; --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; box-sizing: border-box;
} }
html {
min-height: 100%;
background: var(--bg);
}
body { body {
margin: 0; margin: 0;
min-height: 100vh; 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); color: var(--text);
font-family: font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
@ -29,263 +44,602 @@ textarea {
font: inherit; 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 { .admin-shell {
position: relative;
z-index: 1;
display: grid; display: grid;
grid-template-columns: 360px minmax(0, 1fr); grid-template-columns: 296px minmax(0, 1fr);
min-height: 100vh; 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 { .sidebar {
border-right: 1px solid var(--line); min-height: 0;
background: #0d0f12;
padding: 20px;
overflow: auto; 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, .sidebar-head {
.editor-head, display: grid;
.actions, gap: 1rem;
.button-row, padding: 0.25rem 0 1.15rem;
.toggle-row { }
display: flex;
.back-link {
display: inline-flex;
align-items: center; 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, .workspace-card {
.editor-head { display: grid;
justify-content: space-between; grid-template-columns: 2.9rem minmax(0, 1fr);
gap: 16px; 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); color: var(--muted);
font-size: 12px;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
} }
h1, .workspace-role {
h2 { margin-top: 0.12rem;
margin: 4px 0 0; font-size: 0.82rem;
font-size: 24px;
line-height: 1.05;
letter-spacing: 0;
} }
.regions { .regions {
display: grid; display: grid;
gap: 18px; gap: 1.45rem;
margin-top: 24px; padding-bottom: 2rem;
} }
.region-title { .region-title {
margin: 0 0 8px; margin: 0 0 0.55rem;
color: var(--muted); padding: 0 0.65rem;
font-size: 12px; color: rgba(255, 255, 255, 0.64);
font-weight: 700; font-size: 0.72rem;
letter-spacing: 0; font-weight: 800;
letter-spacing: 0.22em;
text-transform: uppercase; text-transform: uppercase;
} }
.block-list { .block-list {
display: grid; display: grid;
gap: 8px; gap: 0.45rem;
} }
.block-btn { .block-btn {
display: grid;
width: 100%; width: 100%;
border: 1px solid var(--line); min-height: 3rem;
border-radius: 8px; border: 0;
background: var(--panel); border-radius: var(--radius-md);
color: var(--text); background: transparent;
color: rgba(255, 255, 255, 0.74);
cursor: pointer; cursor: pointer;
padding: 11px 12px; gap: 0.22rem;
padding: 0.72rem 0.85rem;
text-align: left; text-align: left;
} }
.block-btn:hover, .block-btn:hover {
background: rgba(255, 255, 255, 0.04);
color: var(--text);
}
.block-btn.active { .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 { .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 { .block-meta {
display: block; overflow: hidden;
margin-top: 4px; font-size: 0.74rem;
color: var(--muted); line-height: 1.2;
font-size: 12px; text-overflow: ellipsis;
white-space: nowrap;
} }
.editor { .editor {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
min-width: 0; 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 { .editor-head {
border-bottom: 1px solid var(--line); justify-content: space-between;
padding-bottom: 20px; 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, .actions,
.button-row { .button-row {
gap: 8px; gap: 0.55rem;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end;
} }
.primary-btn, .primary-btn,
.ghost-btn, .ghost-btn,
.danger-btn, .danger-btn,
.icon-btn { .icon-btn {
border: 1px solid var(--line); min-height: 2.75rem;
border-radius: 8px; border: 0;
color: var(--text); border-radius: 1.25rem;
cursor: pointer; cursor: pointer;
min-height: 36px; padding: 0.72rem 1.08rem;
padding: 8px 12px;
text-decoration: none; text-decoration: none;
transition:
opacity 0.15s ease,
transform 0.15s ease,
background 0.15s ease;
} }
.primary-btn { .primary-btn {
border-color: var(--accent); min-width: 12rem;
background: var(--accent); background: var(--accent);
color: var(--accent-text);
font-weight: 760;
} }
.ghost-btn, .ghost-btn,
.icon-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 { .danger-btn {
border-color: #5d2525; background: rgba(255, 111, 111, 0.12);
background: #241010; color: #ffd2d2;
color: #ffb9b9; font-weight: 740;
} }
.icon-btn { .icon-btn {
width: 36px; width: 2.75rem;
padding: 0; 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 { .empty-state {
margin-top: 24px; display: grid;
max-width: 680px; grid-template-columns: 3rem minmax(0, 1fr);
border: 1px solid var(--line); align-self: start;
border-radius: 8px; gap: 1rem;
background: var(--panel); max-width: 44rem;
color: var(--muted); 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; line-height: 1.45;
padding: 16px;
} }
.block-form { .block-form {
min-height: 0;
overflow: auto;
display: grid; display: grid;
gap: 18px; gap: 1.15rem;
margin-top: 24px; padding: 0 0.1rem 5rem;
scrollbar-color: rgba(255, 255, 255, 0.25) transparent;
} }
.hidden { .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 { .field-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px; gap: 0.85rem;
margin-top: 1.2rem;
} }
.content-panel { .content-panel {
border: 1px solid var(--line); display: grid;
border-radius: 8px; gap: 1rem;
background: rgba(255, 255, 255, 0.02); padding: 1.2rem;
padding: 16px;
} }
.section-title { .panel-note {
margin-bottom: 14px; max-width: 22rem;
color: var(--text); font-size: 0.8rem;
font-size: 14px; line-height: 1.35;
font-weight: 800; text-align: right;
} }
.content-fields { .content-fields {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem;
gap: 14px;
} }
.content-fields .wide { .content-group {
grid-column: 1 / -1;
}
.content-fields .empty-note {
grid-column: 1 / -1;
color: var(--muted);
font-size: 14px;
line-height: 1.4;
}
label {
display: grid; display: grid;
gap: 8px; gap: 1rem;
color: var(--muted); padding: 1.1rem;
font-size: 13px; border-radius: 1.25rem;
font-weight: 700; 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, input,
textarea { textarea {
width: 100%; width: 100%;
border: 1px solid var(--line); border: 0;
border-radius: 8px; outline: 0;
background: var(--panel); 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); color: var(--text);
outline: none; padding: 0.88rem 0.95rem;
padding: 10px 12px;
} }
input:focus, input:focus,
textarea: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 { .toggle-row {
justify-content: flex-start; 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 { .toggle-row input {
width: 18px; width: 2.7rem;
height: 18px; 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 { .json-field textarea {
min-height: 360px; min-height: 24rem;
resize: vertical;
font-family: "SFMono-Regular", Consolas, monospace; font-family: "SFMono-Regular", Consolas, monospace;
font-size: 13px; font-size: 0.8rem;
line-height: 1.45; line-height: 1.45;
} }
.status { .status {
min-height: 24px; min-height: 1.25rem;
margin: 18px 0 0; margin: 1rem 0 0;
color: var(--muted); overflow: hidden;
font-size: 0.78rem;
line-height: 1.35;
text-overflow: ellipsis;
white-space: pre-wrap; white-space: pre-wrap;
} }
@media (max-width: 900px) { @media (max-width: 1100px) {
body {
overflow: auto;
}
.admin-shell { .admin-shell {
grid-template-columns: 1fr; grid-template-columns: 1fr;
width: min(100vw - 1.5rem, 58rem);
height: auto;
min-height: calc(100vh - 1.5rem);
margin: 0.75rem auto;
} }
.sidebar { .sidebar {
border-right: 0; max-height: 38vh;
border-bottom: 1px solid var(--line); box-shadow: inset 0 -1px rgba(255, 255, 255, 0.035);
max-height: 48vh; }
.editor {
min-height: 62vh;
} }
.editor-head { .editor-head {
@ -293,11 +647,35 @@ textarea:focus {
flex-direction: column; 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; grid-template-columns: 1fr;
} }
.content-fields { .panel-head,
grid-template-columns: 1fr; .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"), 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) { function setStatus(message) {
el.status.textContent = message; el.status.textContent = message;
} }
@ -46,29 +147,83 @@ function setByPath(source, path, nextValue) {
const lastKey = keys.pop(); const lastKey = keys.pop();
let target = source; let target = source;
for (const key of keys) { keys.forEach((key, index) => {
if (target[key] == null) { const nextKey = keys[index + 1] ?? lastKey;
target[key] = /^\d+$/.test(keys[0]) ? [] : {};
if (target[key] == null || typeof target[key] !== "object") {
target[key] = /^\d+$/.test(nextKey) ? [] : {};
} }
target = target[key]; target = target[key];
} });
target[lastKey] = nextValue; 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() { function activeRegion() {
if (!state.selected) return null; if (!state.selected) return null;
if (isStaticSelection()) {
return {
id: "static",
label: "Статичные элементы",
description: "Shell-слой страницы: шапка, уведомления, dock и базовые ассеты.",
};
}
return state.page.regions[state.selected.regionIndex]; return state.page.regions[state.selected.regionIndex];
} }
function activeBlock() { function activeBlock() {
if (!state.selected) return null;
if (isStaticSelection()) {
return state.page.staticElements;
}
const region = activeRegion(); const region = activeRegion();
if (!region || state.selected.blockIndex == null) return null; if (!region || state.selected.blockIndex == null) return null;
return region.blocks[state.selected.blockIndex]; return region.blocks[state.selected.blockIndex];
} }
function uniqueId(base) { 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 candidate = `${base}-copy`;
let index = 2; let index = 2;
@ -80,36 +235,78 @@ function uniqueId(base) {
return candidate; 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() { function renderRegions() {
el.regions.innerHTML = ""; 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) => { state.page.regions.forEach((region, regionIndex) => {
const regionNode = document.createElement("section"); const regionNode = document.createElement("section");
const title = document.createElement("h3"); const title = document.createElement("h3");
const list = document.createElement("div"); const list = document.createElement("div");
title.className = "region-title"; title.className = "region-title";
title.textContent = region.label || region.id; title.textContent = labelForRegion(region);
list.className = "block-list"; list.className = "block-list";
region.blocks.forEach((block, blockIndex) => { region.blocks.forEach((block, blockIndex) => {
const button = document.createElement("button");
const meta = document.createElement("span");
const isActive = 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"; renderBlockButton({
button.className = `block-btn${isActive ? " active" : ""}${block.enabled === false ? " disabled" : ""}`; list,
button.textContent = block.adminLabel || block.id; block,
meta.className = "block-meta"; meta: `${block.id} · ${block.template || block.type}`,
meta.textContent = `${block.id} · ${block.template}`; active: isActive,
button.append(meta); disabled: block.enabled === false,
button.addEventListener("click", () => { onClick: () => {
state.selected = { regionIndex, blockIndex }; state.selected = { kind: "block", regionIndex, blockIndex };
renderAll(); renderAll();
},
}); });
list.append(button);
}); });
regionNode.append(title, list); 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() { function renderEditor() {
const region = activeRegion(); const region = activeRegion();
const block = activeBlock(); const block = activeBlock();
const isStatic = isStaticSelection();
if (!block) { if (!block) {
el.empty.classList.remove("hidden"); el.empty.classList.remove("hidden");
el.form.classList.add("hidden"); el.form.classList.add("hidden");
el.selectedRegion.textContent = "Выберите блок"; el.selectedRegion.textContent = "NODE.DC / Выберите блок";
el.selectedTitle.textContent = "Контентный слой"; el.selectedTitle.textContent = "Контентный слой";
return; return;
} }
el.empty.classList.add("hidden"); el.empty.classList.add("hidden");
el.form.classList.remove("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.selectedTitle.textContent = block.adminLabel || block.id;
el.id.value = block.id || ""; el.id.value = block.id || "";
el.label.value = block.adminLabel || ""; el.label.value = block.adminLabel || "";
el.template.value = block.template || ""; el.template.value = block.template || block.type || "";
el.anchor.value = block.anchor || ""; el.anchor.value = block.anchor || "";
el.enabled.checked = block.enabled !== false; el.enabled.checked = block.enabled !== false;
el.id.readOnly = isStatic;
el.anchor.disabled = isStatic;
el.enabled.disabled = isStatic;
setStructuralControlsDisabled(isStatic);
renderContentFields(block); renderContentFields(block);
el.json.value = JSON.stringify(block, null, 2); el.json.value = JSON.stringify(block, null, 2);
} }
@ -152,14 +360,30 @@ function syncBlockFromFields() {
if (!block) return; if (!block) return;
syncContentFields(); syncContentFields();
block.adminLabel = el.label.value.trim() || block.adminLabel || block.id;
if (!isStaticSelection()) {
block.id = el.id.value.trim(); block.id = el.id.value.trim();
block.adminLabel = el.label.value.trim();
block.anchor = el.anchor.value.trim() || null; block.anchor = el.anchor.value.trim() || null;
block.enabled = el.enabled.checked; block.enabled = el.enabled.checked;
}
el.json.value = JSON.stringify(block, null, 2); el.json.value = JSON.stringify(block, null, 2);
renderRegions(); 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) { function renderContentFields(block) {
el.contentFields.innerHTML = ""; el.contentFields.innerHTML = "";
@ -171,19 +395,48 @@ function renderContentFields(block) {
return; 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 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 value = getByPath(block, editableField.path) ?? "";
const isLong = editableField.kind === "html" || editableField.kind === "textarea"; const isLong = editableField.kind === "html" || editableField.kind === "textarea";
const input = isLong ? document.createElement("textarea") : document.createElement("input"); const input = isLong ? document.createElement("textarea") : document.createElement("input");
label.className = isLong ? "wide" : ""; label.className = `field-control${isLong ? " wide" : ""}`;
label.textContent = editableField.label || editableField.path; 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.dataset.path = editableField.path;
input.value = value; input.value = value;
if (!isLong) { if (!isLong) {
input.type = editableField.kind === "media" || editableField.kind === "url" ? "text" : "text"; input.type = "text";
input.autocomplete = "off"; input.autocomplete = "off";
} }
@ -194,8 +447,12 @@ function renderContentFields(block) {
el.json.value = JSON.stringify(active, null, 2); el.json.value = JSON.stringify(active, null, 2);
}); });
label.append(input); label.append(labelRow, input, path);
el.contentFields.append(label); grid.append(label);
}
group.append(head, grid);
el.contentFields.append(group);
} }
} }
@ -209,12 +466,17 @@ function syncContentFields() {
} }
function syncBlockFromJson() { function syncBlockFromJson() {
const region = activeRegion(); if (!state.selected) return false;
if (!region) return false;
try { try {
const parsed = JSON.parse(el.json.value); 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; region.blocks[state.selected.blockIndex] = parsed;
}
renderAll(); renderAll();
setStatus("JSON блока применён локально. Нажмите «Сохранить JSON», чтобы записать файл."); setStatus("JSON блока применён локально. Нажмите «Сохранить JSON», чтобы записать файл.");
return true; return true;
@ -228,7 +490,8 @@ async function loadPage() {
const response = await fetch("/api/page/home"); const response = await fetch("/api/page/home");
if (!response.ok) throw new Error(await response.text()); if (!response.ok) throw new Error(await response.text());
state.page = await response.json(); state.page = await response.json();
state.selected = null; ensureStaticElements();
state.selected = { kind: "static" };
renderAll(); renderAll();
setStatus("Модель home.json загружена."); setStatus("Модель home.json загружена.");
} }
@ -258,6 +521,7 @@ async function renderHome() {
} }
function moveSelected(delta) { function moveSelected(delta) {
if (isStaticSelection()) return;
const region = activeRegion(); const region = activeRegion();
const index = state.selected?.blockIndex; const index = state.selected?.blockIndex;
if (!region || index == null) return; if (!region || index == null) return;
@ -272,6 +536,7 @@ function moveSelected(delta) {
} }
function duplicateSelected() { function duplicateSelected() {
if (isStaticSelection()) return;
const region = activeRegion(); const region = activeRegion();
const block = activeBlock(); const block = activeBlock();
if (!region || !block) return; if (!region || !block) return;
@ -286,6 +551,7 @@ function duplicateSelected() {
} }
function copySelected() { function copySelected() {
if (isStaticSelection()) return;
const block = activeBlock(); const block = activeBlock();
if (!block) return; if (!block) return;
@ -294,6 +560,7 @@ function copySelected() {
} }
function pasteAfterSelected() { function pasteAfterSelected() {
if (isStaticSelection()) return;
const region = activeRegion(); const region = activeRegion();
if (!region || !state.clipboard) return; if (!region || !state.clipboard) return;
@ -307,12 +574,13 @@ function pasteAfterSelected() {
} }
function deleteSelected() { function deleteSelected() {
if (isStaticSelection()) return;
const region = activeRegion(); const region = activeRegion();
if (!region || state.selected.blockIndex == null) return; if (!region || state.selected.blockIndex == null) return;
region.blocks.splice(state.selected.blockIndex, 1); region.blocks.splice(state.selected.blockIndex, 1);
state.selected.blockIndex = Math.min(state.selected.blockIndex, region.blocks.length - 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(); renderAll();
} }

View File

@ -7,14 +7,21 @@
<link rel="stylesheet" href="./admin.css" /> <link rel="stylesheet" href="./admin.css" />
</head> </head>
<body> <body>
<div class="admin-backdrop" aria-hidden="true"></div>
<main class="admin-shell"> <main class="admin-shell">
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-head"> <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>
<div class="eyebrow">NODE.DC</div> <div class="workspace-name">NODE.DC</div>
<h1>Блоки страницы</h1> <div class="workspace-role">Администратор сайта</div>
</div>
</div> </div>
<button id="reload" class="icon-btn" type="button" title="Перезагрузить данные"></button>
</div> </div>
<div id="regions" class="regions"></div> <div id="regions" class="regions"></div>
</aside> </aside>
@ -22,46 +29,64 @@
<section class="editor"> <section class="editor">
<header class="editor-head"> <header class="editor-head">
<div> <div>
<div id="selected-region" class="eyebrow">Выберите блок</div> <h1>Настройки сайта</h1>
<div id="selected-region" class="breadcrumb">NODE.DC / Выберите блок</div>
<h2 id="selected-title">Контентный слой</h2> <h2 id="selected-title">Контентный слой</h2>
</div> </div>
<div class="actions"> <div class="actions">
<a class="ghost-btn" href="/" target="_blank">Открыть сайт</a> <button id="reload" class="icon-btn" type="button" title="Перезагрузить данные"></button>
<button id="render" class="ghost-btn" type="button">Собрать index.html</button>
<button id="save" class="primary-btn" type="button">Сохранить JSON</button> <button id="save" class="primary-btn" type="button">Сохранить JSON</button>
<button id="render" class="ghost-btn" type="button">Собрать index.html</button>
</div> </div>
</header> </header>
<div id="empty-state" class="empty-state"> <div id="empty-state" class="empty-state">
Слева выберите блок. Сейчас это первый этап: блоки можно включать, выключать, <div class="empty-icon">ND</div>
дублировать и менять порядок внутри своего DOM-региона. <div>
<h3>Выберите блок слева</h3>
<p>Статика, hero, форма и секции страницы разделены на рабочие группы. Сначала редактируем typed-поля, сырой JSON оставлен как технический fallback.</p>
</div>
</div> </div>
<form id="block-form" class="block-form hidden"> <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"> <div class="field-grid">
<label> <label class="field-control">
ID блока <span>ID блока</span>
<input id="block-id" name="id" type="text" autocomplete="off" /> <input id="block-id" name="id" type="text" autocomplete="off" />
</label> </label>
<label> <label class="field-control">
Название в админке <span>Название в админке</span>
<input id="block-label" name="adminLabel" type="text" autocomplete="off" /> <input id="block-label" name="adminLabel" type="text" autocomplete="off" />
</label> </label>
<label> <label class="field-control">
Шаблон <span>Шаблон / тип</span>
<input id="block-template" name="template" type="text" autocomplete="off" readonly /> <input id="block-template" name="template" type="text" autocomplete="off" readonly />
</label> </label>
<label> <label class="field-control">
Anchor <span>Anchor</span>
<input id="block-anchor" name="anchor" type="text" autocomplete="off" /> <input id="block-anchor" name="anchor" type="text" autocomplete="off" />
</label> </label>
</div> </div>
</section>
<label class="toggle-row"> <section class="settings-card structure-card">
<input id="block-enabled" name="enabled" type="checkbox" /> <div class="card-head">
<span>Блок включён в рендер</span> <div>
</label> <div class="section-kicker">Порядок</div>
<h3>Операции с блоком</h3>
</div>
</div>
<div class="button-row"> <div class="button-row">
<button id="move-up" class="ghost-btn" type="button">Вверх</button> <button id="move-up" class="ghost-btn" type="button">Вверх</button>
<button id="move-down" 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="paste-after" class="ghost-btn" type="button">Вставить после</button>
<button id="delete-block" class="danger-btn" type="button">Удалить</button> <button id="delete-block" class="danger-btn" type="button">Удалить</button>
</div> </div>
</section>
<section class="content-panel"> <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> <div id="content-fields" class="content-fields"></div>
</section> </section>
<details class="json-details">
<summary>JSON выбранного блока</summary>
<label class="json-field"> <label class="json-field">
JSON выбранного блока <span>Техническое представление</span>
<textarea id="block-json" spellcheck="false"></textarea> <textarea id="block-json" spellcheck="false"></textarea>
</label> </label>
</details>
</form> </form>
<pre id="status" class="status"></pre> <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", "slug": "home",
"output": "index.html", "output": "index.html",
"title": "NODE.DC", "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": { "seo": {
"title": "NODE.DC — ИИ-платформа для Webflow", "title": "NODE.DC — ИИ-платформа для Webflow",
"description": "Единая платформа для кастомного кода, MCP и ИИ-агентов в Webflow: контекст проекта, автоматизация, деплой и управляемая разработка." "description": "Единая платформа для кастомного кода, MCP и ИИ-агентов в Webflow: контекст проекта, автоматизация, деплой и управляемая разработка."
@ -71,181 +348,211 @@
"path": "content.heading.muted", "path": "content.heading.muted",
"label": "Hero: приглушённая строка", "label": "Hero: приглушённая строка",
"kind": "text", "kind": "text",
"renderAs": "text" "renderAs": "text",
"group": "Заголовок"
}, },
{ {
"path": "content.heading.main", "path": "content.heading.main",
"label": "Hero: основная строка", "label": "Hero: основная строка",
"kind": "text", "kind": "text",
"renderAs": "text" "renderAs": "text",
"group": "Заголовок"
}, },
{ {
"path": "content.introHtml", "path": "content.introHtml",
"label": "Hero: вводный текст", "label": "Hero: вводный текст",
"kind": "html", "kind": "html",
"renderAs": "html" "renderAs": "html",
"group": "Заголовок"
}, },
{ {
"path": "content.cards.0.muted", "path": "content.cards.0.muted",
"label": "Карточка 1: приглушённая строка", "label": "Карточка 1: приглушённая строка",
"kind": "text", "kind": "text",
"renderAs": "text" "renderAs": "text",
"group": "Тезисы первого экрана"
}, },
{ {
"path": "content.cards.0.main", "path": "content.cards.0.main",
"label": "Карточка 1: основная строка", "label": "Карточка 1: основная строка",
"kind": "text", "kind": "text",
"renderAs": "text" "renderAs": "text",
"group": "Тезисы первого экрана"
}, },
{ {
"path": "content.cards.0.bodyHtml", "path": "content.cards.0.bodyHtml",
"label": "Карточка 1: текст", "label": "Карточка 1: текст",
"kind": "html", "kind": "html",
"renderAs": "html" "renderAs": "html",
"group": "Тезисы первого экрана"
}, },
{ {
"path": "content.cards.1.muted", "path": "content.cards.1.muted",
"label": "Карточка 2: приглушённая строка", "label": "Карточка 2: приглушённая строка",
"kind": "text", "kind": "text",
"renderAs": "text" "renderAs": "text",
"group": "Тезисы первого экрана"
}, },
{ {
"path": "content.cards.1.main", "path": "content.cards.1.main",
"label": "Карточка 2: основная строка", "label": "Карточка 2: основная строка",
"kind": "text", "kind": "text",
"renderAs": "text" "renderAs": "text",
"group": "Тезисы первого экрана"
}, },
{ {
"path": "content.cards.1.bodyHtml", "path": "content.cards.1.bodyHtml",
"label": "Карточка 2: текст", "label": "Карточка 2: текст",
"kind": "html", "kind": "html",
"renderAs": "html" "renderAs": "html",
"group": "Тезисы первого экрана"
}, },
{ {
"path": "content.cards.2.muted", "path": "content.cards.2.muted",
"label": "Карточка 3: приглушённая строка", "label": "Карточка 3: приглушённая строка",
"kind": "text", "kind": "text",
"renderAs": "text" "renderAs": "text",
"group": "Тезисы первого экрана"
}, },
{ {
"path": "content.cards.2.main", "path": "content.cards.2.main",
"label": "Карточка 3: основная строка", "label": "Карточка 3: основная строка",
"kind": "text", "kind": "text",
"renderAs": "text" "renderAs": "text",
"group": "Тезисы первого экрана"
}, },
{ {
"path": "content.cards.2.bodyHtml", "path": "content.cards.2.bodyHtml",
"label": "Карточка 3: текст", "label": "Карточка 3: текст",
"kind": "html", "kind": "html",
"renderAs": "html" "renderAs": "html",
"group": "Тезисы первого экрана"
}, },
{ {
"path": "content.app.iconAlt", "path": "content.app.iconAlt",
"label": "App: alt иконки", "label": "App: alt иконки",
"kind": "text", "kind": "text",
"renderAs": "attr" "renderAs": "attr",
"group": "Карточка раннего доступа"
}, },
{ {
"path": "content.app.logoAlt", "path": "content.app.logoAlt",
"label": "App: alt логотипа", "label": "App: alt логотипа",
"kind": "text", "kind": "text",
"renderAs": "attr" "renderAs": "attr",
"group": "Карточка раннего доступа"
}, },
{ {
"path": "content.app.release", "path": "content.app.release",
"label": "App: релиз", "label": "App: релиз",
"kind": "text", "kind": "text",
"renderAs": "text" "renderAs": "text",
"group": "Карточка раннего доступа"
}, },
{ {
"path": "content.app.heading", "path": "content.app.heading",
"label": "App: заголовок", "label": "App: заголовок",
"kind": "text", "kind": "text",
"renderAs": "text" "renderAs": "text",
"group": "Карточка раннего доступа"
}, },
{ {
"path": "content.app.description", "path": "content.app.description",
"label": "App: описание", "label": "App: описание",
"kind": "text", "kind": "text",
"renderAs": "text" "renderAs": "text",
"group": "Карточка раннего доступа"
}, },
{ {
"path": "content.form.messagePlaceholder", "path": "content.form.messagePlaceholder",
"label": "Форма: placeholder сообщения", "label": "Форма: placeholder сообщения",
"kind": "text", "kind": "text",
"renderAs": "attr" "renderAs": "attr",
"group": "Форма"
}, },
{ {
"path": "content.form.namePlaceholder", "path": "content.form.namePlaceholder",
"label": "Форма: placeholder имени", "label": "Форма: placeholder имени",
"kind": "text", "kind": "text",
"renderAs": "attr" "renderAs": "attr",
"group": "Форма"
}, },
{ {
"path": "content.form.emailPlaceholder", "path": "content.form.emailPlaceholder",
"label": "Форма: placeholder email", "label": "Форма: placeholder email",
"kind": "text", "kind": "text",
"renderAs": "attr" "renderAs": "attr",
"group": "Форма"
}, },
{ {
"path": "content.form.submitLabel", "path": "content.form.submitLabel",
"label": "Форма: кнопка", "label": "Форма: кнопка",
"kind": "text", "kind": "text",
"renderAs": "text" "renderAs": "text",
"group": "Форма"
}, },
{ {
"path": "content.form.successHeading", "path": "content.form.successHeading",
"label": "Форма: успех, заголовок", "label": "Форма: успех, заголовок",
"kind": "text", "kind": "text",
"renderAs": "text" "renderAs": "text",
"group": "Форма"
}, },
{ {
"path": "content.form.successTextHtml", "path": "content.form.successTextHtml",
"label": "Форма: успех, текст", "label": "Форма: успех, текст",
"kind": "html", "kind": "html",
"renderAs": "html" "renderAs": "html",
"group": "Форма"
}, },
{ {
"path": "content.form.betaCtaLabel", "path": "content.form.betaCtaLabel",
"label": "Форма: CTA беты", "label": "Форма: CTA беты",
"kind": "text", "kind": "text",
"renderAs": "text" "renderAs": "text",
"group": "Форма"
}, },
{ {
"path": "content.form.founderIntroHtml", "path": "content.form.founderIntroHtml",
"label": "Форма: Founder intro", "label": "Форма: Founder intro",
"kind": "html", "kind": "html",
"renderAs": "html" "renderAs": "html",
"group": "Форма"
}, },
{ {
"path": "content.form.founderCtaHtml", "path": "content.form.founderCtaHtml",
"label": "Форма: Founder CTA", "label": "Форма: Founder CTA",
"kind": "html", "kind": "html",
"renderAs": "html" "renderAs": "html",
"group": "Форма"
}, },
{ {
"path": "content.form.spotsLabel", "path": "content.form.spotsLabel",
"label": "Форма: лимит мест", "label": "Форма: лимит мест",
"kind": "text", "kind": "text",
"renderAs": "text" "renderAs": "text",
"group": "Форма"
}, },
{ {
"path": "content.form.failureHeading", "path": "content.form.failureHeading",
"label": "Форма: ошибка, заголовок", "label": "Форма: ошибка, заголовок",
"kind": "text", "kind": "text",
"renderAs": "text" "renderAs": "text",
"group": "Форма"
}, },
{ {
"path": "content.form.failureTextHtml", "path": "content.form.failureTextHtml",
"label": "Форма: ошибка, текст", "label": "Форма: ошибка, текст",
"kind": "html", "kind": "html",
"renderAs": "html" "renderAs": "html",
"group": "Форма"
}, },
{ {
"path": "content.legalHtml", "path": "content.legalHtml",
"label": "Юридический текст", "label": "Юридический текст",
"kind": "html", "kind": "html",
"renderAs": "html" "renderAs": "html",
"group": "Юридический блок"
} }
], ],
"enabled": true "enabled": true
@ -289,79 +596,92 @@
"path": "content.windowTitle", "path": "content.windowTitle",
"label": "Окно: название файла", "label": "Окно: название файла",
"kind": "text", "kind": "text",
"renderAs": "text" "renderAs": "text",
"group": "Видео-окно"
}, },
{ {
"path": "content.videoSrc", "path": "content.videoSrc",
"label": "Окно: видео", "label": "Окно: видео",
"kind": "media", "kind": "media",
"renderAs": "attr" "renderAs": "attr",
"group": "Видео-окно"
}, },
{ {
"path": "content.heading.muted", "path": "content.heading.muted",
"label": "Заголовок: приглушённая строка", "label": "Заголовок: приглушённая строка",
"kind": "text", "kind": "text",
"renderAs": "text" "renderAs": "text",
"group": "Заголовок"
}, },
{ {
"path": "content.heading.main", "path": "content.heading.main",
"label": "Заголовок: основная строка", "label": "Заголовок: основная строка",
"kind": "text", "kind": "text",
"renderAs": "text" "renderAs": "text",
"group": "Заголовок"
}, },
{ {
"path": "content.introHtml", "path": "content.introHtml",
"label": "Вводный текст", "label": "Вводный текст",
"kind": "html", "kind": "html",
"renderAs": "html" "renderAs": "html",
"group": "Заголовок"
}, },
{ {
"path": "content.features.0.eyebrow", "path": "content.features.0.eyebrow",
"label": "Пункт 1: подпись", "label": "Пункт 1: подпись",
"kind": "text", "kind": "text",
"renderAs": "text" "renderAs": "text",
"group": "Пункты"
}, },
{ {
"path": "content.features.0.bodyHtml", "path": "content.features.0.bodyHtml",
"label": "Пункт 1: текст", "label": "Пункт 1: текст",
"kind": "html", "kind": "html",
"renderAs": "html" "renderAs": "html",
"group": "Пункты"
}, },
{ {
"path": "content.features.1.eyebrow", "path": "content.features.1.eyebrow",
"label": "Пункт 2: подпись", "label": "Пункт 2: подпись",
"kind": "text", "kind": "text",
"renderAs": "text" "renderAs": "text",
"group": "Пункты"
}, },
{ {
"path": "content.features.1.bodyHtml", "path": "content.features.1.bodyHtml",
"label": "Пункт 2: текст", "label": "Пункт 2: текст",
"kind": "html", "kind": "html",
"renderAs": "html" "renderAs": "html",
"group": "Пункты"
}, },
{ {
"path": "content.features.2.eyebrow", "path": "content.features.2.eyebrow",
"label": "Пункт 3: подпись", "label": "Пункт 3: подпись",
"kind": "text", "kind": "text",
"renderAs": "text" "renderAs": "text",
"group": "Пункты"
}, },
{ {
"path": "content.features.2.bodyHtml", "path": "content.features.2.bodyHtml",
"label": "Пункт 3: текст", "label": "Пункт 3: текст",
"kind": "html", "kind": "html",
"renderAs": "html" "renderAs": "html",
"group": "Пункты"
}, },
{ {
"path": "content.features.3.eyebrow", "path": "content.features.3.eyebrow",
"label": "Пункт 4: подпись", "label": "Пункт 4: подпись",
"kind": "text", "kind": "text",
"renderAs": "text" "renderAs": "text",
"group": "Пункты"
}, },
{ {
"path": "content.features.3.bodyHtml", "path": "content.features.3.bodyHtml",
"label": "Пункт 4: текст", "label": "Пункт 4: текст",
"kind": "html", "kind": "html",
"renderAs": "html" "renderAs": "html",
"group": "Пункты"
} }
], ],
"enabled": true "enabled": true

View File

@ -67,7 +67,7 @@
</script><link rel="stylesheet" href="./assets/custom/css/out.css"> </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 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> <script>
window.__API_ORIGIN__="https://ssscript-website.vercel.app"; window.__API_ORIGIN__="https://ssscript-website.vercel.app";
(function(d,h,host){ (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 blockRoot = join(templateRoot, "blocks");
const contentPath = join(repoRoot, "content", "pages", "home.json"); 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 = { const marker = {
hero: '<header id="hero" class="s h-screen">', 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 footerStart = findRequired(marker.footer, demoVideoFolderStart);
const footerEnd = findRequired("</footer>", footerStart) + "</footer>".length; const footerEnd = findRequired("</footer>", footerStart) + "</footer>".length;
const shell = [ let shell = [
html.slice(0, heroStart), html.slice(0, heroStart),
"{{NODEDC_BLOCKS_BEFORE_CVIZ}}", "{{NODEDC_BLOCKS_BEFORE_CVIZ}}",
html.slice(cVizStart, cVizOpenEnd), html.slice(cVizStart, cVizOpenEnd),
@ -116,8 +117,23 @@ function getByPath(source, path) {
return path.split(".").reduce((value, key) => value?.[key], source); return path.split(".").reduce((value, key) => value?.[key], source);
} }
function field(path, label, kind = "text", renderAs = kind === "html" ? "html" : "text") { function inferFieldGroup(path) {
return { path, label, kind, renderAs }; 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) { 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 preparedBlocks = blocks.map((block) => {
const definition = typedBlockDefinitions[block.id]; const definition = typedBlockDefinitions[block.id];
return definition ? tokenizeBlock(block, definition) : block; return definition ? tokenizeBlock(block, definition) : block;
@ -296,6 +501,7 @@ const page = {
slug: "home", slug: "home",
output: "index.html", output: "index.html",
title: "NODE.DC", title: "NODE.DC",
staticElements,
seo: { seo: {
title: "NODE.DC — ИИ-платформа для Webflow", title: "NODE.DC — ИИ-платформа для Webflow",
description: description:

View File

@ -48,8 +48,19 @@ function escapeAttr(value) {
return escapeHtml(value).replaceAll('"', "&quot;").replaceAll("'", "&#39;"); 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) { 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); const value = getByPath(block, path);
if (value === undefined) { if (value === undefined) {
@ -64,6 +75,10 @@ function renderTemplateTokens(html, block) {
return value == null ? "" : escapeAttr(value); return value == null ? "" : escapeAttr(value);
} }
if (mode === "js") {
return value == null ? "" : escapeJsString(value);
}
return value == null ? "" : escapeHtml(value); return value == null ? "" : escapeHtml(value);
}); });
} }
@ -117,7 +132,7 @@ async function renderRegion(region) {
return blocks.join(""); return blocks.join("");
} }
let output = shell; let output = renderTemplateTokens(shell, { id: page.slug, ...page });
for (const region of page.regions ?? []) { for (const region of page.regions ?? []) {
const token = REGION_TOKEN[region.id]; const token = REGION_TOKEN[region.id];
if (!token) { if (!token) {