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
+508 -130
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;
}
}
+321 -53
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 };
renderAll();
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.id = el.id.value.trim();
block.adminLabel = el.label.value.trim();
block.anchor = el.anchor.value.trim() || null;
block.enabled = el.enabled.checked;
block.adminLabel = el.label.value.trim() || block.adminLabel || block.id;
if (!isStaticSelection()) {
block.id = el.id.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,31 +395,64 @@ function renderContentFields(block) {
return;
}
for (const editableField of block.editableFields) {
const label = document.createElement("label");
const value = getByPath(block, editableField.path) ?? "";
const isLong = editableField.kind === "html" || editableField.kind === "textarea";
const input = isLong ? document.createElement("textarea") : document.createElement("input");
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");
label.className = isLong ? "wide" : "";
label.textContent = editableField.label || editableField.path;
input.dataset.path = editableField.path;
input.value = value;
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";
if (!isLong) {
input.type = editableField.kind === "media" || editableField.kind === "url" ? "text" : "text";
input.autocomplete = "off";
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 = `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 = "text";
input.autocomplete = "off";
}
input.addEventListener("input", () => {
const active = activeBlock();
if (!active) return;
setByPath(active, editableField.path, input.value);
el.json.value = JSON.stringify(active, null, 2);
});
label.append(labelRow, input, path);
grid.append(label);
}
input.addEventListener("input", () => {
const active = activeBlock();
if (!active) return;
setByPath(active, editableField.path, input.value);
el.json.value = JSON.stringify(active, null, 2);
});
label.append(input);
el.contentFields.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);
region.blocks[state.selected.blockIndex] = parsed;
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();
}
+64 -29
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">
<div>
<div class="eyebrow">NODE.DC</div>
<h1>Блоки страницы</h1>
<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="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,47 +29,65 @@
<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">
<div class="field-grid">
<label>
ID блока
<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 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>
<div class="button-row">
<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>
<button id="duplicate" 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>
<label class="json-field">
JSON выбранного блока
<textarea id="block-json" spellcheck="false"></textarea>
</label>
<details class="json-details">
<summary>JSON выбранного блока</summary>
<label class="json-field">
<span>Техническое представление</span>
<textarea id="block-json" spellcheck="false"></textarea>
</label>
</details>
</form>
<pre id="status" class="status"></pre>