Refine admin editor around static content
This commit is contained in:
parent
69678a441e
commit
d0020a48e3
638
admin/admin.css
638
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
334
admin/admin.js
334
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: '<span class="darker-70">ДАЛЕЕ</span> — ИЮЛЬ 2026',
|
||||
title: "Открытая бета",
|
||||
body: "Открываем доступ всем. Будем собирать и отлаживать вместе.",
|
||||
},
|
||||
{
|
||||
eyebrowHtml: '<span class="darker-70">ДАЛЕЕ</span> —<span class="darker-70"> </span>ИЮЛЬ 2026',
|
||||
title: "Альфа для Founder-доступа",
|
||||
body: "На второй фазе тестирования бета открыта для всех подписчиков Founder-тарифа.",
|
||||
},
|
||||
{
|
||||
eyebrowHtml: "СЕЙЧАС",
|
||||
title: "Инвайты в закрытую бету уже отправлены.",
|
||||
body: "Сейчас тестируем базовую функциональность и ИИ-интеграцию. ",
|
||||
},
|
||||
],
|
||||
dock: {
|
||||
appTarget: "app-data",
|
||||
trashAlt: "Белая иконка корзины.",
|
||||
demoFileLabel: "демо-видео.txt",
|
||||
},
|
||||
assets: {
|
||||
webglModelSrc: "./assets/custom/icon.glb",
|
||||
logoPng: "./assets/custom/logo.png",
|
||||
logoPngAlt: "Логотип NODE.DC",
|
||||
appIconSrc: "./assets/webflow/images/69ad964d951f9d17cc5a919e_logo-app.svg",
|
||||
appIconAlt: "Иконка NODE.DC",
|
||||
appWordmarkSrc: "./assets/webflow/images/69ad96a28b06b7552a339137_logtype.svg?v=node-dc",
|
||||
appWordmarkAlt: "Логотип NODE.DC",
|
||||
},
|
||||
},
|
||||
editableFields: [
|
||||
field("content.navigation.logoAlt", "Логотип в шапке: alt", "text", "text", "Шапка"),
|
||||
field("content.navigation.brandLabel", "Бренд в шапке", "text", "text", "Шапка"),
|
||||
field("content.navigation.links.0.label", "Ссылка 1: текст", "text", "text", "Шапка"),
|
||||
field("content.navigation.links.0.href", "Ссылка 1: адрес", "url", "attr", "Шапка"),
|
||||
field("content.navigation.links.1.label", "Ссылка 2: текст", "text", "text", "Шапка"),
|
||||
field("content.navigation.links.1.href", "Ссылка 2: адрес", "url", "attr", "Шапка"),
|
||||
field("content.navigation.links.2.label", "Ссылка 3: текст", "text", "text", "Шапка"),
|
||||
field("content.navigation.links.2.href", "Ссылка 3: адрес", "url", "attr", "Шапка"),
|
||||
field("content.navigation.links.2.target", "Ссылка 3: target", "text", "attr", "Шапка"),
|
||||
field("content.navigation.links.3.label", "Ссылка 4: текст", "text", "text", "Шапка"),
|
||||
field("content.navigation.links.3.href", "Ссылка 4: адрес", "url", "attr", "Шапка"),
|
||||
field("content.notifications.0.eyebrowHtml", "Уведомление 1: дата/статус", "html", "html", "Уведомления"),
|
||||
field("content.notifications.0.title", "Уведомление 1: заголовок", "text", "text", "Уведомления"),
|
||||
field("content.notifications.0.body", "Уведомление 1: текст", "textarea", "text", "Уведомления"),
|
||||
field("content.notifications.1.eyebrowHtml", "Уведомление 2: дата/статус", "html", "html", "Уведомления"),
|
||||
field("content.notifications.1.title", "Уведомление 2: заголовок", "text", "text", "Уведомления"),
|
||||
field("content.notifications.1.body", "Уведомление 2: текст", "textarea", "text", "Уведомления"),
|
||||
field("content.notifications.2.eyebrowHtml", "Уведомление 3: дата/статус", "html", "html", "Уведомления"),
|
||||
field("content.notifications.2.title", "Уведомление 3: заголовок", "text", "text", "Уведомления"),
|
||||
field("content.notifications.2.body", "Уведомление 3: текст", "textarea", "text", "Уведомления"),
|
||||
field("content.dock.appTarget", "Dock: ID приложения", "text", "text", "Dock"),
|
||||
field("content.dock.trashAlt", "Dock: alt корзины", "text", "text", "Dock"),
|
||||
field("content.dock.demoFileLabel", "Dock: подпись демо-файла", "text", "text", "Dock"),
|
||||
field("content.assets.webglModelSrc", "WebGL-модель логотипа", "media", "attr", "Модель и логотипы"),
|
||||
field("content.assets.logoPng", "PNG-логотип", "media", "attr", "Модель и логотипы"),
|
||||
field("content.assets.logoPngAlt", "PNG-логотип: alt", "text", "text", "Модель и логотипы"),
|
||||
field("content.assets.appIconSrc", "Иконка приложения", "media", "attr", "Модель и логотипы"),
|
||||
field("content.assets.appIconAlt", "Иконка приложения: alt", "text", "text", "Модель и логотипы"),
|
||||
field("content.assets.appWordmarkSrc", "Wordmark приложения", "media", "attr", "Модель и логотипы"),
|
||||
field("content.assets.appWordmarkAlt", "Wordmark приложения: alt", "text", "text", "Модель и логотипы"),
|
||||
],
|
||||
};
|
||||
|
||||
const GROUP_COPY = {
|
||||
Шапка: "Навигация верхней mac-панели: бренд, ссылки и целевые адреса.",
|
||||
Уведомления: "Карточки уведомлений справа сверху. Порядок соответствует текущему stack в shell.",
|
||||
Dock: "Нижняя системная панель: связь кнопки приложения, корзина и подписи файлов.",
|
||||
"Модель и логотипы": "Базовые ассеты статичного слоя: GLB-модель, PNG/SVG-логотипы и alt-тексты.",
|
||||
Заголовок: "Главный заголовок и вводный текст выбранной секции.",
|
||||
"Тезисы первого экрана": "Четыре смысловых тезиса вокруг формы раннего доступа.",
|
||||
"Карточка раннего доступа": "Текст внутри центрального окна приложения.",
|
||||
Форма: "Плейсхолдеры, кнопки, success/failure состояния формы.",
|
||||
"Юридический блок": "Политики, права и нижний текст формы.",
|
||||
"Видео-окно": "Окно демо-видео и подключенный медиа-файл.",
|
||||
Пункты: "Текстовые пункты внутри секции.",
|
||||
Контент: "Остальные typed-поля выбранного блока.",
|
||||
};
|
||||
|
||||
function field(path, label, kind = "text", renderAs = kind === "html" ? "html" : "text", group = null) {
|
||||
return { path, label, kind, renderAs, group: group || inferFieldGroup(path) };
|
||||
}
|
||||
|
||||
function setStatus(message) {
|
||||
el.status.textContent = message;
|
||||
}
|
||||
|
|
@ -46,29 +147,83 @@ function setByPath(source, path, nextValue) {
|
|||
const lastKey = keys.pop();
|
||||
let target = source;
|
||||
|
||||
for (const key of keys) {
|
||||
if (target[key] == null) {
|
||||
target[key] = /^\d+$/.test(keys[0]) ? [] : {};
|
||||
keys.forEach((key, index) => {
|
||||
const nextKey = keys[index + 1] ?? lastKey;
|
||||
|
||||
if (target[key] == null || typeof target[key] !== "object") {
|
||||
target[key] = /^\d+$/.test(nextKey) ? [] : {};
|
||||
}
|
||||
|
||||
target = target[key];
|
||||
}
|
||||
});
|
||||
|
||||
target[lastKey] = nextValue;
|
||||
}
|
||||
|
||||
function inferFieldGroup(path) {
|
||||
if (path.startsWith("content.navigation.")) return "Шапка";
|
||||
if (path.startsWith("content.notifications.")) return "Уведомления";
|
||||
if (path.startsWith("content.dock.")) return "Dock";
|
||||
if (path.startsWith("content.assets.")) return "Модель и логотипы";
|
||||
if (path.startsWith("content.cards.")) return "Тезисы первого экрана";
|
||||
if (path.startsWith("content.app.")) return "Карточка раннего доступа";
|
||||
if (path.startsWith("content.form.")) return "Форма";
|
||||
if (path === "content.legalHtml") return "Юридический блок";
|
||||
if (path === "content.windowTitle" || path === "content.videoSrc") return "Видео-окно";
|
||||
if (path.startsWith("content.features.")) return "Пункты";
|
||||
if (path.startsWith("content.heading.") || path === "content.introHtml") return "Заголовок";
|
||||
return "Контент";
|
||||
}
|
||||
|
||||
function ensureStaticElements() {
|
||||
if (!state.page.staticElements) {
|
||||
state.page.staticElements = clone(STATIC_FALLBACK);
|
||||
return;
|
||||
}
|
||||
|
||||
state.page.staticElements.id ||= STATIC_FALLBACK.id;
|
||||
state.page.staticElements.type ||= STATIC_FALLBACK.type;
|
||||
state.page.staticElements.adminLabel ||= STATIC_FALLBACK.adminLabel;
|
||||
state.page.staticElements.enabled = true;
|
||||
state.page.staticElements.content ||= clone(STATIC_FALLBACK.content);
|
||||
state.page.staticElements.editableFields ||= clone(STATIC_FALLBACK.editableFields);
|
||||
}
|
||||
|
||||
function isStaticSelection() {
|
||||
return state.selected?.kind === "static";
|
||||
}
|
||||
|
||||
function activeRegion() {
|
||||
if (!state.selected) return null;
|
||||
|
||||
if (isStaticSelection()) {
|
||||
return {
|
||||
id: "static",
|
||||
label: "Статичные элементы",
|
||||
description: "Shell-слой страницы: шапка, уведомления, dock и базовые ассеты.",
|
||||
};
|
||||
}
|
||||
|
||||
return state.page.regions[state.selected.regionIndex];
|
||||
}
|
||||
|
||||
function activeBlock() {
|
||||
if (!state.selected) return null;
|
||||
|
||||
if (isStaticSelection()) {
|
||||
return state.page.staticElements;
|
||||
}
|
||||
|
||||
const region = activeRegion();
|
||||
if (!region || state.selected.blockIndex == null) return null;
|
||||
return region.blocks[state.selected.blockIndex];
|
||||
}
|
||||
|
||||
function uniqueId(base) {
|
||||
const ids = new Set(state.page.regions.flatMap((region) => region.blocks.map((block) => block.id)));
|
||||
const ids = new Set([
|
||||
state.page.staticElements?.id,
|
||||
...state.page.regions.flatMap((region) => region.blocks.map((block) => block.id)),
|
||||
]);
|
||||
let candidate = `${base}-copy`;
|
||||
let index = 2;
|
||||
|
||||
|
|
@ -80,36 +235,78 @@ function uniqueId(base) {
|
|||
return candidate;
|
||||
}
|
||||
|
||||
function labelForRegion(region) {
|
||||
if (region.id === "beforeCViz") return "Главная";
|
||||
if (region.id === "cViz") return "Секции";
|
||||
return region.label || region.id;
|
||||
}
|
||||
|
||||
function renderBlockButton({ list, block, meta, active, disabled, onClick }) {
|
||||
const button = document.createElement("button");
|
||||
const name = document.createElement("span");
|
||||
const description = document.createElement("span");
|
||||
|
||||
button.type = "button";
|
||||
button.className = `block-btn${active ? " active" : ""}${disabled ? " disabled" : ""}`;
|
||||
name.className = "block-name";
|
||||
name.textContent = block.adminLabel || block.id;
|
||||
description.className = "block-meta";
|
||||
description.textContent = meta;
|
||||
button.append(name, description);
|
||||
button.addEventListener("click", onClick);
|
||||
list.append(button);
|
||||
}
|
||||
|
||||
function renderRegions() {
|
||||
el.regions.innerHTML = "";
|
||||
|
||||
const staticNode = document.createElement("section");
|
||||
const staticTitle = document.createElement("h3");
|
||||
const staticList = document.createElement("div");
|
||||
|
||||
staticTitle.className = "region-title";
|
||||
staticTitle.textContent = "Static";
|
||||
staticList.className = "block-list";
|
||||
renderBlockButton({
|
||||
list: staticList,
|
||||
block: state.page.staticElements,
|
||||
meta: "shell · nav · notifications · dock",
|
||||
active: isStaticSelection(),
|
||||
disabled: false,
|
||||
onClick: () => {
|
||||
state.selected = { kind: "static" };
|
||||
renderAll();
|
||||
},
|
||||
});
|
||||
staticNode.append(staticTitle, staticList);
|
||||
el.regions.append(staticNode);
|
||||
|
||||
state.page.regions.forEach((region, regionIndex) => {
|
||||
const regionNode = document.createElement("section");
|
||||
const title = document.createElement("h3");
|
||||
const list = document.createElement("div");
|
||||
|
||||
title.className = "region-title";
|
||||
title.textContent = region.label || region.id;
|
||||
title.textContent = labelForRegion(region);
|
||||
list.className = "block-list";
|
||||
|
||||
region.blocks.forEach((block, blockIndex) => {
|
||||
const button = document.createElement("button");
|
||||
const meta = document.createElement("span");
|
||||
const isActive =
|
||||
state.selected?.regionIndex === regionIndex && state.selected?.blockIndex === blockIndex;
|
||||
state.selected?.kind === "block" &&
|
||||
state.selected?.regionIndex === regionIndex &&
|
||||
state.selected?.blockIndex === blockIndex;
|
||||
|
||||
button.type = "button";
|
||||
button.className = `block-btn${isActive ? " active" : ""}${block.enabled === false ? " disabled" : ""}`;
|
||||
button.textContent = block.adminLabel || block.id;
|
||||
meta.className = "block-meta";
|
||||
meta.textContent = `${block.id} · ${block.template}`;
|
||||
button.append(meta);
|
||||
button.addEventListener("click", () => {
|
||||
state.selected = { regionIndex, blockIndex };
|
||||
renderBlockButton({
|
||||
list,
|
||||
block,
|
||||
meta: `${block.id} · ${block.template || block.type}`,
|
||||
active: isActive,
|
||||
disabled: block.enabled === false,
|
||||
onClick: () => {
|
||||
state.selected = { kind: "block", regionIndex, blockIndex };
|
||||
renderAll();
|
||||
},
|
||||
});
|
||||
|
||||
list.append(button);
|
||||
});
|
||||
|
||||
regionNode.append(title, list);
|
||||
|
|
@ -117,27 +314,38 @@ function renderRegions() {
|
|||
});
|
||||
}
|
||||
|
||||
function setStructuralControlsDisabled(disabled) {
|
||||
for (const control of [el.moveUp, el.moveDown, el.duplicate, el.copy, el.pasteAfter, el.deleteBlock]) {
|
||||
control.disabled = disabled;
|
||||
}
|
||||
}
|
||||
|
||||
function renderEditor() {
|
||||
const region = activeRegion();
|
||||
const block = activeBlock();
|
||||
const isStatic = isStaticSelection();
|
||||
|
||||
if (!block) {
|
||||
el.empty.classList.remove("hidden");
|
||||
el.form.classList.add("hidden");
|
||||
el.selectedRegion.textContent = "Выберите блок";
|
||||
el.selectedRegion.textContent = "NODE.DC / Выберите блок";
|
||||
el.selectedTitle.textContent = "Контентный слой";
|
||||
return;
|
||||
}
|
||||
|
||||
el.empty.classList.add("hidden");
|
||||
el.form.classList.remove("hidden");
|
||||
el.selectedRegion.textContent = region.label || region.id;
|
||||
el.selectedRegion.textContent = `NODE.DC / ${region.label || region.id}`;
|
||||
el.selectedTitle.textContent = block.adminLabel || block.id;
|
||||
el.id.value = block.id || "";
|
||||
el.label.value = block.adminLabel || "";
|
||||
el.template.value = block.template || "";
|
||||
el.template.value = block.template || block.type || "";
|
||||
el.anchor.value = block.anchor || "";
|
||||
el.enabled.checked = block.enabled !== false;
|
||||
el.id.readOnly = isStatic;
|
||||
el.anchor.disabled = isStatic;
|
||||
el.enabled.disabled = isStatic;
|
||||
setStructuralControlsDisabled(isStatic);
|
||||
renderContentFields(block);
|
||||
el.json.value = JSON.stringify(block, null, 2);
|
||||
}
|
||||
|
|
@ -152,14 +360,30 @@ function syncBlockFromFields() {
|
|||
if (!block) return;
|
||||
|
||||
syncContentFields();
|
||||
block.adminLabel = el.label.value.trim() || block.adminLabel || block.id;
|
||||
|
||||
if (!isStaticSelection()) {
|
||||
block.id = el.id.value.trim();
|
||||
block.adminLabel = el.label.value.trim();
|
||||
block.anchor = el.anchor.value.trim() || null;
|
||||
block.enabled = el.enabled.checked;
|
||||
}
|
||||
|
||||
el.json.value = JSON.stringify(block, null, 2);
|
||||
renderRegions();
|
||||
}
|
||||
|
||||
function groupEditableFields(block) {
|
||||
const groups = new Map();
|
||||
|
||||
for (const editableField of block.editableFields ?? []) {
|
||||
const groupName = editableField.group || inferFieldGroup(editableField.path);
|
||||
if (!groups.has(groupName)) groups.set(groupName, []);
|
||||
groups.get(groupName).push(editableField);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function renderContentFields(block) {
|
||||
el.contentFields.innerHTML = "";
|
||||
|
||||
|
|
@ -171,19 +395,48 @@ function renderContentFields(block) {
|
|||
return;
|
||||
}
|
||||
|
||||
for (const editableField of block.editableFields) {
|
||||
for (const [groupName, fields] of groupEditableFields(block)) {
|
||||
const group = document.createElement("section");
|
||||
const head = document.createElement("div");
|
||||
const titleWrap = document.createElement("div");
|
||||
const title = document.createElement("h3");
|
||||
const desc = document.createElement("p");
|
||||
const grid = document.createElement("div");
|
||||
|
||||
group.className = "content-group";
|
||||
head.className = "group-head";
|
||||
title.textContent = groupName;
|
||||
desc.className = "group-desc";
|
||||
desc.textContent = GROUP_COPY[groupName] || GROUP_COPY["Контент"];
|
||||
titleWrap.append(title, desc);
|
||||
head.append(titleWrap);
|
||||
grid.className = "group-grid";
|
||||
|
||||
for (const editableField of fields) {
|
||||
const label = document.createElement("label");
|
||||
const labelRow = document.createElement("span");
|
||||
const labelText = document.createElement("span");
|
||||
const kind = document.createElement("span");
|
||||
const path = document.createElement("span");
|
||||
const value = getByPath(block, editableField.path) ?? "";
|
||||
const isLong = editableField.kind === "html" || editableField.kind === "textarea";
|
||||
const input = isLong ? document.createElement("textarea") : document.createElement("input");
|
||||
|
||||
label.className = isLong ? "wide" : "";
|
||||
label.textContent = editableField.label || editableField.path;
|
||||
label.className = `field-control${isLong ? " wide" : ""}`;
|
||||
labelRow.className = "field-label-row";
|
||||
labelText.className = "field-label-text";
|
||||
labelText.textContent = editableField.label || editableField.path;
|
||||
kind.className = "field-kind";
|
||||
kind.textContent = editableField.kind || "text";
|
||||
path.className = "field-path";
|
||||
path.textContent = editableField.path;
|
||||
labelRow.append(labelText, kind);
|
||||
|
||||
input.dataset.path = editableField.path;
|
||||
input.value = value;
|
||||
|
||||
if (!isLong) {
|
||||
input.type = editableField.kind === "media" || editableField.kind === "url" ? "text" : "text";
|
||||
input.type = "text";
|
||||
input.autocomplete = "off";
|
||||
}
|
||||
|
||||
|
|
@ -194,8 +447,12 @@ function renderContentFields(block) {
|
|||
el.json.value = JSON.stringify(active, null, 2);
|
||||
});
|
||||
|
||||
label.append(input);
|
||||
el.contentFields.append(label);
|
||||
label.append(labelRow, input, path);
|
||||
grid.append(label);
|
||||
}
|
||||
|
||||
group.append(head, grid);
|
||||
el.contentFields.append(group);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -209,12 +466,17 @@ function syncContentFields() {
|
|||
}
|
||||
|
||||
function syncBlockFromJson() {
|
||||
const region = activeRegion();
|
||||
if (!region) return false;
|
||||
if (!state.selected) return false;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(el.json.value);
|
||||
if (isStaticSelection()) {
|
||||
state.page.staticElements = parsed;
|
||||
} else {
|
||||
const region = activeRegion();
|
||||
if (!region) return false;
|
||||
region.blocks[state.selected.blockIndex] = parsed;
|
||||
}
|
||||
renderAll();
|
||||
setStatus("JSON блока применён локально. Нажмите «Сохранить JSON», чтобы записать файл.");
|
||||
return true;
|
||||
|
|
@ -228,7 +490,8 @@ async function loadPage() {
|
|||
const response = await fetch("/api/page/home");
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
state.page = await response.json();
|
||||
state.selected = null;
|
||||
ensureStaticElements();
|
||||
state.selected = { kind: "static" };
|
||||
renderAll();
|
||||
setStatus("Модель home.json загружена.");
|
||||
}
|
||||
|
|
@ -258,6 +521,7 @@ async function renderHome() {
|
|||
}
|
||||
|
||||
function moveSelected(delta) {
|
||||
if (isStaticSelection()) return;
|
||||
const region = activeRegion();
|
||||
const index = state.selected?.blockIndex;
|
||||
if (!region || index == null) return;
|
||||
|
|
@ -272,6 +536,7 @@ function moveSelected(delta) {
|
|||
}
|
||||
|
||||
function duplicateSelected() {
|
||||
if (isStaticSelection()) return;
|
||||
const region = activeRegion();
|
||||
const block = activeBlock();
|
||||
if (!region || !block) return;
|
||||
|
|
@ -286,6 +551,7 @@ function duplicateSelected() {
|
|||
}
|
||||
|
||||
function copySelected() {
|
||||
if (isStaticSelection()) return;
|
||||
const block = activeBlock();
|
||||
if (!block) return;
|
||||
|
||||
|
|
@ -294,6 +560,7 @@ function copySelected() {
|
|||
}
|
||||
|
||||
function pasteAfterSelected() {
|
||||
if (isStaticSelection()) return;
|
||||
const region = activeRegion();
|
||||
if (!region || !state.clipboard) return;
|
||||
|
||||
|
|
@ -307,12 +574,13 @@ function pasteAfterSelected() {
|
|||
}
|
||||
|
||||
function deleteSelected() {
|
||||
if (isStaticSelection()) return;
|
||||
const region = activeRegion();
|
||||
if (!region || state.selected.blockIndex == null) return;
|
||||
|
||||
region.blocks.splice(state.selected.blockIndex, 1);
|
||||
state.selected.blockIndex = Math.min(state.selected.blockIndex, region.blocks.length - 1);
|
||||
if (state.selected.blockIndex < 0) state.selected = null;
|
||||
if (state.selected.blockIndex < 0) state.selected = { kind: "static" };
|
||||
renderAll();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,14 +7,21 @@
|
|||
<link rel="stylesheet" href="./admin.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-backdrop" aria-hidden="true"></div>
|
||||
<main class="admin-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-head">
|
||||
<a class="back-link" href="/" target="_blank" aria-label="Открыть сайт">
|
||||
<span>←</span>
|
||||
<span>Открыть сайт</span>
|
||||
</a>
|
||||
<div class="workspace-card">
|
||||
<div class="workspace-logo">ND</div>
|
||||
<div>
|
||||
<div class="eyebrow">NODE.DC</div>
|
||||
<h1>Блоки страницы</h1>
|
||||
<div class="workspace-name">NODE.DC</div>
|
||||
<div class="workspace-role">Администратор сайта</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="reload" class="icon-btn" type="button" title="Перезагрузить данные">↻</button>
|
||||
</div>
|
||||
<div id="regions" class="regions"></div>
|
||||
</aside>
|
||||
|
|
@ -22,46 +29,64 @@
|
|||
<section class="editor">
|
||||
<header class="editor-head">
|
||||
<div>
|
||||
<div id="selected-region" class="eyebrow">Выберите блок</div>
|
||||
<h1>Настройки сайта</h1>
|
||||
<div id="selected-region" class="breadcrumb">NODE.DC / Выберите блок</div>
|
||||
<h2 id="selected-title">Контентный слой</h2>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="ghost-btn" href="/" target="_blank">Открыть сайт</a>
|
||||
<button id="render" class="ghost-btn" type="button">Собрать index.html</button>
|
||||
<button id="reload" class="icon-btn" type="button" title="Перезагрузить данные">↻</button>
|
||||
<button id="save" class="primary-btn" type="button">Сохранить JSON</button>
|
||||
<button id="render" class="ghost-btn" type="button">Собрать index.html</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="empty-state" class="empty-state">
|
||||
Слева выберите блок. Сейчас это первый этап: блоки можно включать, выключать,
|
||||
дублировать и менять порядок внутри своего DOM-региона.
|
||||
<div class="empty-icon">ND</div>
|
||||
<div>
|
||||
<h3>Выберите блок слева</h3>
|
||||
<p>Статика, hero, форма и секции страницы разделены на рабочие группы. Сначала редактируем typed-поля, сырой JSON оставлен как технический fallback.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="block-form" class="block-form hidden">
|
||||
<section class="settings-card block-meta-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<div class="section-kicker">Идентификатор</div>
|
||||
<h3>Служебные параметры</h3>
|
||||
</div>
|
||||
<label class="toggle-row">
|
||||
<input id="block-enabled" name="enabled" type="checkbox" />
|
||||
<span>В рендере</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<label>
|
||||
ID блока
|
||||
<label class="field-control">
|
||||
<span>ID блока</span>
|
||||
<input id="block-id" name="id" type="text" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
Название в админке
|
||||
<label class="field-control">
|
||||
<span>Название в админке</span>
|
||||
<input id="block-label" name="adminLabel" type="text" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
Шаблон
|
||||
<label class="field-control">
|
||||
<span>Шаблон / тип</span>
|
||||
<input id="block-template" name="template" type="text" autocomplete="off" readonly />
|
||||
</label>
|
||||
<label>
|
||||
Anchor
|
||||
<label class="field-control">
|
||||
<span>Anchor</span>
|
||||
<input id="block-anchor" name="anchor" type="text" autocomplete="off" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<label class="toggle-row">
|
||||
<input id="block-enabled" name="enabled" type="checkbox" />
|
||||
<span>Блок включён в рендер</span>
|
||||
</label>
|
||||
|
||||
<section class="settings-card structure-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<div class="section-kicker">Порядок</div>
|
||||
<h3>Операции с блоком</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="move-up" class="ghost-btn" type="button">Вверх</button>
|
||||
<button id="move-down" class="ghost-btn" type="button">Вниз</button>
|
||||
|
|
@ -70,16 +95,26 @@
|
|||
<button id="paste-after" class="ghost-btn" type="button">Вставить после</button>
|
||||
<button id="delete-block" class="danger-btn" type="button">Удалить</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="content-panel">
|
||||
<div class="section-title">Контент блока</div>
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<div class="section-kicker">Контент</div>
|
||||
<h3>Редактируемые поля</h3>
|
||||
</div>
|
||||
<span class="panel-note">Поля сгруппированы по смысловым подблокам верстки.</span>
|
||||
</div>
|
||||
<div id="content-fields" class="content-fields"></div>
|
||||
</section>
|
||||
|
||||
<details class="json-details">
|
||||
<summary>JSON выбранного блока</summary>
|
||||
<label class="json-field">
|
||||
JSON выбранного блока
|
||||
<span>Техническое представление</span>
|
||||
<textarea id="block-json" spellcheck="false"></textarea>
|
||||
</label>
|
||||
</details>
|
||||
</form>
|
||||
|
||||
<pre id="status" class="status"></pre>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -3,6 +3,283 @@
|
|||
"slug": "home",
|
||||
"output": "index.html",
|
||||
"title": "NODE.DC",
|
||||
"staticElements": {
|
||||
"id": "static-elements",
|
||||
"type": "staticElements",
|
||||
"adminLabel": "Статичные элементы",
|
||||
"enabled": true,
|
||||
"content": {
|
||||
"navigation": {
|
||||
"logoAlt": "главная",
|
||||
"brandLabel": "NODE.DC",
|
||||
"links": [
|
||||
{
|
||||
"label": "О продукте",
|
||||
"href": "index.html#features",
|
||||
"target": ""
|
||||
},
|
||||
{
|
||||
"label": "Лист ожидания",
|
||||
"href": "index.html#hero",
|
||||
"target": ""
|
||||
},
|
||||
{
|
||||
"label": "Документация",
|
||||
"href": "https://docs.ssscript.app/",
|
||||
"target": "_blank"
|
||||
},
|
||||
{
|
||||
"label": "Тарифы",
|
||||
"href": "index.html#pricing-wrap",
|
||||
"target": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"notifications": [
|
||||
{
|
||||
"eyebrowHtml": "<span class=\"darker-70\">ДАЛЕЕ</span> — ИЮЛЬ 2026",
|
||||
"title": "Открытая бета",
|
||||
"body": "Открываем доступ всем. Будем собирать и отлаживать вместе."
|
||||
},
|
||||
{
|
||||
"eyebrowHtml": "<span class=\"darker-70\">ДАЛЕЕ</span> —<span class=\"darker-70\"> </span>ИЮЛЬ 2026",
|
||||
"title": "Альфа для Founder-доступа",
|
||||
"body": "На второй фазе тестирования бета открыта для всех подписчиков Founder-тарифа."
|
||||
},
|
||||
{
|
||||
"eyebrowHtml": "СЕЙЧАС",
|
||||
"title": "Инвайты в закрытую бету уже отправлены.",
|
||||
"body": "Сейчас тестируем базовую функциональность и ИИ-интеграцию. "
|
||||
}
|
||||
],
|
||||
"dock": {
|
||||
"appTarget": "app-data",
|
||||
"trashAlt": "Белая иконка корзины.",
|
||||
"demoFileLabel": "демо-видео.txt"
|
||||
},
|
||||
"assets": {
|
||||
"webglModelSrc": "./assets/custom/icon.glb",
|
||||
"logoPng": "./assets/custom/logo.png",
|
||||
"logoPngAlt": "Логотип NODE.DC",
|
||||
"appIconSrc": "./assets/webflow/images/69ad964d951f9d17cc5a919e_logo-app.svg",
|
||||
"appIconAlt": "Иконка NODE.DC",
|
||||
"appWordmarkSrc": "./assets/webflow/images/69ad96a28b06b7552a339137_logtype.svg?v=node-dc",
|
||||
"appWordmarkAlt": "Логотип NODE.DC"
|
||||
}
|
||||
},
|
||||
"editableFields": [
|
||||
{
|
||||
"path": "content.navigation.logoAlt",
|
||||
"label": "Логотип в шапке: alt",
|
||||
"kind": "text",
|
||||
"renderAs": "text",
|
||||
"group": "Шапка"
|
||||
},
|
||||
{
|
||||
"path": "content.navigation.brandLabel",
|
||||
"label": "Бренд в шапке",
|
||||
"kind": "text",
|
||||
"renderAs": "text",
|
||||
"group": "Шапка"
|
||||
},
|
||||
{
|
||||
"path": "content.navigation.links.0.label",
|
||||
"label": "Ссылка 1: текст",
|
||||
"kind": "text",
|
||||
"renderAs": "text",
|
||||
"group": "Шапка"
|
||||
},
|
||||
{
|
||||
"path": "content.navigation.links.0.href",
|
||||
"label": "Ссылка 1: адрес",
|
||||
"kind": "url",
|
||||
"renderAs": "attr",
|
||||
"group": "Шапка"
|
||||
},
|
||||
{
|
||||
"path": "content.navigation.links.1.label",
|
||||
"label": "Ссылка 2: текст",
|
||||
"kind": "text",
|
||||
"renderAs": "text",
|
||||
"group": "Шапка"
|
||||
},
|
||||
{
|
||||
"path": "content.navigation.links.1.href",
|
||||
"label": "Ссылка 2: адрес",
|
||||
"kind": "url",
|
||||
"renderAs": "attr",
|
||||
"group": "Шапка"
|
||||
},
|
||||
{
|
||||
"path": "content.navigation.links.2.label",
|
||||
"label": "Ссылка 3: текст",
|
||||
"kind": "text",
|
||||
"renderAs": "text",
|
||||
"group": "Шапка"
|
||||
},
|
||||
{
|
||||
"path": "content.navigation.links.2.href",
|
||||
"label": "Ссылка 3: адрес",
|
||||
"kind": "url",
|
||||
"renderAs": "attr",
|
||||
"group": "Шапка"
|
||||
},
|
||||
{
|
||||
"path": "content.navigation.links.2.target",
|
||||
"label": "Ссылка 3: target",
|
||||
"kind": "text",
|
||||
"renderAs": "attr",
|
||||
"group": "Шапка"
|
||||
},
|
||||
{
|
||||
"path": "content.navigation.links.3.label",
|
||||
"label": "Ссылка 4: текст",
|
||||
"kind": "text",
|
||||
"renderAs": "text",
|
||||
"group": "Шапка"
|
||||
},
|
||||
{
|
||||
"path": "content.navigation.links.3.href",
|
||||
"label": "Ссылка 4: адрес",
|
||||
"kind": "url",
|
||||
"renderAs": "attr",
|
||||
"group": "Шапка"
|
||||
},
|
||||
{
|
||||
"path": "content.notifications.0.eyebrowHtml",
|
||||
"label": "Уведомление 1: дата/статус",
|
||||
"kind": "html",
|
||||
"renderAs": "html",
|
||||
"group": "Уведомления"
|
||||
},
|
||||
{
|
||||
"path": "content.notifications.0.title",
|
||||
"label": "Уведомление 1: заголовок",
|
||||
"kind": "text",
|
||||
"renderAs": "text",
|
||||
"group": "Уведомления"
|
||||
},
|
||||
{
|
||||
"path": "content.notifications.0.body",
|
||||
"label": "Уведомление 1: текст",
|
||||
"kind": "textarea",
|
||||
"renderAs": "text",
|
||||
"group": "Уведомления"
|
||||
},
|
||||
{
|
||||
"path": "content.notifications.1.eyebrowHtml",
|
||||
"label": "Уведомление 2: дата/статус",
|
||||
"kind": "html",
|
||||
"renderAs": "html",
|
||||
"group": "Уведомления"
|
||||
},
|
||||
{
|
||||
"path": "content.notifications.1.title",
|
||||
"label": "Уведомление 2: заголовок",
|
||||
"kind": "text",
|
||||
"renderAs": "text",
|
||||
"group": "Уведомления"
|
||||
},
|
||||
{
|
||||
"path": "content.notifications.1.body",
|
||||
"label": "Уведомление 2: текст",
|
||||
"kind": "textarea",
|
||||
"renderAs": "text",
|
||||
"group": "Уведомления"
|
||||
},
|
||||
{
|
||||
"path": "content.notifications.2.eyebrowHtml",
|
||||
"label": "Уведомление 3: дата/статус",
|
||||
"kind": "html",
|
||||
"renderAs": "html",
|
||||
"group": "Уведомления"
|
||||
},
|
||||
{
|
||||
"path": "content.notifications.2.title",
|
||||
"label": "Уведомление 3: заголовок",
|
||||
"kind": "text",
|
||||
"renderAs": "text",
|
||||
"group": "Уведомления"
|
||||
},
|
||||
{
|
||||
"path": "content.notifications.2.body",
|
||||
"label": "Уведомление 3: текст",
|
||||
"kind": "textarea",
|
||||
"renderAs": "text",
|
||||
"group": "Уведомления"
|
||||
},
|
||||
{
|
||||
"path": "content.dock.appTarget",
|
||||
"label": "Dock: ID приложения",
|
||||
"kind": "text",
|
||||
"renderAs": "text",
|
||||
"group": "Dock"
|
||||
},
|
||||
{
|
||||
"path": "content.dock.trashAlt",
|
||||
"label": "Dock: alt корзины",
|
||||
"kind": "text",
|
||||
"renderAs": "text",
|
||||
"group": "Dock"
|
||||
},
|
||||
{
|
||||
"path": "content.dock.demoFileLabel",
|
||||
"label": "Dock: подпись демо-файла",
|
||||
"kind": "text",
|
||||
"renderAs": "text",
|
||||
"group": "Dock"
|
||||
},
|
||||
{
|
||||
"path": "content.assets.webglModelSrc",
|
||||
"label": "WebGL-модель логотипа",
|
||||
"kind": "media",
|
||||
"renderAs": "attr",
|
||||
"group": "Модель и логотипы"
|
||||
},
|
||||
{
|
||||
"path": "content.assets.logoPng",
|
||||
"label": "PNG-логотип",
|
||||
"kind": "media",
|
||||
"renderAs": "attr",
|
||||
"group": "Модель и логотипы"
|
||||
},
|
||||
{
|
||||
"path": "content.assets.logoPngAlt",
|
||||
"label": "PNG-логотип: alt",
|
||||
"kind": "text",
|
||||
"renderAs": "text",
|
||||
"group": "Модель и логотипы"
|
||||
},
|
||||
{
|
||||
"path": "content.assets.appIconSrc",
|
||||
"label": "Иконка приложения",
|
||||
"kind": "media",
|
||||
"renderAs": "attr",
|
||||
"group": "Модель и логотипы"
|
||||
},
|
||||
{
|
||||
"path": "content.assets.appIconAlt",
|
||||
"label": "Иконка приложения: alt",
|
||||
"kind": "text",
|
||||
"renderAs": "text",
|
||||
"group": "Модель и логотипы"
|
||||
},
|
||||
{
|
||||
"path": "content.assets.appWordmarkSrc",
|
||||
"label": "Wordmark приложения",
|
||||
"kind": "media",
|
||||
"renderAs": "attr",
|
||||
"group": "Модель и логотипы"
|
||||
},
|
||||
{
|
||||
"path": "content.assets.appWordmarkAlt",
|
||||
"label": "Wordmark приложения: alt",
|
||||
"kind": "text",
|
||||
"renderAs": "text",
|
||||
"group": "Модель и логотипы"
|
||||
}
|
||||
]
|
||||
},
|
||||
"seo": {
|
||||
"title": "NODE.DC — ИИ-платформа для Webflow",
|
||||
"description": "Единая платформа для кастомного кода, MCP и ИИ-агентов в Webflow: контекст проекта, автоматизация, деплой и управляемая разработка."
|
||||
|
|
@ -71,181 +348,211 @@
|
|||
"path": "content.heading.muted",
|
||||
"label": "Hero: приглушённая строка",
|
||||
"kind": "text",
|
||||
"renderAs": "text"
|
||||
"renderAs": "text",
|
||||
"group": "Заголовок"
|
||||
},
|
||||
{
|
||||
"path": "content.heading.main",
|
||||
"label": "Hero: основная строка",
|
||||
"kind": "text",
|
||||
"renderAs": "text"
|
||||
"renderAs": "text",
|
||||
"group": "Заголовок"
|
||||
},
|
||||
{
|
||||
"path": "content.introHtml",
|
||||
"label": "Hero: вводный текст",
|
||||
"kind": "html",
|
||||
"renderAs": "html"
|
||||
"renderAs": "html",
|
||||
"group": "Заголовок"
|
||||
},
|
||||
{
|
||||
"path": "content.cards.0.muted",
|
||||
"label": "Карточка 1: приглушённая строка",
|
||||
"kind": "text",
|
||||
"renderAs": "text"
|
||||
"renderAs": "text",
|
||||
"group": "Тезисы первого экрана"
|
||||
},
|
||||
{
|
||||
"path": "content.cards.0.main",
|
||||
"label": "Карточка 1: основная строка",
|
||||
"kind": "text",
|
||||
"renderAs": "text"
|
||||
"renderAs": "text",
|
||||
"group": "Тезисы первого экрана"
|
||||
},
|
||||
{
|
||||
"path": "content.cards.0.bodyHtml",
|
||||
"label": "Карточка 1: текст",
|
||||
"kind": "html",
|
||||
"renderAs": "html"
|
||||
"renderAs": "html",
|
||||
"group": "Тезисы первого экрана"
|
||||
},
|
||||
{
|
||||
"path": "content.cards.1.muted",
|
||||
"label": "Карточка 2: приглушённая строка",
|
||||
"kind": "text",
|
||||
"renderAs": "text"
|
||||
"renderAs": "text",
|
||||
"group": "Тезисы первого экрана"
|
||||
},
|
||||
{
|
||||
"path": "content.cards.1.main",
|
||||
"label": "Карточка 2: основная строка",
|
||||
"kind": "text",
|
||||
"renderAs": "text"
|
||||
"renderAs": "text",
|
||||
"group": "Тезисы первого экрана"
|
||||
},
|
||||
{
|
||||
"path": "content.cards.1.bodyHtml",
|
||||
"label": "Карточка 2: текст",
|
||||
"kind": "html",
|
||||
"renderAs": "html"
|
||||
"renderAs": "html",
|
||||
"group": "Тезисы первого экрана"
|
||||
},
|
||||
{
|
||||
"path": "content.cards.2.muted",
|
||||
"label": "Карточка 3: приглушённая строка",
|
||||
"kind": "text",
|
||||
"renderAs": "text"
|
||||
"renderAs": "text",
|
||||
"group": "Тезисы первого экрана"
|
||||
},
|
||||
{
|
||||
"path": "content.cards.2.main",
|
||||
"label": "Карточка 3: основная строка",
|
||||
"kind": "text",
|
||||
"renderAs": "text"
|
||||
"renderAs": "text",
|
||||
"group": "Тезисы первого экрана"
|
||||
},
|
||||
{
|
||||
"path": "content.cards.2.bodyHtml",
|
||||
"label": "Карточка 3: текст",
|
||||
"kind": "html",
|
||||
"renderAs": "html"
|
||||
"renderAs": "html",
|
||||
"group": "Тезисы первого экрана"
|
||||
},
|
||||
{
|
||||
"path": "content.app.iconAlt",
|
||||
"label": "App: alt иконки",
|
||||
"kind": "text",
|
||||
"renderAs": "attr"
|
||||
"renderAs": "attr",
|
||||
"group": "Карточка раннего доступа"
|
||||
},
|
||||
{
|
||||
"path": "content.app.logoAlt",
|
||||
"label": "App: alt логотипа",
|
||||
"kind": "text",
|
||||
"renderAs": "attr"
|
||||
"renderAs": "attr",
|
||||
"group": "Карточка раннего доступа"
|
||||
},
|
||||
{
|
||||
"path": "content.app.release",
|
||||
"label": "App: релиз",
|
||||
"kind": "text",
|
||||
"renderAs": "text"
|
||||
"renderAs": "text",
|
||||
"group": "Карточка раннего доступа"
|
||||
},
|
||||
{
|
||||
"path": "content.app.heading",
|
||||
"label": "App: заголовок",
|
||||
"kind": "text",
|
||||
"renderAs": "text"
|
||||
"renderAs": "text",
|
||||
"group": "Карточка раннего доступа"
|
||||
},
|
||||
{
|
||||
"path": "content.app.description",
|
||||
"label": "App: описание",
|
||||
"kind": "text",
|
||||
"renderAs": "text"
|
||||
"renderAs": "text",
|
||||
"group": "Карточка раннего доступа"
|
||||
},
|
||||
{
|
||||
"path": "content.form.messagePlaceholder",
|
||||
"label": "Форма: placeholder сообщения",
|
||||
"kind": "text",
|
||||
"renderAs": "attr"
|
||||
"renderAs": "attr",
|
||||
"group": "Форма"
|
||||
},
|
||||
{
|
||||
"path": "content.form.namePlaceholder",
|
||||
"label": "Форма: placeholder имени",
|
||||
"kind": "text",
|
||||
"renderAs": "attr"
|
||||
"renderAs": "attr",
|
||||
"group": "Форма"
|
||||
},
|
||||
{
|
||||
"path": "content.form.emailPlaceholder",
|
||||
"label": "Форма: placeholder email",
|
||||
"kind": "text",
|
||||
"renderAs": "attr"
|
||||
"renderAs": "attr",
|
||||
"group": "Форма"
|
||||
},
|
||||
{
|
||||
"path": "content.form.submitLabel",
|
||||
"label": "Форма: кнопка",
|
||||
"kind": "text",
|
||||
"renderAs": "text"
|
||||
"renderAs": "text",
|
||||
"group": "Форма"
|
||||
},
|
||||
{
|
||||
"path": "content.form.successHeading",
|
||||
"label": "Форма: успех, заголовок",
|
||||
"kind": "text",
|
||||
"renderAs": "text"
|
||||
"renderAs": "text",
|
||||
"group": "Форма"
|
||||
},
|
||||
{
|
||||
"path": "content.form.successTextHtml",
|
||||
"label": "Форма: успех, текст",
|
||||
"kind": "html",
|
||||
"renderAs": "html"
|
||||
"renderAs": "html",
|
||||
"group": "Форма"
|
||||
},
|
||||
{
|
||||
"path": "content.form.betaCtaLabel",
|
||||
"label": "Форма: CTA беты",
|
||||
"kind": "text",
|
||||
"renderAs": "text"
|
||||
"renderAs": "text",
|
||||
"group": "Форма"
|
||||
},
|
||||
{
|
||||
"path": "content.form.founderIntroHtml",
|
||||
"label": "Форма: Founder intro",
|
||||
"kind": "html",
|
||||
"renderAs": "html"
|
||||
"renderAs": "html",
|
||||
"group": "Форма"
|
||||
},
|
||||
{
|
||||
"path": "content.form.founderCtaHtml",
|
||||
"label": "Форма: Founder CTA",
|
||||
"kind": "html",
|
||||
"renderAs": "html"
|
||||
"renderAs": "html",
|
||||
"group": "Форма"
|
||||
},
|
||||
{
|
||||
"path": "content.form.spotsLabel",
|
||||
"label": "Форма: лимит мест",
|
||||
"kind": "text",
|
||||
"renderAs": "text"
|
||||
"renderAs": "text",
|
||||
"group": "Форма"
|
||||
},
|
||||
{
|
||||
"path": "content.form.failureHeading",
|
||||
"label": "Форма: ошибка, заголовок",
|
||||
"kind": "text",
|
||||
"renderAs": "text"
|
||||
"renderAs": "text",
|
||||
"group": "Форма"
|
||||
},
|
||||
{
|
||||
"path": "content.form.failureTextHtml",
|
||||
"label": "Форма: ошибка, текст",
|
||||
"kind": "html",
|
||||
"renderAs": "html"
|
||||
"renderAs": "html",
|
||||
"group": "Форма"
|
||||
},
|
||||
{
|
||||
"path": "content.legalHtml",
|
||||
"label": "Юридический текст",
|
||||
"kind": "html",
|
||||
"renderAs": "html"
|
||||
"renderAs": "html",
|
||||
"group": "Юридический блок"
|
||||
}
|
||||
],
|
||||
"enabled": true
|
||||
|
|
@ -289,79 +596,92 @@
|
|||
"path": "content.windowTitle",
|
||||
"label": "Окно: название файла",
|
||||
"kind": "text",
|
||||
"renderAs": "text"
|
||||
"renderAs": "text",
|
||||
"group": "Видео-окно"
|
||||
},
|
||||
{
|
||||
"path": "content.videoSrc",
|
||||
"label": "Окно: видео",
|
||||
"kind": "media",
|
||||
"renderAs": "attr"
|
||||
"renderAs": "attr",
|
||||
"group": "Видео-окно"
|
||||
},
|
||||
{
|
||||
"path": "content.heading.muted",
|
||||
"label": "Заголовок: приглушённая строка",
|
||||
"kind": "text",
|
||||
"renderAs": "text"
|
||||
"renderAs": "text",
|
||||
"group": "Заголовок"
|
||||
},
|
||||
{
|
||||
"path": "content.heading.main",
|
||||
"label": "Заголовок: основная строка",
|
||||
"kind": "text",
|
||||
"renderAs": "text"
|
||||
"renderAs": "text",
|
||||
"group": "Заголовок"
|
||||
},
|
||||
{
|
||||
"path": "content.introHtml",
|
||||
"label": "Вводный текст",
|
||||
"kind": "html",
|
||||
"renderAs": "html"
|
||||
"renderAs": "html",
|
||||
"group": "Заголовок"
|
||||
},
|
||||
{
|
||||
"path": "content.features.0.eyebrow",
|
||||
"label": "Пункт 1: подпись",
|
||||
"kind": "text",
|
||||
"renderAs": "text"
|
||||
"renderAs": "text",
|
||||
"group": "Пункты"
|
||||
},
|
||||
{
|
||||
"path": "content.features.0.bodyHtml",
|
||||
"label": "Пункт 1: текст",
|
||||
"kind": "html",
|
||||
"renderAs": "html"
|
||||
"renderAs": "html",
|
||||
"group": "Пункты"
|
||||
},
|
||||
{
|
||||
"path": "content.features.1.eyebrow",
|
||||
"label": "Пункт 2: подпись",
|
||||
"kind": "text",
|
||||
"renderAs": "text"
|
||||
"renderAs": "text",
|
||||
"group": "Пункты"
|
||||
},
|
||||
{
|
||||
"path": "content.features.1.bodyHtml",
|
||||
"label": "Пункт 2: текст",
|
||||
"kind": "html",
|
||||
"renderAs": "html"
|
||||
"renderAs": "html",
|
||||
"group": "Пункты"
|
||||
},
|
||||
{
|
||||
"path": "content.features.2.eyebrow",
|
||||
"label": "Пункт 3: подпись",
|
||||
"kind": "text",
|
||||
"renderAs": "text"
|
||||
"renderAs": "text",
|
||||
"group": "Пункты"
|
||||
},
|
||||
{
|
||||
"path": "content.features.2.bodyHtml",
|
||||
"label": "Пункт 3: текст",
|
||||
"kind": "html",
|
||||
"renderAs": "html"
|
||||
"renderAs": "html",
|
||||
"group": "Пункты"
|
||||
},
|
||||
{
|
||||
"path": "content.features.3.eyebrow",
|
||||
"label": "Пункт 4: подпись",
|
||||
"kind": "text",
|
||||
"renderAs": "text"
|
||||
"renderAs": "text",
|
||||
"group": "Пункты"
|
||||
},
|
||||
{
|
||||
"path": "content.features.3.bodyHtml",
|
||||
"label": "Пункт 4: текст",
|
||||
"kind": "html",
|
||||
"renderAs": "html"
|
||||
"renderAs": "html",
|
||||
"group": "Пункты"
|
||||
}
|
||||
],
|
||||
"enabled": true
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@
|
|||
</script><link rel="stylesheet" href="./assets/custom/css/out.css">
|
||||
<script defer src="./assets/vendor/umami-script.js" data-website-id="4f6678ae-c91d-431b-92aa-2d822e34f910"></script>
|
||||
|
||||
|
||||
<script id="node-dc-static-config" type="application/json">{"assets":{"icon":"./assets/custom/icon.glb","logo":"./assets/custom/logo.png"}}</script>
|
||||
<script>
|
||||
window.__API_ORIGIN__="https://ssscript-website.vercel.app";
|
||||
(function(d,h,host){
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -8,7 +8,8 @@ const templateRoot = join(repoRoot, "templates", "pages", "home");
|
|||
const blockRoot = join(templateRoot, "blocks");
|
||||
const contentPath = join(repoRoot, "content", "pages", "home.json");
|
||||
|
||||
const html = await readFile(sourcePath, "utf8");
|
||||
let html = await readFile(sourcePath, "utf8");
|
||||
html = html.replace(/\n*<script id="node-dc-static-config"(?: type="application\/json")?>[\s\S]*?<\/script>\n*/g, "\n");
|
||||
|
||||
const marker = {
|
||||
hero: '<header id="hero" class="s h-screen">',
|
||||
|
|
@ -38,7 +39,7 @@ const demoVideoFolderStart = findRequired(marker.demoVideoFolder, waitlistFilesS
|
|||
const footerStart = findRequired(marker.footer, demoVideoFolderStart);
|
||||
const footerEnd = findRequired("</footer>", footerStart) + "</footer>".length;
|
||||
|
||||
const shell = [
|
||||
let shell = [
|
||||
html.slice(0, heroStart),
|
||||
"{{NODEDC_BLOCKS_BEFORE_CVIZ}}",
|
||||
html.slice(cVizStart, cVizOpenEnd),
|
||||
|
|
@ -116,8 +117,23 @@ function getByPath(source, path) {
|
|||
return path.split(".").reduce((value, key) => value?.[key], source);
|
||||
}
|
||||
|
||||
function field(path, label, kind = "text", renderAs = kind === "html" ? "html" : "text") {
|
||||
return { path, label, kind, renderAs };
|
||||
function inferFieldGroup(path) {
|
||||
if (path.startsWith("content.navigation.")) return "Шапка";
|
||||
if (path.startsWith("content.notifications.")) return "Уведомления";
|
||||
if (path.startsWith("content.dock.")) return "Dock";
|
||||
if (path.startsWith("content.assets.")) return "Модель и логотипы";
|
||||
if (path.startsWith("content.cards.")) return "Тезисы первого экрана";
|
||||
if (path.startsWith("content.app.")) return "Карточка раннего доступа";
|
||||
if (path.startsWith("content.form.")) return "Форма";
|
||||
if (path === "content.legalHtml") return "Юридический блок";
|
||||
if (path === "content.windowTitle" || path === "content.videoSrc") return "Видео-окно";
|
||||
if (path.startsWith("content.features.")) return "Пункты";
|
||||
if (path.startsWith("content.heading.") || path === "content.introHtml") return "Заголовок";
|
||||
return "Контент";
|
||||
}
|
||||
|
||||
function field(path, label, kind = "text", renderAs = kind === "html" ? "html" : "text", group = null) {
|
||||
return { path, label, kind, renderAs, group: group || inferFieldGroup(path) };
|
||||
}
|
||||
|
||||
function tokenizeBlock(block, definition) {
|
||||
|
|
@ -286,6 +302,195 @@ const typedBlockDefinitions = {
|
|||
},
|
||||
};
|
||||
|
||||
const staticElements = {
|
||||
id: "static-elements",
|
||||
type: "staticElements",
|
||||
adminLabel: "Статичные элементы",
|
||||
enabled: true,
|
||||
content: {
|
||||
navigation: {
|
||||
logoAlt: "главная",
|
||||
brandLabel: "NODE.DC",
|
||||
links: [
|
||||
{
|
||||
label: "О продукте",
|
||||
href: "index.html#features",
|
||||
target: "",
|
||||
},
|
||||
{
|
||||
label: "Лист ожидания",
|
||||
href: "index.html#hero",
|
||||
target: "",
|
||||
},
|
||||
{
|
||||
label: "Документация",
|
||||
href: "https://docs.ssscript.app/",
|
||||
target: "_blank",
|
||||
},
|
||||
{
|
||||
label: "Тарифы",
|
||||
href: "index.html#pricing-wrap",
|
||||
target: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
notifications: [
|
||||
{
|
||||
eyebrowHtml: '<span class="darker-70">ДАЛЕЕ</span> — ИЮЛЬ 2026',
|
||||
title: "Открытая бета",
|
||||
body: "Открываем доступ всем. Будем собирать и отлаживать вместе.",
|
||||
},
|
||||
{
|
||||
eyebrowHtml: '<span class="darker-70">ДАЛЕЕ</span> —<span class="darker-70"> </span>ИЮЛЬ 2026',
|
||||
title: "Альфа для Founder-доступа",
|
||||
body: "На второй фазе тестирования бета открыта для всех подписчиков Founder-тарифа.",
|
||||
},
|
||||
{
|
||||
eyebrowHtml: "СЕЙЧАС",
|
||||
title: "Инвайты в закрытую бету уже отправлены.",
|
||||
body: "Сейчас тестируем базовую функциональность и ИИ-интеграцию. ",
|
||||
},
|
||||
],
|
||||
dock: {
|
||||
appTarget: "app-data",
|
||||
trashAlt: "Белая иконка корзины.",
|
||||
demoFileLabel: "демо-видео.txt",
|
||||
},
|
||||
assets: {
|
||||
webglModelSrc: "./assets/custom/icon.glb",
|
||||
logoPng: "./assets/custom/logo.png",
|
||||
logoPngAlt: "Логотип NODE.DC",
|
||||
appIconSrc: "./assets/webflow/images/69ad964d951f9d17cc5a919e_logo-app.svg",
|
||||
appIconAlt: "Иконка NODE.DC",
|
||||
appWordmarkSrc: "./assets/webflow/images/69ad96a28b06b7552a339137_logtype.svg?v=node-dc",
|
||||
appWordmarkAlt: "Логотип NODE.DC",
|
||||
},
|
||||
},
|
||||
editableFields: [
|
||||
field("content.navigation.logoAlt", "Логотип в шапке: alt"),
|
||||
field("content.navigation.brandLabel", "Бренд в шапке"),
|
||||
field("content.navigation.links.0.label", "Ссылка 1: текст"),
|
||||
field("content.navigation.links.0.href", "Ссылка 1: адрес", "url", "attr"),
|
||||
field("content.navigation.links.1.label", "Ссылка 2: текст"),
|
||||
field("content.navigation.links.1.href", "Ссылка 2: адрес", "url", "attr"),
|
||||
field("content.navigation.links.2.label", "Ссылка 3: текст"),
|
||||
field("content.navigation.links.2.href", "Ссылка 3: адрес", "url", "attr"),
|
||||
field("content.navigation.links.2.target", "Ссылка 3: target", "text", "attr"),
|
||||
field("content.navigation.links.3.label", "Ссылка 4: текст"),
|
||||
field("content.navigation.links.3.href", "Ссылка 4: адрес", "url", "attr"),
|
||||
field("content.notifications.0.eyebrowHtml", "Уведомление 1: дата/статус", "html"),
|
||||
field("content.notifications.0.title", "Уведомление 1: заголовок"),
|
||||
field("content.notifications.0.body", "Уведомление 1: текст", "textarea"),
|
||||
field("content.notifications.1.eyebrowHtml", "Уведомление 2: дата/статус", "html"),
|
||||
field("content.notifications.1.title", "Уведомление 2: заголовок"),
|
||||
field("content.notifications.1.body", "Уведомление 2: текст", "textarea"),
|
||||
field("content.notifications.2.eyebrowHtml", "Уведомление 3: дата/статус", "html"),
|
||||
field("content.notifications.2.title", "Уведомление 3: заголовок"),
|
||||
field("content.notifications.2.body", "Уведомление 3: текст", "textarea"),
|
||||
field("content.dock.appTarget", "Dock: ID приложения"),
|
||||
field("content.dock.trashAlt", "Dock: alt корзины"),
|
||||
field("content.dock.demoFileLabel", "Dock: подпись демо-файла"),
|
||||
field("content.assets.webglModelSrc", "WebGL-модель логотипа", "media", "attr"),
|
||||
field("content.assets.logoPng", "PNG-логотип", "media", "attr"),
|
||||
field("content.assets.logoPngAlt", "PNG-логотип: alt"),
|
||||
field("content.assets.appIconSrc", "Иконка приложения", "media", "attr"),
|
||||
field("content.assets.appIconAlt", "Иконка приложения: alt"),
|
||||
field("content.assets.appWordmarkSrc", "Wordmark приложения", "media", "attr"),
|
||||
field("content.assets.appWordmarkAlt", "Wordmark приложения: alt"),
|
||||
],
|
||||
};
|
||||
|
||||
function shellToken(mode, path) {
|
||||
return `{{${mode}:staticElements.${path}}}`;
|
||||
}
|
||||
|
||||
function replaceShellLiteral(source, literal, replacement, label) {
|
||||
if (!source.includes(literal)) {
|
||||
throw new Error(`Could not find shell fragment while tokenizing static elements: ${label}`);
|
||||
}
|
||||
|
||||
return source.replace(literal, replacement);
|
||||
}
|
||||
|
||||
function tokenizeStaticShell(source, definition) {
|
||||
const { navigation, notifications, dock } = definition.content;
|
||||
|
||||
let tokenized = source;
|
||||
|
||||
tokenized = replaceShellLiteral(
|
||||
tokenized,
|
||||
`aria-label="${navigation.logoAlt}" aria-current`,
|
||||
`aria-label="${shellToken("attr", "content.navigation.logoAlt")}" aria-current`,
|
||||
"navigation.logoAlt",
|
||||
);
|
||||
tokenized = replaceShellLiteral(
|
||||
tokenized,
|
||||
`class="bar-txt medium w--current">${navigation.brandLabel}</a>`,
|
||||
`class="bar-txt medium w--current">${shellToken("text", "content.navigation.brandLabel")}</a>`,
|
||||
"navigation.brandLabel",
|
||||
);
|
||||
|
||||
for (const [index, link] of navigation.links.entries()) {
|
||||
const targetPart = link.target ? ` target="${link.target}"` : "";
|
||||
tokenized = replaceShellLiteral(
|
||||
tokenized,
|
||||
`<a href="${link.href}"${targetPart} class="bar-txt">${link.label}</a>`,
|
||||
`<a href="${shellToken("attr", `content.navigation.links.${index}.href`)}"${
|
||||
link.target ? ` target="${shellToken("attr", `content.navigation.links.${index}.target`)}"` : ""
|
||||
} class="bar-txt">${shellToken("text", `content.navigation.links.${index}.label`)}</a>`,
|
||||
`navigation.links.${index}`,
|
||||
);
|
||||
}
|
||||
|
||||
notifications.forEach((notification, index) => {
|
||||
tokenized = replaceShellLiteral(
|
||||
tokenized,
|
||||
notification.eyebrowHtml,
|
||||
shellToken("html", `content.notifications.${index}.eyebrowHtml`),
|
||||
`notifications.${index}.eyebrowHtml`,
|
||||
);
|
||||
tokenized = replaceShellLiteral(
|
||||
tokenized,
|
||||
`<div>${notification.title}</div>`,
|
||||
`<div>${shellToken("text", `content.notifications.${index}.title`)}</div>`,
|
||||
`notifications.${index}.title`,
|
||||
);
|
||||
tokenized = replaceShellLiteral(
|
||||
tokenized,
|
||||
`<div>${notification.body}</div>`,
|
||||
`<div>${shellToken("text", `content.notifications.${index}.body`)}</div>`,
|
||||
`notifications.${index}.body`,
|
||||
);
|
||||
});
|
||||
|
||||
tokenized = replaceShellLiteral(
|
||||
tokenized,
|
||||
`data-target="${dock.appTarget}" class="dock-icon ssscript"`,
|
||||
`data-target="${shellToken("attr", "content.dock.appTarget")}" class="dock-icon ssscript"`,
|
||||
"dock.appTarget",
|
||||
);
|
||||
tokenized = replaceShellLiteral(
|
||||
tokenized,
|
||||
`alt="${dock.trashAlt}" class="dock-app-img trash"`,
|
||||
`alt="${shellToken("attr", "content.dock.trashAlt")}" class="dock-app-img trash"`,
|
||||
"dock.trashAlt",
|
||||
);
|
||||
|
||||
tokenized = replaceShellLiteral(
|
||||
tokenized,
|
||||
"<script>\nwindow.__API_ORIGIN__=",
|
||||
`<script id="node-dc-static-config" type="application/json">{"assets":{"icon":"${shellToken(
|
||||
"js",
|
||||
"content.assets.webglModelSrc",
|
||||
)}","logo":"${shellToken("js", "content.assets.logoPng")}"}}</script>\n<script>\nwindow.__API_ORIGIN__=`,
|
||||
"static asset config",
|
||||
);
|
||||
|
||||
return tokenized;
|
||||
}
|
||||
|
||||
shell = tokenizeStaticShell(shell, staticElements);
|
||||
|
||||
const preparedBlocks = blocks.map((block) => {
|
||||
const definition = typedBlockDefinitions[block.id];
|
||||
return definition ? tokenizeBlock(block, definition) : block;
|
||||
|
|
@ -296,6 +501,7 @@ const page = {
|
|||
slug: "home",
|
||||
output: "index.html",
|
||||
title: "NODE.DC",
|
||||
staticElements,
|
||||
seo: {
|
||||
title: "NODE.DC — ИИ-платформа для Webflow",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -48,8 +48,19 @@ function escapeAttr(value) {
|
|||
return escapeHtml(value).replaceAll('"', """).replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function escapeJsString(value) {
|
||||
return String(value)
|
||||
.replaceAll("\\", "\\\\")
|
||||
.replaceAll('"', '\\"')
|
||||
.replaceAll("\n", "\\n")
|
||||
.replaceAll("\r", "\\r")
|
||||
.replaceAll("\u2028", "\\u2028")
|
||||
.replaceAll("\u2029", "\\u2029")
|
||||
.replaceAll("</script", "<\\/script");
|
||||
}
|
||||
|
||||
function renderTemplateTokens(html, block) {
|
||||
return html.replace(/\{\{\s*(text|html|attr):([a-zA-Z0-9_.-]+)\s*\}\}/g, (_match, mode, path) => {
|
||||
return html.replace(/\{\{\s*(text|html|attr|js):([a-zA-Z0-9_.-]+)\s*\}\}/g, (_match, mode, path) => {
|
||||
const value = getByPath(block, path);
|
||||
|
||||
if (value === undefined) {
|
||||
|
|
@ -64,6 +75,10 @@ function renderTemplateTokens(html, block) {
|
|||
return value == null ? "" : escapeAttr(value);
|
||||
}
|
||||
|
||||
if (mode === "js") {
|
||||
return value == null ? "" : escapeJsString(value);
|
||||
}
|
||||
|
||||
return value == null ? "" : escapeHtml(value);
|
||||
});
|
||||
}
|
||||
|
|
@ -117,7 +132,7 @@ async function renderRegion(region) {
|
|||
return blocks.join("");
|
||||
}
|
||||
|
||||
let output = shell;
|
||||
let output = renderTemplateTokens(shell, { id: page.slug, ...page });
|
||||
for (const region of page.regions ?? []) {
|
||||
const token = REGION_TOKEN[region.id];
|
||||
if (!token) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue