From d0020a48e358fd790dff8fe5b5d1807b09892bc2 Mon Sep 17 00:00:00 2001 From: dcconstructions Date: Thu, 25 Jun 2026 23:10:48 +0300 Subject: [PATCH] Refine admin editor around static content --- admin/admin.css | 638 +++++++++++++++++++++++++------- admin/admin.js | 374 ++++++++++++++++--- admin/index.html | 93 +++-- assets/custom/js/app.js | 2 +- content/pages/home.json | 406 +++++++++++++++++--- index.html | 2 +- templates/pages/home/shell.html | 4 +- tools/extract-home-blocks.mjs | 214 ++++++++++- tools/render-home.mjs | 19 +- 9 files changed, 1487 insertions(+), 265 deletions(-) diff --git a/admin/admin.css b/admin/admin.css index ba12ddf..e1510a7 100644 --- a/admin/admin.css +++ b/admin/admin.css @@ -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; } } diff --git a/admin/admin.js b/admin/admin.js index dfc4caa..866e119 100644 --- a/admin/admin.js +++ b/admin/admin.js @@ -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: 'ДАЛЕЕ — ИЮЛЬ 2026', + title: "Открытая бета", + body: "Открываем доступ всем. Будем собирать и отлаживать вместе.", + }, + { + eyebrowHtml: 'ДАЛЕЕ ИЮЛЬ 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(); } diff --git a/admin/index.html b/admin/index.html index 42b6fed..e41572b 100644 --- a/admin/index.html +++ b/admin/index.html @@ -7,14 +7,21 @@ +
@@ -22,47 +29,65 @@
-
Выберите блок
+

Настройки сайта

+

Контентный слой

- Открыть сайт - + +
- Слева выберите блок. Сейчас это первый этап: блоки можно включать, выключать, - дублировать и менять порядок внутри своего DOM-региона. +
ND
+
+

Выберите блок слева

+

Статика, hero, форма и секции страницы разделены на рабочие группы. Сначала редактируем typed-поля, сырой JSON оставлен как технический fallback.

+