diff --git a/README.md b/README.md index 3d0afce..75a785f 100644 --- a/README.md +++ b/README.md @@ -6,18 +6,17 @@ Static promo site for NODE.DC: an enterprise platform for processes, data, AI ag - `content/pages/home.json` - structured content model for the homepage. - `templates/pages/home/shell.html` - homepage shell and shared head/runtime includes. -- `templates/pages/home/blocks` - block templates used by the renderer and admin. -- `content/block-templates/home.json` - sanitized admin block presets. +- `templates/pages/home/blocks` - block templates used by the renderer. +- `content/block-templates/home.json` - sanitized block presets consumed by the external CMS. - `assets/site` - local runtime CSS, JS, fonts, and base UI images. - `assets/custom` - NODE.DC custom runtime and overrides. -- `assets/uploads` - product media managed by the site/admin flow. -- `admin` and `tools/admin-server.mjs` - local content admin. +- `assets/uploads` - product media managed by the external CMS flow. ## Commands ```bash npm run render:home -npm run admin +npm run render:knowledge ``` For a static preview: diff --git a/admin/admin.css b/admin/admin.css deleted file mode 100644 index f4baa1e..0000000 --- a/admin/admin.css +++ /dev/null @@ -1,2935 +0,0 @@ -:root { - color-scheme: dark; - --nodedc-component-accent-rgb: 247 248 244; - --nodedc-component-on-accent-rgb: 11 17 23; - --nodedc-on-accent-rgb: 11 17 23; - --launcher-radius-modal: 1.75rem; - --launcher-radius-card: 1.35rem; - --launcher-radius-control: 1.25rem; - --launcher-radius-circle: 999px; - --nodedc-shell-padding-x: 1.25rem; - --nodedc-shell-padding-top: 1rem; - --nodedc-shell-padding-bottom: 0.75rem; - --nodedc-shell-frame-gutter-x: 18px; - --nodedc-shell-height: 4.25rem; - --nodedc-shell-row-height: 3rem; - --nodedc-shell-logo-width: 7.25rem; - --nodedc-shell-logo-height: 1.79rem; - --nodedc-shell-pill-height: 3.45rem; - --nodedc-shell-pill-padding: 0.32rem; - --nodedc-shell-control-height: 2.78rem; - --admin-viewport-gap: 1.5rem; - --admin-nav-width: clamp(22.5rem, 21vw, 25rem); - --admin-nav-pad: 1.1rem; - --admin-panel-gap: 1.25rem; - --admin-control-ring: 2.78rem; - --admin-control-inset: 5px; - --surface-base: rgba(8, 8, 11, 0.78); - --surface-strong: rgba(8, 8, 11, 0.9); - --surface-soft: rgba(255, 255, 255, 0.08); - --field: rgba(255, 255, 255, 0.06); - --nodedc-glass-panel-bg: rgba(28, 28, 30, 0.72); - --nodedc-glass-panel-bg-strong: rgba(28, 28, 30, 0.84); - --nodedc-glass-panel-surface: linear-gradient(180deg, rgba(255, 255, 255, 0.055) 0%, rgba(255, 255, 255, 0.014) 100%), - var(--nodedc-glass-panel-bg); - --nodedc-glass-panel-surface-strong: linear-gradient(180deg, rgba(255, 255, 255, 0.068) 0%, rgba(255, 255, 255, 0.018) 100%), - var(--nodedc-glass-panel-bg-strong); - --nodedc-glass-outline: rgba(255, 255, 255, 0.09); - --nodedc-glass-panel-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.075), 0 22px 72px rgba(0, 0, 0, 0.42); - --nodedc-glass-filter: blur(30px) saturate(1.26) brightness(1.05); - --text-primary: #f7f8f4; - --text-secondary: rgba(247, 248, 244, 0.72); - --text-muted: rgba(247, 248, 244, 0.48); - --danger: #ff7474; - font-family: - Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -} - -* { - box-sizing: border-box; -} - -html, -body { - min-height: 100%; - background: #050506; -} - -body { - margin: 0; - min-height: 100vh; - overflow: hidden; - color: var(--text-primary); - font-family: inherit; - font-synthesis: none; - -webkit-font-smoothing: antialiased; -} - -button, -input, -textarea, -select { - font: inherit; - outline: none; -} - -button { - border: 0; - cursor: pointer; -} - -a { - color: inherit; - text-decoration: none; -} - -button:disabled, -input:disabled, -textarea:disabled { - cursor: not-allowed; - opacity: 0.42; -} - -h1, -h2, -h3, -p { - margin: 0; - letter-spacing: 0; -} - -.admin-backdrop { - position: fixed; - inset: 0; - background: #050506; -} - -.nodedc-expanded-toolbar-shell { - position: relative; - z-index: 80; - width: min(100%, calc(100vw - var(--nodedc-shell-frame-gutter-x))); - margin-inline: auto; - flex-shrink: 0; - padding: var(--nodedc-shell-padding-top) var(--nodedc-shell-padding-x) var(--nodedc-shell-padding-bottom); -} - -.nodedc-expanded-toolbar { - display: flex; - min-height: var(--nodedc-shell-height); - width: 100%; - flex-direction: column; -} - -.nodedc-expanded-toolbar-top { - display: grid; - grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); - min-height: var(--nodedc-shell-row-height); - width: 100%; - align-items: center; - gap: 1rem; -} - -.nodedc-expanded-toolbar-left, -.nodedc-expanded-toolbar-center, -.nodedc-expanded-toolbar-right { - display: flex; - min-width: 0; - align-items: center; - gap: 0.75rem; -} - -.nodedc-expanded-toolbar-left { - justify-content: flex-start; -} - -.nodedc-expanded-toolbar-center { - justify-content: center; -} - -.nodedc-expanded-toolbar-right { - justify-content: flex-end; -} - -.nodedc-expanded-brand-link { - display: inline-flex; - width: var(--nodedc-shell-logo-width); - height: var(--nodedc-shell-logo-height); - flex: 0 0 auto; - align-items: center; - justify-content: flex-start; - line-height: 0; -} - -.nodedc-expanded-brand-logo { - display: block; - width: 100%; - height: 100%; - object-fit: contain; -} - -.nodedc-expanded-workspace-button { - position: relative; - display: flex; - width: 3rem; - min-width: 3rem; - max-width: 3rem; - height: 3rem; - min-height: 3rem; - max-height: 3rem; - flex: 0 0 3rem; - align-items: center; - justify-content: center; - overflow: hidden; - border-radius: 999px; - background: #000; - padding: 0; -} - -.nodedc-expanded-workspace-avatar { - display: block; - width: 100%; - height: 100%; - border-radius: inherit; - object-fit: cover; -} - -.nodedc-expanded-nav-group, -.nodedc-expanded-user-group { - display: inline-flex; - min-height: var(--nodedc-shell-pill-height); - align-items: center; - gap: 0.18rem; - border-radius: 999px; - background: rgba(64, 64, 64, 0.48); - padding: var(--nodedc-shell-pill-padding); - user-select: none; -} - -.nodedc-expanded-user-group { - gap: 0.22rem; -} - -.nodedc-expanded-nav-button { - position: relative; - display: inline-flex; - min-height: var(--nodedc-shell-control-height); - align-items: center; - justify-content: center; - border-radius: 999px; - background: transparent; - color: rgba(255, 255, 255, 0.68); - padding: 0.2rem 1.22rem; - font-size: 0.74rem; - font-weight: 760; - line-height: 1; - white-space: nowrap; - transition: - background-color 160ms ease, - color 160ms ease, - opacity 160ms ease; -} - -.nodedc-expanded-nav-button:hover { - background: rgba(255, 255, 255, 0.07); - color: rgba(255, 255, 255, 0.96); -} - -.nodedc-expanded-nav-button[data-active="true"] { - background: rgba(255, 255, 255, 0.92); - color: rgba(8, 8, 10, 0.96); -} - -.nodedc-toolbar-icon-button { - display: inline-grid; - width: var(--nodedc-shell-control-height); - height: var(--nodedc-shell-control-height); - place-items: center; - border-radius: 999px; - background: transparent; - color: rgba(255, 255, 255, 0.72); - font-size: 0.82rem; -} - -.nodedc-expanded-profile-trigger { - display: inline-flex; - align-items: center; - gap: 0.22rem; - border-radius: 999px; -} - -.nodedc-expanded-user-avatar-button, -.nodedc-expanded-user-avatar { - display: grid; - width: var(--nodedc-shell-control-height); - height: var(--nodedc-shell-control-height); - place-items: center; - border-radius: 999px; -} - -.nodedc-expanded-user-avatar { - background: - radial-gradient(circle at 70% 18%, rgba(255, 255, 255, 0.8), transparent 24%), - linear-gradient(135deg, #c8c9cb, #8d8f96 55%, #f1f1f1); - color: rgba(8, 8, 10, 0.96); - font-size: 0.74rem; - font-weight: 850; -} - -.admin-shell { - position: relative; - z-index: 1; - display: grid; - grid-template-columns: var(--admin-nav-width) minmax(0, 1fr); - gap: var(--admin-panel-gap); - width: calc(100vw - (var(--admin-viewport-gap) * 2)); - height: calc( - 100vh - var(--nodedc-shell-height) - var(--nodedc-shell-padding-top) - var(--nodedc-shell-padding-bottom) - - var(--admin-viewport-gap) - ); - margin: 0 auto var(--admin-viewport-gap); - overflow: visible; -} - -.sidebar, -.editor { - min-width: 0; - min-height: 0; - border-radius: var(--launcher-radius-card); - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.012)), - rgba(10, 10, 13, 0.88); - box-shadow: 0 34px 110px rgba(0, 0, 0, 0.52); - backdrop-filter: blur(28px); - -webkit-backdrop-filter: blur(28px); -} - -body[data-file-picker-active="true"] .editor { - transform: translateZ(0); -} - -.sidebar { - position: relative; - display: grid; - grid-template-rows: auto minmax(0, 1fr); - gap: 1.05rem; - overflow: hidden; - padding: var(--admin-nav-pad); -} - -.sidebar-head { - position: relative; - display: grid; - gap: 1.05rem; - padding: 0.42rem 0 0; -} - -.sidebar-head h1 { - margin-top: 0.35rem; - font-size: 1.15rem; - line-height: 1.1; -} - -.admin-panel-close { - position: absolute; - top: var(--admin-control-inset); - right: var(--admin-control-inset); - display: grid; - width: var(--admin-control-ring); - height: var(--admin-control-ring); - place-items: center; - border: 1px solid rgba(255, 255, 255, 0.22); - border-radius: var(--launcher-radius-circle); - background: transparent; - color: rgba(255, 255, 255, 0.8); - font-size: 1.25rem; - line-height: 1; -} - -.add-block-button { - width: calc(var(--admin-control-ring) * 0.85); - height: calc(var(--admin-control-ring) * 0.85); - font-size: 1.32rem; - font-weight: 520; -} - -.admin-panel-close:hover { - border-color: rgba(255, 255, 255, 0.3); - background: rgba(255, 255, 255, 0.07); - color: var(--text-primary); -} - -.workspace-card, -.admin-panel-client-select { - position: relative; - display: flex; - width: calc(100% + (var(--admin-nav-pad) * 2)); - min-height: calc(var(--admin-control-ring) + (var(--admin-control-inset) * 2)); - margin-inline: calc(var(--admin-nav-pad) * -1); - align-items: center; - gap: 0.65rem; - overflow: hidden; - border-radius: var(--launcher-radius-circle); - background: rgba(255, 255, 255, 0.04); - padding: var(--admin-control-inset); - color: rgba(255, 255, 255, 0.66); - opacity: 0.82; -} - -.workspace-logo, -.empty-icon, -.admin-panel-client-select__icon, -.block-icon { - display: grid; - width: var(--admin-control-ring); - height: var(--admin-control-ring); - place-items: center; - flex: 0 0 auto; - border-radius: var(--launcher-radius-circle); - background: rgba(255, 255, 255, 0.055); - color: rgba(255, 255, 255, 0.58); - font-size: 0.7rem; - font-weight: 850; -} - -.workspace-name { - overflow: hidden; - color: var(--text-primary); - font-size: 0.92rem; - font-weight: 820; - text-overflow: ellipsis; - white-space: nowrap; -} - -.workspace-role, -.breadcrumb, -.panel-note, -.field-path, -.media-upload-hint, -.block-meta, -.group-desc, -.status, -.empty-state p { - color: var(--text-muted); -} - -.workspace-role { - margin-top: 0.12rem; - font-size: 0.72rem; - font-weight: 750; -} - -.regions { - display: grid; - align-content: start; - gap: 1.2rem; - width: calc(100% + (var(--admin-nav-pad) * 2)); - min-height: 0; - margin-inline: calc(var(--admin-nav-pad) * -1); - overflow-x: hidden; - overflow-y: auto; - padding-bottom: 1rem; -} - -.region-title { - margin: 0 0 0.48rem; - padding-inline: calc(var(--admin-nav-pad) + 0.35rem); - color: var(--text-muted); - font-size: 0.68rem; - font-weight: 850; - letter-spacing: 0.12em; - text-transform: uppercase; -} - -.block-list { - display: grid; - width: 100%; - gap: 0.22rem; -} - -.block-btn { - position: relative; - display: grid; - grid-template-columns: var(--admin-control-ring) minmax(0, 1fr); - width: 100%; - min-height: calc(var(--admin-control-ring) + (var(--admin-control-inset) * 2)); - align-items: center; - gap: 0.86rem; - border: 0; - border-radius: var(--launcher-radius-circle); - background: rgba(255, 255, 255, 0.04); - color: rgba(255, 255, 255, 0.66); - cursor: pointer; - opacity: 0.66; - padding: var(--admin-control-inset) 0.9rem var(--admin-control-inset) var(--admin-control-inset); - text-align: left; - transition: - background 160ms ease, - color 160ms ease, - opacity 160ms ease, - transform 160ms ease, - box-shadow 160ms ease; -} - -.block-btn.has-drag-handle { - grid-template-columns: var(--admin-control-ring) minmax(0, 1fr) 1.35rem; -} - -.block-btn:hover { - background: rgba(255, 255, 255, 0.085); - color: var(--text-primary); - opacity: 0.92; - transform: translateY(-1px); -} - -.block-btn.active { - background: rgba(255, 255, 255, 0.115); - color: var(--text-primary); - opacity: 1; - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.048), - 0 10px 22px rgba(0, 0, 0, 0.12); -} - -.block-btn.active .block-icon { - background: rgba(247, 248, 244, 0.96); - color: rgb(var(--nodedc-on-accent-rgb)); -} - -.block-btn.disabled { - opacity: 0.3; -} - -.block-btn.dragging { - opacity: 0.16; -} - -.sorting-source { - display: none !important; -} - -.block-btn.drag-over { - background: rgba(255, 255, 255, 0.16); -} - -.block-list.sorting-active .block-btn { - transition: - background 160ms ease, - color 160ms ease, - opacity 160ms ease, - transform 180ms ease, - box-shadow 160ms ease; -} - -.sortable-placeholder { - border: 1px dashed rgba(255, 255, 255, 0.32); - border-radius: var(--launcher-radius-circle); - background: rgba(255, 255, 255, 0.07); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.06), - 0 12px 34px rgba(0, 0, 0, 0.22); -} - -.collection-list .sortable-placeholder { - border-radius: var(--launcher-radius-control); -} - -.sortable-ghost { - position: fixed; - z-index: 300; - pointer-events: none; - opacity: 0.94; - transform: scale(1.015); - box-shadow: 0 24px 70px rgba(0, 0, 0, 0.45); -} - -.block-copy { - display: grid; - min-width: 0; - gap: 0.12rem; -} - -.block-name { - overflow: hidden; - font-size: 0.88rem; - font-weight: 760; - line-height: 1.16; - text-overflow: ellipsis; - white-space: nowrap; -} - -.block-meta { - overflow: hidden; - font-size: 0.68rem; - font-weight: 650; - line-height: 1.15; - text-overflow: ellipsis; - white-space: nowrap; -} - -.block-drag-handle { - display: grid; - width: 1.35rem; - height: 2.2rem; - place-items: center; - border-radius: var(--launcher-radius-circle); - color: rgba(255, 255, 255, 0.38); - cursor: grab; - font-size: 1rem; - letter-spacing: -0.12em; - line-height: 1; - touch-action: none; - user-select: none; -} - -.block-drag-handle:active { - cursor: grabbing; -} - -.block-drag-handle:hover { - background: rgba(255, 255, 255, 0.06); - color: rgba(255, 255, 255, 0.78); -} - -.editor { - display: grid; - grid-template-rows: auto minmax(0, 1fr) auto; - overflow: hidden; - padding: 1rem; - font-size: 0.875rem; -} - -.editor-head, -.actions, -.button-row, -.toggle-row, -.card-head, -.panel-head, -.group-head { - display: flex; - align-items: center; -} - -.editor-head { - justify-content: space-between; - gap: 1rem; - padding: 0.35rem 0.35rem 0.95rem; -} - -.editor-head h1 { - color: var(--text-muted); - font-size: 0.72rem; - font-weight: 850; - letter-spacing: 0.12em; - line-height: 1.2; - text-transform: uppercase; -} - -.breadcrumb { - margin-top: 0.34rem; - font-size: 0.74rem; - line-height: 1.35; -} - -.editor-head h2 { - margin-top: 0.68rem; - font-size: 1.15rem; - font-weight: 850; - line-height: 1.12; -} - -.actions, -.button-row { - gap: 0.55rem; - flex-wrap: wrap; - justify-content: flex-end; -} - -.dirty-state { - display: inline-flex; - min-height: 2rem; - align-items: center; - border-radius: var(--launcher-radius-circle); - background: rgba(255, 255, 255, 0.055); - color: var(--text-muted); - padding: 0 0.75rem; - font-size: 0.68rem; - font-weight: 820; - white-space: nowrap; -} - -.dirty-state[data-dirty="true"] { - background: rgba(255, 255, 255, 0.12); - color: var(--text-primary); -} - -.structure-card { - padding-block: 0.78rem; -} - -.structure-head { - min-height: var(--nodedc-shell-control-height); -} - -.icon-action-row { - gap: 0.42rem; -} - -.primary-btn, -.ghost-btn, -.danger-btn, -.icon-btn, -.icon-action-btn { - display: inline-flex; - min-height: var(--nodedc-shell-control-height); - align-items: center; - justify-content: center; - gap: 0.45rem; - border-radius: var(--launcher-radius-circle); - padding: 0 1.05rem; - font-size: 0.78rem; - font-weight: 850; - transition: - background 160ms ease, - color 160ms ease, - transform 160ms ease, - opacity 160ms ease; -} - -.icon-action-btn { - width: var(--nodedc-shell-control-height); - min-width: var(--nodedc-shell-control-height); - padding: 0; - background: rgba(255, 255, 255, 0.075); - color: var(--text-primary); - font-size: 1rem; -} - -.icon-action-btn:hover { - background: rgba(255, 255, 255, 0.13); -} - -.trash-can-icon { - display: block; - width: 1rem; - height: 1rem; - fill: none; - stroke: currentColor; - stroke-linecap: round; - stroke-linejoin: round; - stroke-width: 1.65; -} - -.danger-action-btn { - background: transparent; - color: rgba(255, 255, 255, 0.82); -} - -.operation-extra { - display: none; -} - -.primary-btn { - min-width: 9rem; - background: rgba(255, 255, 255, 0.92); - color: rgba(8, 8, 10, 0.96); -} - -.ghost-btn, -.icon-btn { - background: rgba(255, 255, 255, 0.075); - color: var(--text-primary); -} - -.danger-btn { - background: transparent; - color: var(--text-primary); -} - -.primary-btn:hover, -.ghost-btn:hover, -.danger-btn:hover, -.icon-btn:hover { - transform: translateY(-1px); -} - -.ghost-btn:hover, -.icon-btn:hover, -.danger-btn:hover { - background: rgba(255, 255, 255, 0.12); -} - -.icon-btn { - width: var(--nodedc-shell-control-height); - padding: 0; -} - -.empty-state { - display: grid; - grid-template-columns: 3rem minmax(0, 1fr); - align-self: start; - gap: 1rem; - margin: 1.4rem 0.35rem 0; - padding: 1.15rem; - border-radius: var(--launcher-radius-card); - background: rgba(255, 255, 255, 0.045); -} - -.empty-state h3 { - font-size: 1.02rem; - line-height: 1.25; -} - -.empty-state p { - margin-top: 0.35rem; - font-size: 0.84rem; - font-weight: 650; - line-height: 1.45; -} - -.block-form { - display: grid; - min-height: 0; - overflow: auto; - gap: 1rem; - padding: 0 0.2rem 5rem; - scrollbar-color: rgba(255, 255, 255, 0.25) transparent; -} - -body[data-admin-mode="seo"] #add-block, -body[data-admin-mode="seo"] .block-meta-card, -body[data-admin-mode="seo"] .structure-card, -body[data-admin-mode="seo"] .json-details { - display: none; -} - -body[data-admin-mode="knowledge"] #block-form, -body[data-admin-mode="knowledge"] #empty-state { - display: none !important; -} - -body:not([data-admin-mode="knowledge"]) #knowledge-form { - display: none !important; -} - -.hidden { - display: none !important; -} - -.settings-card, -.content-panel, -.json-details, -.content-group, -.seo-diagnostics { - border-radius: var(--launcher-radius-card); - background: rgba(255, 255, 255, 0.045); -} - -.settings-card { - padding: 1rem; -} - -.card-head, -.panel-head, -.group-head { - justify-content: space-between; - gap: 1rem; -} - -.card-head h3, -.panel-head h3, -.group-head h3 { - color: var(--text-primary); - font-size: 1rem; - font-weight: 850; - line-height: 1.2; -} - -.section-kicker { - margin-bottom: 0.32rem; - color: var(--text-muted); - font-size: 0.72rem; - font-weight: 850; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.field-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.75rem; - margin-top: 1rem; -} - -.content-panel { - display: grid; - gap: 1rem; - padding: 1rem; -} - -.panel-note { - max-width: 24rem; - font-size: 0.8rem; - font-weight: 650; - line-height: 1.35; - text-align: right; -} - -.content-fields { - display: grid; - gap: 1rem; -} - -.content-group { - display: grid; - gap: 1rem; - padding: 1rem; -} - -.group-desc { - margin-top: 0.22rem; - font-size: 0.78rem; - font-weight: 650; - line-height: 1.35; -} - -.group-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.75rem; -} - -.field-control { - display: grid; - min-width: 0; - gap: 0.38rem; - color: var(--text-muted); - font-size: 0.72rem; - font-weight: 800; - text-transform: uppercase; -} - -.field-control.wide { - grid-column: 1 / -1; -} - -.field-label-row { - display: flex; - min-width: 0; - align-items: center; - justify-content: space-between; - gap: 0.75rem; -} - -.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: var(--text-muted); - font-size: 0.64rem; - font-weight: 820; - letter-spacing: 0.04em; - padding: 0.18rem 0.42rem; - text-transform: uppercase; -} - -.field-path { - overflow: hidden; - font-size: 0.68rem; - font-weight: 600; - text-overflow: ellipsis; - text-transform: none; - white-space: nowrap; -} - -.seo-diagnostics { - display: grid; - gap: 1rem; - padding: 1rem; -} - -.seo-metric-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 0.75rem; -} - -.seo-metric { - display: grid; - gap: 0.35rem; - min-width: 0; - border-radius: 1rem; - background: rgba(255, 255, 255, 0.055); - padding: 0.82rem; -} - -.seo-metric span { - overflow: hidden; - color: var(--text-muted); - font-size: 0.68rem; - font-weight: 850; - text-overflow: ellipsis; - text-transform: uppercase; - white-space: nowrap; -} - -.seo-metric strong { - overflow: hidden; - color: var(--text-primary); - font-size: 0.88rem; - font-weight: 850; - line-height: 1.2; - text-overflow: ellipsis; - white-space: nowrap; -} - -.seo-metric[data-status="good"] strong, -.seo-field-meter[data-status="good"] { - color: #bcf4d1; -} - -.seo-metric[data-status="warn"] strong, -.seo-field-meter[data-status="warn"] { - color: #f3d28b; -} - -.seo-metric[data-status="bad"] strong, -.seo-field-meter[data-status="bad"] { - color: #ffb7b7; -} - -.seo-field-meter { - overflow: hidden; - color: var(--text-muted); - font-size: 0.68rem; - font-weight: 720; - text-overflow: ellipsis; - text-transform: none; - white-space: nowrap; -} - -.boolean-field { - align-content: start; -} - -.range-field { - gap: 0.46rem; -} - -.range-control { - display: flex; - min-height: 2.72rem; - align-items: center; - gap: 0.85rem; - border-radius: var(--launcher-radius-control); - background: var(--field); - padding: 0 0.86rem; -} - -.range-control input[type="range"] { - height: 1.5rem; - min-width: 0; - flex: 1 1 auto; - appearance: none; - border: 0; - border-radius: 0; - background: transparent; - padding: 0; - accent-color: #f7f8f4; -} - -.range-control input[type="range"]::-webkit-slider-runnable-track { - height: 0.32rem; - border-radius: 999px; - background: rgba(255, 255, 255, 0.16); -} - -.range-control input[type="range"]::-webkit-slider-thumb { - width: 1.08rem; - height: 1.08rem; - margin-top: -0.38rem; - appearance: none; - border-radius: 50%; - background: #f7f8f4; - box-shadow: 0 0 0 0.28rem rgba(255, 255, 255, 0.08); -} - -.range-control input[type="range"]::-moz-range-track { - height: 0.32rem; - border-radius: 999px; - background: rgba(255, 255, 255, 0.16); -} - -.range-control input[type="range"]::-moz-range-thumb { - width: 1.08rem; - height: 1.08rem; - border: 0; - border-radius: 50%; - background: #f7f8f4; - box-shadow: 0 0 0 0.28rem rgba(255, 255, 255, 0.08); -} - -.range-value { - min-width: 3.4rem; - color: var(--text-secondary); - font-size: 0.78rem; - font-weight: 850; - text-align: right; - white-space: nowrap; -} - -.number-control { - display: flex; - min-height: 2.72rem; - align-items: center; - gap: 0.52rem; - border-radius: var(--launcher-radius-control); - background: var(--field); - padding: 0 0.86rem; -} - -.number-control input[type="number"] { - width: 100%; - min-width: 0; - border: 0; - background: transparent; - color: var(--text-primary); - font-size: 0.86rem; - font-weight: 820; - padding: 0; -} - -.number-suffix { - flex: 0 0 auto; - color: var(--text-muted); - font-size: 0.72rem; - font-weight: 850; -} - -.link-mode-field { - gap: 0.48rem; -} - -.link-mode-grid { - display: grid; - gap: 0.48rem; -} - -.link-mode-row { - display: grid; - grid-template-columns: auto minmax(4.5rem, 0.22fr) minmax(0, 1fr); - min-width: 0; - align-items: center; - gap: 0.55rem; - border-radius: var(--launcher-radius-control); - background: var(--field); - padding: 0.42rem 0.6rem; - color: var(--text-secondary); -} - -.link-mode-row input[type="checkbox"] { - width: 1.18rem; - height: 1.18rem; - margin: 0; - appearance: none; - border: 0.18rem solid rgba(255, 255, 255, 0.84); - border-radius: 50%; - background: transparent; -} - -.link-mode-row input[type="checkbox"]:checked { - box-shadow: inset 0 0 0 0.22rem #050506; - background: rgba(255, 255, 255, 0.94); -} - -.link-mode-row span { - overflow: hidden; - color: var(--text-muted); - font-size: 0.68rem; - font-weight: 850; - text-overflow: ellipsis; - text-transform: uppercase; - white-space: nowrap; -} - -.link-mode-row input[type="text"] { - min-width: 0; - min-height: 2rem; - border-radius: 0.78rem; - background: rgba(255, 255, 255, 0.04); - padding: 0 0.72rem; -} - -.boolean-field input, -.group-head-toggle input, -.collection-visibility-toggle input { - width: 2.42rem; - height: 1.32rem; - margin: 0; - appearance: none; - border-radius: 999px; - background: rgba(255, 255, 255, 0.16); - padding: 0.16rem; -} - -.boolean-field input::before, -.group-head-toggle input::before, -.collection-visibility-toggle input::before { - display: block; - width: 1rem; - aspect-ratio: 1; - border-radius: 50%; - background: rgba(255, 255, 255, 0.82); - content: ""; - transition: transform 0.16s ease; -} - -.boolean-field input:checked, -.group-head-toggle input:checked, -.collection-visibility-toggle input:checked { - background: rgba(255, 255, 255, 0.9); -} - -.boolean-field input:checked::before, -.group-head-toggle input:checked::before, -.collection-visibility-toggle input:checked::before { - background: #050506; - transform: translateX(1.1rem); -} - -.group-head-toggle { - display: inline-flex; - flex: 0 0 auto; - align-items: center; - gap: 0.52rem; - color: var(--text-secondary); - font-size: 0.78rem; - font-weight: 850; - white-space: nowrap; -} - -.collection-field { - display: grid; - grid-column: 1 / -1; - gap: 0.75rem; - min-width: 0; -} - -.collection-head, -.collection-summary, -.collection-item-actions { - display: flex; - align-items: center; -} - -.collection-head, -.collection-summary { - justify-content: space-between; - gap: 0.75rem; -} - -.collection-title { - color: var(--text-primary); - font-size: 0.88rem; - font-weight: 850; - text-transform: none; -} - -.collection-add-btn { - display: grid; - width: 2.05rem; - height: 2.05rem; - min-height: 2.05rem; - place-items: center; - border-radius: var(--launcher-radius-circle); - background: rgba(255, 255, 255, 0.9); - color: rgba(8, 8, 10, 0.96); - padding: 0; - font-size: 1.05rem; - font-weight: 850; - line-height: 1; -} - -.collection-list { - display: grid; - gap: 0.5rem; -} - -.collection-item-card { - overflow: hidden; - border-radius: var(--launcher-radius-control); - background: rgba(255, 255, 255, 0.045); - transition: - background 160ms ease, - opacity 160ms ease, - transform 180ms ease; -} - -.collection-item-card[open] { - background: rgba(255, 255, 255, 0.065); -} - -.collection-item-card.dragging { - opacity: 0.16; -} - -.collection-item-card.is-hidden { - opacity: 0.55; -} - -.collection-summary { - min-height: 3rem; - cursor: pointer; - list-style: none; - padding: 0.42rem 0.55rem 0.42rem 0.85rem; -} - -.collection-summary::-webkit-details-marker { - display: none; -} - -.collection-summary-title { - min-width: 0; - flex: 1 1 auto; - overflow: hidden; - color: var(--text-secondary); - font-size: 0.82rem; - font-weight: 820; - text-overflow: ellipsis; - text-transform: none; - white-space: nowrap; -} - -.collection-item-actions { - flex: 0 0 auto; - gap: 0.22rem; -} - -.collection-visibility-toggle { - display: inline-flex; - height: 2.2rem; - align-items: center; - gap: 0.38rem; - border-radius: var(--launcher-radius-circle); - background: rgba(255, 255, 255, 0.07); - padding: 0 0.52rem; - color: var(--text-secondary); - cursor: pointer; -} - -.collection-visibility-toggle:hover { - background: rgba(255, 255, 255, 0.14); - color: var(--text-primary); -} - -.collection-visibility-toggle span { - font-size: 0.68rem; - font-weight: 850; - line-height: 1; - text-transform: uppercase; -} - -.collection-visibility-toggle input { - width: 2rem; - height: 1.12rem; - flex: 0 0 auto; - padding: 0.13rem; -} - -.collection-visibility-toggle input::before { - width: 0.86rem; -} - -.collection-visibility-toggle input:checked::before { - transform: translateX(0.88rem); -} - -.collection-drag-handle, -.collection-icon-btn { - display: grid; - width: 2.2rem; - height: 2.2rem; - place-items: center; - border-radius: var(--launcher-radius-circle); - background: rgba(255, 255, 255, 0.07); - color: var(--text-secondary); - font-size: 0.84rem; -} - -.collection-drag-handle { - cursor: grab; - touch-action: none; - user-select: none; -} - -.collection-drag-handle svg { - display: block; - width: 0.75rem; - height: 1.125rem; - fill: currentColor; - opacity: 0.78; -} - -.collection-icon-btn .trash-can-icon { - width: 0.95rem; - height: 0.95rem; - opacity: 0.82; -} - -.collection-drag-handle:active { - cursor: grabbing; -} - -.collection-drag-handle:hover, -.collection-icon-btn:hover { - background: rgba(255, 255, 255, 0.14); - color: var(--text-primary); -} - -.collection-item-body { - padding: 0 0.75rem 0.85rem; -} - -input, -textarea, -select { - width: 100%; - border: 0; - border-radius: 1rem; - background: var(--field); - color: var(--text-primary); - padding: 0.75rem 0.85rem; -} - -input:focus, -textarea:focus, -select:focus { - background: rgba(255, 255, 255, 0.1); - box-shadow: none; -} - -textarea { - min-height: 8rem; - resize: vertical; -} - -select { - appearance: none; -} - -.toggle-row { - justify-content: flex-start; - gap: 0.65rem; - color: var(--text-secondary); - font-size: 0.82rem; - font-weight: 800; -} - -.toggle-row input { - 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: rgba(255, 255, 255, 0.92); -} - -.toggle-row input:checked::before { - background: #050506; - transform: translateX(1.22rem); -} - -.empty-note { - color: var(--text-muted); - font-size: 0.86rem; - font-weight: 650; - line-height: 1.42; -} - -.editor-error-note { - border-radius: var(--launcher-radius-control); - background: rgba(255, 116, 116, 0.09); - color: #ffd8d8; - padding: 1rem; -} - -.service-media-field { - min-width: 0; -} - -.service-media-control { - display: grid; - grid-template-columns: minmax(0, 1fr) auto auto; - align-items: center; - min-height: 3.35rem; - gap: 0.22rem; - overflow: hidden; - border-radius: var(--launcher-radius-circle); - background: rgba(255, 255, 255, 0.06); - padding: 0.25rem; -} - -.service-media-file-control { - display: flex; - min-width: 0; - height: 100%; - align-items: center; - gap: 0.7rem; - padding-left: 0.15rem; -} - -.admin-file-picker-input, -.service-media-hidden-input { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; -} - -.service-media-file-button { - display: inline-flex; - min-height: 2.78rem; - align-items: center; - justify-content: center; - border-radius: var(--launcher-radius-circle); - background: rgba(255, 255, 255, 0.92); - color: rgba(8, 8, 10, 0.96); - padding: 0 1rem; - font-size: 0.78rem; - font-weight: 850; - text-transform: none; - white-space: nowrap; - cursor: pointer; -} - -.service-media-file-button:hover { - background: #fff; -} - -.service-media-file-name { - min-width: 0; - overflow: hidden; - color: var(--text-muted); - font-size: 0.78rem; - font-weight: 650; - text-overflow: ellipsis; - text-transform: none; - white-space: nowrap; -} - -.service-media-url-input { - min-width: 0; - height: 100%; - border-radius: var(--launcher-radius-circle); - background: transparent; - padding: 0 0.82rem; -} - -.service-media-source-switch { - display: inline-flex; - align-items: center; - gap: 0.12rem; -} - -.service-media-source-button { - display: grid; - width: 2.78rem; - height: 2.78rem; - place-items: center; - border: 1px solid rgba(255, 255, 255, 0.18); - border-radius: var(--launcher-radius-circle); - background: transparent; - color: rgba(255, 255, 255, 0.66); -} - -.service-media-source-button:hover { - background: rgba(255, 255, 255, 0.07); - color: var(--text-primary); -} - -.service-media-source-button[data-active="true"] { - border-color: rgba(255, 255, 255, 0.92); - background: rgba(255, 255, 255, 0.92); - color: rgba(8, 8, 10, 0.96); -} - -.service-media-preview { - position: relative; - display: grid; - width: 2.78rem; - min-width: 2.78rem; - height: 2.78rem; - place-items: center; - overflow: hidden; - border: 1px solid rgba(255, 255, 255, 0.16); - border-radius: var(--launcher-radius-circle); - background: - linear-gradient(45deg, rgba(255, 255, 255, 0.18) 25%, transparent 25% 75%, rgba(255, 255, 255, 0.18) 75%), - linear-gradient(45deg, rgba(255, 255, 255, 0.18) 25%, transparent 25% 75%, rgba(255, 255, 255, 0.18) 75%), - rgba(18, 18, 20, 0.92); - background-position: - 0 0, - 6px 6px, - 0 0; - background-size: - 12px 12px, - 12px 12px, - auto; - color: rgba(255, 255, 255, 0.62); - font-size: 0.78rem; - text-transform: none; -} - -.service-media-preview img { - position: absolute; - inset: 0.28rem; - display: block; - width: calc(100% - 0.56rem); - height: calc(100% - 0.56rem); - border-radius: inherit; - object-fit: contain; -} - -.service-media-preview video { - position: absolute; - inset: 0; - display: block; - width: 100%; - height: 100%; - border-radius: inherit; - object-fit: cover; -} - -.service-media-preview.is-solid-dark { - border-color: rgba(255, 255, 255, 0.34); - box-shadow: - inset 0 0 0 1px rgba(255, 255, 255, 0.08), - 0 0 0 1px rgba(255, 255, 255, 0.08); -} - -.media-upload-hint { - font-size: 0.72rem; - font-weight: 720; - line-height: 1.32; - text-transform: none; -} - -.media-upload-hint:empty { - display: none; -} - -.media-upload-hint:not(:empty) { - color: rgba(255, 228, 170, 0.9); -} - -.media-upload-error { - color: #ffd2d2; - font-size: 0.72rem; - font-weight: 750; - text-transform: none; -} - -.json-details { - padding: 1rem; -} - -.json-details summary { - cursor: pointer; - color: var(--text-secondary); - font-size: 0.86rem; - font-weight: 800; -} - -.json-field { - display: grid; - gap: 0.7rem; - margin-top: 1rem; - color: var(--text-muted); - font-size: 0.82rem; - font-weight: 720; -} - -.json-field textarea { - min-height: 24rem; - font-family: "SFMono-Regular", Consolas, monospace; - font-size: 0.8rem; - line-height: 1.45; -} - -.knowledge-editor { - display: grid; - min-height: 0; - overflow: auto; - gap: 1rem; - padding: 0 0.2rem 5rem; - scrollbar-color: rgba(255, 255, 255, 0.25) transparent; -} - -.knowledge-tree { - display: grid; - gap: 0.28rem; - padding-inline: 0.35rem; -} - -.knowledge-tree-item, -.knowledge-tree-children { - display: grid; - gap: 0.22rem; -} - -.knowledge-tree-row { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - min-height: 3.1rem; - align-items: center; - gap: 0.28rem; - border-radius: var(--launcher-radius-circle); - background: rgba(255, 255, 255, 0.035); - padding: 0.24rem 0.34rem 0.24rem calc(0.34rem + (var(--depth, 0) * 0.82rem)); - opacity: 0.86; -} - -.knowledge-tree-row[data-active="true"] { - background: rgba(255, 255, 255, 0.14); - opacity: 1; -} - -.knowledge-tree-row[data-disabled="true"] { - opacity: 0.42; -} - -.knowledge-tree-select { - display: grid; - grid-template-columns: 2.2rem minmax(0, 1fr); - min-width: 0; - min-height: 2.55rem; - align-items: center; - gap: 0.5rem; - border-radius: var(--launcher-radius-circle); - background: transparent; - color: var(--text-secondary); - padding: 0; - text-align: left; -} - -.knowledge-tree-icon { - display: grid; - width: 2.2rem; - height: 2.2rem; - place-items: center; - border-radius: var(--launcher-radius-circle); - background: rgba(255, 255, 255, 0.055); - color: var(--text-muted); - font-size: 0.72rem; - font-weight: 850; -} - -.knowledge-tree-copy { - display: grid; - min-width: 0; - gap: 0.1rem; -} - -.knowledge-tree-title, -.knowledge-tree-meta { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.knowledge-tree-title { - color: var(--text-primary); - font-size: 0.82rem; - font-weight: 850; -} - -.knowledge-tree-meta { - color: var(--text-muted); - font-size: 0.68rem; - font-weight: 720; -} - -.knowledge-tree-actions { - display: inline-flex; - gap: 0.18rem; - opacity: 0; - transition: opacity 160ms ease; -} - -.knowledge-tree-row:hover .knowledge-tree-actions, -.knowledge-tree-row[data-active="true"] .knowledge-tree-actions { - opacity: 1; -} - -.knowledge-tree-action { - display: grid; - width: 2rem; - height: 2rem; - place-items: center; - border-radius: var(--launcher-radius-circle); - background: rgba(255, 255, 255, 0.07); - color: var(--text-secondary); - padding: 0; - font-size: 0.82rem; -} - -.knowledge-tree-action:hover { - background: rgba(255, 255, 255, 0.14); - color: var(--text-primary); -} - -.knowledge-tree-action .trash-can-icon { - width: 0.92rem; - height: 0.92rem; -} - -.knowledge-empty-tree { - margin-inline: calc(var(--admin-nav-pad) + 0.35rem); -} - -.knowledge-rich-panel { - gap: 0.8rem; -} - -.knowledge-rich-toolbar { - display: flex; - flex-wrap: wrap; - gap: 0.35rem; -} - -.knowledge-rich-toolbar button { - min-height: 2.15rem; - border-radius: var(--launcher-radius-circle); - background: rgba(255, 255, 255, 0.075); - color: var(--text-secondary); - padding: 0 0.72rem; - font-size: 0.72rem; - font-weight: 850; -} - -.knowledge-rich-toolbar button:hover { - background: rgba(255, 255, 255, 0.14); - color: var(--text-primary); -} - -.knowledge-rich-editor { - min-height: 28rem; - overflow: auto; - border-radius: var(--launcher-radius-card); - background: rgba(255, 255, 255, 0.04); - color: var(--text-secondary); - padding: 1.1rem; - font-size: 1rem; - line-height: 1.55; - outline: none; -} - -.knowledge-rich-editor:focus { - background: rgba(255, 255, 255, 0.06); -} - -.knowledge-rich-editor h2, -.knowledge-rich-editor h3, -.knowledge-rich-editor strong { - color: var(--text-primary); -} - -.knowledge-rich-editor h2 { - margin: 1.4rem 0 0.6rem; - font-size: 1.85rem; - line-height: 1.1; -} - -.knowledge-rich-editor h3 { - margin: 1.2rem 0 0.55rem; - font-size: 1.25rem; -} - -.knowledge-rich-editor p { - margin: 0 0 1rem; -} - -.knowledge-rich-editor img { - display: block; - max-width: 100%; - height: auto; - border-radius: 0.65rem; - margin: 1rem 0; -} - -.knowledge-rich-editor .knowledge-button { - display: inline-flex; - min-height: 2.75rem; - align-items: center; - justify-content: center; - border-radius: 999px; - background: rgba(255, 255, 255, 0.92); - color: rgba(8, 8, 10, 0.96); - padding: 0 1.25rem; - font-size: 0.88rem; - font-weight: 850; - text-decoration: none; -} - -.status { - min-height: 1.25rem; - margin: 0.8rem 0.35rem 0; - overflow: hidden; - font-size: 0.78rem; - font-weight: 650; - line-height: 1.35; - text-overflow: ellipsis; - white-space: pre-wrap; -} - -.template-modal-layer { - position: fixed; - z-index: 200; - inset: 0; - display: grid; - place-items: center; - background: rgba(0, 0, 0, 0.38); - padding: 1.4rem; - backdrop-filter: blur(22px) saturate(1.08); - -webkit-backdrop-filter: blur(22px) saturate(1.08); -} - -.template-modal { - position: relative; - display: grid; - width: min(58rem, calc(100vw - 2.8rem)); - max-height: min(46rem, calc(100vh - 2.8rem)); - overflow: hidden; - border: 1px solid var(--nodedc-glass-outline); - border-radius: var(--launcher-radius-modal); - background: var(--nodedc-glass-panel-surface-strong); - box-shadow: - var(--nodedc-glass-panel-shadow), - 0 46px 140px rgba(0, 0, 0, 0.62); - backdrop-filter: var(--nodedc-glass-filter); - -webkit-backdrop-filter: var(--nodedc-glass-filter); -} - -.template-modal::before { - position: absolute; - inset: 0; - border-radius: inherit; - background: - radial-gradient(circle at 22% 0%, rgba(255, 255, 255, 0.14), transparent 32%), - linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.01)); - content: ""; - pointer-events: none; -} - -.template-modal > * { - position: relative; - z-index: 1; -} - -.template-modal-head, -.template-modal-foot { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - padding: 1.25rem; -} - -.template-modal-head { - padding-right: 4.5rem; -} - -.template-modal-head h2 { - font-size: 1.18rem; - line-height: 1.1; -} - -.template-modal-head p { - margin-top: 0.4rem; - color: var(--text-muted); - font-size: 0.82rem; - font-weight: 650; - line-height: 1.38; -} - -.modal-close-button { - top: 1.05rem; - right: 1.05rem; -} - -.template-list { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.75rem; - min-height: 0; - overflow: auto; - padding: 0 1.25rem 1.25rem; -} - -.template-card { - display: grid; - grid-template-columns: var(--admin-control-ring) minmax(0, 1fr); - align-items: center; - gap: 0.86rem; - min-height: 4.8rem; - border-radius: var(--launcher-radius-card); - background: rgba(255, 255, 255, 0.055); - padding: 0.7rem; - color: var(--text-secondary); - text-align: left; -} - -.template-card:hover { - background: rgba(255, 255, 255, 0.095); - color: var(--text-primary); - transform: translateY(-1px); -} - -.template-card-icon { - display: grid; - width: var(--admin-control-ring); - height: var(--admin-control-ring); - place-items: center; - border-radius: var(--launcher-radius-circle); - background: rgba(255, 255, 255, 0.92); - color: rgba(8, 8, 10, 0.96); - font-size: 0.72rem; - font-weight: 850; -} - -.template-card-copy { - display: grid; - min-width: 0; - gap: 0.18rem; -} - -.template-card-title { - overflow: hidden; - font-size: 0.9rem; - font-weight: 820; - text-overflow: ellipsis; - white-space: nowrap; -} - -.template-card-meta, -.template-card-region { - overflow: hidden; - color: var(--text-muted); - font-size: 0.72rem; - font-weight: 650; - text-overflow: ellipsis; - white-space: nowrap; -} - -.template-modal-foot { - justify-content: flex-start; - padding-top: 0; -} - -.delete-modal-layer { - z-index: 240; -} - -.delete-modal { - width: min(30rem, calc(100vw - 2.8rem)); -} - -.delete-modal-head { - padding-right: 1.25rem; -} - -.delete-modal-foot { - justify-content: flex-end; -} - -.delete-confirm-btn { - min-width: 4.4rem; - background: rgba(255, 255, 255, 0.92); - color: rgba(8, 8, 10, 0.96); -} - -.delete-confirm-btn:hover { - background: #fff; - color: rgba(8, 8, 10, 1); -} - -.media-modal-layer { - z-index: 230; -} - -.media-modal { - width: min(82rem, calc(100vw - 2.8rem)); - height: min(52rem, calc(100vh - 2.8rem)); - grid-template-rows: auto auto minmax(0, 1fr) auto; -} - -.media-modal-head { - padding-bottom: 0.85rem; -} - -.media-modal-actions { - position: absolute; - top: 1.05rem; - right: 1.05rem; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.media-site-size { - display: inline-flex; - min-height: 2.8rem; - align-items: center; - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: var(--launcher-radius-circle); - background: rgba(255, 255, 255, 0.045); - color: var(--text-muted); - padding: 0 0.9rem; - font-size: 0.68rem; - font-weight: 850; - letter-spacing: 0.04em; - text-transform: uppercase; - white-space: nowrap; -} - -.media-header-action { - position: static; -} - -.media-header-action svg, -.media-delete-btn svg { - width: 1.05rem; - height: 1.05rem; - fill: none; - stroke: currentColor; - stroke-linecap: round; - stroke-linejoin: round; - stroke-width: 1.65; -} - -.media-trash-toggle[data-active="true"] { - border-color: rgba(255, 255, 255, 0.92); - background: rgba(255, 255, 255, 0.92); - color: rgba(8, 8, 10, 0.96); -} - -.media-toolbar { - position: relative; - z-index: 1; - display: grid; - grid-template-columns: minmax(15rem, 1fr) auto auto auto; - gap: 0.7rem; - align-items: end; - padding: 0 1.25rem 1rem; -} - -.media-search { - display: grid; - gap: 0.38rem; - color: var(--text-muted); - font-size: 0.68rem; - font-weight: 850; - letter-spacing: 0.04em; - text-transform: uppercase; -} - -.media-search input { - min-height: var(--nodedc-shell-control-height); - border-radius: var(--launcher-radius-circle); - padding-inline: 1rem; -} - -.media-filter-row { - display: inline-flex; - max-width: 24rem; - align-items: center; - gap: 0.25rem; - overflow-x: auto; - border-radius: var(--launcher-radius-circle); - background: rgba(255, 255, 255, 0.055); - padding: 0.24rem; - scrollbar-width: none; -} - -.media-filter-row::-webkit-scrollbar { - display: none; -} - -.media-filter-button { - display: inline-flex; - min-height: 2.3rem; - align-items: center; - justify-content: center; - border-radius: var(--launcher-radius-circle); - background: transparent; - color: var(--text-muted); - padding: 0 0.78rem; - font-size: 0.72rem; - font-weight: 850; - white-space: nowrap; -} - -.media-filter-button:disabled { - opacity: 0.45; - pointer-events: none; -} - -.media-filter-button:hover { - background: rgba(255, 255, 255, 0.07); - color: var(--text-primary); -} - -.media-filter-button[data-active="true"] { - background: rgba(255, 255, 255, 0.92); - color: rgba(8, 8, 10, 0.96); -} - -.media-upload-btn { - white-space: nowrap; -} - -.media-browser { - position: relative; - z-index: 1; - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(19rem, 0.32fr); - gap: 1rem; - min-height: 0; - padding: 0 1.25rem 1rem; -} - -.media-browser[data-drag-active="true"]::after { - position: absolute; - inset: 0 1.25rem 1rem; - display: grid; - place-items: center; - border: 1px dashed rgba(255, 255, 255, 0.38); - border-radius: var(--launcher-radius-card); - background: rgba(8, 8, 10, 0.72); - color: var(--text-primary); - content: "Отпустите файл, чтобы загрузить"; - font-size: 1rem; - font-weight: 850; - pointer-events: none; -} - -.media-main, -.media-preview-panel { - min-width: 0; - min-height: 0; - border-radius: var(--launcher-radius-card); - background: rgba(255, 255, 255, 0.04); -} - -.media-main { - position: relative; - display: grid; - grid-template-rows: auto minmax(0, 1fr); - overflow: hidden; -} - -.media-breadcrumbs { - display: flex; - min-width: 0; - align-items: center; - gap: 0.25rem; - overflow-x: auto; - border-bottom: 1px solid rgba(255, 255, 255, 0.06); - padding: 0.62rem 0.78rem; - scrollbar-width: none; -} - -.media-breadcrumbs::-webkit-scrollbar { - display: none; -} - -.media-breadcrumb-button { - display: inline-flex; - min-height: 2rem; - align-items: center; - border-radius: var(--launcher-radius-circle); - background: rgba(255, 255, 255, 0.06); - color: var(--text-muted); - padding: 0 0.68rem; - font-size: 0.7rem; - font-weight: 850; - white-space: nowrap; -} - -.media-breadcrumb-button::after { - margin-left: 0.5rem; - color: rgba(255, 255, 255, 0.28); - content: "›"; -} - -.media-breadcrumb-button[data-active="true"] { - background: rgba(255, 255, 255, 0.92); - color: rgba(8, 8, 10, 0.96); -} - -.media-breadcrumb-button[data-active="true"]::after { - content: ""; - margin: 0; -} - -.media-columns { - display: flex; - min-height: 0; - overflow: auto; - padding: 0.72rem; - scroll-padding-inline: 0.72rem; -} - -.media-column { - display: grid; - grid-template-rows: auto minmax(0, 1fr); - width: clamp(14rem, 24vw, 19rem); - min-width: clamp(14rem, 24vw, 19rem); - min-height: 0; - overflow: hidden; - border-right: 1px solid rgba(255, 255, 255, 0.06); - padding-right: 0.62rem; - margin-right: 0.62rem; -} - -.media-column:last-child { - border-right: 0; - margin-right: 0; -} - -.media-column-head { - display: flex; - min-width: 0; - align-items: center; - justify-content: space-between; - gap: 0.65rem; - padding: 0.18rem 0.1rem 0.58rem; -} - -.media-column-title { - overflow: hidden; - color: var(--text-primary); - font-size: 0.78rem; - font-weight: 850; - text-overflow: ellipsis; - white-space: nowrap; -} - -.media-column-count { - display: inline-grid; - min-width: 1.65rem; - height: 1.65rem; - place-items: center; - border-radius: 999px; - background: rgba(255, 255, 255, 0.07); - color: var(--text-muted); - font-size: 0.68rem; - font-weight: 850; -} - -.media-column-list { - display: grid; - align-content: start; - gap: 0.22rem; - min-height: 0; - overflow: auto; - padding-right: 0.12rem; - padding-bottom: 0.45rem; - scroll-padding-block: 0.5rem; - scrollbar-width: thin; -} - -.media-column-list[data-drop-active="true"] { - border-radius: var(--launcher-radius-control); - outline: 1px solid rgba(255, 255, 255, 0.34); - outline-offset: -1px; -} - -.media-column-empty { - color: var(--text-muted); - font-size: 0.76rem; - font-weight: 720; - padding: 0.8rem 0.25rem; -} - -.media-empty { - position: absolute; - inset: 0; - display: grid; - place-items: center; - color: var(--text-muted); - font-size: 0.92rem; - font-weight: 720; -} - -.media-entry { - display: grid; - grid-template-columns: 3rem minmax(0, 1fr) auto auto; - min-width: 0; - min-height: 3.35rem; - align-items: center; - gap: 0.58rem; - border: 0; - border-radius: var(--launcher-radius-control); - background: rgba(255, 255, 255, 0.038); - padding: 0.28rem 0.5rem 0.28rem 0.28rem; - color: var(--text-secondary); - text-align: left; -} - -.media-entry:hover { - background: rgba(255, 255, 255, 0.075); - color: var(--text-primary); -} - -.media-entry[data-active="true"] { - background: rgba(255, 255, 255, 0.14); - color: var(--text-primary); -} - -.media-entry[data-dragging="true"] { - opacity: 0.48; -} - -.media-entry[data-drop-active="true"] { - background: rgba(255, 255, 255, 0.16); - outline: 1px solid rgba(255, 255, 255, 0.38); - outline-offset: -1px; -} - -.media-entry-icon { - display: grid; - width: 3rem; - height: 3rem; - place-items: center; - border-radius: 0.82rem; - background: rgba(255, 255, 255, 0.07); - color: rgba(255, 255, 255, 0.52); - font-size: 0.64rem; - font-weight: 850; - overflow: hidden; -} - -.media-entry[data-type="directory"] .media-entry-icon { - background: rgba(255, 255, 255, 0.12); - color: rgba(255, 255, 255, 0.76); -} - -.media-entry-icon.is-preview { - background: rgba(255, 255, 255, 0.1); -} - -.media-entry-icon.is-preview img, -.media-entry-icon.is-preview video { - display: block; - width: 100%; - height: 100%; - object-fit: cover; -} - -.media-entry-copy { - display: grid; - min-width: 0; - gap: 0.1rem; -} - -.media-entry-name, -.media-entry-meta { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.media-entry-name { - color: var(--text-primary); - font-size: 0.78rem; - font-weight: 820; -} - -.media-entry-meta { - color: var(--text-muted); - font-size: 0.64rem; - font-weight: 680; -} - -.media-entry-status { - position: static; - flex: 0 0 auto; -} - -.media-entry-arrow { - color: var(--text-muted); - font-size: 1.25rem; - line-height: 1; -} - -.media-preview img, -.media-preview video { - display: block; - width: 100%; - height: 100%; - object-fit: contain; -} - -.media-preview video { - object-fit: cover; -} - -.media-status-dot, -.media-usage-dot { - display: block; - width: 0.58rem; - height: 0.58rem; - border-radius: 50%; - background: rgba(255, 255, 255, 0.36); - box-shadow: 0 0 0 0.22rem rgba(255, 255, 255, 0.06); -} - -.media-status-dot { - position: absolute; - top: 0.48rem; - right: 0.48rem; -} - -.media-status-dot.media-entry-status { - position: static; - top: auto; - right: auto; -} - -.media-status-dot[data-status="rendered"], -.media-usage-pill[data-status="rendered"] .media-usage-dot { - background: #58d77a; - box-shadow: 0 0 0 0.22rem rgba(88, 215, 122, 0.16); -} - -.media-status-dot[data-status="referenced"], -.media-usage-pill[data-status="referenced"] .media-usage-dot { - background: #ffd36a; - box-shadow: 0 0 0 0.22rem rgba(255, 211, 106, 0.16); -} - -.media-status-dot[data-status="unused"], -.media-usage-pill[data-status="unused"] .media-usage-dot { - background: rgba(255, 255, 255, 0.34); -} - -.media-status-dot[data-status="trash"], -.media-usage-pill[data-status="trash"] .media-usage-dot { - background: #8aa4ff; - box-shadow: 0 0 0 0.22rem rgba(138, 164, 255, 0.16); -} - -.media-preview-name, -.media-preview-path, -.media-preview-meta { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.media-preview-panel { - position: relative; - display: grid; - grid-template-rows: auto auto auto 1fr; - align-content: stretch; - gap: 0.9rem; - padding: 0.8rem; - padding-bottom: calc(var(--nodedc-shell-control-height) + 1rem); -} - -.media-preview { - display: grid; - aspect-ratio: 1.25; - width: 100%; - place-items: center; - overflow: hidden; - border-radius: var(--launcher-radius-control); - background: rgba(255, 255, 255, 0.05); - color: var(--text-muted); - font-size: 0.86rem; - font-weight: 850; -} - -.media-preview-copy { - display: grid; - gap: 0.22rem; - min-width: 0; -} - -.media-preview-name { - color: var(--text-primary); - font-size: 0.92rem; - font-weight: 850; -} - -.media-preview-path, -.media-preview-meta { - color: var(--text-muted); - font-size: 0.72rem; - font-weight: 680; -} - -.media-usage-pill { - display: inline-flex; - width: fit-content; - max-width: 100%; - align-items: center; - gap: 0.48rem; - border-radius: var(--launcher-radius-circle); - background: rgba(255, 255, 255, 0.07); - color: var(--text-secondary); - padding: 0.48rem 0.68rem; - font-size: 0.72rem; - font-weight: 850; -} - -.media-preview-actions { - display: flex; - align-items: end; - gap: 0.55rem; - align-self: end; - flex-wrap: wrap; - padding-right: calc(var(--admin-control-ring) + 0.65rem); -} - -.media-delete-btn { - position: absolute; - right: 0.8rem; - bottom: 0.8rem; - display: inline-grid; - width: var(--admin-control-ring); - min-width: var(--admin-control-ring); - height: var(--admin-control-ring); - place-items: center; - border: 1px solid rgba(255, 255, 255, 0.22); - border-radius: var(--launcher-radius-circle); - background: transparent; - color: rgba(255, 255, 255, 0.8); - padding: 0; - line-height: 1; -} - -.media-delete-btn:hover { - border-color: rgba(255, 255, 255, 0.3); - background: rgba(255, 255, 255, 0.07); - color: var(--text-primary); -} - -.media-delete-btn:disabled { - display: none; -} - -.media-context-menu { - position: fixed; - z-index: 320; - display: grid; - min-width: 12rem; - overflow: hidden; - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: var(--launcher-radius-control); - background: rgba(34, 34, 38, 0.94); - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.42); - padding: 0.35rem; - backdrop-filter: blur(22px) saturate(1.15); - -webkit-backdrop-filter: blur(22px) saturate(1.15); -} - -.media-context-menu.hidden { - display: none; -} - -.media-context-menu button { - display: flex; - min-height: 2.25rem; - align-items: center; - border-radius: calc(var(--launcher-radius-control) - 0.18rem); - background: transparent; - color: var(--text-secondary); - padding: 0 0.72rem; - font-size: 0.76rem; - font-weight: 780; - text-align: left; -} - -.media-context-menu button:hover { - background: rgba(255, 255, 255, 0.09); - color: var(--text-primary); -} - -.media-context-menu button:disabled { - opacity: 0.38; - pointer-events: none; -} - -.media-action-modal-layer { - z-index: 300; -} - -.media-action-modal { - width: min(28rem, calc(100vw - 2.8rem)); -} - -.knowledge-button-modal-layer { - z-index: 300; -} - -.knowledge-button-modal { - width: min(34rem, calc(100vw - 2.8rem)); -} - -.media-action-head { - padding-right: 1.25rem; -} - -.knowledge-button-head { - padding-right: 4.5rem; -} - -.media-action-body { - padding: 0 1.25rem 1.25rem; -} - -.knowledge-button-body { - display: grid; - gap: 0.9rem; - padding: 0 1.25rem 1.25rem; -} - -.knowledge-button-hint, -.knowledge-button-error { - margin: 0; - font-size: 0.78rem; - font-weight: 650; - line-height: 1.4; -} - -.knowledge-button-hint { - color: var(--text-muted); -} - -.knowledge-button-error { - min-height: 1.1rem; - color: rgba(255, 123, 108, 0.95); -} - -.media-action-foot { - justify-content: flex-end; -} - -.knowledge-button-foot { - justify-content: flex-end; -} - -.media-modal-foot { - justify-content: space-between; -} - -@media (max-width: 1100px) { - body { - overflow: auto; - } - - .nodedc-expanded-toolbar-top { - grid-template-columns: 1fr; - justify-items: center; - } - - .nodedc-expanded-toolbar-center { - width: 100%; - justify-content: flex-start; - justify-self: stretch; - overflow-x: auto; - } - - .nodedc-expanded-nav-group { - max-width: 100%; - overflow-x: auto; - } - - .nodedc-expanded-toolbar-left, - .nodedc-expanded-toolbar-right { - display: none; - } - - .admin-shell { - grid-template-columns: 1fr; - width: min(100vw - 1.5rem, 58rem); - height: auto; - min-height: calc(100vh - 6rem); - } - - .sidebar { - max-height: 38vh; - } - - .editor { - min-height: 62vh; - } - - .editor-head { - align-items: flex-start; - flex-direction: column; - } - - .actions { - justify-content: flex-start; - } - - .template-list { - grid-template-columns: 1fr; - } - - .media-toolbar { - grid-template-columns: 1fr; - align-items: stretch; - } - - .media-filter-row { - max-width: 100%; - } - - .media-browser { - grid-template-columns: 1fr; - } - - .seo-metric-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .media-preview-panel { - grid-template-columns: 9rem minmax(0, 1fr); - grid-template-rows: auto auto; - } - - .media-preview-actions, - .media-usage-pill { - grid-column: 2; - } -} - -@media (max-width: 720px) { - .nodedc-expanded-toolbar-shell { - width: 100%; - padding-inline: 0.75rem; - } - - .nodedc-expanded-toolbar-center { - display: grid; - grid-template-columns: 3rem minmax(0, 1fr); - gap: 0.55rem; - overflow: visible; - } - - .nodedc-expanded-nav-group { - display: grid; - width: 100%; - grid-template-columns: repeat(2, minmax(0, 1fr)); - overflow: visible; - } - - .nodedc-expanded-nav-button { - min-width: 0; - padding: 0.2rem 0.45rem; - font-size: 0.66rem; - } - - .admin-shell { - width: 100vw; - min-height: calc(100vh - 5.25rem); - margin: 0; - gap: 0.75rem; - padding: 0 0.75rem 0.75rem; - } - - .field-grid, - .group-grid, - .seo-metric-grid { - grid-template-columns: 1fr; - } - - .panel-head, - .card-head, - .group-head { - align-items: flex-start; - flex-direction: column; - } - - .structure-head { - align-items: flex-start; - } - - .icon-action-row { - justify-content: flex-start; - } - - .panel-note { - text-align: left; - } - - .service-media-control { - grid-template-columns: minmax(0, 1fr) auto; - } - - .service-media-preview { - display: none; - } - - .media-modal { - width: 100vw; - height: 100vh; - max-height: 100vh; - border-radius: 0; - } - - .media-columns { - padding: 0.55rem; - } - - .media-preview-panel { - grid-template-columns: 1fr; - } - - .media-preview-actions, - .media-usage-pill { - grid-column: auto; - } -} - -@media (max-width: 480px) { - .nodedc-expanded-toolbar-center { - grid-template-columns: 1fr; - } - - .nodedc-expanded-workspace-button { - display: none; - } - - .nodedc-expanded-nav-button { - padding-inline: 0.24rem; - font-size: 0.61rem; - } -} diff --git a/admin/admin.js b/admin/admin.js deleted file mode 100644 index f894c0d..0000000 --- a/admin/admin.js +++ /dev/null @@ -1,5442 +0,0 @@ -const ADMIN_LAYOUT_STORAGE_KEY = "nodedc-admin-layout-v1"; -const KNOWLEDGE_ROOT_ID = "__knowledge-root__"; - -function readAdminLayoutState() { - try { - return JSON.parse(localStorage.getItem(ADMIN_LAYOUT_STORAGE_KEY) || "{}"); - } catch { - return {}; - } -} - -function writeAdminLayoutState(nextLayout) { - try { - localStorage.setItem(ADMIN_LAYOUT_STORAGE_KEY, JSON.stringify(nextLayout)); - } catch { - // Storage can be unavailable in private or restricted browser contexts. - } -} - -function updateAdminLayoutState(patch) { - writeAdminLayoutState({ - ...readAdminLayoutState(), - ...patch, - }); -} - -function readCollectionOpenState() { - const collections = readAdminLayoutState().collections || {}; - return new Map( - Object.entries(collections).map(([key, value]) => [key, new Set(Array.isArray(value) ? value : [])]), - ); -} - -function readLastMediaDirectory() { - return String(readAdminLayoutState().lastMediaDirectory || "").replace(/^\/+/, ""); -} - -function readAdminMode() { - const mode = readAdminLayoutState().mode; - return mode === "seo" || mode === "knowledge" ? mode : "content"; -} - -const state = { - mode: readAdminMode(), - page: null, - knowledge: null, - knowledgeSelectedId: null, - knowledgeSavedSelection: null, - knowledgeOpenIds: new Set(readAdminLayoutState().knowledgeOpenIds || []), - templates: [], - selected: null, - clipboard: null, - drag: null, - dragTarget: null, - dirty: false, - filePickerActive: false, - mediaUploadActive: false, - filePickerResetTimer: null, - deleteConfirmResolve: null, - mediaActionResolve: null, - knowledgeButtonResolve: null, - collectionOpenState: readCollectionOpenState(), - mediaLibrary: { - columns: [], - context: null, - contextMenu: null, - trashMode: false, - previousPath: "", - selectedPath: "", - selectedEntry: null, - selectedUrl: "", - root: "", - kind: "all", - query: "", - loaded: false, - dragEntryPath: "", - lastPath: readLastMediaDirectory(), - }, -}; - -const SECTIONS_REGION_ID = "sections"; -const SECTIONS_REGION_LABEL = "Секции"; - -const PAGE_SEO_DEFAULTS = { - title: "NODE.DC - платформа для процессов, данных и ИИ-агентов", - description: - "NODE.DC объединяет Hub, Engine, Ops, прикладные модули и сквозного ассистента для задач, workflow, данных, ролей, интеграций и инженерных моделей.", - canonicalUrl: "https://nodedc.dctouch.ru/", - robots: "index,follow,max-image-preview:large", - siteName: "NODE.DC", - locale: "ru_RU", - ogImage: "https://nodedc.dctouch.ru/assets/site/images/69b6acdde3123ef078e2b7f3_og-img.jpg", - imageAlt: "NODE.DC - платформа для процессов, данных и ИИ-агентов", - organizationName: "DCTOUCH", - organizationUrl: "https://dctouch.ru/", - applicationCategory: "BusinessApplication", -}; - -const BLOCK_SEO_DEFAULTS = { - targetCluster: "", - searchIntent: "", - primaryQuery: "", - secondaryQueries: "", - title: "", - description: "", - coverageNotes: "", - indexHint: "section", -}; - -const PAGE_SEO_FIELDS = [ - { path: "seo.title", label: "Title страницы", kind: "text", group: "Сниппет", min: 35, max: 70 }, - { path: "seo.description", label: "Description страницы", kind: "textarea", group: "Сниппет", min: 110, max: 170 }, - { path: "seo.canonicalUrl", label: "Canonical URL", kind: "url", group: "Индексация" }, - { - path: "seo.robots", - label: "Robots meta", - kind: "select", - group: "Индексация", - options: ["index,follow,max-image-preview:large", "noindex,nofollow", "index,follow"], - }, - { path: "seo.siteName", label: "Site name", kind: "text", group: "Open Graph" }, - { path: "seo.locale", label: "Locale", kind: "text", group: "Open Graph" }, - { path: "seo.ogImage", label: "OG image", kind: "url", group: "Open Graph" }, - { path: "seo.imageAlt", label: "OG image alt", kind: "textarea", group: "Open Graph", min: 20, max: 120 }, - { path: "seo.organizationName", label: "Organization", kind: "text", group: "Schema.org" }, - { path: "seo.organizationUrl", label: "Organization URL", kind: "url", group: "Schema.org" }, - { path: "seo.applicationCategory", label: "Application category", kind: "text", group: "Schema.org" }, -]; - -const BLOCK_SEO_FIELDS = [ - { path: "seo.targetCluster", label: "Кластер", kind: "text", group: "Семантика" }, - { path: "seo.searchIntent", label: "Интент", kind: "textarea", group: "Семантика", min: 20, max: 220 }, - { path: "seo.primaryQuery", label: "Основной запрос", kind: "text", group: "Семантика" }, - { path: "seo.secondaryQueries", label: "Смежные запросы", kind: "textarea", group: "Семантика" }, - { path: "seo.title", label: "SEO-заголовок блока", kind: "text", group: "Сниппет блока", min: 35, max: 80 }, - { path: "seo.description", label: "SEO-описание блока", kind: "textarea", group: "Сниппет блока", min: 90, max: 180 }, - { - path: "seo.indexHint", - label: "Роль в индексации", - kind: "select", - group: "Индексация", - options: ["section", "supporting", "conversion", "noindex"], - }, - { path: "seo.coverageNotes", label: "Заметки по охвату", kind: "textarea", group: "Аналитика", min: 20, max: 500 }, -]; - -const el = { - modeContent: document.querySelector("#admin-mode-content"), - modeSeo: document.querySelector("#admin-mode-seo"), - modeKnowledge: document.querySelector("#admin-mode-knowledge"), - sidebarTitle: document.querySelector("#sidebar-title"), - workspaceRole: document.querySelector("#workspace-role"), - editorHeading: document.querySelector("#editor-heading"), - panelKicker: document.querySelector("#content-panel-kicker"), - panelTitle: document.querySelector("#content-panel-title"), - panelNote: document.querySelector("#content-panel-note"), - regions: document.querySelector("#regions"), - addBlock: document.querySelector("#add-block"), - reload: document.querySelector("#reload"), - save: document.querySelector("#save"), - render: document.querySelector("#render"), - dirtyState: document.querySelector("#dirty-state"), - status: document.querySelector("#status"), - empty: document.querySelector("#empty-state"), - form: document.querySelector("#block-form"), - knowledgeForm: document.querySelector("#knowledge-form"), - selectedRegion: document.querySelector("#selected-region"), - selectedTitle: document.querySelector("#selected-title"), - id: document.querySelector("#block-id"), - label: document.querySelector("#block-label"), - template: document.querySelector("#block-template"), - anchor: document.querySelector("#block-anchor"), - targetOptions: document.querySelector("#block-target-options"), - enabled: document.querySelector("#block-enabled"), - contentFields: document.querySelector("#content-fields"), - json: document.querySelector("#block-json"), - moveUp: document.querySelector("#move-up"), - moveDown: document.querySelector("#move-down"), - duplicate: document.querySelector("#duplicate"), - copy: document.querySelector("#copy"), - pasteAfter: document.querySelector("#paste-after"), - deleteBlock: document.querySelector("#delete-block"), - templateModal: document.querySelector("#template-modal"), - templateList: document.querySelector("#template-list"), - templateModalClose: document.querySelector("#template-modal-close"), - templateModalCancel: document.querySelector("#template-modal-cancel"), - deleteModal: document.querySelector("#delete-modal"), - deleteModalTitle: document.querySelector("#delete-modal-title"), - deleteModalBody: document.querySelector("#delete-modal-body"), - deleteModalCancel: document.querySelector("#delete-modal-cancel"), - deleteModalConfirm: document.querySelector("#delete-modal-confirm"), - mediaModal: document.querySelector("#media-modal"), - mediaModalTitle: document.querySelector("#media-modal-title"), - mediaModalSubtitle: document.querySelector("#media-modal-subtitle"), - mediaSiteSize: document.querySelector("#media-site-size"), - mediaModalClose: document.querySelector("#media-modal-close"), - mediaModalCancel: document.querySelector("#media-modal-cancel"), - mediaTrashToggle: document.querySelector("#media-trash-toggle"), - mediaSearch: document.querySelector("#media-search"), - mediaKindFilters: document.querySelector("#media-kind-filters"), - mediaRootFilters: document.querySelector("#media-root-filters"), - mediaUpload: document.querySelector("#media-upload"), - mediaRefresh: document.querySelector("#media-refresh"), - mediaDropZone: document.querySelector("#media-drop-zone"), - mediaBreadcrumbs: document.querySelector("#media-breadcrumbs"), - mediaList: document.querySelector("#media-list"), - mediaEmpty: document.querySelector("#media-empty"), - mediaPreview: document.querySelector("#media-preview"), - mediaPreviewName: document.querySelector("#media-preview-name"), - mediaPreviewPath: document.querySelector("#media-preview-path"), - mediaPreviewMeta: document.querySelector("#media-preview-meta"), - mediaPreviewUsage: document.querySelector("#media-preview-usage"), - mediaSelect: document.querySelector("#media-select"), - mediaDelete: document.querySelector("#media-delete"), - mediaContextMenu: document.querySelector("#media-context-menu"), - mediaActionModal: document.querySelector("#media-action-modal"), - mediaActionTitle: document.querySelector("#media-action-title"), - mediaActionBody: document.querySelector("#media-action-body"), - mediaActionLabel: document.querySelector("#media-action-label"), - mediaActionInput: document.querySelector("#media-action-input"), - mediaActionCancel: document.querySelector("#media-action-cancel"), - mediaActionConfirm: document.querySelector("#media-action-confirm"), - knowledgeButtonModal: document.querySelector("#knowledge-button-modal"), - knowledgeButtonClose: document.querySelector("#knowledge-button-close"), - knowledgeButtonName: document.querySelector("#knowledge-button-name"), - knowledgeButtonLink: document.querySelector("#knowledge-button-link"), - knowledgeButtonTarget: document.querySelector("#knowledge-button-target"), - knowledgeButtonTargetOptions: document.querySelector("#knowledge-button-target-options"), - knowledgeButtonError: document.querySelector("#knowledge-button-error"), - knowledgeButtonCancel: document.querySelector("#knowledge-button-cancel"), - knowledgeButtonConfirm: document.querySelector("#knowledge-button-confirm"), -}; - -const STATIC_FALLBACK = { - id: "static-elements", - type: "staticElements", - adminLabel: "Статичные элементы", - enabled: true, - content: { - navigation: { - logoSrc: "./assets/site/images/69ad964d951f9d17cc5a919e_logo-app.svg", - logoAlt: "главная", - brandLabel: "NODE.DC", - links: [ - { enabled: true, label: "О продукте", href: "", target: "index.html#platform-composition", linkMode: "target", linkTarget: "" }, - { enabled: true, label: "Лист ожидания", href: "", target: "index.html#hero", linkMode: "target", linkTarget: "" }, - { - enabled: true, - label: "Документация", - href: "https://nodedc.dctouch.ru/", - target: "", - linkMode: "href", - linkTarget: "_blank", - }, - { enabled: true, label: "Тарифы", href: "", target: "index.html#pricing-intro", linkMode: "target", linkTarget: "" }, - ], - }, - notifications: [ - { - enabled: true, - eyebrowHtml: 'ДАЛЕЕ — ИЮЛЬ 2026', - eyebrowClass: "red-txt gray", - title: "Открытая бета", - body: "Открываем доступ всем. Будем собирать и отлаживать вместе.", - }, - { - enabled: true, - eyebrowHtml: 'ДАЛЕЕ ИЮЛЬ 2026', - eyebrowClass: "red-txt gray", - title: "Альфа для Founder-доступа", - body: "На второй фазе тестирования бета открыта для всех подписчиков Founder-тарифа.", - }, - { - enabled: true, - eyebrowHtml: "СЕЙЧАС", - eyebrowClass: "red-txt", - title: "Инвайты в закрытую бету уже отправлены.", - body: "Сейчас тестируем базовую функциональность и ИИ-интеграцию. ", - }, - ], - dock: { - items: [ - { - enabled: true, - title: "NODE.DC", - href: "javascript:void(0)", - linkTarget: "", - targetId: "app-data", - iconSrc: "./assets/site/images/69ad964d951f9d17cc5a919e_logo-app.svg", - iconAlt: "Иконка NODE.DC", - className: "dock-icon nodedc-app", - iconClass: "logo", - dividerBefore: false, - }, - { - enabled: true, - title: "Корзина", - href: "javascript:void(0)", - linkTarget: "", - targetId: "", - iconSrc: "./assets/site/images/69a9ae818a76c773dc3cf7bc_trash.avif", - iconAlt: "Белая иконка корзины.", - className: "dock-icon no-bg", - iconClass: "dock-app-img trash", - dividerBefore: true, - }, - ], - demoFileLabel: "демо-видео.txt", - }, - assets: { - webglModelSrc: "./assets/custom/icon.glb", - logoPng: "./assets/uploads/images/Logo/dclogo_white_alpha-mqus5if2.png", - logoPngAlt: "Логотип NODE.DC", - appIconSrc: "./assets/site/images/69ad964d951f9d17cc5a919e_logo-app.svg", - appIconAlt: "Иконка NODE.DC", - appWordmarkSrc: "./assets/site/images/69ad96a28b06b7552a339137_logtype.svg?v=node-dc", - appWordmarkAlt: "Логотип NODE.DC", - faviconSvg: "./assets/uploads/favicon/icon-adaptive.svg", - faviconIco: "./assets/uploads/favicon/favicon.ico", - faviconAppleTouchIcon: "./assets/uploads/favicon/apple-touch-icon.png", - faviconPng192: "./assets/uploads/favicon/icon-192.png", - faviconPng512: "./assets/uploads/favicon/icon-512.png", - faviconManifest: "./assets/uploads/favicon/manifest.webmanifest.json", - }, - }, - editableFields: [ - field("content.navigation.logoSrc", "Логотип в шапке", "media", "attr", "Шапка: логотип"), - field("content.navigation.logoAlt", "Логотип в шапке: alt", "text", "text", "Шапка: логотип"), - field("content.navigation.brandLabel", "Бренд в шапке", "text", "text", "Шапка: логотип"), - collection( - "content.navigation.links", - "Кнопки шапки", - "Шапка: ссылки", - { enabled: true, label: "Новая ссылка", href: "", target: "index.html#", linkMode: "target", linkTarget: "" }, - [ - field("label", "Текст кнопки", "text", "text", "Шапка: ссылки"), - field("linkMode", "Источник ссылки", "link-mode", "attr", "Шапка: ссылки"), - ], - ), - collection( - "content.notifications", - "Уведомления", - "Уведомления", - { - enabled: true, - eyebrowHtml: "СЕЙЧАС", - eyebrowClass: "red-txt gray", - title: "Новое уведомление", - body: "Текст уведомления.", - }, - [ - field("eyebrowHtml", "Дата / статус", "html", "html", "Уведомления"), - field("eyebrowClass", "CSS-класс статуса", "text", "attr", "Уведомления"), - field("title", "Заголовок", "text", "text", "Уведомления"), - field("body", "Текст", "textarea", "text", "Уведомления"), - ], - ), - collection( - "content.dock.items", - "Иконки dock-панели", - "Dock", - { - enabled: true, - title: "Новое приложение", - href: "javascript:void(0)", - linkTarget: "", - targetId: "", - iconSrc: "", - iconAlt: "", - className: "dock-icon nodedc-app", - iconClass: "", - dividerBefore: false, - }, - [ - field("enabled", "Отображение", "boolean", "boolean", "Dock"), - field("title", "Название", "text", "text", "Dock"), - field("href", "Ссылка / адрес", "url", "attr", "Dock"), - field("linkTarget", "Target ссылки", "text", "attr", "Dock"), - field("targetId", "ID окна / приложения", "text", "attr", "Dock"), - field("iconSrc", "Иконка", "media", "attr", "Dock"), - field("iconAlt", "Alt иконки", "text", "text", "Dock"), - field("dividerBefore", "Разделитель слева", "boolean", "boolean", "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", "Модель и логотипы"), - field("content.assets.faviconSvg", "Favicon SVG", "media", "attr", "Модель и логотипы"), - field("content.assets.faviconIco", "Favicon ICO", "media", "attr", "Модель и логотипы"), - field("content.assets.faviconAppleTouchIcon", "Favicon Apple 180", "media", "attr", "Модель и логотипы"), - field("content.assets.faviconPng192", "Favicon PNG 192", "media", "attr", "Модель и логотипы"), - field("content.assets.faviconPng512", "Favicon PNG 512", "media", "attr", "Модель и логотипы"), - field("content.assets.faviconManifest", "Favicon manifest", "media", "attr", "Модель и логотипы"), - ], -}; - -const GROUP_COPY = { - "Шапка: логотип": "Статичный знак верхней mac-панели: файл логотипа, alt и брендовая подпись.", - "Шапка: ссылки": "Пункты навигации верхней mac-панели. Каждый пункт можно добавить, удалить и переставить.", - Уведомления: "Карточки уведомлений справа сверху. Порядок в админке соответствует порядку на сайте.", - Dock: "Нижняя системная панель: приложения, корзина, ссылки и подписи файлов.", - "Модель и логотипы": "Базовые ассеты статичного слоя: GLB-модель, PNG/SVG-логотипы, favicon-комплект и alt-тексты.", - Заголовок: "Главный заголовок и вводный текст выбранной секции.", - "Верхний информационный блок": "Верхняя пара: длинный текст и крупный заголовок над медиаслайдером.", - "Нижний информационный блок": "Нижняя зеркальная пара: крупный заголовок и длинный текст под медиаслайдером.", - "Тезисы первого экрана": "Четыре смысловых тезиса вокруг формы раннего доступа.", - "Карточка доступа": "Единый компонент окна заявки: редактируется здесь и повторяется в нижнем блоке запроса DEMO.", - Форма: "Плейсхолдеры, кнопки, success/failure состояния формы.", - "Юридический блок": "Политики, права и нижний текст формы.", - "Видео-окно": "Окно демо-видео и подключенный медиа-файл.", - Пункты: "Текстовые пункты внутри секции.", - Слайдер: "Видео-превью и подписи карточек внутри горизонтального слайдера.", - Скролл: "Скорость локальной прокрутки внутри блока: меньше значение — спокойнее шаг, больше — быстрее.", - "Один клик": "Подблок про быстрое добавление и настройку компонентов.", - MCP: "Подблок про MCP-сервер и контекст NODE.DC.", - "Окно FAQ": "Название окна FAQ и служебные подписи mail-интерфейса.", - Отправитель: "Профиль отправителя в FAQ: аватар, имя и подписи письма.", - FAQ: "Вопросы и ответы mail-интерфейса. Каждый пункт можно добавить, удалить и переставить.", - CTA: "Финальный призыв к действию: заголовок, пояснение, кнопка и ссылка.", - "Центральный текст": "Отдельный центрированный текстовый блок: надзаголовок, крупная строка и три подписи.", - Тарифы: "Окно тарифной таблицы: планы, цены, кнопки и примечания.", - "Строки тарифов": "Повторяемые строки тарифной таблицы и значения по каждому плану.", - "Медиа-галерея": "Окна с изображениями, подписи файлов и связанные иконки.", - "Карточки скриншотов": "Скриншоты в виде окон и файлов: каждый элемент можно скрыть, переставить, дублировать или удалить.", - "TXT-файлы": "Список файлов листа ожидания: название окна, подпись и текст внутри файла.", - Футер: "Брендовый блок, копирайт и базовые ассеты футера.", - "Навигация футера": "Ссылки навигационной колонки футера. Каждый пункт можно скрыть, переставить, дублировать или удалить.", - "Контактные ссылки": "Ссылки контактной колонки футера. Каждый пункт можно скрыть, переставить, дублировать или удалить.", - Политики: "Нижняя строка политик и условия отображения.", - Контент: "Остальные typed-поля выбранного блока.", -}; - -const MEDIA_ACCEPT = - "image/*,video/*,.gif,.webm,.mov,.mp4,.m4v,.avi,.mkv,.glb,.gltf,.svg,.ico,.avif,.webp,.webmanifest,.json,.woff,.woff2"; -const MEDIA_DRAG_MIME = "application/x-nodedc-media-entry"; -const MEDIA_KIND_FILTERS = [ - ["all", "Все"], - ["image", "Изображения"], - ["video", "Видео"], - ["model", "3D"], -]; -const MEDIA_ROOT_FILTERS = [ - ["", "Проект"], - ["assets", "Assets"], - ["assets/uploads", "Uploads"], - ["assets/media", "Media"], - ["assets/site/images", "NODE.DC"], - ["assets/custom", "Custom"], -]; -const FINDER_TRASH_ICON_SVG = - ''; -const mediaFilePicker = createMediaFilePicker(); -const collectionItemRuntimeKeys = new WeakMap(); -let collectionItemRuntimeKeyCounter = 0; - -function field(path, label, kind = "text", renderAs = kind === "html" ? "html" : "text", group = null, options = {}) { - return { path, label, kind, renderAs, group: group || inferFieldGroup(path), ...options }; -} - -function collection(path, label, group, itemTemplate, itemFields) { - return { - path, - label, - kind: "collection", - renderAs: "collection", - group, - itemTemplate, - itemFields, - }; -} - -function setStatus(message) { - el.status.textContent = message; -} - -function setDirty(isDirty, message = null) { - state.dirty = isDirty; - el.dirtyState.dataset.dirty = String(isDirty); - el.dirtyState.textContent = isDirty ? "Есть несохранённые изменения" : "Синхронизировано"; - - if (message) { - setStatus(message); - } -} - -function markDirty(message = null) { - setDirty(true, message); -} - -function createMediaFilePicker() { - const input = document.createElement("input"); - input.type = "file"; - input.accept = MEDIA_ACCEPT; - input.className = "admin-file-picker-input"; - input.tabIndex = -1; - input.setAttribute("aria-hidden", "true"); - document.body.append(input); - return input; -} - -function setFilePickerActive(active) { - state.filePickerActive = active; - - if (active) { - document.body.dataset.filePickerActive = "true"; - } else { - delete document.body.dataset.filePickerActive; - } -} - -function recoverEditorSurface() { - if (!activeBlock()) return; - el.knowledgeForm.classList.add("hidden"); - el.empty.classList.add("hidden"); - el.form.classList.remove("hidden"); -} - -function scheduleFilePickerRecovery(delay = 360) { - window.clearTimeout(state.filePickerResetTimer); - state.filePickerResetTimer = window.setTimeout(() => { - if (state.mediaUploadActive) return; - setFilePickerActive(false); - recoverEditorSurface(); - }, delay); -} - -function clone(value) { - return JSON.parse(JSON.stringify(value)); -} - -function escapeHtml(value) { - return String(value ?? "") - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} - -function getByPath(source, path) { - return path.split(".").reduce((value, key) => value?.[key], source); -} - -function setByPath(source, path, nextValue) { - const keys = path.split("."); - const lastKey = keys.pop(); - let target = source; - - 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 ensurePageSeo() { - if (!state.page) return; - state.page.seo = { - ...PAGE_SEO_DEFAULTS, - ...(state.page.seo || {}), - }; -} - -function ensureBlockSeo(block) { - if (!block || isStaticSelection()) return; - block.seo = { - ...BLOCK_SEO_DEFAULTS, - ...(block.seo || {}), - }; -} - -function ensureSeoModel() { - ensurePageSeo(); - - for (const region of state.page?.regions || []) { - for (const block of region.blocks || []) { - block.seo = { - ...BLOCK_SEO_DEFAULTS, - ...(block.seo || {}), - }; - } - } -} - -function setAdminMode(nextMode) { - const mode = nextMode === "seo" || nextMode === "knowledge" ? nextMode : "content"; - state.mode = mode; - updateAdminLayoutState({ mode }); - if (mode === "knowledge" && !state.knowledge) { - loadKnowledge().catch((error) => setStatus(error.message)); - updateAdminModeSurface(); - return; - } - if (state.page) { - renderAll(); - } else { - updateAdminModeSurface(); - } -} - -function updateAdminModeSurface() { - const isSeo = state.mode === "seo"; - const isKnowledge = state.mode === "knowledge"; - document.body.dataset.adminMode = state.mode; - - if (el.modeContent) el.modeContent.dataset.active = String(!isSeo && !isKnowledge); - if (el.modeSeo) el.modeSeo.dataset.active = String(isSeo); - if (el.modeKnowledge) el.modeKnowledge.dataset.active = String(isKnowledge); - if (el.sidebarTitle) el.sidebarTitle.textContent = isKnowledge ? "База знаний" : isSeo ? "SEO" : "Администрирование"; - if (el.workspaceRole) { - el.workspaceRole.textContent = isKnowledge ? "Редактор материалов" : isSeo ? "SEO-режим сайта" : "Администратор сайта"; - } - if (el.editorHeading) el.editorHeading.textContent = isKnowledge ? "База знаний" : isSeo ? "SEO сайта" : "Настройки сайта"; - if (el.panelKicker) el.panelKicker.textContent = isKnowledge ? "Knowledge" : isSeo ? "SEO" : "Контент"; - if (el.panelTitle) el.panelTitle.textContent = isKnowledge ? "Дерево и статья" : isSeo ? "Семантика и сниппеты" : "Редактируемые поля"; - if (el.panelNote) { - el.panelNote.textContent = isKnowledge - ? "Материалы генерируются в статические SEO-страницы раздела /knowledge/." - : isSeo - ? "Поля готовят страницу и секции к семантической настройке и дальнейшей автоматизации." - : "Поля сгруппированы по смысловым подблокам верстки."; - } - if (el.addBlock) { - el.addBlock.title = isKnowledge ? "Добавить раздел в корень базы знаний" : "Добавить блок"; - el.addBlock.setAttribute("aria-label", el.addBlock.title); - } - if (el.save) { - el.save.textContent = isKnowledge ? "Сохранить базу" : "Сохранить модель"; - el.save.title = isKnowledge ? "Записать дерево в content/knowledge.json" : "Записать текущую модель в content/pages/home.json"; - } - if (el.render) { - el.render.textContent = isKnowledge ? "Обновить /knowledge/" : "Обновить index.html"; - el.render.title = isKnowledge ? "Сохранить дерево и пересобрать публичный раздел /knowledge/" : "Сохранить модель и пересобрать публичный index.html"; - } -} - -function editorJsonPayload(block) { - if (state.mode === "seo" && isStaticSelection()) { - return state.page?.seo || {}; - } - - return block || {}; -} - -function groupedSeoFields(fields) { - const groups = new Map(); - - for (const seoField of fields) { - const groupName = seoField.group || "SEO"; - if (!groups.has(groupName)) groups.set(groupName, []); - groups.get(groupName).push(seoField); - } - - return groups; -} - -function compactText(value) { - if (value == null) return ""; - if (Array.isArray(value)) return value.map(compactText).join(" "); - if (typeof value === "object") return Object.values(value).map(compactText).join(" "); - - return String(value) - .replace(/<[^>]*>/g, " ") - .replace(/https?:\/\/\S+/g, " ") - .replace(/\s+/g, " ") - .trim(); -} - -function seoLengthStatus(value, min, max) { - const length = String(value || "").trim().length; - if (!min && !max) return { status: "info", text: length ? `${length} симв.` : "пусто" }; - if (!length) return { status: "bad", text: `пусто · цель ${min || 0}-${max || "∞"}` }; - if ((min && length < min) || (max && length > max)) { - return { status: "warn", text: `${length} симв. · цель ${min || 0}-${max || "∞"}` }; - } - return { status: "good", text: `${length} симв.` }; -} - -function createSeoMetric(label, value, status = "info") { - const item = document.createElement("div"); - const caption = document.createElement("span"); - const number = document.createElement("strong"); - - item.className = "seo-metric"; - item.dataset.status = status; - caption.textContent = label; - number.textContent = value; - item.append(caption, number); - return item; -} - -function blockTextStats(block) { - const text = compactText(block?.content || ""); - const words = text ? text.split(/\s+/).filter(Boolean).length : 0; - return { text, words, chars: text.length }; -} - -function renderSeoDiagnostics({ targetModel, fields, isStatic }) { - const diagnostics = document.createElement("section"); - const head = document.createElement("div"); - const title = document.createElement("h3"); - const desc = document.createElement("p"); - const metrics = document.createElement("div"); - - diagnostics.className = "seo-diagnostics"; - head.className = "group-head"; - title.textContent = isStatic ? "Диагностика страницы" : "Диагностика блока"; - desc.className = "group-desc"; - desc.textContent = isStatic - ? "Базовые поля, которые попадают в head, Open Graph, sitemap и structured data." - : "Черновой слой для семантики блока. Позже его сможет заполнять внешний SEO-модуль."; - head.append(title, desc); - metrics.className = "seo-metric-grid"; - - if (isStatic) { - const titleStatus = seoLengthStatus(getByPath(targetModel, "seo.title"), 35, 70); - const descriptionStatus = seoLengthStatus(getByPath(targetModel, "seo.description"), 110, 170); - const canonical = String(getByPath(targetModel, "seo.canonicalUrl") || ""); - const ogImage = String(getByPath(targetModel, "seo.ogImage") || ""); - - metrics.append( - createSeoMetric("Title", titleStatus.text, titleStatus.status), - createSeoMetric("Description", descriptionStatus.text, descriptionStatus.status), - createSeoMetric("Canonical", canonical.startsWith("https://") ? "абсолютный" : "проверить", canonical.startsWith("https://") ? "good" : "warn"), - createSeoMetric("OG image", ogImage ? "задан" : "пусто", ogImage ? "good" : "bad"), - ); - } else { - const stats = blockTextStats(targetModel); - const primaryQuery = String(getByPath(targetModel, "seo.primaryQuery") || "").trim(); - const secondary = String(getByPath(targetModel, "seo.secondaryQueries") || "") - .split(/\n|,/) - .map((item) => item.trim()) - .filter(Boolean); - const titleStatus = seoLengthStatus(getByPath(targetModel, "seo.title"), 35, 80); - - metrics.append( - createSeoMetric("Текст блока", `${stats.words} слов`, stats.words >= 40 ? "good" : "warn"), - createSeoMetric("Основной запрос", primaryQuery ? "задан" : "пусто", primaryQuery ? "good" : "bad"), - createSeoMetric("Смежные запросы", `${secondary.length}`, secondary.length ? "good" : "warn"), - createSeoMetric("SEO-заголовок", titleStatus.text, titleStatus.status), - ); - } - - diagnostics.append(head, metrics); - return diagnostics; -} - -function updateSeoFieldMeter(meter, input, config) { - if (!meter) return; - const lengthStatus = seoLengthStatus(fieldInputValue(input), config.min, config.max); - meter.dataset.status = lengthStatus.status; - meter.textContent = lengthStatus.text; -} - -function renderSeoInput({ targetModel, seoField, grid }) { - 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 isLong = seoField.kind === "textarea"; - const input = seoField.kind === "select" ? document.createElement("select") : isLong ? document.createElement("textarea") : document.createElement("input"); - const value = getByPath(targetModel, seoField.path) ?? ""; - const meter = seoField.min || seoField.max ? document.createElement("span") : null; - - label.className = `field-control seo-field${isLong ? " wide" : ""}`; - labelRow.className = "field-label-row"; - labelText.className = "field-label-text"; - labelText.textContent = seoField.label || seoField.path; - kind.className = "field-kind"; - kind.textContent = seoField.kind || "text"; - path.className = "field-path"; - path.textContent = seoField.path; - labelRow.append(labelText, kind); - - if (seoField.kind === "select") { - const values = new Set([...(seoField.options || []), value].filter(Boolean)); - for (const optionValue of values) { - const option = document.createElement("option"); - option.value = optionValue; - option.textContent = optionValue; - input.append(option); - } - } else { - if (!isLong) { - input.autocomplete = "off"; - input.type = seoField.kind === "url" ? "url" : "text"; - } - } - - input.dataset.seoPath = seoField.path; - input.value = value; - input.placeholder = seoField.placeholder || ""; - updateSeoFieldMeter(meter, input, seoField); - - input.addEventListener(seoField.kind === "select" ? "change" : "input", () => { - setByPath(targetModel, seoField.path, fieldInputValue(input)); - updateSeoFieldMeter(meter, input, seoField); - el.json.value = JSON.stringify(editorJsonPayload(activeBlock()), null, 2); - markDirty("SEO-поля обновлены локально. Нажмите «Сохранить модель» или «Обновить index.html»."); - }); - - label.append(labelRow, input); - if (meter) { - meter.className = "seo-field-meter"; - label.append(meter); - } - label.append(path); - grid.append(label); -} - -function renderSeoFields(block) { - const isStatic = isStaticSelection(); - const targetModel = isStatic ? state.page : block; - const fields = isStatic ? PAGE_SEO_FIELDS : BLOCK_SEO_FIELDS; - - if (isStatic) ensurePageSeo(); - else ensureBlockSeo(block); - - el.contentFields.innerHTML = ""; - el.contentFields.append(renderSeoDiagnostics({ targetModel, fields, isStatic })); - - for (const [groupName, groupFields] of groupedSeoFields(fields)) { - 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 seo-group"; - head.className = "group-head"; - title.textContent = groupName; - desc.className = "group-desc"; - desc.textContent = isStatic ? "Поля уровня страницы и поискового сниппета." : "Поля уровня секции для будущей семантической карты."; - titleWrap.append(title, desc); - head.append(titleWrap); - grid.className = "group-grid"; - - for (const seoField of groupFields) { - renderSeoInput({ targetModel, seoField, grid }); - } - - group.append(head, grid); - el.contentFields.append(group); - } -} - -const LINK_WINDOW_TARGETS = new Set(["_blank", "_self", "_parent", "_top"]); - -function isWindowTarget(value) { - return LINK_WINDOW_TARGETS.has(String(value || "").trim()); -} - -function isExternalHref(value) { - return /^(https?:)?\/\//i.test(String(value || "")) || /^(mailto:|tel:)/i.test(String(value || "")); -} - -function normalizeLandingTargetCandidate(value) { - const raw = String(value || "").trim(); - if (!raw || /^javascript:/i.test(raw) || isExternalHref(raw)) return ""; - - let target = raw; - if (target.includes("#")) { - target = target.slice(target.indexOf("#") + 1); - } - - target = target - .replace(/^\/+/, "") - .replace(/^index\.html#?/i, "") - .replace(/^#+/, "") - .trim(); - - if (!target || target === "/" || target === "index.html" || target.startsWith("knowledge/") || isWindowTarget(target)) { - return ""; - } - - return target; -} - -function landingBlockTargets() { - const targets = []; - - for (const region of state.page?.regions || []) { - for (const block of region.blocks || []) { - if (block.enabled === false) continue; - const target = normalizeLandingTargetCandidate(block.anchor); - if (!target) continue; - targets.push({ - block, - id: block.id, - label: block.adminLabel || block.id, - target, - }); - } - } - - return targets.sort((left, right) => left.target.localeCompare(right.target, "ru")); -} - -function collectLandingButtonTargets() { - return landingBlockTargets().map((item) => item.target); -} - -function findLandingTargetByInput(value) { - const input = String(value || "").trim(); - const normalized = normalizeLandingTargetCandidate(input); - const lowerInput = input.toLowerCase(); - const lowerTarget = normalized.toLowerCase(); - - return ( - landingBlockTargets().find((item) => item.target.toLowerCase() === lowerTarget) || - landingBlockTargets().find((item) => item.id.toLowerCase() === lowerInput) || - landingBlockTargets().find((item) => item.label.toLowerCase() === lowerInput) || - null - ); -} - -function uniqueLandingTarget(base, exceptBlock = null) { - const usedTargets = new Set( - landingBlockTargets() - .filter((item) => item.block !== exceptBlock) - .map((item) => item.target), - ); - const root = safeBucketSegment(normalizeLandingTargetCandidate(base) || base || "section"); - let candidate = root; - let index = 2; - - while (usedTargets.has(candidate)) { - candidate = `${root}-${index}`; - index += 1; - } - - return candidate; -} - -function landingTargetDuplicates() { - const seen = new Map(); - const duplicates = []; - - for (const item of landingBlockTargets()) { - if (seen.has(item.target)) { - duplicates.push({ target: item.target, first: seen.get(item.target), second: item }); - continue; - } - seen.set(item.target, item); - } - - return duplicates; -} - -function assertUniqueLandingTargets() { - const duplicates = landingTargetDuplicates(); - if (!duplicates.length) return; - - const details = duplicates - .map(({ target, first, second }) => `${target}: ${first.label} / ${second.label}`) - .join("; "); - throw new Error(`Target должен быть уникальным для каждого блока. Дубли: ${details}`); -} - -function renderLandingTargetOptions() { - if (!el.targetOptions) return; - - el.targetOptions.innerHTML = ""; - for (const { target, label } of landingBlockTargets()) { - const option = document.createElement("option"); - option.value = target; - option.label = label; - el.targetOptions.append(option); - } -} - -function knowledgeTargetPromptText() { - const targets = landingBlockTargets(); - const hint = targets.length ? `\n\nДоступные блоки:\n${targets.map((item) => `${item.target} — ${item.label}`).join("\n")}` : ""; - return `Target или ID блока лендинга. Если пусто, откроется первый экран.${hint}`; -} - -function normalizeChoiceLink(item, fallback = {}) { - if (!item || typeof item !== "object") return; - - let href = item.href ?? fallback.href ?? ""; - let target = item.target ?? fallback.target ?? ""; - let linkTarget = item.linkTarget ?? fallback.linkTarget ?? ""; - - if (isWindowTarget(target)) { - linkTarget ||= target; - target = ""; - } - - const inferredMode = isExternalHref(href) ? "href" : "target"; - const linkMode = item.linkMode === "href" || item.linkMode === "target" ? item.linkMode : inferredMode; - - if (linkMode === "target" && !target && href && !isExternalHref(href)) { - target = href; - href = ""; - } - - item.enabled = item.enabled !== false; - item.href = href; - item.target = target; - item.linkMode = linkMode; - item.linkTarget = linkTarget; -} - -function normalizeChoiceLinks(items, fallbacks = []) { - if (!Array.isArray(items)) return; - items.forEach((item, index) => normalizeChoiceLink(item, fallbacks[index] || fallbacks[0] || {})); -} - -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.") || path.startsWith("content.accessCard.")) return "Карточка доступа"; - if (path.startsWith("content.form.")) return "Форма"; - if (path === "content.legalHtml" || path.startsWith("content.legal.")) return "Юридический блок"; - if (path === "content.windowTitle" || path === "content.videoSrc") return "Видео-окно"; - if (path.startsWith("content.features.")) return "Пункты"; - if (path.startsWith("content.scroll.")) return "Скролл"; - if (path.startsWith("content.sliderItems.")) return "Слайдер"; - if (path.startsWith("content.bottomInfo.")) return "Нижний информационный блок"; - if (path.startsWith("content.oneClick.")) return "Один клик"; - if (path.startsWith("content.mcp.")) return "MCP"; - if (path.startsWith("content.faq.")) return "FAQ"; - if (path.startsWith("content.cta.")) return "CTA"; - if (path.startsWith("content.plans.") || path.startsWith("content.pricing.")) return "Тарифы"; - if (path.startsWith("content.pricingCells.")) return "Строки тарифов"; - if (path === "content.galleryItems" || path.startsWith("content.galleryItems.")) return "Карточки скриншотов"; - if (path === "content.filesEnabled" || path.startsWith("content.files.")) return "TXT-файлы"; - if (path === "content.footer.policyEnabled" || path === "content.footer.policyHtml") return "Политики"; - if (path.startsWith("content.footer.navLinks.")) return "Навигация футера"; - if (path.startsWith("content.footer.contactLinks.")) return "Контактные ссылки"; - if (path.startsWith("content.footer.")) return "Футер"; - if (path.startsWith("content.heading.") || path === "content.introHtml") return "Заголовок"; - return "Контент"; -} - -function createDefaultDockItems(dock = {}, assets = {}) { - return [ - { - enabled: true, - title: "NODE.DC", - href: "javascript:void(0)", - linkTarget: "", - targetId: dock.appTarget || "app-data", - iconSrc: assets.appIconSrc || STATIC_FALLBACK.content.assets.appIconSrc, - iconAlt: assets.appIconAlt || STATIC_FALLBACK.content.assets.appIconAlt, - className: "dock-icon nodedc-app", - iconClass: "logo", - dividerBefore: false, - }, - { - enabled: true, - title: "Корзина", - href: "javascript:void(0)", - linkTarget: "", - targetId: "", - iconSrc: "./assets/site/images/69a9ae818a76c773dc3cf7bc_trash.avif", - iconAlt: dock.trashAlt || "Белая иконка корзины.", - className: "dock-icon no-bg", - iconClass: "dock-app-img trash", - dividerBefore: true, - }, - ]; -} - -function normalizeDock(dock, assets = {}) { - const fallbackItems = createDefaultDockItems(dock, assets); - - if (!Array.isArray(dock.items)) { - dock.items = fallbackItems; - } else { - dock.items = dock.items.map((item, index) => { - const fallback = fallbackItems[index] || fallbackItems[0]; - const hasOwn = (key) => Object.prototype.hasOwnProperty.call(item, key); - - return { - enabled: item.enabled !== false, - title: item.title || fallback.title || `Элемент ${index + 1}`, - href: item.href ?? fallback.href ?? "", - linkTarget: item.linkTarget ?? fallback.linkTarget ?? "", - targetId: item.targetId ?? fallback.targetId ?? "", - iconSrc: hasOwn("iconSrc") ? item.iconSrc : fallback.iconSrc, - iconAlt: hasOwn("iconAlt") ? item.iconAlt : item.alt || fallback.iconAlt, - className: item.className || fallback.className || "dock-icon nodedc-app", - iconClass: hasOwn("iconClass") ? item.iconClass : fallback.iconClass || "logo", - dividerBefore: Boolean(item.dividerBefore), - }; - }); - } - - dock.demoFileLabel ||= STATIC_FALLBACK.content.dock.demoFileLabel; - delete dock.appTarget; - delete dock.trashAlt; -} - -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.content.navigation ||= clone(STATIC_FALLBACK.content.navigation); - state.page.staticElements.content.navigation.logoSrc ||= STATIC_FALLBACK.content.navigation.logoSrc; - state.page.staticElements.content.navigation.links ||= clone(STATIC_FALLBACK.content.navigation.links); - normalizeChoiceLinks(state.page.staticElements.content.navigation.links, STATIC_FALLBACK.content.navigation.links); - state.page.staticElements.content.notifications ||= clone(STATIC_FALLBACK.content.notifications); - state.page.staticElements.content.notifications.forEach((notification, index) => { - notification.enabled = notification.enabled !== false; - notification.eyebrowClass ||= STATIC_FALLBACK.content.notifications[index]?.eyebrowClass || "red-txt gray"; - }); - state.page.staticElements.content.assets ||= clone(STATIC_FALLBACK.content.assets); - for (const [key, value] of Object.entries(STATIC_FALLBACK.content.assets)) { - if (!Object.prototype.hasOwnProperty.call(state.page.staticElements.content.assets, key)) { - state.page.staticElements.content.assets[key] = value; - } - } - state.page.staticElements.content.dock ||= clone(STATIC_FALLBACK.content.dock); - normalizeDock(state.page.staticElements.content.dock, state.page.staticElements.content.assets); - state.page.staticElements.editableFields = clone(STATIC_FALLBACK.editableFields); -} - -function ensureSectionsRegion() { - const regions = Array.isArray(state.page.regions) ? state.page.regions : []; - const blocks = regions.flatMap((region) => - (region.blocks ?? []).map((block) => { - block.region ||= region.id; - return block; - }), - ); - - state.page.regions = [ - { - id: SECTIONS_REGION_ID, - label: SECTIONS_REGION_LABEL, - description: "Единый порядок секций главной страницы.", - blocks, - }, - ]; -} - -function sectionsRegionIndex() { - return Math.max( - 0, - state.page.regions.findIndex((region) => region.id === SECTIONS_REGION_ID), - ); -} - -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.staticElements?.id, - ...state.page.regions.flatMap((region) => region.blocks.map((block) => block.id)), - ]); - let candidate = `${base}-copy`; - let index = 2; - - while (ids.has(candidate)) { - candidate = `${base}-copy-${index}`; - index += 1; - } - - return candidate; -} - -function uniqueBlockId(base) { - return uniqueId(safeBucketSegment(base || "block").replace(/-copy(?:-\d+)?$/, "")); -} - -function truncateText(value, maxLength = 22) { - if (!value) return ""; - return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value; -} - -function fileNameFromUrl(value) { - if (!value) return "Файл не выбран"; - - try { - const clean = value.split("?")[0].split("#")[0]; - return decodeURIComponent(clean.slice(clean.lastIndexOf("/") + 1)) || value; - } catch { - return value; - } -} - -function safeBucketSegment(value) { - const safe = String(value || "site") - .normalize("NFKD") - .replace(/[^\w.-]+/g, "-") - .replace(/^-+|-+$/g, "") - .toLowerCase(); - - return safe || "site"; -} - -function adminPreviewUrl(value) { - if (!value) return ""; - if (/^(blob:|data:|https?:|\/)/i.test(value)) return value; - if (value.startsWith("./")) return `.${value}`; - return value; -} - -function mediaKindFromValue(value) { - if (!value) return null; - if (/\.(mp4|webm|mov|m4v|avi|mkv)(\?.*)?$/i.test(value)) return "video"; - if (/\.(png|jpe?g|gif|svg|ico|avif|webp)(\?.*)?$/i.test(value)) return "image"; - if (/\.(glb|gltf)(\?.*)?$/i.test(value)) return "model"; - if (/\.(woff2?|ttf|otf)(\?.*)?$/i.test(value)) return "font"; - return null; -} - -function formatFileSize(bytes) { - const value = Number(bytes); - if (!Number.isFinite(value) || value < 0) return ""; - if (value < 1024) return `${value} B`; - const units = ["KB", "MB", "GB"]; - let size = value / 1024; - let unitIndex = 0; - - while (size >= 1024 && unitIndex < units.length - 1) { - size /= 1024; - unitIndex += 1; - } - - return `${size >= 10 ? size.toFixed(0) : size.toFixed(1)} ${units[unitIndex]}`; -} - -function formatFileDate(ms) { - const value = Number(ms); - if (!Number.isFinite(value)) return ""; - return new Intl.DateTimeFormat("ru-RU", { - day: "2-digit", - month: "2-digit", - year: "2-digit", - hour: "2-digit", - minute: "2-digit", - }).format(new Date(value)); -} - -function mediaUsageStatus(file) { - return file?.usage?.status || "unused"; -} - -function mediaUsageLabel(file) { - return file?.usage?.label || "Не используется"; -} - -function mediaFileMeta(file) { - return [file.kind, file.extension, formatFileSize(file.size)].filter(Boolean).join(" · "); -} - -function selectedMediaFile() { - const entry = state.mediaLibrary.selectedEntry; - return entry?.type === "file" ? entry : null; -} - -function shouldWarnAboutSolidLogoBackground(editableField) { - const path = String(editableField?.path || "").toLowerCase(); - const label = String(editableField?.label || "").toLowerCase(); - return /logo|icon|wordmark|логотип|икон/.test(`${path} ${label}`); -} - -function uploadBucketForField(block, editableField) { - const path = editableField.path.toLowerCase(); - const currentValue = String(getByPath(block, editableField.path) ?? "").toLowerCase(); - const blockSegment = safeBucketSegment(block?.id || block?.type || "site"); - let root = "images"; - - if (path.includes("webgl") || currentValue.endsWith(".glb") || currentValue.endsWith(".gltf")) root = "models"; - if (path.includes("video") || path.includes("media") || path.includes("sliders") || path.includes("slideritems")) root = "media"; - if (path.includes("font") || /\.(woff2?|ttf|otf)(\?.*)?$/i.test(currentValue)) root = "fonts"; - if (path.includes("favicon")) return "favicon"; - - return `${root}/${blockSegment}`; -} - -function blockIcon(block) { - const id = block.id || ""; - const type = block.type || ""; - - if (id === "static-elements") return "SE"; - if (id.includes("gallery") || id.includes("screenshot")) return "SC"; - if (id.includes("hero")) return "H"; - if (id.includes("feature") || id.includes("video")) return "V"; - if (id.includes("component")) return "C"; - if (id.includes("modes")) return "AI"; - if (id.includes("faq")) return "Q"; - if (id.includes("pricing")) return "P"; - if (id.includes("waitlist")) return "W"; - if (id.includes("footer")) return "F"; - if (type.includes("Cta")) return "GO"; - return "B"; -} - -function readFileAsDataUrl(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.addEventListener("load", () => resolve(String(reader.result))); - reader.addEventListener("error", () => reject(reader.error ?? new Error("Не удалось прочитать файл"))); - reader.readAsDataURL(file); - }); -} - -function labelForRegion(region) { - if (region.id === SECTIONS_REGION_ID || region.id === "beforeCViz" || region.id === "cViz") return SECTIONS_REGION_LABEL; - return region.label || region.id; -} - -function blockMeta(block) { - return block.template || block.type || block.id; -} - -function cleanupDragState() { - state.drag = null; - state.dragTarget = null; - document.querySelectorAll(".dragging, .drag-over, .sorting-source, .sorting-active").forEach((node) => { - node.classList.remove("dragging", "drag-over", "sorting-source", "sorting-active"); - }); - document.querySelectorAll(".sortable-ghost, .sortable-placeholder").forEach((node) => node.remove()); -} - -function createSortableGhost(source, event) { - const rect = source.getBoundingClientRect(); - const ghost = source.cloneNode(true); - - ghost.classList.add("sortable-ghost"); - ghost.style.width = `${rect.width}px`; - ghost.style.height = `${rect.height}px`; - ghost.dataset.dragOffsetX = String(event.clientX - rect.left); - ghost.dataset.dragOffsetY = String(event.clientY - rect.top); - document.body.append(ghost); - moveSortableGhost(ghost, event); - - return ghost; -} - -function moveSortableGhost(ghost, event) { - ghost.style.left = `${event.clientX - Number(ghost.dataset.dragOffsetX || 0)}px`; - ghost.style.top = `${event.clientY - Number(ghost.dataset.dragOffsetY || 0)}px`; -} - -function createSortablePlaceholder(source) { - const rect = source.getBoundingClientRect(); - const placeholder = document.createElement("div"); - - placeholder.className = "sortable-placeholder"; - placeholder.style.height = `${rect.height}px`; - return placeholder; -} - -function pointInsideRect(event, rect) { - return event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom; -} - -function movePlaceholderByPoint({ event, list, source, placeholder, selector }) { - const rect = list.getBoundingClientRect(); - if (!pointInsideRect(event, rect)) return; - - const candidates = [...list.querySelectorAll(selector)].filter((node) => node !== source && node !== placeholder); - - for (const candidate of candidates) { - const candidateRect = candidate.getBoundingClientRect(); - if (event.clientY < candidateRect.top + candidateRect.height / 2) { - list.insertBefore(placeholder, candidate); - return; - } - } - - list.append(placeholder); -} - -function sortablePlaceholderIndex({ list, placeholder, source, selector }) { - return [...list.querySelectorAll(`${selector}, .sortable-placeholder`)] - .filter((node) => node === placeholder || node !== source) - .indexOf(placeholder); -} - -function moveBlockWithinRegionToIndex(regionIndex, fromIndex, toIndex) { - const region = state.page.regions[regionIndex]; - if (!region || fromIndex === toIndex || toIndex < 0) { - cleanupDragState(); - return; - } - - const [block] = region.blocks.splice(fromIndex, 1); - region.blocks.splice(Math.min(toIndex, region.blocks.length), 0, block); - state.selected = { kind: "block", regionIndex, blockIndex: Math.min(toIndex, region.blocks.length - 1) }; - cleanupDragState(); - renderAll(); - markDirty("Порядок блоков изменён локально. Нажмите «Сохранить модель» или «Обновить index.html»."); -} - -function startPointerDrag(event, button, dragContext) { - if (event.button !== 0) return; - event.preventDefault(); - event.stopPropagation(); - - const list = button.closest(".block-list"); - if (!list) return; - - const placeholder = createSortablePlaceholder(button); - const ghost = createSortableGhost(button, event); - - state.drag = dragContext; - state.dragTarget = dragContext; - button.classList.add("dragging", "sorting-source"); - list.classList.add("sorting-active"); - button.after(placeholder); - - const onMove = (moveEvent) => { - moveSortableGhost(ghost, moveEvent); - movePlaceholderByPoint({ - event: moveEvent, - list, - source: button, - placeholder, - selector: ".block-btn.has-drag-handle", - }); - }; - - const onFinish = () => { - document.removeEventListener("pointermove", onMove); - document.removeEventListener("pointerup", onFinish); - document.removeEventListener("pointercancel", onCancel); - - const nextIndex = sortablePlaceholderIndex({ - list, - placeholder, - source: button, - selector: ".block-btn.has-drag-handle", - }); - moveBlockWithinRegionToIndex(dragContext.regionIndex, dragContext.blockIndex, nextIndex); - }; - - const onCancel = () => { - document.removeEventListener("pointermove", onMove); - document.removeEventListener("pointerup", onFinish); - document.removeEventListener("pointercancel", onCancel); - cleanupDragState(); - }; - - document.addEventListener("pointermove", onMove); - document.addEventListener("pointerup", onFinish); - document.addEventListener("pointercancel", onCancel); -} - -function reorderBlockWithinRegion(from, to) { - if (!from || from.regionIndex !== to.regionIndex || from.blockIndex === to.blockIndex) { - cleanupDragState(); - return; - } - - const region = state.page.regions[from.regionIndex]; - if (!region) { - cleanupDragState(); - return; - } - - const [block] = region.blocks.splice(from.blockIndex, 1); - let insertIndex = to.blockIndex; - if (from.blockIndex < to.blockIndex) insertIndex -= 1; - region.blocks.splice(insertIndex, 0, block); - state.selected = { kind: "block", regionIndex: from.regionIndex, blockIndex: insertIndex }; - cleanupDragState(); - renderAll(); - markDirty("Порядок блоков изменён локально. Нажмите «Сохранить модель» или «Обновить index.html»."); -} - -function renderBlockButton({ list, block, meta, active, disabled, onClick, dragContext = null }) { - const button = document.createElement("div"); - const icon = document.createElement("span"); - const copy = document.createElement("span"); - const name = document.createElement("span"); - const description = document.createElement("span"); - const canDrag = Boolean(dragContext); - - button.role = "button"; - button.tabIndex = 0; - button.className = `block-btn${active ? " active" : ""}${disabled ? " disabled" : ""}${canDrag ? " has-drag-handle" : ""}`; - button.dataset.adminBlockId = block.id || ""; - button.dataset.adminBlockType = block.type || ""; - button.dataset.adminBlockTemplate = block.template || ""; - if (canDrag) { - button.dataset.regionIndex = String(dragContext.regionIndex); - button.dataset.blockIndex = String(dragContext.blockIndex); - } - icon.className = "block-icon"; - icon.textContent = blockIcon(block); - copy.className = "block-copy"; - name.className = "block-name"; - name.textContent = block.adminLabel || block.id; - description.className = "block-meta"; - description.textContent = meta; - copy.append(name, description); - button.append(icon, copy); - - if (canDrag) { - const handle = document.createElement("span"); - handle.className = "block-drag-handle"; - handle.draggable = false; - handle.title = "Перетащить блок"; - handle.setAttribute("aria-label", "Перетащить блок"); - handle.textContent = "⋮⋮"; - handle.addEventListener("click", (event) => event.stopPropagation()); - handle.addEventListener("pointerdown", (event) => startPointerDrag(event, button, dragContext)); - handle.addEventListener("dragstart", (event) => { - state.drag = dragContext; - button.classList.add("dragging"); - event.dataTransfer.effectAllowed = "move"; - event.dataTransfer.setData("text/plain", `${dragContext.regionIndex}:${dragContext.blockIndex}`); - }); - handle.addEventListener("dragend", cleanupDragState); - handle.addEventListener("keydown", (event) => event.stopPropagation()); - button.append(handle); - } - - if (canDrag) { - button.addEventListener("dragover", (event) => { - if (!state.drag || state.drag.regionIndex !== dragContext.regionIndex) return; - event.preventDefault(); - event.dataTransfer.dropEffect = "move"; - button.classList.add("drag-over"); - }); - button.addEventListener("dragleave", () => button.classList.remove("drag-over")); - button.addEventListener("drop", (event) => { - event.preventDefault(); - button.classList.remove("drag-over"); - reorderBlockWithinRegion(state.drag, dragContext); - }); - } - - button.addEventListener("click", onClick); - button.addEventListener("keydown", (event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - onClick(event); - }); - 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 = labelForRegion(region); - list.className = "block-list"; - - region.blocks.forEach((block, blockIndex) => { - const isActive = - state.selected?.kind === "block" && - state.selected?.regionIndex === regionIndex && - state.selected?.blockIndex === blockIndex; - - renderBlockButton({ - list, - block, - meta: blockMeta(block), - active: isActive, - disabled: block.enabled === false, - dragContext: { regionIndex, blockIndex }, - onClick: () => { - state.selected = { kind: "block", regionIndex, blockIndex }; - renderAll(); - }, - }); - }); - - regionNode.append(title, list); - el.regions.append(regionNode); - }); -} - -function blockTemplates() { - const regionIndex = sectionsRegionIndex(); - - return state.templates.map((template) => { - return { - ...template, - regionIndex, - regionLabel: SECTIONS_REGION_LABEL, - }; - }); -} - -function renderTemplateModal() { - el.templateList.innerHTML = ""; - - for (const template of blockTemplates()) { - const card = document.createElement("button"); - const icon = document.createElement("span"); - const copy = document.createElement("span"); - const title = document.createElement("span"); - const meta = document.createElement("span"); - const region = document.createElement("span"); - - card.type = "button"; - card.className = "template-card"; - icon.className = "template-card-icon"; - icon.textContent = blockIcon(template.block); - copy.className = "template-card-copy"; - title.className = "template-card-title"; - title.textContent = template.block.adminLabel || template.block.id; - meta.className = "template-card-meta"; - meta.textContent = blockMeta(template.block); - region.className = "template-card-region"; - region.textContent = `Добавится в: ${template.regionLabel}`; - copy.append(title, meta, region); - card.append(icon, copy); - card.addEventListener("click", () => addBlockFromTemplate(template.key)); - el.templateList.append(card); - } -} - -function openTemplateModal() { - renderTemplateModal(); - el.templateModal.classList.remove("hidden"); - el.templateList.querySelector("button")?.focus(); -} - -function closeTemplateModal() { - el.templateModal.classList.add("hidden"); - el.addBlock.focus(); -} - -function closeDeleteModal(confirmed = false) { - const resolve = state.deleteConfirmResolve; - - state.deleteConfirmResolve = null; - el.deleteModal.classList.add("hidden"); - if (resolve) resolve(confirmed); -} - -function requestDeleteConfirmation({ - title = "Подтвердите действие", - body = "Вы действительно подтвердить удаление элемента?", -} = {}) { - if (state.deleteConfirmResolve) closeDeleteModal(false); - - return new Promise((resolve) => { - state.deleteConfirmResolve = resolve; - el.deleteModalTitle.textContent = title; - el.deleteModalBody.textContent = body; - el.deleteModal.classList.remove("hidden"); - el.deleteModalConfirm.focus(); - }); -} - -function closeMediaActionModal(value = null) { - const resolve = state.mediaActionResolve; - - state.mediaActionResolve = null; - el.mediaActionModal.classList.add("hidden"); - if (resolve) resolve(value); -} - -function requestMediaActionInput({ - title = "Действие", - body = "Введите имя.", - label = "Имя", - value = "", - confirmLabel = "Подтвердить", -} = {}) { - if (state.mediaActionResolve) closeMediaActionModal(null); - - return new Promise((resolve) => { - state.mediaActionResolve = resolve; - el.mediaActionTitle.textContent = title; - el.mediaActionBody.textContent = body; - el.mediaActionLabel.textContent = label; - el.mediaActionConfirm.textContent = confirmLabel; - el.mediaActionInput.value = value; - el.mediaActionModal.classList.remove("hidden"); - el.mediaActionInput.focus(); - el.mediaActionInput.select(); - }); -} - -function addBlockFromTemplate(templateKey) { - const template = blockTemplates().find((candidate) => candidate.key === templateKey); - if (!template) return; - - const region = state.page.regions[template.regionIndex]; - const block = clone(template.block); - - block.id = uniqueBlockId(block.id); - block.enabled = true; - if (block.anchor) { - block.anchor = uniqueLandingTarget(block.anchor || block.adminLabel || block.id, block); - } - block.scopeIds = true; - block.seo = { - ...BLOCK_SEO_DEFAULTS, - ...(block.seo || {}), - }; - region.blocks.push(block); - state.selected = { kind: "block", regionIndex: template.regionIndex, blockIndex: region.blocks.length - 1 }; - closeTemplateModal(); - renderAll(); - markDirty(`Добавлен блок «${block.adminLabel || block.id}». Нажмите «Сохранить модель» или «Обновить index.html».`); -} - -function setStructuralControlsDisabled(disabled) { - for (const control of [el.moveUp, el.moveDown, el.duplicate, el.copy, el.pasteAfter, el.deleteBlock]) { - control.disabled = disabled; - } -} - -function renderEditorError(error) { - console.error(error); - el.knowledgeForm.classList.add("hidden"); - el.empty.classList.add("hidden"); - el.form.classList.remove("hidden"); - el.contentFields.innerHTML = ""; - - const note = document.createElement("div"); - note.className = "empty-note editor-error-note"; - note.textContent = `Редактор не смог отрисовать поля блока: ${error.message}`; - el.contentFields.append(note); - setStatus(`Ошибка редактора: ${error.message}`); -} - -function renderEditor() { - updateAdminModeSurface(); - el.knowledgeForm.classList.add("hidden"); - - const region = activeRegion(); - const block = activeBlock(); - const isStatic = isStaticSelection(); - const isSeo = state.mode === "seo"; - - if (!block) { - el.empty.classList.remove("hidden"); - el.form.classList.add("hidden"); - el.selectedRegion.textContent = "NODE.DC / Выберите блок"; - el.selectedTitle.textContent = isSeo ? "SEO-слой" : "Контентный слой"; - renderLandingTargetOptions(); - return; - } - - el.empty.classList.add("hidden"); - el.form.classList.remove("hidden"); - el.form.dataset.static = String(isStatic); - el.form.dataset.mode = state.mode; - el.selectedRegion.textContent = isSeo - ? `NODE.DC / SEO / ${isStatic ? "Страница" : region.label || region.id}` - : `NODE.DC / ${region.label || region.id}`; - el.selectedTitle.textContent = isSeo ? `SEO: ${isStatic ? "страница" : block.adminLabel || block.id}` : block.adminLabel || block.id; - el.id.value = block.id || ""; - el.label.value = block.adminLabel || ""; - el.template.value = block.template || block.type || ""; - el.anchor.value = block.anchor || ""; - el.anchor.placeholder = "hub-video / engine-video / applied-modules"; - el.anchor.title = "Публичный Target конкретного блока: используется как якорь лендинга и как ссылка из Knowledge."; - renderLandingTargetOptions(); - el.enabled.checked = block.enabled !== false; - el.id.readOnly = isStatic; - el.anchor.disabled = isStatic; - el.enabled.disabled = isStatic; - el.enabled.closest(".toggle-row").hidden = isStatic; - setStructuralControlsDisabled(isStatic || isSeo); - - try { - if (isSeo) { - renderSeoFields(block); - } else { - renderContentFields(block); - } - } catch (error) { - renderEditorError(error); - } - - el.json.value = JSON.stringify(editorJsonPayload(block), null, 2); -} - -function renderAll() { - try { - if (state.mode === "knowledge") { - renderKnowledgeAll(); - return; - } - renderRegions(); - renderEditor(); - } catch (error) { - console.error(error); - setStatus(`Ошибка рендера админки: ${error.message}`); - } -} - -function syncBlockFromFields() { - const block = activeBlock(); - if (!block) return; - - syncContentFields(); - block.adminLabel = el.label.value.trim() || block.adminLabel || block.id; - - if (!isStaticSelection()) { - block.id = el.id.value.trim(); - block.anchor = normalizeLandingTargetCandidate(el.anchor.value) || null; - el.anchor.value = block.anchor || ""; - block.enabled = el.enabled.checked; - renderLandingTargetOptions(); - const duplicate = landingTargetDuplicates().find((item) => item.second.block === block || item.first.block === block); - if (duplicate) { - setStatus(`Target «${duplicate.target}» уже используется в другом блоке. Переименуйте один из target-ов перед сохранением.`); - } - } - - el.json.value = JSON.stringify(block, null, 2); - renderRegions(); - markDirty(); -} - -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 resetMediaPreview(preview, hint) { - preview.classList.remove("is-image", "is-video", "is-solid-dark"); - preview.innerHTML = ""; - preview.title = ""; - if (hint) hint.textContent = ""; -} - -function analyzeLogoPreview(image, preview, hint, editableField) { - if (!hint || !shouldWarnAboutSolidLogoBackground(editableField)) return; - - try { - const sampleSize = 24; - const canvas = document.createElement("canvas"); - const context = canvas.getContext("2d", { willReadFrequently: true }); - if (!context) return; - - canvas.width = sampleSize; - canvas.height = sampleSize; - context.drawImage(image, 0, 0, sampleSize, sampleSize); - const data = context.getImageData(0, 0, sampleSize, sampleSize).data; - let opaque = 0; - let edgeOpaque = 0; - let edgeDark = 0; - - for (let y = 0; y < sampleSize; y += 1) { - for (let x = 0; x < sampleSize; x += 1) { - const offset = (y * sampleSize + x) * 4; - const alpha = data[offset + 3]; - if (alpha < 240) continue; - - opaque += 1; - const isEdge = x <= 1 || y <= 1 || x >= sampleSize - 2 || y >= sampleSize - 2; - const isDark = data[offset] + data[offset + 1] + data[offset + 2] < 66; - - if (isEdge) { - edgeOpaque += 1; - if (isDark) edgeDark += 1; - } - } - } - - const total = sampleSize * sampleSize; - const edgeRatio = edgeOpaque ? edgeDark / edgeOpaque : 0; - const opaqueRatio = opaque / total; - - if (opaqueRatio > 0.92 && edgeRatio > 0.82) { - preview.classList.add("is-solid-dark"); - hint.textContent = "Файл с непрозрачным тёмным фоном. Для логотипа на тёмном интерфейсе лучше PNG/SVG с прозрачностью."; - } - } catch { - // External images can be blocked by canvas CORS. Preview still works, just without diagnostics. - } -} - -function renderMediaPreview(preview, value, hint = null, editableField = null) { - resetMediaPreview(preview, hint); - - const src = adminPreviewUrl(value); - const kind = mediaKindFromValue(value); - - if (!src) { - preview.textContent = "FILE"; - return; - } - - if (kind === "video") { - const video = document.createElement("video"); - preview.classList.add("is-video"); - video.src = src; - video.autoplay = true; - video.loop = true; - video.muted = true; - video.playsInline = true; - preview.append(video); - return; - } - - if (kind === "image") { - const image = document.createElement("img"); - preview.classList.add("is-image"); - image.src = src; - image.alt = ""; - image.loading = "lazy"; - image.addEventListener("load", () => analyzeLogoPreview(image, preview, hint, editableField)); - image.addEventListener("error", () => { - resetMediaPreview(preview, hint); - preview.textContent = "FILE"; - if (hint) hint.textContent = "Превью не загрузилось. Проверьте путь или внешний URL."; - }); - preview.append(image); - return; - } - - preview.textContent = "FILE"; -} - -function updateMediaFieldValue({ block, editableField, hiddenInput, urlInput, fileName, preview, hint, value }) { - hiddenInput.value = value; - urlInput.value = value; - fileName.textContent = truncateText(fileNameFromUrl(value), 28); - fileName.title = fileNameFromUrl(value); - setByPath(block, editableField.path, value); - renderMediaPreview(preview, value, hint, editableField); - - if (activeBlock() === block) { - el.json.value = JSON.stringify(block, null, 2); - } - - markDirty(); -} - -async function uploadMediaFile({ block, editableField, hiddenInput, urlInput, fileName, preview, hint, error, setSource, afterUpload, file }) { - if (!file) return; - - state.mediaUploadActive = true; - setFilePickerActive(true); - recoverEditorSurface(); - error.textContent = ""; - fileName.textContent = "Сохраняем в assets..."; - - try { - const formData = new FormData(); - formData.append("file", file, file.name); - formData.append("fileName", file.name); - formData.append("mimeType", file.type || "application/octet-stream"); - formData.append("bucket", uploadBucketForField(block, editableField)); - - const response = await fetch("/api/upload/asset", { - method: "POST", - body: formData, - }); - const payload = await response.json().catch(() => ({ - ok: false, - error: `Сервер вернул ${response.status}`, - })); - - if (!response.ok || !payload.ok) { - throw new Error(payload.error || "Файл не удалось сохранить"); - } - - updateMediaFieldValue({ - block, - editableField, - hiddenInput, - urlInput, - fileName, - preview, - hint, - value: payload.url, - }); - setSource("file"); - markDirty(`Файл сохранён: ${payload.url}. Нажмите «Сохранить модель», чтобы записать путь в content/pages/home.json.`); - if (typeof afterUpload === "function") await afterUpload(payload); - return payload; - } catch (uploadError) { - error.textContent = uploadError.message; - fileName.textContent = truncateText(fileNameFromUrl(hiddenInput.value), 28); - setStatus(`Ошибка загрузки: ${uploadError.message}`); - return null; - } finally { - state.mediaUploadActive = false; - mediaFilePicker.value = ""; - setFilePickerActive(false); - recoverEditorSurface(); - } -} - -function openMediaFilePicker(context) { - setFilePickerActive(true); - recoverEditorSurface(); - window.clearTimeout(state.filePickerResetTimer); - - mediaFilePicker.accept = MEDIA_ACCEPT; - mediaFilePicker.value = ""; - mediaFilePicker.oncancel = () => { - setFilePickerActive(false); - recoverEditorSurface(); - }; - mediaFilePicker.onchange = async () => { - const file = mediaFilePicker.files?.[0]; - - if (!file) { - setFilePickerActive(false); - recoverEditorSurface(); - return; - } - - await uploadMediaFile({ ...context, file }); - }; - - window.addEventListener( - "focus", - () => { - window.setTimeout(() => { - if (state.mediaUploadActive || mediaFilePicker.files?.length) return; - setFilePickerActive(false); - recoverEditorSurface(); - }, 180); - }, - { once: true }, - ); - - try { - mediaFilePicker.click(); - } catch (error) { - setFilePickerActive(false); - recoverEditorSurface(); - context.error.textContent = error.message; - setStatus(`Ошибка открытия выбора файла: ${error.message}`); - } -} - -function renderMediaThumb(target, file, { large = false } = {}) { - target.innerHTML = ""; - target.classList.remove("is-image", "is-video"); - - if (!file) { - target.textContent = "FILE"; - return; - } - - if (file.kind === "image") { - const image = document.createElement("img"); - target.classList.add("is-image"); - image.src = adminPreviewUrl(file.url); - image.alt = file.name || ""; - image.loading = "lazy"; - image.decoding = "async"; - target.append(image); - return; - } - - if (file.kind === "video") { - const video = document.createElement("video"); - target.classList.add("is-video"); - video.src = adminPreviewUrl(file.url); - video.muted = true; - video.loop = true; - video.playsInline = true; - video.preload = "metadata"; - if (large) video.controls = true; - target.append(video); - return; - } - - const label = file.kind === "model" ? "3D" : file.kind === "font" ? "FONT" : file.extension?.toUpperCase() || "FILE"; - target.textContent = label; -} - -function renderMediaFilterButtons() { - el.mediaKindFilters.innerHTML = ""; - el.mediaRootFilters.innerHTML = ""; - const activeRoot = activeMediaRootFilter(); - - for (const [kind, label] of MEDIA_KIND_FILTERS) { - const button = document.createElement("button"); - button.type = "button"; - button.className = "media-filter-button"; - button.dataset.active = String(state.mediaLibrary.kind === kind); - button.disabled = state.mediaLibrary.trashMode; - button.textContent = label; - button.addEventListener("click", () => { - state.mediaLibrary.kind = kind; - renderMediaLibrary(); - }); - el.mediaKindFilters.append(button); - } - - for (const [rootPath, label] of MEDIA_ROOT_FILTERS) { - const button = document.createElement("button"); - button.type = "button"; - button.className = "media-filter-button"; - button.dataset.active = String(activeRoot === rootPath); - button.disabled = state.mediaLibrary.trashMode; - button.textContent = label; - button.title = rootPath || "Корень проекта NODEDC_SITE"; - button.addEventListener("click", async () => { - state.mediaLibrary.root = rootPath; - state.mediaLibrary.selectedEntry = null; - state.mediaLibrary.selectedUrl = ""; - await loadMediaColumns(rootPath).catch((error) => setStatus(`Ошибка медиатеки: ${error.message}`)); - }); - el.mediaRootFilters.append(button); - } -} - -function mediaPathParts(path) { - return String(path || "") - .split("/") - .filter(Boolean); -} - -function isSameOrChildPath(path, rootPath) { - if (!rootPath) return !path; - return path === rootPath || path.startsWith(`${rootPath}/`); -} - -function activeMediaRootFilter() { - const selectedPath = state.mediaLibrary.selectedPath || state.mediaLibrary.root || ""; - let activeRoot = ""; - - for (const [rootPath] of MEDIA_ROOT_FILTERS) { - if (rootPath && isSameOrChildPath(selectedPath, rootPath)) { - activeRoot = rootPath; - } - } - - return activeRoot; -} - -function mediaPathChain(path) { - const parts = mediaPathParts(path); - const chain = [""]; - let current = ""; - - for (const part of parts) { - current = current ? `${current}/${part}` : part; - chain.push(current); - } - - return chain; -} - -function parentMediaPath(path) { - const parts = mediaPathParts(path); - parts.pop(); - return parts.join("/"); -} - -function rememberMediaDirectory(path) { - const nextPath = String(path || "").replace(/^\/+/, ""); - if (nextPath === "__trash__") return; - - state.mediaLibrary.lastPath = nextPath; - updateAdminLayoutState({ lastMediaDirectory: nextPath }); -} - -function pathFromUrl(url) { - let value = String(url || "").split("?")[0].split("#")[0]; - if (value.startsWith("./")) value = value.slice(2); - - try { - value = decodeURIComponent(value); - } catch { - // Keep raw value for malformed escape sequences. - } - - return value; -} - -function mediaReplacementVariants(pathOrUrl) { - const path = pathFromUrl(pathOrUrl); - if (!path) return []; - - const encodedPath = path - .split("/") - .map((part) => encodeURIComponent(part)) - .join("/"); - const variants = [path, `./${path}`, `/${path}`, encodedPath, `./${encodedPath}`, `/${encodedPath}`]; - - return [...new Set(variants)].sort((left, right) => right.length - left.length); -} - -function replaceMediaReferencesInString(value, replacements) { - let nextValue = value; - - for (const replacement of replacements) { - const fromVariants = mediaReplacementVariants(replacement.fromUrl || replacement.from); - const toPath = pathFromUrl(replacement.toUrl || replacement.to); - if (!fromVariants.length || !toPath) continue; - - const encodedToPath = toPath - .split("/") - .map((part) => encodeURIComponent(part)) - .join("/"); - - for (const fromVariant of fromVariants) { - const hasDotPrefix = fromVariant.startsWith("./"); - const hasRootPrefix = fromVariant.startsWith("/"); - const toVariant = `${hasDotPrefix ? "./" : hasRootPrefix ? "/" : ""}${fromVariant.includes("%") ? encodedToPath : toPath}`; - nextValue = nextValue.split(fromVariant).join(toVariant); - } - } - - return nextValue; -} - -function replaceMediaReferencesInValue(value, replacements) { - if (typeof value === "string") { - return replaceMediaReferencesInString(value, replacements); - } - - if (Array.isArray(value)) { - let changed = false; - const nextValue = value.map((item) => { - const nextItem = replaceMediaReferencesInValue(item, replacements); - changed ||= nextItem !== item; - return nextItem; - }); - return changed ? nextValue : value; - } - - if (value && typeof value === "object") { - let changed = false; - const nextValue = { ...value }; - - for (const [key, item] of Object.entries(value)) { - const nextItem = replaceMediaReferencesInValue(item, replacements); - if (nextItem !== item) { - nextValue[key] = nextItem; - changed = true; - } - } - - return changed ? nextValue : value; - } - - return value; -} - -async function browseMediaPath(path) { - const response = await fetch(`/api/media/browse?path=${encodeURIComponent(path || "")}`); - const payload = await response.json().catch(() => ({ ok: false, error: `Сервер вернул ${response.status}` })); - - if (!response.ok || !payload.ok) { - throw new Error(payload.error || "Не удалось открыть папку"); - } - - return payload; -} - -async function refreshMediaSiteSize() { - if (!el.mediaSiteSize) return; - - el.mediaSiteSize.textContent = "Сайт: считаем..."; - const response = await fetch("/api/media/site-size"); - const payload = await response.json().catch(() => ({ ok: false, error: `Сервер вернул ${response.status}` })); - - if (!response.ok || !payload.ok) { - el.mediaSiteSize.textContent = "Сайт: --"; - throw new Error(payload.error || "Не удалось посчитать размер сайта"); - } - - el.mediaSiteSize.textContent = `Сайт: ${payload.label}`; - el.mediaSiteSize.title = `${Number(payload.bytes || 0).toLocaleString("ru-RU")} байт`; -} - -async function postMediaAction(endpoint, payload = {}) { - const response = await fetch(endpoint, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(payload), - }); - const result = await response.json().catch(() => ({ ok: false, error: `Сервер вернул ${response.status}` })); - - if (!response.ok || !result.ok) { - throw new Error(result.error || "Операция не выполнена"); - } - - return result; -} - -function mediaDragHasEntry(event) { - return Array.from(event.dataTransfer?.types || []).includes(MEDIA_DRAG_MIME); -} - -function mediaDragPayload(event) { - const rawPayload = event.dataTransfer?.getData(MEDIA_DRAG_MIME); - if (!rawPayload) return null; - - try { - return JSON.parse(rawPayload); - } catch { - return null; - } -} - -function syncMediaFieldValueFromServer(context, value) { - if (!context || !value) return; - - const { block, editableField, hiddenInput, urlInput, fileName, preview, hint } = context; - hiddenInput.value = value; - urlInput.value = value; - fileName.textContent = truncateText(fileNameFromUrl(value), 28); - fileName.title = fileNameFromUrl(value); - setByPath(block, editableField.path, value); - renderMediaPreview(preview, value, hint, editableField); - - if (activeBlock() === block) { - el.json.value = JSON.stringify(block, null, 2); - } - - context.setSource("file"); -} - -function applyMediaReferenceReplacements(replacements = []) { - const context = state.mediaLibrary.context; - if (!Array.isArray(replacements) || !replacements.length) return false; - let changed = false; - const contextPath = context?.hiddenInput ? pathFromUrl(context.hiddenInput.value || "") : ""; - - const nextPage = replaceMediaReferencesInValue(state.page, replacements); - if (nextPage !== state.page) { - state.page = nextPage; - changed = true; - } - - document.querySelectorAll("input.service-media-hidden-input").forEach((input) => { - const nextValue = replaceMediaReferencesInString(input.value || "", replacements); - if (nextValue === input.value) return; - - input.value = nextValue; - const field = input.closest(".service-media-field"); - const fileName = field?.querySelector(".service-media-file-name"); - const urlInput = field?.querySelector(".service-media-url-input"); - if (urlInput) urlInput.value = nextValue; - if (fileName) { - fileName.textContent = truncateText(fileNameFromUrl(nextValue), 28); - fileName.title = fileNameFromUrl(nextValue); - } - changed = true; - }); - - if (activeBlock()) { - el.json.value = JSON.stringify(activeBlock(), null, 2); - } - - if (!context?.hiddenInput) return changed; - - const match = replacements.find((replacement) => pathFromUrl(replacement.fromUrl || replacement.from || "") === contextPath); - if (!match?.toUrl) return changed; - - syncMediaFieldValueFromServer(context, match.toUrl); - return true; -} - -function canDropMediaEntryOnTarget(entry, targetPath) { - if (!entry || state.mediaLibrary.trashMode) return false; - if (!entry.path) return false; - - const normalizedTarget = String(targetPath || "").replace(/^\/+/, ""); - const sourceParent = parentMediaPath(entry.path); - - if (entry.type === "directory" && (normalizedTarget === entry.path || normalizedTarget.startsWith(`${entry.path}/`))) { - return false; - } - - return normalizedTarget !== sourceParent; -} - -function setMediaDropActive(node, active) { - if (!node) return; - if (active) { - node.dataset.dropActive = "true"; - return; - } - - delete node.dataset.dropActive; -} - -function handleMediaDragOver(event, targetPath) { - if (!mediaDragHasEntry(event)) return; - const entry = mediaDragPayload(event) || findEntryByPath(state.mediaLibrary.dragEntryPath); - - event.preventDefault(); - event.stopPropagation(); - event.dataTransfer.dropEffect = canDropMediaEntryOnTarget(entry, targetPath) ? "move" : "none"; - setMediaDropActive(event.currentTarget, event.dataTransfer.dropEffect === "move"); -} - -function handleMediaDragLeave(event) { - if (event.currentTarget.contains(event.relatedTarget)) return; - setMediaDropActive(event.currentTarget, false); -} - -async function moveMediaEntryToPath(entry, targetPath) { - if (!canDropMediaEntryOnTarget(entry, targetPath)) return; - - const payload = await postMediaAction("/api/media/move", { path: entry.path, targetPath }); - applyMediaReferenceReplacements(payload.replacements); - - const nextParentPath = parentMediaPath(payload.path); - await loadMediaColumns(nextParentPath, { selectedUrl: payload.url || "" }); - - const movedEntry = findEntryByPath(payload.path); - if (movedEntry) { - state.mediaLibrary.selectedPath = nextParentPath; - state.mediaLibrary.selectedEntry = movedEntry; - state.mediaLibrary.selectedUrl = movedEntry.type === "file" ? movedEntry.url || "" : ""; - renderMediaLibrary(); - } - - const updatedCount = Array.isArray(payload.updatedFiles) ? payload.updatedFiles.length : 0; - setStatus(`Перенесено: ${payload.path}${updatedCount ? `. Ссылки обновлены: ${updatedCount}` : ""}.`); -} - -function handleMediaDrop(event, targetPath) { - if (!mediaDragHasEntry(event)) return; - const entry = mediaDragPayload(event) || findEntryByPath(state.mediaLibrary.dragEntryPath); - - event.preventDefault(); - event.stopPropagation(); - setMediaDropActive(event.currentTarget, false); - if (!canDropMediaEntryOnTarget(entry, targetPath)) return; - - moveMediaEntryToPath(entry, targetPath).catch((error) => setStatus(`Ошибка переноса: ${error.message}`)); -} - -async function browseMediaTrash() { - const response = await fetch("/api/media/trash"); - const payload = await response.json().catch(() => ({ ok: false, error: `Сервер вернул ${response.status}` })); - - if (!response.ok || !payload.ok) { - throw new Error(payload.error || "Не удалось открыть удалённые"); - } - - return { - ok: true, - path: "__trash__", - breadcrumbs: [{ name: "Удалённые", path: "__trash__" }], - entries: payload.files || [], - }; -} - -function entryMatchesMediaFilters(entry) { - const query = state.mediaLibrary.query.trim().toLowerCase(); - - if (query) { - const matchesQuery = [entry.name, entry.path, entry.url, mediaUsageLabel(entry)] - .filter(Boolean) - .some((value) => String(value).toLowerCase().includes(query)); - if (!matchesQuery) return false; - } - - if (entry.type === "directory") return true; - if (state.mediaLibrary.kind === "all") return true; - return entry.kind === state.mediaLibrary.kind; -} - -function findEntryByUrl(url) { - for (const column of state.mediaLibrary.columns) { - const match = column.entries.find((entry) => entry.url === url); - if (match) return match; - } - - return null; -} - -function findEntryByPath(path) { - if (!path) return null; - - for (const column of state.mediaLibrary.columns) { - const match = column.entries.find((entry) => entry.path === path); - if (match) return match; - } - - return null; -} - -function canDeleteMediaEntry(entry) { - if (!entry || state.mediaLibrary.trashMode) return false; - if (entry.type === "directory") return entry.path?.startsWith("assets/") && !entry.path.split("/").includes(".trash"); - - const isCurrentFieldValue = state.mediaLibrary.context?.hiddenInput?.value === entry.url; - return entry.type === "file" && entry.writable && mediaUsageStatus(entry) === "unused" && !isCurrentFieldValue; -} - -function selectMediaEntry(entry, columnPath = state.mediaLibrary.selectedPath) { - if (!entry) return; - - if (entry.type === "file") { - state.mediaLibrary.selectedPath = columnPath; - state.mediaLibrary.selectedUrl = entry.url || ""; - } else { - state.mediaLibrary.selectedUrl = ""; - } - - state.mediaLibrary.selectedEntry = entry; - renderMediaLibrary(); -} - -function closeMediaContextMenu() { - el.mediaContextMenu.classList.add("hidden"); - el.mediaContextMenu.innerHTML = ""; - state.mediaLibrary.contextMenu = null; -} - -function appendMediaContextAction(label, action, { disabled = false } = {}) { - const button = document.createElement("button"); - - button.type = "button"; - button.setAttribute("role", "menuitem"); - button.textContent = label; - button.disabled = disabled; - button.addEventListener("click", () => { - closeMediaContextMenu(); - action(); - }); - el.mediaContextMenu.append(button); -} - -function openMediaContextMenu(event, { entry = null, columnPath = state.mediaLibrary.selectedPath } = {}) { - event.preventDefault(); - closeMediaContextMenu(); - state.mediaLibrary.contextMenu = { entry, columnPath }; - - if (entry) { - selectMediaEntry(entry, columnPath); - } - - if (state.mediaLibrary.trashMode) { - if (entry?.type === "file") { - appendMediaContextAction("Восстановить", () => restoreSelectedMediaFile().catch((error) => setStatus(`Ошибка восстановления: ${error.message}`))); - appendMediaContextAction("Удалить навсегда", () => deleteSelectedTrashFile().catch((error) => setStatus(`Ошибка удаления из корзины: ${error.message}`))); - } - } else if (entry?.type === "file" || entry?.type === "directory") { - appendMediaContextAction("Дублировать", () => duplicateMediaEntry(entry).catch((error) => setStatus(`Ошибка дублирования: ${error.message}`))); - appendMediaContextAction("Переименовать", () => renameMediaEntry(entry).catch((error) => setStatus(`Ошибка переименования: ${error.message}`))); - appendMediaContextAction("Удалить", () => deleteSelectedMediaEntry().catch((error) => setStatus(`Ошибка удаления: ${error.message}`)), { - disabled: !canDeleteMediaEntry(entry), - }); - } else { - appendMediaContextAction("Создать папку", () => createMediaFolderAt(columnPath).catch((error) => setStatus(`Ошибка создания папки: ${error.message}`))); - } - - if (!el.mediaContextMenu.children.length) return; - - el.mediaContextMenu.classList.remove("hidden"); - const rect = el.mediaContextMenu.getBoundingClientRect(); - const x = Math.min(event.clientX, window.innerWidth - rect.width - 12); - const y = Math.min(event.clientY, window.innerHeight - rect.height - 12); - el.mediaContextMenu.style.left = `${Math.max(12, x)}px`; - el.mediaContextMenu.style.top = `${Math.max(12, y)}px`; -} - -function scrollMediaBrowserToSelection() { - window.requestAnimationFrame(() => { - const columns = [...el.mediaList.querySelectorAll(".media-column")]; - const targetColumn = columns.at(-1); - const exactEntry = el.mediaList.querySelector('.media-entry[data-exact-active="true"]'); - const activeEntry = exactEntry || el.mediaList.querySelector('.media-entry[data-active="true"]'); - - if (targetColumn) { - const padding = 14; - const maxScrollLeft = Math.max(0, el.mediaList.scrollWidth - el.mediaList.clientWidth); - const targetRight = targetColumn.offsetLeft + targetColumn.offsetWidth + padding; - const targetLeft = targetColumn.offsetLeft - padding; - let nextScrollLeft = el.mediaList.scrollLeft; - - if (targetRight > nextScrollLeft + el.mediaList.clientWidth) { - nextScrollLeft = targetRight - el.mediaList.clientWidth; - } - - if (targetLeft < nextScrollLeft) { - nextScrollLeft = targetLeft; - } - - el.mediaList.scrollLeft = Math.min(Math.max(nextScrollLeft, 0), maxScrollLeft); - } - - if (activeEntry) { - const list = activeEntry.closest(".media-column-list"); - if (!list) return; - - const padding = 8; - const nextSlotHeight = activeEntry.offsetHeight + 4; - const targetBottom = activeEntry.offsetTop + activeEntry.offsetHeight + nextSlotHeight + padding; - const targetTop = activeEntry.offsetTop - padding; - const maxScrollTop = Math.max(0, list.scrollHeight - list.clientHeight); - let nextScrollTop = list.scrollTop; - - if (targetBottom > nextScrollTop + list.clientHeight) { - nextScrollTop = targetBottom - list.clientHeight; - } - - if (targetTop < nextScrollTop) { - nextScrollTop = targetTop; - } - - list.scrollTop = Math.min(Math.max(nextScrollTop, 0), maxScrollTop); - } - }); -} - -async function loadMediaColumns(targetPath, { selectedUrl = state.mediaLibrary.selectedUrl } = {}) { - const path = String(targetPath || "").replace(/^\/+/, ""); - const chain = mediaPathChain(path); - const columns = []; - - state.mediaLibrary.trashMode = false; - setStatus("Открываем файловую структуру..."); - - for (const columnPath of chain) { - columns.push(await browseMediaPath(columnPath)); - } - - state.mediaLibrary.columns = columns; - state.mediaLibrary.selectedPath = path; - state.mediaLibrary.loaded = true; - state.mediaLibrary.selectedUrl = selectedUrl || ""; - state.mediaLibrary.selectedEntry = selectedUrl ? findEntryByUrl(selectedUrl) : findEntryByPath(path); - rememberMediaDirectory(path); - renderMediaLibrary(); - setStatus(`Файловая структура: ${path || "корень проекта"}.`); -} - -async function loadMediaTrash({ selectedTrashPath = "" } = {}) { - setStatus("Открываем удалённые файлы..."); - const column = await browseMediaTrash(); - - state.mediaLibrary.trashMode = true; - state.mediaLibrary.columns = [column]; - state.mediaLibrary.selectedPath = "__trash__"; - state.mediaLibrary.selectedUrl = ""; - state.mediaLibrary.loaded = true; - state.mediaLibrary.selectedEntry = selectedTrashPath ? findEntryByPath(selectedTrashPath) : column.entries[0] || null; - renderMediaLibrary(); - setStatus(`Удалённые файлы: ${column.entries.length}.`); -} - -function renderMediaBreadcrumbs() { - el.mediaBreadcrumbs.innerHTML = ""; - const breadcrumbs = state.mediaLibrary.columns.at(-1)?.breadcrumbs || [{ name: "NODEDC_SITE", path: "" }]; - - breadcrumbs.forEach((crumb, index) => { - const button = document.createElement("button"); - button.type = "button"; - button.className = "media-breadcrumb-button"; - button.textContent = crumb.name; - button.dataset.active = String(index === breadcrumbs.length - 1); - button.addEventListener("click", () => { - state.mediaLibrary.root = crumb.path; - state.mediaLibrary.selectedEntry = null; - state.mediaLibrary.selectedUrl = ""; - loadMediaColumns(crumb.path).catch((error) => setStatus(`Ошибка медиатеки: ${error.message}`)); - }); - el.mediaBreadcrumbs.append(button); - }); -} - -function renderMediaEntry(entry, columnPath) { - const button = document.createElement("button"); - const icon = document.createElement("span"); - const copy = document.createElement("span"); - const name = document.createElement("span"); - const meta = document.createElement("span"); - const statusDot = document.createElement("span"); - const arrow = document.createElement("span"); - const isExactSelected = state.mediaLibrary.selectedEntry?.path === entry.path; - const isSelected = - isExactSelected || - (entry.type === "directory" && isSameOrChildPath(state.mediaLibrary.selectedPath, entry.path)); - - button.type = "button"; - button.className = "media-entry"; - button.dataset.type = entry.type; - button.dataset.path = entry.path; - button.dataset.active = String(isSelected); - button.dataset.exactActive = String(isExactSelected); - button.title = entry.url || entry.path; - button.draggable = !state.mediaLibrary.trashMode; - - icon.className = "media-entry-icon"; - if (entry.type === "file" && entry.url && entry.kind === "image") { - const image = document.createElement("img"); - icon.classList.add("is-preview"); - image.src = adminPreviewUrl(entry.url); - image.alt = ""; - image.loading = "lazy"; - image.decoding = "async"; - icon.append(image); - } else if (entry.type === "file" && entry.url && entry.kind === "video") { - const video = document.createElement("video"); - icon.classList.add("is-preview"); - video.src = adminPreviewUrl(entry.url); - video.muted = true; - video.playsInline = true; - video.preload = "metadata"; - icon.append(video); - } else { - icon.textContent = - entry.type === "directory" - ? "DIR" - : entry.kind === "model" - ? "3D" - : entry.kind === "font" - ? "Aa" - : entry.extension?.toUpperCase() || "FILE"; - } - - copy.className = "media-entry-copy"; - name.className = "media-entry-name"; - name.textContent = entry.name; - meta.className = "media-entry-meta"; - meta.textContent = - entry.type === "directory" - ? entry.path || "Корень проекта" - : [mediaUsageLabel(entry), entry.extension, formatFileSize(entry.size)].filter(Boolean).join(" · "); - copy.append(name, meta); - - statusDot.className = "media-status-dot media-entry-status"; - statusDot.dataset.status = mediaUsageStatus(entry); - statusDot.title = mediaUsageLabel(entry); - - arrow.className = "media-entry-arrow"; - arrow.textContent = entry.type === "directory" ? "›" : ""; - - button.append(icon, copy); - if (entry.type === "file") button.append(statusDot); - button.append(arrow); - - button.addEventListener("click", async () => { - if (entry.type === "directory") { - state.mediaLibrary.root = entry.path; - state.mediaLibrary.selectedEntry = entry; - state.mediaLibrary.selectedUrl = ""; - await loadMediaColumns(entry.path); - return; - } - - selectMediaEntry(entry, columnPath); - }); - button.addEventListener("contextmenu", (event) => { - event.stopPropagation(); - openMediaContextMenu(event, { entry, columnPath }); - }); - button.addEventListener("dblclick", () => { - if (entry.type === "file" && entry.selectable) chooseSelectedMediaFile(); - }); - button.addEventListener("dragstart", (event) => { - if (state.mediaLibrary.trashMode) { - event.preventDefault(); - return; - } - - const payload = JSON.stringify({ path: entry.path, type: entry.type }); - state.mediaLibrary.dragEntryPath = entry.path; - button.dataset.dragging = "true"; - event.dataTransfer.effectAllowed = "move"; - event.dataTransfer.setData(MEDIA_DRAG_MIME, payload); - event.dataTransfer.setData("text/plain", entry.path); - }); - button.addEventListener("dragend", () => { - state.mediaLibrary.dragEntryPath = ""; - delete button.dataset.dragging; - el.mediaList.querySelectorAll("[data-drop-active]").forEach((node) => setMediaDropActive(node, false)); - }); - - if (entry.type === "directory") { - button.addEventListener("dragover", (event) => handleMediaDragOver(event, entry.path)); - button.addEventListener("dragleave", handleMediaDragLeave); - button.addEventListener("drop", (event) => handleMediaDrop(event, entry.path)); - } - - return button; -} - -function renderMediaColumn(column) { - const columnNode = document.createElement("section"); - const head = document.createElement("div"); - const title = document.createElement("div"); - const count = document.createElement("div"); - const list = document.createElement("div"); - const entries = (column.entries || []).filter(entryMatchesMediaFilters); - - columnNode.className = "media-column"; - head.className = "media-column-head"; - title.className = "media-column-title"; - title.textContent = column.path ? mediaPathParts(column.path).at(-1) : "NODEDC_SITE"; - count.className = "media-column-count"; - count.textContent = `${entries.length}`; - head.append(title, count); - - list.className = "media-column-list"; - list.dataset.path = column.path || ""; - list.addEventListener("dragover", (event) => handleMediaDragOver(event, column.path || "")); - list.addEventListener("dragleave", handleMediaDragLeave); - list.addEventListener("drop", (event) => handleMediaDrop(event, column.path || "")); - list.addEventListener("contextmenu", (event) => { - if (event.target.closest(".media-entry")) return; - openMediaContextMenu(event, { columnPath: column.path || "" }); - }); - for (const entry of entries) { - list.append(renderMediaEntry(entry, column.path)); - } - - if (!entries.length) { - const empty = document.createElement("div"); - empty.className = "media-column-empty"; - empty.textContent = "Пусто"; - list.append(empty); - } - - columnNode.append(head, list); - return columnNode; -} - -function mediaColumnByPath(path) { - return state.mediaLibrary.columns.find((column) => column.path === path) || null; -} - -function adjacentMediaFileUrl(file) { - const selectedPath = state.mediaLibrary.selectedPath || file?.directory || parentMediaPath(pathFromUrl(file?.url || "")); - const column = mediaColumnByPath(selectedPath); - if (!column) return ""; - - const visibleFiles = column.entries.filter(entryMatchesMediaFilters).filter((entry) => entry.type === "file"); - let index = visibleFiles.findIndex((entry) => entry.path === file.path); - let adjacent = index >= 0 ? visibleFiles[index + 1] || visibleFiles[index - 1] : null; - - if (!adjacent) { - const files = column.entries.filter((entry) => entry.type === "file"); - index = files.findIndex((entry) => entry.path === file.path); - adjacent = index >= 0 ? files[index + 1] || files[index - 1] : null; - } - - return adjacent?.url || ""; -} - -function adjacentMediaEntryPath(entry) { - if (!entry?.path) return ""; - - const column = mediaColumnForEntry(entry) || mediaColumnByPath(parentMediaPath(entry.path)); - if (!column) return ""; - - const entries = visibleMediaEntries(column); - let index = entries.findIndex((candidate) => candidate.path === entry.path); - let adjacent = index >= 0 ? entries[index + 1] || entries[index - 1] : null; - - if (!adjacent) { - index = column.entries.findIndex((candidate) => candidate.path === entry.path); - adjacent = index >= 0 ? column.entries[index + 1] || column.entries[index - 1] : null; - } - - return adjacent?.path || ""; -} - -function visibleMediaEntries(column) { - return (column?.entries || []).filter(entryMatchesMediaFilters); -} - -function mediaColumnForEntry(entry) { - if (!entry) return null; - return state.mediaLibrary.columns.find((column) => column.entries.some((candidate) => candidate.path === entry.path)) || null; -} - -function activeMediaColumn() { - return ( - mediaColumnForEntry(state.mediaLibrary.selectedEntry) || - state.mediaLibrary.columns.find((column) => column.path === state.mediaLibrary.selectedPath) || - state.mediaLibrary.columns.at(-1) || - null - ); -} - -async function moveMediaSelection(delta) { - const column = activeMediaColumn(); - const entries = visibleMediaEntries(column); - if (!column || !entries.length) return; - - const currentIndex = entries.findIndex((entry) => entry.path === state.mediaLibrary.selectedEntry?.path); - const fallbackIndex = delta > 0 ? 0 : entries.length - 1; - const nextIndex = currentIndex < 0 ? fallbackIndex : Math.min(Math.max(currentIndex + delta, 0), entries.length - 1); - const nextEntry = entries[nextIndex]; - - if (nextEntry.type === "directory" && !state.mediaLibrary.trashMode) { - state.mediaLibrary.root = nextEntry.path; - state.mediaLibrary.selectedUrl = ""; - await loadMediaColumns(nextEntry.path, { selectedUrl: "" }); - - const selectedDirectory = findEntryByPath(nextEntry.path); - if (selectedDirectory) { - state.mediaLibrary.selectedPath = column.path; - state.mediaLibrary.selectedEntry = selectedDirectory; - state.mediaLibrary.selectedUrl = ""; - renderMediaLibrary(); - } - return; - } - - selectMediaEntry(nextEntry, column.path); -} - -async function openSelectedMediaDirectory() { - const entry = state.mediaLibrary.selectedEntry; - if (!entry || entry.type !== "directory" || state.mediaLibrary.trashMode) return; - - state.mediaLibrary.root = entry.path; - state.mediaLibrary.selectedEntry = entry; - state.mediaLibrary.selectedUrl = ""; - await loadMediaColumns(entry.path, { selectedUrl: "" }); - - const openedColumn = mediaColumnByPath(entry.path); - const firstEntry = visibleMediaEntries(openedColumn)[0]; - if (firstEntry) { - state.mediaLibrary.selectedPath = entry.path; - state.mediaLibrary.selectedEntry = firstEntry; - state.mediaLibrary.selectedUrl = firstEntry.type === "file" ? firstEntry.url || "" : ""; - renderMediaLibrary(); - } -} - -async function moveMediaHierarchyBack() { - if (state.mediaLibrary.trashMode) return; - const path = state.mediaLibrary.selectedPath || state.mediaLibrary.selectedEntry?.path || ""; - if (!path) return; - - const parentPath = parentMediaPath(path); - state.mediaLibrary.selectedUrl = ""; - await loadMediaColumns(parentPath, { selectedUrl: "" }); - - const closedDirectory = findEntryByPath(path); - if (closedDirectory) { - state.mediaLibrary.selectedPath = parentPath; - state.mediaLibrary.selectedEntry = closedDirectory; - state.mediaLibrary.selectedUrl = ""; - renderMediaLibrary(); - } -} - -function isTypingTarget(target) { - return Boolean(target?.closest?.("input, textarea, select, [contenteditable='true']")); -} - -function renderMediaPreviewPanel() { - const file = selectedMediaFile(); - const entry = state.mediaLibrary.selectedEntry; - const usageText = el.mediaPreviewUsage.lastElementChild; - - renderMediaThumb(el.mediaPreview, file, { large: true }); - el.mediaSelect.textContent = state.mediaLibrary.trashMode ? "Восстановить" : "Выбрать"; - el.mediaSelect.disabled = !file; - el.mediaDelete.disabled = true; - - if (!entry) { - el.mediaPreviewName.textContent = "Выберите файл или папку"; - el.mediaPreviewPath.textContent = state.mediaLibrary.selectedPath || "NODEDC_SITE"; - el.mediaPreviewMeta.textContent = "Column view проекта"; - el.mediaPreviewUsage.dataset.status = "idle"; - if (usageText) usageText.textContent = "Нет выбора"; - return; - } - - if (entry.type === "directory") { - const canDeleteDirectory = canDeleteMediaEntry(entry); - el.mediaPreview.innerHTML = "DIR"; - el.mediaPreviewName.textContent = entry.name; - el.mediaPreviewPath.textContent = entry.path || "NODEDC_SITE"; - el.mediaPreviewPath.title = entry.path || "NODEDC_SITE"; - el.mediaPreviewMeta.textContent = "Папка проекта"; - el.mediaPreviewUsage.dataset.status = "idle"; - if (usageText) usageText.textContent = "Папка"; - el.mediaDelete.disabled = !canDeleteDirectory; - el.mediaDelete.title = canDeleteDirectory ? "Перенести папку в assets/.trash" : "Удаление доступно только для папок внутри assets"; - return; - } - - if (state.mediaLibrary.trashMode) { - el.mediaSelect.disabled = !entry.restorable; - el.mediaPreviewName.textContent = entry.originalName || entry.name; - el.mediaPreviewPath.textContent = entry.originalPath || entry.path; - el.mediaPreviewPath.title = entry.originalPath || entry.path; - el.mediaPreviewMeta.textContent = [mediaFileMeta(entry), formatFileDate(entry.mtimeMs)].filter(Boolean).join(" · "); - el.mediaPreviewUsage.dataset.status = "trash"; - if (usageText) usageText.textContent = "Удалён"; - el.mediaDelete.disabled = !entry.trashPath; - el.mediaDelete.title = "Удалить выбранный файл из корзины навсегда"; - return; - } - - const canDelete = canDeleteMediaEntry(entry); - - el.mediaSelect.disabled = !entry.selectable; - el.mediaPreviewName.textContent = entry.name; - el.mediaPreviewPath.textContent = entry.url || entry.path; - el.mediaPreviewPath.title = entry.url || entry.path; - el.mediaPreviewMeta.textContent = [mediaFileMeta(entry), formatFileDate(entry.mtimeMs)].filter(Boolean).join(" · "); - el.mediaPreviewUsage.dataset.status = mediaUsageStatus(entry); - if (usageText) usageText.textContent = entry.selectable ? mediaUsageLabel(entry) : "Не публичный asset"; - el.mediaDelete.disabled = !canDelete; - el.mediaDelete.title = canDelete - ? "Перенести unused-файл в assets/.trash" - : "Удаление доступно только для unused-файлов из assets"; -} - -function renderMediaLibrary() { - el.mediaTrashToggle.dataset.active = String(state.mediaLibrary.trashMode); - el.mediaUpload.textContent = state.mediaLibrary.trashMode ? "Очистить корзину" : "+ Загрузить"; - renderMediaFilterButtons(); - renderMediaBreadcrumbs(); - el.mediaList.innerHTML = ""; - el.mediaList.classList.add("media-columns"); - - const columns = state.mediaLibrary.columns || []; - el.mediaEmpty.classList.toggle("hidden", columns.length > 0); - for (const column of columns) { - el.mediaList.append(renderMediaColumn(column)); - } - - renderMediaPreviewPanel(); - scrollMediaBrowserToSelection(); -} - -async function refreshMediaLibrary() { - await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`)); - - if (state.mediaLibrary.trashMode) { - await loadMediaTrash({ selectedTrashPath: state.mediaLibrary.selectedEntry?.trashPath || "" }); - return; - } - - await loadMediaColumns(state.mediaLibrary.selectedPath || state.mediaLibrary.root || ""); -} - -async function toggleMediaTrash() { - if (state.mediaLibrary.trashMode) { - await loadMediaColumns(state.mediaLibrary.previousPath || state.mediaLibrary.root || ""); - return; - } - - state.mediaLibrary.previousPath = state.mediaLibrary.selectedPath || state.mediaLibrary.root || ""; - state.mediaLibrary.selectedEntry = null; - state.mediaLibrary.selectedUrl = ""; - await loadMediaTrash(); -} - -async function openMediaLibrary(context) { - const currentValue = context.hiddenInput?.value || ""; - const currentValuePath = pathFromUrl(currentValue); - const suggestedPath = `assets/uploads/${uploadBucketForField(context.block, context.editableField)}`; - const lastPath = state.mediaLibrary.lastPath || readLastMediaDirectory(); - const startPath = lastPath || (currentValuePath ? parentMediaPath(currentValuePath) : suggestedPath); - - state.mediaLibrary.context = context; - state.mediaLibrary.trashMode = false; - state.mediaLibrary.previousPath = ""; - state.mediaLibrary.selectedUrl = currentValue; - state.mediaLibrary.selectedEntry = null; - state.mediaLibrary.selectedPath = startPath; - state.mediaLibrary.query = ""; - state.mediaLibrary.root = startPath; - state.mediaLibrary.kind = "all"; - el.mediaSearch.value = ""; - el.mediaModalTitle.textContent = context.editableField.label || "Выбрать файл"; - el.mediaModalSubtitle.textContent = `${context.editableField.path} -> ${suggestedPath}`; - el.mediaModal.classList.remove("hidden"); - el.mediaSearch.focus(); - refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`)); - - try { - await loadMediaColumns(startPath, { selectedUrl: currentValue }); - } catch (error) { - setStatus(`Ошибка медиатеки: ${error.message}`); - renderMediaLibrary(); - } -} - -function closeMediaLibrary() { - closeMediaContextMenu(); - el.mediaModal.classList.add("hidden"); - delete el.mediaDropZone.dataset.dragActive; - state.mediaLibrary.context = null; - state.mediaLibrary.trashMode = false; - state.mediaLibrary.selectedUrl = ""; - state.mediaLibrary.selectedEntry = null; - recoverEditorSurface(); -} - -function chooseSelectedMediaFile() { - const file = selectedMediaFile(); - const context = state.mediaLibrary.context; - if (!file || !context) return; - - if (state.mediaLibrary.trashMode) { - restoreSelectedMediaFile().catch((error) => setStatus(`Ошибка восстановления: ${error.message}`)); - return; - } - - if (typeof context.onSelect === "function") { - context.onSelect(file.url, file); - } else { - updateMediaFieldValue({ - ...context, - value: file.url, - }); - context.setSource("file"); - } - rememberMediaDirectory(file.directory || parentMediaPath(pathFromUrl(file.url))); - closeMediaLibrary(); - markDirty(`Выбран файл: ${file.url}. Нажмите «Сохранить модель», чтобы записать путь.`); -} - -async function restoreSelectedMediaFile() { - const file = selectedMediaFile(); - if (!file?.trashPath) return; - - const payload = await postMediaAction("/api/media/restore", { trashPath: file.trashPath }); - await loadMediaTrash(); - await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`)); - setStatus(`Файл восстановлен: ${payload.path}.`); -} - -async function clearMediaTrash() { - const confirmed = await requestDeleteConfirmation({ - title: "Очистить корзину?", - body: "Все файлы из удалённых будут удалены окончательно.", - }); - if (!confirmed) return; - - await postMediaAction("/api/media/trash/clear"); - await loadMediaTrash(); - await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`)); - setStatus("Корзина очищена."); -} - -async function createMediaFolderAt(path) { - if (state.mediaLibrary.trashMode) return; - - const folderName = await requestMediaActionInput({ - title: "Создать папку", - body: `Новая папка будет создана в ${path || "корне проекта"}.`, - label: "Название папки", - value: "new-folder", - confirmLabel: "Создать", - }); - - if (!folderName) return; - - const payload = await postMediaAction("/api/media/folder", { path, name: folderName }); - await loadMediaColumns(path); - state.mediaLibrary.selectedEntry = findEntryByPath(payload.path); - state.mediaLibrary.selectedUrl = ""; - renderMediaLibrary(); - setStatus(`Папка создана: ${payload.path}.`); -} - -async function renameMediaEntry(entry) { - if (!entry || state.mediaLibrary.trashMode) return; - - const nextName = await requestMediaActionInput({ - title: "Переименовать", - body: entry.path, - label: "Новое имя", - value: entry.name, - confirmLabel: "Переименовать", - }); - - if (!nextName || nextName === entry.name) return; - - const payload = await postMediaAction("/api/media/rename", { path: entry.path, name: nextName }); - applyMediaReferenceReplacements(payload.replacements); - await loadMediaColumns(parentMediaPath(payload.path), { selectedUrl: payload.url || "" }); - const renamedEntry = findEntryByPath(payload.path); - if (renamedEntry) { - state.mediaLibrary.selectedPath = parentMediaPath(payload.path); - state.mediaLibrary.selectedEntry = renamedEntry; - state.mediaLibrary.selectedUrl = renamedEntry.type === "file" ? renamedEntry.url || "" : ""; - renderMediaLibrary(); - } - const updatedCount = Array.isArray(payload.updatedFiles) ? payload.updatedFiles.length : 0; - setStatus(`Переименовано: ${payload.path}${updatedCount ? `. Ссылки обновлены: ${updatedCount}` : ""}.`); -} - -async function duplicateMediaEntry(entry) { - if (!entry || state.mediaLibrary.trashMode) return; - - const payload = await postMediaAction("/api/media/duplicate", { path: entry.path }); - await loadMediaColumns(parentMediaPath(payload.path), { selectedUrl: payload.url || "" }); - await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`)); - if (!payload.url) { - state.mediaLibrary.selectedEntry = findEntryByPath(payload.path); - state.mediaLibrary.selectedUrl = ""; - renderMediaLibrary(); - } - setStatus(`${payload.type === "directory" ? "Папка" : "Файл"} продублирован: ${payload.path}.`); -} - -async function uploadIntoMediaLibrary(file) { - const context = state.mediaLibrary.context; - if (!context || !file) return; - - await uploadMediaFile({ - ...context, - file, - afterUpload: async (payload) => { - state.mediaLibrary.selectedUrl = payload.url; - const uploadedPath = pathFromUrl(payload.url); - await loadMediaColumns(parentMediaPath(uploadedPath), { selectedUrl: payload.url }); - await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`)); - }, - }); -} - -function openMediaLibraryUploader() { - const context = state.mediaLibrary.context; - if (!context) return; - - openMediaFilePicker({ - ...context, - afterUpload: async (payload) => { - state.mediaLibrary.selectedUrl = payload.url; - const uploadedPath = pathFromUrl(payload.url); - await loadMediaColumns(parentMediaPath(uploadedPath), { selectedUrl: payload.url }); - await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`)); - }, - }); -} - -async function deleteSelectedTrashFile() { - const file = selectedMediaFile(); - if (!file?.trashPath) return; - - const nextSelectedPath = adjacentMediaEntryPath(file); - const confirmed = await requestDeleteConfirmation({ - title: "Удалить из корзины навсегда?", - body: "Этот файл будет удалён окончательно. Восстановить его через админку уже не получится.", - }); - if (!confirmed) return; - - await postMediaAction("/api/media/trash/delete", { trashPath: file.trashPath }); - await loadMediaTrash({ selectedTrashPath: nextSelectedPath }); - setStatus(`Файл удалён из корзины навсегда: ${file.originalPath || file.path}.`); -} - -async function deleteSelectedMediaEntry() { - const entry = state.mediaLibrary.selectedEntry; - if (!entry || el.mediaDelete.disabled) return; - - if (state.mediaLibrary.trashMode) { - await deleteSelectedTrashFile(); - return; - } - - const parentPath = entry.type === "file" ? state.mediaLibrary.selectedPath || parentMediaPath(pathFromUrl(entry.url)) : parentMediaPath(entry.path); - const nextSelectedPath = adjacentMediaEntryPath(entry); - const confirmed = await requestDeleteConfirmation({ - title: entry.type === "directory" ? "Удалить папку?" : "Удалить unused-файл?", - body: - entry.type === "directory" - ? "Папка будет перенесена в assets/.trash. Если внутри есть файлы, которые используются в модели или текущей верстке, сервер остановит удаление." - : "Файл будет перенесён в assets/.trash. Файлы, которые используются в модели или текущей верстке, сервер не удаляет.", - }); - if (!confirmed) return; - - const payload = await postMediaAction("/api/media/delete-entry", { path: entry.path }); - await loadMediaColumns(parentPath, { selectedUrl: "" }); - await refreshMediaSiteSize().catch((error) => setStatus(`Ошибка размера сайта: ${error.message}`)); - - const nextEntry = nextSelectedPath ? findEntryByPath(nextSelectedPath) : null; - if (nextEntry) { - state.mediaLibrary.selectedPath = parentPath; - state.mediaLibrary.selectedEntry = nextEntry; - state.mediaLibrary.selectedUrl = nextEntry.type === "file" ? nextEntry.url || "" : ""; - renderMediaLibrary(); - } - - setStatus(`${payload.type === "directory" ? "Папка" : "Файл"} перенесён в ${payload.trashPath}.`); -} - -function renderMediaField({ block, editableField, grid }) { - const value = getByPath(block, editableField.path) ?? ""; - const container = document.createElement("div"); - const labelRow = document.createElement("div"); - const labelText = document.createElement("span"); - const kind = document.createElement("span"); - const hiddenInput = document.createElement("input"); - const control = document.createElement("div"); - const fileControl = document.createElement("div"); - const fileButton = document.createElement("button"); - const fileName = document.createElement("span"); - const urlInput = document.createElement("input"); - const sourceSwitch = document.createElement("div"); - const fileSourceButton = document.createElement("button"); - const urlSourceButton = document.createElement("button"); - const preview = document.createElement("div"); - const path = document.createElement("span"); - const hint = document.createElement("span"); - const error = document.createElement("span"); - let source = /^(https?:)?\/\//i.test(value) ? "url" : "file"; - - function setSource(nextSource) { - source = nextSource; - fileControl.hidden = source !== "file"; - urlInput.hidden = source !== "url"; - fileSourceButton.dataset.active = String(source === "file"); - urlSourceButton.dataset.active = String(source === "url"); - } - - container.className = "field-control wide service-media-field"; - labelRow.className = "field-label-row"; - labelText.className = "field-label-text"; - labelText.textContent = editableField.label || editableField.path; - kind.className = "field-kind"; - kind.textContent = editableField.kind || "media"; - labelRow.append(labelText, kind); - - hiddenInput.className = "service-media-hidden-input"; - hiddenInput.type = "hidden"; - hiddenInput.dataset.path = editableField.path; - hiddenInput.value = value; - - control.className = "service-media-control"; - - fileControl.className = "service-media-file-control"; - fileButton.className = "service-media-file-button"; - fileButton.type = "button"; - fileButton.textContent = "Выберите файл"; - fileName.className = "service-media-file-name"; - fileName.textContent = truncateText(fileNameFromUrl(value), 28); - fileName.title = fileNameFromUrl(value); - fileControl.append(fileButton, fileName); - - urlInput.className = "service-media-url-input"; - urlInput.type = "text"; - urlInput.placeholder = "https://..."; - urlInput.autocomplete = "off"; - urlInput.value = value; - - sourceSwitch.className = "service-media-source-switch"; - sourceSwitch.setAttribute("aria-label", `${editableField.label || editableField.path}: источник`); - fileSourceButton.className = "service-media-source-button"; - fileSourceButton.type = "button"; - fileSourceButton.title = "Файл с диска"; - fileSourceButton.setAttribute("aria-label", "Файл с диска"); - fileSourceButton.textContent = "HD"; - urlSourceButton.className = "service-media-source-button"; - urlSourceButton.type = "button"; - urlSourceButton.title = "Внешняя ссылка"; - urlSourceButton.setAttribute("aria-label", "Внешняя ссылка"); - urlSourceButton.textContent = "URL"; - sourceSwitch.append(fileSourceButton, urlSourceButton); - - preview.className = "service-media-preview"; - renderMediaPreview(preview, value, hint, editableField); - - path.className = "field-path"; - path.textContent = `${editableField.path} -> assets/uploads/${uploadBucketForField(block, editableField)}`; - hint.className = "media-upload-hint"; - error.className = "media-upload-error"; - - fileSourceButton.addEventListener("click", () => setSource("file")); - urlSourceButton.addEventListener("click", () => { - setSource("url"); - urlInput.focus(); - }); - - urlInput.addEventListener("input", () => { - updateMediaFieldValue({ - block, - editableField, - hiddenInput, - urlInput, - fileName, - preview, - hint, - value: urlInput.value, - }); - }); - - fileButton.addEventListener("click", () => { - openMediaLibrary({ - block, - editableField, - hiddenInput, - urlInput, - fileName, - preview, - hint, - error, - setSource, - }).catch((errorMessage) => setStatus(`Ошибка медиатеки: ${errorMessage.message}`)); - }); - - setSource(source); - control.append(fileControl, urlInput, sourceSwitch, preview); - container.append(labelRow, hiddenInput, control, path, hint, error); - grid.append(container); -} - -function collectionItems(block, editableField) { - const value = getByPath(block, editableField.path); - if (Array.isArray(value)) return value; - - const nextValue = []; - setByPath(block, editableField.path, nextValue); - return nextValue; -} - -function collectionItemTitle(item, index) { - return item?.adminLabel || item?.title || item?.name || item?.label || item?.question || item?.windowTitle || `Элемент ${index + 1}`; -} - -function collectionItemTemplate(editableField) { - if (editableField.itemTemplate) { - const item = clone(editableField.itemTemplate); - if (editableField.path === "content.dock.items") { - item.iconSrc = ""; - item.iconAlt = ""; - item.iconClass = ""; - } - return item; - } - - const item = {}; - for (const itemField of editableField.itemFields ?? []) { - setByPath(item, itemField.path, itemField.defaultValue ?? ""); - } - return item; -} - -function uniqueCollectionItemId(items, baseId) { - const root = String(baseId || "item").trim() || "item"; - const ids = new Set(items.map((item) => item?.id).filter(Boolean)); - if (!ids.has(root)) return root; - - let index = 2; - while (ids.has(`${root}-${index}`)) { - index += 1; - } - return `${root}-${index}`; -} - -function prepareCollectionItemForInsert(items, item) { - const nextItem = clone(item); - - if (Object.prototype.hasOwnProperty.call(nextItem, "id")) { - nextItem.id = uniqueCollectionItemId(items, nextItem.id); - } - - return nextItem; -} - -function collectionInstanceKey(block, editableField) { - return `${block?.id || "static-elements"}::${editableField.path}`; -} - -function stableCollectionPart(value) { - return String(value || "") - .normalize("NFKD") - .replace(/[^\w.-]+/g, "-") - .replace(/^-+|-+$/g, "") - .toLowerCase() - .slice(0, 80); -} - -function collectionItemKey(item, index) { - if (item && typeof item === "object") { - if (item.id) return `id:${item.id}`; - - const stableIdentity = item.targetId || item.href || ""; - const stableIdentityPart = stableCollectionPart(stableIdentity); - if (stableIdentityPart) return `item:${stableIdentityPart}`; - - const stableTitle = item.title || item.name || item.label || item.question || item.windowTitle || item.adminLabel || ""; - const stableTitlePart = stableCollectionPart(stableTitle); - if (stableTitlePart) return `item:${stableTitlePart}`; - - let runtimeKey = collectionItemRuntimeKeys.get(item); - if (!runtimeKey) { - collectionItemRuntimeKeyCounter += 1; - runtimeKey = `runtime:${collectionItemRuntimeKeyCounter}`; - collectionItemRuntimeKeys.set(item, runtimeKey); - } - - return runtimeKey; - } - - return `value:${index}:${String(item)}`; -} - -function persistCollectionOpenState() { - const collections = {}; - - for (const [key, openKeys] of state.collectionOpenState.entries()) { - collections[key] = [...openKeys]; - } - - updateAdminLayoutState({ collections }); -} - -function collectionOpenKeys(block, editableField, items) { - const key = collectionInstanceKey(block, editableField); - let openKeys = state.collectionOpenState.get(key); - - if (!openKeys) { - openKeys = new Set(); - if (items.length > 0 && items.length <= 8) { - openKeys.add(collectionItemKey(items[0], 0)); - } - state.collectionOpenState.set(key, openKeys); - persistCollectionOpenState(); - } - - return openKeys; -} - -function fieldInputValue(input) { - if (input.type === "checkbox") return input.checked; - - if (input.dataset.valueType === "number" || input.type === "range" || input.type === "number") { - const value = Number.parseFloat(input.value); - return Number.isFinite(value) ? value : 0; - } - - return input.value; -} - -function syncFieldValue(block, path, input) { - setByPath(block, path, fieldInputValue(input)); - el.json.value = JSON.stringify(block, null, 2); - markDirty(); -} - -function renderBooleanField({ block, editableField, grid }) { - 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 input = document.createElement("input"); - - label.className = "field-control boolean-field"; - labelRow.className = "field-label-row"; - labelText.className = "field-label-text"; - labelText.textContent = editableField.label || editableField.path; - kind.className = "field-kind"; - kind.textContent = editableField.kind || "boolean"; - path.className = "field-path"; - path.textContent = editableField.path; - labelRow.append(labelText, kind); - - input.dataset.path = editableField.path; - input.type = "checkbox"; - input.checked = Boolean(getByPath(block, editableField.path)); - - input.addEventListener("change", () => { - const active = activeBlock(); - if (!active) return; - syncFieldValue(active, editableField.path, input); - }); - - label.append(labelRow, input, path); - grid.append(label); -} - -function formatRangeValue(value, editableField) { - const step = Number.parseFloat(editableField.step ?? 1); - const precision = Number.isFinite(step) && step > 0 && step < 1 ? String(step).split(".")[1]?.length || 0 : 0; - return `${Number(value).toFixed(precision)}${editableField.suffix || ""}`; -} - -function renderRangeField({ block, editableField, grid }) { - 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 control = document.createElement("span"); - const input = document.createElement("input"); - const output = document.createElement("span"); - const min = Number.parseFloat(editableField.min ?? 0); - const max = Number.parseFloat(editableField.max ?? 1); - const step = Number.parseFloat(editableField.step ?? 0.01); - const rawValue = Number.parseFloat(getByPath(block, editableField.path)); - const value = Number.isFinite(rawValue) ? rawValue : min; - - label.className = "field-control range-field 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 || "range"; - path.className = "field-path"; - path.textContent = editableField.path; - control.className = "range-control"; - output.className = "range-value"; - labelRow.append(labelText, kind); - - input.dataset.path = editableField.path; - input.dataset.valueType = "number"; - input.type = "range"; - input.min = Number.isFinite(min) ? String(min) : "0"; - input.max = Number.isFinite(max) ? String(max) : "1"; - input.step = Number.isFinite(step) ? String(step) : "0.01"; - input.value = String(value); - output.textContent = formatRangeValue(value, editableField); - - input.addEventListener("input", () => { - const active = activeBlock(); - if (!active) return; - output.textContent = formatRangeValue(input.value, editableField); - syncFieldValue(active, editableField.path, input); - }); - - control.append(input, output); - label.append(labelRow, control, path); - grid.append(label); -} - -function renderNumberField({ block, editableField, grid }) { - 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 control = document.createElement("span"); - const input = document.createElement("input"); - const suffix = document.createElement("span"); - const rawValue = Number.parseFloat(getByPath(block, editableField.path)); - const fallback = Number.parseFloat(editableField.defaultValue ?? editableField.min ?? 0); - const value = Number.isFinite(rawValue) ? rawValue : fallback; - - label.className = "field-control number-field"; - labelRow.className = "field-label-row"; - labelText.className = "field-label-text"; - labelText.textContent = editableField.label || editableField.path; - kind.className = "field-kind"; - kind.textContent = editableField.kind || "number"; - path.className = "field-path"; - path.textContent = editableField.path; - control.className = "number-control"; - suffix.className = "number-suffix"; - suffix.textContent = editableField.suffix || ""; - labelRow.append(labelText, kind); - - input.dataset.path = editableField.path; - input.dataset.valueType = "number"; - input.type = "number"; - input.value = Number.isFinite(value) ? String(value) : "0"; - input.autocomplete = "off"; - - if (editableField.min != null) input.min = String(editableField.min); - if (editableField.max != null) input.max = String(editableField.max); - if (editableField.step != null) input.step = String(editableField.step); - - input.addEventListener("input", () => { - const active = activeBlock(); - if (!active) return; - syncFieldValue(active, editableField.path, input); - }); - - control.append(input); - if (suffix.textContent) control.append(suffix); - label.append(labelRow, control, path); - grid.append(label); -} - -function siblingFieldPath(path, siblingKey) { - const keys = path.split("."); - keys[keys.length - 1] = siblingKey; - return keys.join("."); -} - -function renderLinkModeField({ block, field, grid }) { - const container = document.createElement("div"); - const labelRow = document.createElement("div"); - const labelText = document.createElement("span"); - const kind = document.createElement("span"); - const hiddenMode = document.createElement("input"); - const rows = document.createElement("div"); - const path = document.createElement("span"); - const hrefPath = siblingFieldPath(field.path, field.hrefPath || "href"); - const targetPath = siblingFieldPath(field.path, field.targetPath || "target"); - const currentMode = getByPath(block, field.path) === "href" ? "href" : "target"; - - function createRow({ mode, title, inputPath, placeholder }) { - const row = document.createElement("label"); - const check = document.createElement("input"); - const caption = document.createElement("span"); - const input = document.createElement("input"); - - row.className = "link-mode-row"; - check.type = "checkbox"; - check.checked = currentMode === mode; - check.setAttribute("aria-label", `${title}: использовать`); - caption.textContent = title; - input.dataset.path = inputPath; - input.type = "text"; - input.autocomplete = "off"; - input.placeholder = placeholder; - input.value = getByPath(block, inputPath) ?? ""; - - check.addEventListener("change", () => { - if (!check.checked) { - check.checked = true; - return; - } - - const active = activeBlock(); - if (!active) return; - hiddenMode.value = mode; - setByPath(active, field.path, mode); - rows.querySelectorAll('input[type="checkbox"]').forEach((candidate) => { - candidate.checked = candidate === check; - }); - el.json.value = JSON.stringify(active, null, 2); - markDirty(mode === "href" ? "Активен внешний адрес." : "Активен Target."); - }); - - input.addEventListener("input", () => { - const active = activeBlock(); - if (!active) return; - syncFieldValue(active, inputPath, input); - }); - - row.append(check, caption, input); - return row; - } - - container.className = "field-control wide link-mode-field"; - labelRow.className = "field-label-row"; - labelText.className = "field-label-text"; - labelText.textContent = field.label || "Источник ссылки"; - kind.className = "field-kind"; - kind.textContent = "link"; - labelRow.append(labelText, kind); - - hiddenMode.type = "hidden"; - hiddenMode.dataset.path = field.path; - hiddenMode.value = currentMode; - rows.className = "link-mode-grid"; - rows.append( - createRow({ mode: "href", title: "Адрес", inputPath: hrefPath, placeholder: "https://..." }), - createRow({ mode: "target", title: "Target", inputPath: targetPath, placeholder: "index.html#section" }), - ); - - path.className = "field-path"; - path.textContent = `${field.path} · ${hrefPath} · ${targetPath}`; - container.append(labelRow, hiddenMode, rows, path); - grid.append(container); -} - -function renderHeaderBooleanToggle({ block, editableField, head }) { - const label = document.createElement("label"); - const input = document.createElement("input"); - const text = document.createElement("span"); - - label.className = "group-head-toggle"; - input.dataset.path = editableField.path; - input.type = "checkbox"; - input.checked = Boolean(getByPath(block, editableField.path)); - text.textContent = "Отображение"; - - input.addEventListener("change", () => { - const active = activeBlock(); - if (!active) return; - syncFieldValue(active, editableField.path, input); - }); - - label.append(input, text); - head.append(label); -} - -function renderCollectionScalarField({ block, field, grid }) { - if (field.kind === "link-mode") { - renderLinkModeField({ block, field, grid }); - return; - } - - if (field.kind === "boolean") { - renderBooleanField({ block, editableField: field, grid }); - return; - } - - if (field.kind === "range") { - renderRangeField({ block, editableField: field, grid }); - return; - } - - if (field.kind === "number") { - renderNumberField({ block, editableField: field, grid }); - return; - } - - 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, field.path) ?? ""; - const isLong = field.kind === "html" || field.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 = field.label || field.path; - kind.className = "field-kind"; - kind.textContent = field.kind || "text"; - path.className = "field-path"; - path.textContent = field.path; - labelRow.append(labelText, kind); - - input.dataset.path = field.path; - input.value = value; - - if (!isLong) { - input.type = "text"; - input.autocomplete = "off"; - } - - input.addEventListener("input", () => { - const active = activeBlock(); - if (!active) return; - syncFieldValue(active, field.path, input); - }); - - label.append(labelRow, input, path); - grid.append(label); -} - -function startCollectionPointerDrag(event, card, items, index, rerender) { - if (event.button !== 0) return; - event.preventDefault(); - event.stopPropagation(); - - const list = card.closest(".collection-list"); - if (!list) return; - - const placeholder = createSortablePlaceholder(card); - const ghost = createSortableGhost(card, event); - - card.classList.add("dragging", "sorting-source"); - list.classList.add("sorting-active"); - card.after(placeholder); - - const onMove = (moveEvent) => { - moveSortableGhost(ghost, moveEvent); - movePlaceholderByPoint({ - event: moveEvent, - list, - source: card, - placeholder, - selector: ".collection-item-card", - }); - }; - - const onFinish = () => { - document.removeEventListener("pointermove", onMove); - document.removeEventListener("pointerup", onFinish); - document.removeEventListener("pointercancel", onCancel); - - const nextIndex = sortablePlaceholderIndex({ - list, - placeholder, - source: card, - selector: ".collection-item-card", - }); - - cleanupDragState(); - - if (nextIndex < 0 || nextIndex === index) return; - const [moved] = items.splice(index, 1); - items.splice(Math.min(nextIndex, items.length), 0, moved); - rerender("Порядок элементов коллекции изменён локально."); - }; - - const onCancel = () => { - document.removeEventListener("pointermove", onMove); - document.removeEventListener("pointerup", onFinish); - document.removeEventListener("pointercancel", onCancel); - cleanupDragState(); - }; - - document.addEventListener("pointermove", onMove); - document.addEventListener("pointerup", onFinish); - document.addEventListener("pointercancel", onCancel); -} - -function renderCollectionField({ block, editableField, grid }) { - const items = collectionItems(block, editableField); - const openKeys = collectionOpenKeys(block, editableField, items); - const container = document.createElement("section"); - const head = document.createElement("div"); - const title = document.createElement("div"); - const addButton = document.createElement("button"); - const list = document.createElement("div"); - - function rerender(message = null) { - el.json.value = JSON.stringify(block, null, 2); - renderContentFields(block); - markDirty(message); - } - - container.className = "collection-field"; - head.className = "collection-head"; - title.className = "collection-title"; - title.textContent = editableField.label || editableField.path; - addButton.className = "collection-add-btn"; - addButton.type = "button"; - addButton.title = `Добавить элемент в «${editableField.label || editableField.path}»`; - addButton.setAttribute("aria-label", addButton.title); - addButton.textContent = "+"; - addButton.addEventListener("click", () => { - const nextItem = prepareCollectionItemForInsert(items, collectionItemTemplate(editableField)); - items.push(nextItem); - openKeys.add(collectionItemKey(nextItem, items.length - 1)); - persistCollectionOpenState(); - rerender(`Добавлен элемент в «${editableField.label || editableField.path}».`); - }); - head.append(title, addButton); - - list.className = "collection-list"; - - items.forEach((item, index) => { - const itemKey = collectionItemKey(item, index); - const details = document.createElement("details"); - const summary = document.createElement("summary"); - const summaryTitle = document.createElement("span"); - const controls = document.createElement("span"); - const dragHandle = document.createElement("button"); - const body = document.createElement("div"); - const itemGrid = document.createElement("div"); - const hasVisibilityToggle = - item && - typeof item === "object" && - (Object.prototype.hasOwnProperty.call(item, "enabled") || - Object.prototype.hasOwnProperty.call(editableField.itemTemplate ?? {}, "enabled")); - - details.className = "collection-item-card"; - details.classList.toggle("is-hidden", item?.enabled === false); - details.open = openKeys.has(itemKey); - details.addEventListener("toggle", () => { - if (details.open) openKeys.add(itemKey); - else openKeys.delete(itemKey); - persistCollectionOpenState(); - }); - summary.className = "collection-summary"; - summaryTitle.className = "collection-summary-title"; - summaryTitle.textContent = collectionItemTitle(item, index); - controls.className = "collection-item-actions"; - dragHandle.className = "collection-drag-handle"; - dragHandle.type = "button"; - dragHandle.title = "Перетащить элемент"; - dragHandle.setAttribute("aria-label", "Перетащить элемент"); - dragHandle.innerHTML = - ''; - dragHandle.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - }); - dragHandle.addEventListener("pointerdown", (event) => startCollectionPointerDrag(event, details, items, index, rerender)); - - if (hasVisibilityToggle) { - if (!Object.prototype.hasOwnProperty.call(item, "enabled")) item.enabled = true; - - const visibilityToggle = document.createElement("span"); - const visibilityInput = document.createElement("input"); - const visibilityText = document.createElement("span"); - - visibilityToggle.className = "collection-visibility-toggle"; - visibilityToggle.title = "Скрыть/показать"; - visibilityToggle.setAttribute("aria-label", "Скрыть/показать"); - visibilityInput.type = "checkbox"; - visibilityInput.checked = item.enabled !== false; - visibilityText.textContent = "Показать"; - visibilityToggle.addEventListener("click", (event) => event.stopPropagation()); - visibilityInput.addEventListener("click", (event) => event.stopPropagation()); - visibilityInput.addEventListener("change", () => { - item.enabled = visibilityInput.checked; - rerender(visibilityInput.checked ? "Элемент коллекции показан локально." : "Элемент коллекции скрыт локально."); - }); - visibilityToggle.append(visibilityInput, visibilityText); - controls.append(visibilityToggle); - } - - [ - ["↑", "Поднять", () => { - if (index === 0) return; - const [moved] = items.splice(index, 1); - items.splice(index - 1, 0, moved); - persistCollectionOpenState(); - rerender("Порядок элементов коллекции изменён локально."); - }], - ["↓", "Опустить", () => { - if (index >= items.length - 1) return; - const [moved] = items.splice(index, 1); - items.splice(index + 1, 0, moved); - persistCollectionOpenState(); - rerender("Порядок элементов коллекции изменён локально."); - }], - ["⧉", "Дублировать", () => { - const nextItem = prepareCollectionItemForInsert(items, item); - items.splice(index + 1, 0, nextItem); - if (details.open) openKeys.add(collectionItemKey(nextItem, index + 1)); - persistCollectionOpenState(); - rerender("Элемент коллекции продублирован локально."); - }], - [FINDER_TRASH_ICON_SVG, "Удалить", async () => { - const confirmed = await requestDeleteConfirmation(); - if (!confirmed) return; - openKeys.delete(itemKey); - items.splice(index, 1); - persistCollectionOpenState(); - rerender("Элемент коллекции удалён локально."); - }], - ].forEach(([symbol, titleText, handler]) => { - const button = document.createElement("button"); - button.className = "collection-icon-btn"; - button.type = "button"; - button.title = titleText; - button.setAttribute("aria-label", titleText); - if (titleText === "Удалить") { - button.innerHTML = symbol; - } else { - button.textContent = symbol; - } - button.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - Promise.resolve(handler()).catch((error) => setStatus(`Ошибка действия коллекции: ${error.message}`)); - }); - controls.append(button); - }); - controls.append(dragHandle); - - summary.append(summaryTitle, controls); - body.className = "collection-item-body"; - itemGrid.className = "group-grid"; - - for (const itemField of editableField.itemFields ?? []) { - const nestedField = { - ...itemField, - path: `${editableField.path}.${index}.${itemField.path}`, - label: itemField.label || itemField.path, - group: editableField.group, - }; - - if (nestedField.kind === "media") { - renderMediaField({ block, editableField: nestedField, grid: itemGrid }); - } else { - renderCollectionScalarField({ block, field: nestedField, grid: itemGrid }); - } - } - - body.append(itemGrid); - details.append(summary, body); - list.append(details); - }); - - container.append(head, list); - grid.append(container); -} - -function renderContentFields(block) { - el.contentFields.innerHTML = ""; - - if (!block.editableFields?.length) { - const note = document.createElement("div"); - note.className = "empty-note"; - note.textContent = "Для этого блока typed-поля ещё не выделены. Пока доступно редактирование через JSON."; - el.contentFields.append(note); - return; - } - - 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"; - - const headerFields = fields.filter((field) => field.kind === "boolean" && field.headerToggle); - const bodyFields = fields.filter((field) => !headerFields.includes(field)); - headerFields.forEach((editableField) => renderHeaderBooleanToggle({ block, editableField, head })); - - for (const editableField of bodyFields) { - if (editableField.kind === "collection") { - renderCollectionField({ block, editableField, grid }); - continue; - } - - if (editableField.kind === "media") { - renderMediaField({ block, editableField, grid }); - continue; - } - - if (editableField.kind === "link-mode") { - renderLinkModeField({ block, field: editableField, grid }); - continue; - } - - if (editableField.kind === "boolean") { - renderBooleanField({ block, editableField, grid }); - continue; - } - - if (editableField.kind === "range") { - renderRangeField({ block, editableField, grid }); - continue; - } - - if (editableField.kind === "number") { - renderNumberField({ block, editableField, grid }); - continue; - } - - 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; - syncFieldValue(active, editableField.path, input); - }); - - label.append(labelRow, input, path); - grid.append(label); - } - - group.append(head, grid); - el.contentFields.append(group); - } -} - -function syncContentFields() { - const block = activeBlock(); - if (!block) return; - - for (const input of el.contentFields.querySelectorAll("[data-path]")) { - setByPath(block, input.dataset.path, fieldInputValue(input)); - } -} - -function syncBlockFromJson() { - if (!state.selected) return false; - - try { - const parsed = JSON.parse(el.json.value); - if (state.mode === "seo" && isStaticSelection()) { - state.page.seo = parsed; - } else if (isStaticSelection()) { - state.page.staticElements = parsed; - } else { - const region = activeRegion(); - if (!region) return false; - region.blocks[state.selected.blockIndex] = parsed; - } - renderAll(); - markDirty("JSON блока применён локально. Нажмите «Сохранить модель», чтобы записать файл."); - return true; - } catch (error) { - setStatus(`Ошибка JSON: ${error.message}`); - return false; - } -} - -function knowledgeTree() { - if (!state.knowledge) return []; - if (!Array.isArray(state.knowledge.tree)) state.knowledge.tree = []; - return state.knowledge.tree; -} - -function knowledgeWalk(nodes = knowledgeTree(), parent = null, callback = () => {}) { - nodes.forEach((node, index) => { - callback(node, parent, nodes, index); - knowledgeWalk(Array.isArray(node.children) ? node.children : [], node, callback); - }); -} - -function allKnowledgeIds() { - const ids = new Set(); - knowledgeWalk(knowledgeTree(), null, (node) => { - if (node.id) ids.add(node.id); - }); - return ids; -} - -function findKnowledgeNode(id) { - let found = null; - let foundParent = null; - let foundSiblings = null; - let foundIndex = -1; - - knowledgeWalk(knowledgeTree(), null, (node, parent, siblings, index) => { - if (found || node.id !== id) return; - found = node; - foundParent = parent; - foundSiblings = siblings; - foundIndex = index; - }); - - return found ? { node: found, parent: foundParent, siblings: foundSiblings, index: foundIndex } : null; -} - -function isKnowledgeRootId(id) { - return id === KNOWLEDGE_ROOT_ID; -} - -function isKnowledgeRootSelected() { - return isKnowledgeRootId(state.knowledgeSelectedId); -} - -function knowledgeRootTitle() { - return state.knowledge?.title || state.knowledge?.settings?.navTitle || "Knowledge"; -} - -function selectedKnowledgeNode() { - if (isKnowledgeRootSelected()) return state.knowledge; - return findKnowledgeNode(state.knowledgeSelectedId)?.node || null; -} - -function knowledgeSlug(value, fallback = "material") { - const map = { - а: "a", - б: "b", - в: "v", - г: "g", - д: "d", - е: "e", - ё: "e", - ж: "zh", - з: "z", - и: "i", - й: "y", - к: "k", - л: "l", - м: "m", - н: "n", - о: "o", - п: "p", - р: "r", - с: "s", - т: "t", - у: "u", - ф: "f", - х: "h", - ц: "c", - ч: "ch", - ш: "sh", - щ: "sch", - ъ: "", - ы: "y", - ь: "", - э: "e", - ю: "yu", - я: "ya", - }; - const safe = String(value || "") - .toLowerCase() - .replace(/[а-яё]/g, (char) => map[char] || "") - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - - return safe || fallback; -} - -function uniqueKnowledgeValue(values, base) { - const cleanBase = base || "material"; - if (!values.has(cleanBase)) return cleanBase; - let index = 2; - while (values.has(`${cleanBase}-${index}`)) index += 1; - return `${cleanBase}-${index}`; -} - -function createKnowledgeNode(title = "Новый материал", siblings = []) { - const ids = allKnowledgeIds(); - const siblingSlugs = new Set(siblings.map((node) => node.slug).filter(Boolean)); - const slug = uniqueKnowledgeValue(siblingSlugs, knowledgeSlug(title)); - return { - id: uniqueKnowledgeValue(ids, slug), - title, - slug, - enabled: true, - useContent: true, - seo: { - title, - description: "", - }, - bodyHtml: "

Новый материал базы знаний.

", - children: [], - }; -} - -function ensureKnowledgeSelection() { - if (!state.knowledge) return; - if (isKnowledgeRootSelected()) return; - const existing = state.knowledgeSelectedId ? findKnowledgeNode(state.knowledgeSelectedId) : null; - if (existing) return; - state.knowledgeSelectedId = KNOWLEDGE_ROOT_ID; -} - -function selectKnowledgeNode(id) { - state.knowledgeSelectedId = id; - updateAdminLayoutState({ knowledgeSelectedId: id }); - renderKnowledgeAll(); -} - -function persistKnowledgeOpenState() { - updateAdminLayoutState({ knowledgeOpenIds: [...state.knowledgeOpenIds] }); -} - -function setKnowledgeNodeOpen(id, open) { - if (!id) return; - if (open) state.knowledgeOpenIds.add(id); - else state.knowledgeOpenIds.delete(id); - persistKnowledgeOpenState(); -} - -function addKnowledgeNode(parentId = null) { - const parent = parentId ? findKnowledgeNode(parentId)?.node : null; - const siblings = parent ? (parent.children ||= []) : knowledgeTree(); - const node = createKnowledgeNode(parent ? "Новый подраздел" : "Новый раздел", siblings); - siblings.push(node); - if (parent) setKnowledgeNodeOpen(parent.id, true); - state.knowledgeSelectedId = node.id; - renderKnowledgeAll(); - markDirty("Материал добавлен в дерево базы знаний."); -} - -function moveKnowledgeNode(id, delta) { - const match = findKnowledgeNode(id); - if (!match) return; - const nextIndex = match.index + delta; - if (nextIndex < 0 || nextIndex >= match.siblings.length) return; - const [node] = match.siblings.splice(match.index, 1); - match.siblings.splice(nextIndex, 0, node); - renderKnowledgeAll(); - markDirty("Порядок дерева базы знаний изменён."); -} - -async function deleteKnowledgeNode(id) { - const match = findKnowledgeNode(id); - if (!match) return; - const confirmed = await requestDeleteConfirmation({ - title: "Удалить материал?", - body: `«${match.node.title || match.node.id}» и все вложенные элементы будут удалены из дерева.`, - }); - if (!confirmed) return; - match.siblings.splice(match.index, 1); - if (state.knowledgeSelectedId === id) { - state.knowledgeSelectedId = match.siblings[Math.min(match.index, match.siblings.length - 1)]?.id || match.parent?.id || KNOWLEDGE_ROOT_ID; - } - renderKnowledgeAll(); - markDirty("Материал удалён из дерева базы знаний."); -} - -function renderKnowledgeTreeItem(node, depth = 0) { - const item = document.createElement("div"); - const row = document.createElement("div"); - const select = document.createElement("button"); - const icon = document.createElement("span"); - const copy = document.createElement("span"); - const title = document.createElement("span"); - const meta = document.createElement("span"); - const actions = document.createElement("span"); - const children = Array.isArray(node.children) ? node.children : []; - const hasChildren = children.length > 0; - const isOpen = state.knowledgeOpenIds.has(node.id) || node.id === state.knowledgeSelectedId || children.some((child) => child.id === state.knowledgeSelectedId); - - item.className = "knowledge-tree-item"; - item.style.setProperty("--depth", String(depth)); - row.className = "knowledge-tree-row"; - row.dataset.active = String(node.id === state.knowledgeSelectedId); - row.dataset.disabled = String(node.enabled === false); - select.className = "knowledge-tree-select"; - select.type = "button"; - icon.className = "knowledge-tree-icon"; - icon.textContent = hasChildren ? (isOpen ? "▾" : "›") : node.useContent === false ? "G" : "A"; - copy.className = "knowledge-tree-copy"; - title.className = "knowledge-tree-title"; - title.textContent = node.title || node.id; - meta.className = "knowledge-tree-meta"; - meta.textContent = node.useContent === false ? "группа" : hasChildren ? "страница + ветка" : "статья"; - copy.append(title, meta); - select.append(icon, copy); - select.addEventListener("click", () => { - if (hasChildren) setKnowledgeNodeOpen(node.id, !isOpen); - selectKnowledgeNode(node.id); - }); - - actions.className = "knowledge-tree-actions"; - [ - ["+", "Добавить дочерний материал", () => addKnowledgeNode(node.id)], - ["↑", "Поднять", () => moveKnowledgeNode(node.id, -1)], - ["↓", "Опустить", () => moveKnowledgeNode(node.id, 1)], - [FINDER_TRASH_ICON_SVG, "Удалить", () => deleteKnowledgeNode(node.id)], - ].forEach(([symbol, titleText, handler]) => { - const button = document.createElement("button"); - button.type = "button"; - button.className = "knowledge-tree-action"; - button.title = titleText; - button.setAttribute("aria-label", titleText); - if (titleText === "Удалить") button.innerHTML = symbol; - else button.textContent = symbol; - button.addEventListener("click", (event) => { - event.stopPropagation(); - Promise.resolve(handler()).catch((error) => setStatus(`Ошибка дерева: ${error.message}`)); - }); - actions.append(button); - }); - - row.append(select, actions); - item.append(row); - - if (hasChildren && isOpen) { - const childList = document.createElement("div"); - childList.className = "knowledge-tree-children"; - children.forEach((child) => childList.append(renderKnowledgeTreeItem(child, depth + 1))); - item.append(childList); - } - - return item; -} - -function renderKnowledgeRootItem() { - const item = document.createElement("div"); - const row = document.createElement("div"); - const select = document.createElement("button"); - const icon = document.createElement("span"); - const copy = document.createElement("span"); - const title = document.createElement("span"); - const meta = document.createElement("span"); - const actions = document.createElement("span"); - const add = document.createElement("button"); - - item.className = "knowledge-tree-item knowledge-root-item"; - item.style.setProperty("--depth", "0"); - row.className = "knowledge-tree-row"; - row.dataset.active = String(isKnowledgeRootSelected()); - row.dataset.disabled = "false"; - select.className = "knowledge-tree-select"; - select.type = "button"; - icon.className = "knowledge-tree-icon"; - icon.textContent = "K"; - copy.className = "knowledge-tree-copy"; - title.className = "knowledge-tree-title"; - title.textContent = knowledgeRootTitle(); - meta.className = "knowledge-tree-meta"; - meta.textContent = "/knowledge/ главная"; - copy.append(title, meta); - select.append(icon, copy); - select.addEventListener("click", () => selectKnowledgeNode(KNOWLEDGE_ROOT_ID)); - - actions.className = "knowledge-tree-actions"; - add.type = "button"; - add.className = "knowledge-tree-action"; - add.title = "Добавить раздел в корень"; - add.setAttribute("aria-label", "Добавить раздел в корень"); - add.textContent = "+"; - add.addEventListener("click", (event) => { - event.stopPropagation(); - addKnowledgeNode(null); - }); - actions.append(add); - - row.append(select, actions); - item.append(row); - return item; -} - -function renderKnowledgeRegions() { - el.regions.innerHTML = ""; - const group = document.createElement("section"); - const title = document.createElement("h2"); - const root = document.createElement("div"); - - group.className = "region-group knowledge-region"; - title.className = "region-title"; - title.textContent = "База знаний"; - root.className = "knowledge-tree"; - root.append(renderKnowledgeRootItem()); - - if (!knowledgeTree().length) { - const empty = document.createElement("div"); - empty.className = "empty-note knowledge-empty-tree"; - empty.textContent = "Дерево пустое. Нажмите плюс сверху, чтобы добавить первый раздел."; - root.append(empty); - } else { - knowledgeTree().forEach((node) => root.append(renderKnowledgeTreeItem(node))); - } - - group.append(title, root); - el.regions.append(group); -} - -function updateKnowledgeNodeField(node, path, value, message = "Материал базы знаний обновлён.") { - setByPath(node, path, value); - markDirty(message); -} - -function createKnowledgeInput({ node, path, label, kind = "text", wide = false, placeholder = "" }) { - const field = document.createElement("label"); - const labelRow = document.createElement("span"); - const labelText = document.createElement("span"); - const kindLabel = document.createElement("span"); - const input = kind === "textarea" ? document.createElement("textarea") : document.createElement("input"); - const pathLabel = document.createElement("span"); - - field.className = `field-control${wide || kind === "textarea" ? " wide" : ""}`; - labelRow.className = "field-label-row"; - labelText.className = "field-label-text"; - labelText.textContent = label; - kindLabel.className = "field-kind"; - kindLabel.textContent = kind; - input.value = getByPath(node, path) ?? ""; - input.placeholder = placeholder; - input.dataset.knowledgePath = path; - if (input.tagName === "INPUT") { - input.type = kind === "url" ? "url" : "text"; - input.autocomplete = "off"; - } - input.addEventListener("input", () => { - updateKnowledgeNodeField(node, path, input.value); - if (path === "title" || path === "settings.navTitle") renderKnowledgeRegions(); - }); - pathLabel.className = "field-path"; - pathLabel.textContent = path; - labelRow.append(labelText, kindLabel); - field.append(labelRow, input, pathLabel); - return field; -} - -function createKnowledgeCheckbox({ node, path, label, checkedDefault = true }) { - const field = document.createElement("label"); - const labelRow = document.createElement("span"); - const labelText = document.createElement("span"); - const kindLabel = document.createElement("span"); - const input = document.createElement("input"); - const pathLabel = document.createElement("span"); - - field.className = "field-control boolean-field"; - labelRow.className = "field-label-row"; - labelText.className = "field-label-text"; - labelText.textContent = label; - kindLabel.className = "field-kind"; - kindLabel.textContent = "boolean"; - input.type = "checkbox"; - const value = getByPath(node, path); - input.checked = value == null ? checkedDefault : value !== false; - input.addEventListener("change", () => { - updateKnowledgeNodeField(node, path, input.checked); - renderKnowledgeEditor(); - renderKnowledgeRegions(); - }); - pathLabel.className = "field-path"; - pathLabel.textContent = path; - labelRow.append(labelText, kindLabel); - field.append(labelRow, input, pathLabel); - return field; -} - -function knowledgeSelectionNodeInsideEditor(editor, node) { - if (!editor || !node) return false; - const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement; - return Boolean(element && editor.contains(element)); -} - -function saveKnowledgeEditorSelection() { - const selection = window.getSelection(); - if (!selection?.rangeCount) return; - const editor = el.knowledgeForm.querySelector("[data-knowledge-richtext]"); - if (!knowledgeSelectionNodeInsideEditor(editor, selection.anchorNode) || !knowledgeSelectionNodeInsideEditor(editor, selection.focusNode)) return; - state.knowledgeSavedSelection = selection.getRangeAt(0).cloneRange(); -} - -function restoreKnowledgeEditorSelection(editor) { - editor.focus(); - let range = state.knowledgeSavedSelection; - if (!range || !knowledgeSelectionNodeInsideEditor(editor, range.commonAncestorContainer)) { - range = document.createRange(); - range.selectNodeContents(editor); - range.collapse(false); - state.knowledgeSavedSelection = range.cloneRange(); - } - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - return range; -} - -function syncKnowledgeRichText(node, editor) { - setByPath(node, editor.dataset.knowledgeBodyPath || "bodyHtml", editor.innerHTML.trim()); - markDirty("Текст статьи обновлён."); -} - -function execKnowledgeCommand(editor, node, command, value = null) { - restoreKnowledgeEditorSelection(editor); - document.execCommand(command, false, value); - syncKnowledgeRichText(node, editor); -} - -function insertKnowledgeHtml(editor, node, html) { - restoreKnowledgeEditorSelection(editor); - document.execCommand("insertHTML", false, html); - saveKnowledgeEditorSelection(); - syncKnowledgeRichText(node, editor); -} - -function escapeKnowledgeShortcodeAttr(value) { - return String(value ?? "") - .replace(/\s+/g, " ") - .trim() - .replaceAll("&", "&") - .replaceAll('"', """) - .replaceAll("[", "(") - .replaceAll("]", ")"); -} - -function normalizeKnowledgeLandingTarget(value) { - return String(value || "") - .trim() - .replace(/^#+/, "") - .replace(/^index\.html#/i, "") - .replace(/^\/?#/, ""); -} - -function normalizeKnowledgeButtonLink(value) { - return (String(value ?? "").trim() || "/").replace(/^\/+(https?:\/\/)/i, "$1").replace(/^\/+((?:mailto|tel):)/i, "$1"); -} - -function knowledgeButtonShortcode({ name, link, target, blockId }) { - const safeName = escapeKnowledgeShortcodeAttr(name || "Посмотреть"); - const safeLink = escapeKnowledgeShortcodeAttr(normalizeKnowledgeButtonLink(link)); - const safeTarget = normalizeKnowledgeLandingTarget(target); - const safeBlockId = String(blockId || "").trim(); - const blockPart = safeBlockId ? ` block="${escapeKnowledgeShortcodeAttr(safeBlockId)}"` : ""; - const targetPart = safeTarget ? ` target="${escapeKnowledgeShortcodeAttr(safeTarget)}"` : ""; - return `[button name="${safeName}" link="${safeLink}"${blockPart}${targetPart}]`; -} - -function renderKnowledgeButtonTargetOptions() { - if (!el.knowledgeButtonTargetOptions) return; - - el.knowledgeButtonTargetOptions.innerHTML = ""; - const added = new Set(); - - for (const { id, label, target } of landingBlockTargets()) { - const targetKey = target.toLowerCase(); - if (!added.has(targetKey)) { - const option = document.createElement("option"); - option.value = target; - option.label = `${label} / ${id}`; - el.knowledgeButtonTargetOptions.append(option); - added.add(targetKey); - } - - const idKey = String(id || "").toLowerCase(); - if (id && !added.has(idKey)) { - const option = document.createElement("option"); - option.value = id; - option.label = `${label} / block id`; - el.knowledgeButtonTargetOptions.append(option); - added.add(idKey); - } - } -} - -function setKnowledgeButtonError(message = "") { - if (el.knowledgeButtonError) el.knowledgeButtonError.textContent = message; -} - -function closeKnowledgeButtonModal(value = null) { - const resolve = state.knowledgeButtonResolve; - - state.knowledgeButtonResolve = null; - el.knowledgeButtonModal.classList.add("hidden"); - setKnowledgeButtonError(""); - if (resolve) resolve(value); -} - -function confirmKnowledgeButtonModal() { - const name = el.knowledgeButtonName.value.trim(); - if (!name) { - setKnowledgeButtonError("Название кнопки обязательно."); - el.knowledgeButtonName.focus(); - return; - } - - const link = normalizeKnowledgeButtonLink(el.knowledgeButtonLink.value); - el.knowledgeButtonLink.value = link; - closeKnowledgeButtonModal({ - name, - link, - targetInput: el.knowledgeButtonTarget.value.trim(), - }); -} - -function requestKnowledgeButtonInput({ name = "Посмотреть демо", link = "/", target = "" } = {}) { - if (state.knowledgeButtonResolve) closeKnowledgeButtonModal(null); - - return new Promise((resolve) => { - state.knowledgeButtonResolve = resolve; - renderKnowledgeButtonTargetOptions(); - setKnowledgeButtonError(""); - el.knowledgeButtonName.value = name; - el.knowledgeButtonLink.value = normalizeKnowledgeButtonLink(link); - el.knowledgeButtonTarget.value = normalizeKnowledgeLandingTarget(target); - el.knowledgeButtonModal.classList.remove("hidden"); - el.knowledgeButtonName.focus(); - el.knowledgeButtonName.select(); - }); -} - -function openKnowledgeMediaPicker(editor, node) { - saveKnowledgeEditorSelection(); - const fakeInput = { value: "" }; - const fakeText = { textContent: "", title: "" }; - const fakePreview = document.createElement("div"); - const fakeHint = document.createElement("span"); - const fakeError = document.createElement("span"); - const fakeUrlInput = { value: "" }; - openMediaLibrary({ - block: { id: node.id || "knowledge" }, - editableField: { path: "content.bodyHtml", label: "Изображение статьи" }, - hiddenInput: fakeInput, - urlInput: fakeUrlInput, - fileName: fakeText, - preview: fakePreview, - hint: fakeHint, - error: fakeError, - setSource() {}, - onSelect(url) { - const safeUrl = String(url || ""); - if (!safeUrl) return; - insertKnowledgeHtml(editor, node, ``); - }, - }).catch((error) => setStatus(`Ошибка медиатеки: ${error.message}`)); -} - -function renderKnowledgeToolbar(editor, node) { - const toolbar = document.createElement("div"); - toolbar.className = "knowledge-rich-toolbar"; - const buttons = [ - ["P", "Абзац", () => execKnowledgeCommand(editor, node, "formatBlock", "P")], - ["H2", "Заголовок H2", () => execKnowledgeCommand(editor, node, "formatBlock", "H2")], - ["H3", "Заголовок H3", () => execKnowledgeCommand(editor, node, "formatBlock", "H3")], - ["B", "Жирный", () => execKnowledgeCommand(editor, node, "bold")], - ["I", "Курсив", () => execKnowledgeCommand(editor, node, "italic")], - ["UL", "Список", () => execKnowledgeCommand(editor, node, "insertUnorderedList")], - ["OL", "Нумерованный список", () => execKnowledgeCommand(editor, node, "insertOrderedList")], - ["Link", "Ссылка", () => { - const href = window.prompt("Адрес ссылки", "/knowledge/"); - if (!href) return; - execKnowledgeCommand(editor, node, "createLink", href); - }], - ["Button", "Кнопка", async () => { - saveKnowledgeEditorSelection(); - const values = await requestKnowledgeButtonInput(); - if (!values) return; - const targetInput = values.targetInput; - const landingTarget = findLandingTargetByInput(targetInput); - const target = landingTarget ? landingTarget.target : targetInput; - const blockId = landingTarget?.id || ""; - insertKnowledgeHtml( - editor, - node, - `

${escapeHtml(knowledgeButtonShortcode({ name: values.name, link: values.link, target, blockId }))}

`, - ); - }], - ["Image", "Изображение", () => openKnowledgeMediaPicker(editor, node)], - ]; - - buttons.forEach(([label, title, action]) => { - const button = document.createElement("button"); - button.type = "button"; - button.textContent = label; - button.title = title; - button.addEventListener("mousedown", (event) => event.preventDefault()); - button.addEventListener("click", action); - toolbar.append(button); - }); - - return toolbar; -} - -function renderKnowledgeEditor() { - const isRoot = isKnowledgeRootSelected(); - const node = selectedKnowledgeNode(); - el.empty.classList.add("hidden"); - el.form.classList.add("hidden"); - el.knowledgeForm.classList.remove("hidden"); - el.knowledgeForm.innerHTML = ""; - - if (!state.knowledge) { - el.knowledgeForm.innerHTML = '
Загружаем базу знаний...
'; - return; - } - - if (!node) { - const empty = document.createElement("section"); - empty.className = "content-panel"; - empty.innerHTML = "

Дерево базы знаний пустое

Нажмите плюс в левом верхнем углу, чтобы создать первый раздел.

"; - el.knowledgeForm.append(empty); - return; - } - - state.knowledge.settings ||= {}; - const bodyPath = isRoot ? "introHtml" : "bodyHtml"; - const match = isRoot ? null : findKnowledgeNode(node.id); - const parentTitle = match?.parent?.title || state.knowledge.title || "База знаний"; - el.selectedRegion.textContent = isRoot - ? `NODE.DC / ${knowledgeRootTitle()} / /knowledge/` - : `NODE.DC / ${state.knowledge.title || "База знаний"} / ${parentTitle}`; - el.selectedTitle.textContent = isRoot ? knowledgeRootTitle() : node.title || "Материал"; - - const metaPanel = document.createElement("section"); - const metaHead = document.createElement("div"); - const metaTitle = document.createElement("h3"); - const metaKicker = document.createElement("div"); - const grid = document.createElement("div"); - metaPanel.className = "content-panel"; - metaHead.className = "panel-head"; - metaKicker.className = "section-kicker"; - metaKicker.textContent = isRoot ? "Корневая страница" : "Узел дерева"; - metaTitle.textContent = isRoot ? "Настройки /knowledge/" : "Параметры материала"; - metaHead.append(Object.assign(document.createElement("div"), { innerHTML: `${metaKicker.outerHTML}

${metaTitle.textContent}

` })); - grid.className = "group-grid"; - if (isRoot) { - grid.append( - createKnowledgeInput({ node, path: "title", label: "H1 / название в sidebar", wide: false }), - createKnowledgeInput({ node, path: "settings.navTitle", label: "Название в шапке", wide: false }), - createKnowledgeInput({ node, path: "settings.searchPlaceholder", label: "Placeholder поиска", wide: false }), - createKnowledgeInput({ node, path: "settings.homeHref", label: "Ссылка логотипа", kind: "url", wide: false }), - createKnowledgeInput({ node, path: "settings.themeDefault", label: "Тема по умолчанию", wide: false }), - createKnowledgeCheckbox({ node, path: "settings.downloadEnabled", label: "Показывать Download", checkedDefault: false }), - ); - } else { - grid.append( - createKnowledgeInput({ node, path: "title", label: "Название", wide: false }), - createKnowledgeInput({ node, path: "slug", label: "Slug", wide: false }), - createKnowledgeCheckbox({ node, path: "enabled", label: "Публиковать" }), - createKnowledgeCheckbox({ node, path: "useContent", label: "Использовать как информационную страницу" }), - ); - } - metaPanel.append(metaHead, grid); - - const seoPanel = document.createElement("section"); - const seoHead = document.createElement("div"); - const seoGrid = document.createElement("div"); - seoPanel.className = "content-panel"; - seoHead.className = "panel-head"; - seoHead.innerHTML = `
SEO

${isRoot ? "Сниппет раздела" : "Сниппет страницы"}

Поля попадут в title, description, Open Graph и sitemap.`; - seoGrid.className = "group-grid"; - if (isRoot) { - seoGrid.append( - createKnowledgeInput({ node, path: "settings.description", label: "SEO description / описание", kind: "textarea", wide: true }), - ); - } else { - node.seo ||= {}; - seoGrid.append( - createKnowledgeInput({ node, path: "seo.title", label: "SEO title", wide: true }), - createKnowledgeInput({ node, path: "seo.description", label: "SEO description", kind: "textarea", wide: true }), - ); - } - seoPanel.append(seoHead, seoGrid); - - const contentPanel = document.createElement("section"); - const contentHead = document.createElement("div"); - const editor = document.createElement("div"); - contentPanel.className = "content-panel knowledge-rich-panel"; - contentHead.className = "panel-head"; - contentHead.innerHTML = `
Контент

${isRoot ? "Текст главной страницы" : "Текст статьи"}

H2/H3 формируют правый список “На этой странице”.`; - editor.className = "knowledge-rich-editor"; - editor.contentEditable = isRoot || node.useContent !== false ? "true" : "false"; - editor.dataset.knowledgeRichtext = "true"; - editor.dataset.knowledgeBodyPath = bodyPath; - editor.innerHTML = getByPath(node, bodyPath) || "

"; - editor.addEventListener("focusout", () => saveKnowledgeEditorSelection()); - editor.addEventListener("keyup", () => saveKnowledgeEditorSelection()); - editor.addEventListener("mouseup", () => saveKnowledgeEditorSelection()); - editor.addEventListener("input", () => syncKnowledgeRichText(node, editor)); - contentPanel.append(contentHead, renderKnowledgeToolbar(editor, node), editor); - if (!isRoot && node.useContent === false) { - const disabledNote = document.createElement("p"); - disabledNote.className = "group-desc"; - disabledNote.textContent = "Контент сохранится в модели, но публичная страница для этого узла не будет генерироваться, пока флаг выключен."; - contentPanel.append(disabledNote); - } - - const jsonDetails = document.createElement("details"); - jsonDetails.className = "json-details knowledge-json"; - jsonDetails.innerHTML = `${isRoot ? "JSON всей базы знаний" : "JSON выбранного материала"}`; - const jsonField = document.createElement("label"); - const json = document.createElement("textarea"); - jsonField.className = "json-field"; - json.value = JSON.stringify(node, null, 2); - json.addEventListener("blur", () => { - try { - const parsed = JSON.parse(json.value); - if (isRoot) { - state.knowledge = parsed; - state.knowledgeSelectedId = KNOWLEDGE_ROOT_ID; - } else { - const match = findKnowledgeNode(node.id); - if (!match) return; - match.siblings[match.index] = parsed; - state.knowledgeSelectedId = parsed.id || node.id; - } - renderKnowledgeAll(); - markDirty(isRoot ? "JSON базы знаний применён локально." : "JSON материала применён локально."); - } catch (error) { - setStatus(`Ошибка JSON ${isRoot ? "базы знаний" : "материала"}: ${error.message}`); - } - }); - jsonField.innerHTML = "Техническое представление"; - jsonField.append(json); - jsonDetails.append(jsonField); - - el.knowledgeForm.append(metaPanel, seoPanel, contentPanel, jsonDetails); -} - -function renderKnowledgeAll() { - updateAdminModeSurface(); - ensureKnowledgeSelection(); - renderKnowledgeRegions(); - renderKnowledgeEditor(); -} - -async function loadKnowledge() { - const response = await fetch("/api/knowledge"); - if (!response.ok) throw new Error(await response.text()); - state.knowledge = await response.json(); - state.knowledgeSelectedId = readAdminLayoutState().knowledgeSelectedId || state.knowledgeSelectedId || null; - ensureKnowledgeSelection(); - renderAll(); - setDirty(false); - setStatus("Модель knowledge.json загружена."); -} - -async function persistKnowledge() { - const response = await fetch("/api/knowledge", { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify(state.knowledge), - }); - - if (!response.ok) throw new Error(await response.text()); -} - -async function saveKnowledge() { - await persistKnowledge(); - setDirty(false); - setStatus("База знаний сохранена в content/knowledge.json."); -} - -async function renderKnowledge() { - await persistKnowledge(); - const response = await fetch("/api/render/knowledge", { method: "POST" }); - const payload = await response.json(); - - if (!response.ok || !payload.ok) { - throw new Error(payload.error || "Render failed"); - } - - setDirty(false); - setStatus(payload.stdout ? `База знаний сохранена. ${payload.stdout}` : "База знаний сохранена и пересобрана."); -} - -async function loadPage() { - const [pageResponse, templatesResponse] = await Promise.all([ - fetch("/api/page/home"), - fetch("/api/block-templates/home"), - ]); - - if (!pageResponse.ok) throw new Error(await pageResponse.text()); - if (!templatesResponse.ok) throw new Error(await templatesResponse.text()); - - state.page = await pageResponse.json(); - state.templates = await templatesResponse.json(); - ensureStaticElements(); - ensureSectionsRegion(); - ensureSeoModel(); - state.selected = { kind: "static" }; - renderAll(); - setDirty(false); - setStatus("Модель home.json загружена."); - - if (state.mode === "knowledge" && !state.knowledge) { - await loadKnowledge(); - } -} - -async function persistPage() { - if (activeBlock()) syncBlockFromFields(); - assertUniqueLandingTargets(); - - const response = await fetch("/api/page/home", { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify(state.page), - }); - - if (!response.ok) throw new Error(await response.text()); -} - -async function savePage() { - await persistPage(); - setDirty(false); - setStatus("Модель сохранена в content/pages/home.json. Для публичной страницы нажмите «Обновить index.html»."); -} - -async function renderHome() { - await persistPage(); - const response = await fetch("/api/render/home", { method: "POST" }); - const payload = await response.json(); - - if (!response.ok || !payload.ok) { - throw new Error(payload.error || "Render failed"); - } - - setDirty(false); - setStatus(payload.stdout ? `Модель сохранена. ${payload.stdout}` : "Модель сохранена, index.html пересобран."); -} - -function moveSelected(delta) { - if (isStaticSelection()) return; - const region = activeRegion(); - const index = state.selected?.blockIndex; - if (!region || index == null) return; - - const nextIndex = index + delta; - if (nextIndex < 0 || nextIndex >= region.blocks.length) return; - - const [block] = region.blocks.splice(index, 1); - region.blocks.splice(nextIndex, 0, block); - state.selected.blockIndex = nextIndex; - renderAll(); - markDirty("Порядок блоков изменён локально. Нажмите «Сохранить модель» или «Обновить index.html»."); -} - -function duplicateSelected() { - if (isStaticSelection()) return; - const region = activeRegion(); - const block = activeBlock(); - if (!region || !block) return; - - const duplicate = clone(block); - duplicate.id = uniqueId(block.id); - duplicate.adminLabel = `${block.adminLabel || block.id} — копия`; - if (duplicate.anchor) { - duplicate.anchor = uniqueLandingTarget(duplicate.anchor, duplicate); - } - duplicate.scopeIds = true; - region.blocks.splice(state.selected.blockIndex + 1, 0, duplicate); - state.selected.blockIndex += 1; - renderAll(); - markDirty(`Блок «${duplicate.adminLabel || duplicate.id}» продублирован локально.`); -} - -function copySelected() { - if (isStaticSelection()) return; - const block = activeBlock(); - if (!block) return; - - state.clipboard = clone(block); - setStatus(`Скопирован блок: ${block.adminLabel || block.id}`); -} - -function pasteAfterSelected() { - if (isStaticSelection()) return; - const region = activeRegion(); - if (!region || !state.clipboard) return; - - const pasted = clone(state.clipboard); - pasted.id = uniqueId(pasted.id); - pasted.adminLabel = `${pasted.adminLabel || pasted.id} — вставка`; - if (pasted.anchor) { - pasted.anchor = uniqueLandingTarget(pasted.anchor, pasted); - } - pasted.scopeIds = true; - region.blocks.splice(state.selected.blockIndex + 1, 0, pasted); - state.selected.blockIndex += 1; - renderAll(); - markDirty(`Блок «${pasted.adminLabel || pasted.id}» вставлен локально.`); -} - -async function deleteSelected() { - if (isStaticSelection()) return; - const region = activeRegion(); - if (!region || state.selected.blockIndex == null) return; - - const confirmed = await requestDeleteConfirmation(); - if (!confirmed) 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 = { kind: "static" }; - renderAll(); - markDirty("Блок удалён локально. Нажмите «Сохранить модель» или «Обновить index.html»."); -} - -for (const input of [el.id, el.label, el.anchor, el.enabled]) { - input.addEventListener("input", syncBlockFromFields); - input.addEventListener("change", syncBlockFromFields); -} - -el.modeContent?.addEventListener("click", () => setAdminMode("content")); -el.modeSeo?.addEventListener("click", () => setAdminMode("seo")); -el.modeKnowledge?.addEventListener("click", () => setAdminMode("knowledge")); - -el.json.addEventListener("blur", () => { - if (state.mode === "knowledge") return; - if (state.filePickerActive || state.mediaUploadActive) { - recoverEditorSurface(); - return; - } - - syncBlockFromJson(); -}); -el.addBlock.addEventListener("click", () => { - if (state.mode === "knowledge") { - addKnowledgeNode(null); - return; - } - - openTemplateModal(); -}); -el.templateModalClose.addEventListener("click", closeTemplateModal); -el.templateModalCancel.addEventListener("click", closeTemplateModal); -el.templateModal.addEventListener("click", (event) => { - if (event.target === el.templateModal) closeTemplateModal(); -}); -el.deleteModalCancel.addEventListener("click", () => closeDeleteModal(false)); -el.deleteModalConfirm.addEventListener("click", () => closeDeleteModal(true)); -el.deleteModal.addEventListener("click", (event) => { - if (event.target === el.deleteModal) closeDeleteModal(false); -}); -el.mediaActionCancel.addEventListener("click", () => closeMediaActionModal(null)); -el.mediaActionConfirm.addEventListener("click", () => closeMediaActionModal(el.mediaActionInput.value.trim())); -el.mediaActionModal.addEventListener("click", (event) => { - if (event.target === el.mediaActionModal) closeMediaActionModal(null); -}); -el.knowledgeButtonClose.addEventListener("click", () => closeKnowledgeButtonModal(null)); -el.knowledgeButtonCancel.addEventListener("click", () => closeKnowledgeButtonModal(null)); -el.knowledgeButtonConfirm.addEventListener("click", confirmKnowledgeButtonModal); -el.knowledgeButtonLink.addEventListener("blur", () => { - el.knowledgeButtonLink.value = normalizeKnowledgeButtonLink(el.knowledgeButtonLink.value); -}); -el.knowledgeButtonModal.addEventListener("click", (event) => { - if (event.target === el.knowledgeButtonModal) closeKnowledgeButtonModal(null); -}); -el.mediaModalClose.addEventListener("click", closeMediaLibrary); -el.mediaModalCancel.addEventListener("click", closeMediaLibrary); -el.mediaModal.addEventListener("click", (event) => { - if (event.target === el.mediaModal) closeMediaLibrary(); -}); -el.mediaSearch.addEventListener("input", () => { - state.mediaLibrary.query = el.mediaSearch.value; - renderMediaLibrary(); -}); -el.mediaRefresh.addEventListener("click", () => refreshMediaLibrary().catch((error) => setStatus(`Ошибка медиатеки: ${error.message}`))); -el.mediaUpload.addEventListener("click", () => { - if (state.mediaLibrary.trashMode) { - clearMediaTrash().catch((error) => setStatus(`Ошибка очистки корзины: ${error.message}`)); - return; - } - - openMediaLibraryUploader(); -}); -el.mediaSelect.addEventListener("click", chooseSelectedMediaFile); -el.mediaDelete.addEventListener("click", () => deleteSelectedMediaEntry().catch((error) => setStatus(`Ошибка удаления: ${error.message}`))); -el.mediaTrashToggle.addEventListener("click", () => toggleMediaTrash().catch((error) => setStatus(`Ошибка корзины: ${error.message}`))); -el.mediaDropZone.addEventListener("dragover", (event) => { - if (!state.mediaLibrary.context) return; - if (mediaDragHasEntry(event)) return; - event.preventDefault(); - el.mediaDropZone.dataset.dragActive = "true"; -}); -el.mediaDropZone.addEventListener("dragleave", (event) => { - if (el.mediaDropZone.contains(event.relatedTarget)) return; - delete el.mediaDropZone.dataset.dragActive; -}); -el.mediaDropZone.addEventListener("drop", (event) => { - if (!state.mediaLibrary.context) return; - if (mediaDragHasEntry(event)) return; - event.preventDefault(); - delete el.mediaDropZone.dataset.dragActive; - const file = event.dataTransfer?.files?.[0]; - uploadIntoMediaLibrary(file).catch((error) => setStatus(`Ошибка загрузки: ${error.message}`)); -}); -document.addEventListener("click", (event) => { - if (el.mediaContextMenu.classList.contains("hidden")) return; - if (el.mediaContextMenu.contains(event.target)) return; - closeMediaContextMenu(); -}); -document.addEventListener("contextmenu", (event) => { - if (el.mediaModal.classList.contains("hidden")) return; - if (el.mediaModal.contains(event.target)) return; - closeMediaContextMenu(); -}); -document.addEventListener("selectionchange", () => { - if (state.mode !== "knowledge") return; - saveKnowledgeEditorSelection(); -}); -document.addEventListener("keydown", (event) => { - if (!el.deleteModal.classList.contains("hidden")) { - if (event.key === "Enter") { - event.preventDefault(); - closeDeleteModal(true); - } - if (event.key === "Escape") { - event.preventDefault(); - closeDeleteModal(false); - } - return; - } - - if (!el.mediaActionModal.classList.contains("hidden")) { - if (event.key === "Enter") { - event.preventDefault(); - closeMediaActionModal(el.mediaActionInput.value.trim()); - } - if (event.key === "Escape") { - event.preventDefault(); - closeMediaActionModal(null); - } - return; - } - - if (!el.knowledgeButtonModal.classList.contains("hidden")) { - if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { - event.preventDefault(); - confirmKnowledgeButtonModal(); - } - if (event.key === "Enter" && event.target !== el.knowledgeButtonTarget) { - event.preventDefault(); - confirmKnowledgeButtonModal(); - } - if (event.key === "Escape") { - event.preventDefault(); - closeKnowledgeButtonModal(null); - } - return; - } - - if (!el.mediaModal.classList.contains("hidden")) { - if (!el.mediaContextMenu.classList.contains("hidden") && event.key === "Escape") { - event.preventDefault(); - closeMediaContextMenu(); - return; - } - - if (isTypingTarget(event.target)) return; - - if (event.key === "ArrowDown") { - event.preventDefault(); - moveMediaSelection(1).catch((error) => setStatus(`Ошибка навигации: ${error.message}`)); - return; - } - - if (event.key === "ArrowUp") { - event.preventDefault(); - moveMediaSelection(-1).catch((error) => setStatus(`Ошибка навигации: ${error.message}`)); - return; - } - - if (event.key === "ArrowRight") { - event.preventDefault(); - openSelectedMediaDirectory().catch((error) => setStatus(`Ошибка навигации: ${error.message}`)); - return; - } - - if (event.key === "ArrowLeft") { - event.preventDefault(); - moveMediaHierarchyBack().catch((error) => setStatus(`Ошибка навигации: ${error.message}`)); - return; - } - - if (event.key === "Delete" || event.key === "Backspace") { - if (state.mediaLibrary.trashMode ? selectedMediaFile()?.trashPath : canDeleteMediaEntry(state.mediaLibrary.selectedEntry)) { - event.preventDefault(); - deleteSelectedMediaEntry().catch((error) => setStatus(`Ошибка удаления: ${error.message}`)); - } - return; - } - - if (event.key === "Escape") { - event.preventDefault(); - closeMediaLibrary(); - } - return; - } - - if (event.key === "Escape" && !el.templateModal.classList.contains("hidden")) { - closeTemplateModal(); - } -}); -el.reload.addEventListener("click", () => { - const action = state.mode === "knowledge" ? loadKnowledge : loadPage; - action().catch((error) => setStatus(error.message)); -}); -el.save.addEventListener("click", () => { - const action = state.mode === "knowledge" ? saveKnowledge : savePage; - action().catch((error) => setStatus(error.message)); -}); -el.render.addEventListener("click", () => { - const action = state.mode === "knowledge" ? renderKnowledge : renderHome; - action().catch((error) => setStatus(error.message)); -}); -el.moveUp.addEventListener("click", () => moveSelected(-1)); -el.moveDown.addEventListener("click", () => moveSelected(1)); -el.duplicate.addEventListener("click", duplicateSelected); -el.copy.addEventListener("click", copySelected); -el.pasteAfter.addEventListener("click", pasteAfterSelected); -el.deleteBlock.addEventListener("click", () => deleteSelected().catch((error) => setStatus(`Ошибка удаления: ${error.message}`))); - -window.addEventListener("error", (event) => { - recoverEditorSurface(); - setStatus(`Ошибка JS: ${event.message}`); -}); - -window.addEventListener("unhandledrejection", (event) => { - recoverEditorSurface(); - const reason = event.reason instanceof Error ? event.reason.message : String(event.reason); - setStatus(`Ошибка JS: ${reason}`); -}); - -loadPage().catch((error) => setStatus(error.message)); diff --git a/admin/index.html b/admin/index.html deleted file mode 100644 index dfefc99..0000000 --- a/admin/index.html +++ /dev/null @@ -1,344 +0,0 @@ - - - - - - NODE.DC Admin - - - - -
-
-
-
- - - -
-
- - -
-
-
- - - - Профиль - - - -
-
-
-
-
-
- - -
-
-
-

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

- -

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

-
-
- Синхронизировано - - - -
-
- -
-
ND
-
-

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

-

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

-
-
- - - - - -

-      
-
- - - - - - - - - diff --git a/admin/nodedc-logo.svg b/admin/nodedc-logo.svg deleted file mode 100644 index 92b19d8..0000000 --- a/admin/nodedc-logo.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/refactor-admin-plan.md b/docs/refactor-admin-plan.md deleted file mode 100644 index a04dece..0000000 --- a/docs/refactor-admin-plan.md +++ /dev/null @@ -1,5 +0,0 @@ -# NODE.DC Admin Notes - -The current admin flow edits the structured homepage model and renders `index.html` with `npm run render:home`. - -Sanitary rule: admin presets and generated public pages must not contain legacy product names, old external documentation links, or old API origins. Any new SEO/content fields should use the NODE.DC ontology only. diff --git a/package.json b/package.json index de573aa..e47a189 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,6 @@ "scripts": { "extract:home": "node tools/extract-home-blocks.mjs", "render:home": "node tools/render-home.mjs", - "render:knowledge": "node tools/render-knowledge.mjs", - "admin": "node tools/admin-server.mjs" + "render:knowledge": "node tools/render-knowledge.mjs" } } diff --git a/tools/admin-server.mjs b/tools/admin-server.mjs deleted file mode 100644 index 1cddb0e..0000000 --- a/tools/admin-server.mjs +++ /dev/null @@ -1,1655 +0,0 @@ -import { createServer } from "node:http"; -import { createReadStream } from "node:fs"; -import { copyFile, cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; -import { extname, join, normalize, relative } from "node:path"; -import { fileURLToPath } from "node:url"; -import { spawn } from "node:child_process"; -import { dirname } from "node:path"; - -const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); -const port = Number(process.env.PORT || 8090); -const host = process.env.HOST || "127.0.0.1"; -const pagePath = join(repoRoot, "content", "pages", "home.json"); -const knowledgePath = join(repoRoot, "content", "knowledge.json"); -const blockTemplatesPath = join(repoRoot, "content", "block-templates", "home.json"); -const assetRoot = join(repoRoot, "assets"); -const assetTrashRoot = join(assetRoot, ".trash"); -const uploadRoot = join(repoRoot, "assets", "uploads"); -const legacyUploadTrashRoot = join(uploadRoot, ".trash"); -const trashStores = [ - { - id: "assets", - rootPath: assetTrashRoot, - restoreBasePath: assetRoot, - urlPrefix: "./assets/.trash", - displayPrefix: "assets/.trash", - }, - { - id: "uploads-legacy", - rootPath: legacyUploadTrashRoot, - restoreBasePath: uploadRoot, - urlPrefix: "./assets/uploads/.trash", - displayPrefix: "assets/uploads/.trash", - }, -]; -const mediaRoots = [ - { - id: "uploads", - label: "Uploads", - rootPath: uploadRoot, - urlPrefix: "./assets/uploads", - writable: true, - }, - { - id: "media", - label: "Legacy media", - rootPath: join(repoRoot, "assets", "media"), - urlPrefix: "./assets/media", - writable: true, - }, - { - id: "site-images", - label: "Site images", - rootPath: join(repoRoot, "assets", "site", "images"), - urlPrefix: "./assets/site/images", - writable: true, - }, - { - id: "custom-assets", - label: "Custom assets", - rootPath: join(repoRoot, "assets", "custom"), - urlPrefix: "./assets/custom", - writable: true, - }, -]; -const MEDIA_EXTENSIONS = new Set([ - ".avif", - ".gif", - ".glb", - ".gltf", - ".ico", - ".json", - ".jpeg", - ".jpg", - ".m4v", - ".mov", - ".mp4", - ".png", - ".svg", - ".webmanifest", - ".webm", - ".webp", - ".woff", - ".woff2", -]); -const BROWSER_EXCLUDED_DIRS = new Set([".git", ".trash", "node_modules"]); -const REFERENCE_TEXT_EXTENSIONS = new Set([ - ".css", - ".html", - ".js", - ".json", - ".jsx", - ".md", - ".mjs", - ".svg", - ".ts", - ".tsx", - ".txt", - ".webmanifest", - ".xml", -]); - -const MIME = { - ".html": "text/html; charset=utf-8", - ".css": "text/css; charset=utf-8", - ".js": "text/javascript; charset=utf-8", - ".json": "application/json; charset=utf-8", - ".svg": "image/svg+xml", - ".ico": "image/x-icon", - ".webmanifest": "application/manifest+json; charset=utf-8", - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".avif": "image/avif", - ".webp": "image/webp", - ".mp4": "video/mp4", - ".webm": "video/webm", - ".mov": "video/quicktime", - ".m4v": "video/x-m4v", - ".avi": "video/x-msvideo", - ".mkv": "video/x-matroska", - ".glb": "model/gltf-binary", - ".gltf": "model/gltf+json", - ".woff": "font/woff", - ".woff2": "font/woff2", -}; - -function send(res, status, body, contentType = "text/plain; charset=utf-8") { - res.writeHead(status, { - "content-type": contentType, - "cache-control": "no-store", - }); - res.end(body); -} - -function sendStream(req, res, status, stream, headers) { - res.writeHead(status, { - "cache-control": "no-store", - "accept-ranges": "bytes", - ...headers, - }); - - if (req.method === "HEAD") { - stream.destroy(); - res.end(); - return; - } - - stream.on("error", () => { - if (!res.headersSent) { - send(res, 500, "Failed to read file"); - return; - } - - res.destroy(); - }); - stream.pipe(res); -} - -function contentTypeForPath(filePath) { - const normalizedPath = String(filePath || "").toLowerCase(); - if (normalizedPath.endsWith(".webmanifest") || normalizedPath.endsWith(".webmanifest.json")) { - return "application/manifest+json; charset=utf-8"; - } - - return MIME[extname(filePath).toLowerCase()] || "application/octet-stream"; -} - -function sendJson(res, status, payload) { - send(res, status, `${JSON.stringify(payload, null, 2)}\n`, "application/json; charset=utf-8"); -} - -function allPageBlocks(page) { - return (page.regions || []).flatMap((region) => region.blocks || []); -} - -function normalizeLandingTarget(value) { - const raw = String(value || "").trim(); - if (!raw || /^javascript:/i.test(raw) || /^(https?:)?\/\//i.test(raw) || /^(mailto:|tel:)/i.test(raw)) return ""; - const hashValue = raw.includes("#") ? raw.slice(raw.indexOf("#") + 1) : raw; - return hashValue - .replace(/^\/+/, "") - .replace(/^index\.html#?/i, "") - .replace(/^#+/, "") - .trim(); -} - -function landingTargetsForPage(page) { - return allPageBlocks(page) - .filter((block) => block.enabled !== false) - .map((block) => ({ - id: block.id, - label: block.adminLabel || block.id, - template: block.template || block.type || "", - target: normalizeLandingTarget(block.anchor), - })) - .filter((item) => item.target); -} - -function validateLandingTargets(page) { - const targets = new Map(); - - for (const item of landingTargetsForPage(page)) { - if (targets.has(item.target)) { - const first = targets.get(item.target); - throw new Error(`Target "${item.target}" уже используется в блоках "${first.label}" и "${item.label}"`); - } - targets.set(item.target, item); - } -} - -async function readBodyBuffer(req) { - const chunks = []; - for await (const chunk of req) { - chunks.push(chunk); - } - return Buffer.concat(chunks); -} - -async function readBody(req) { - return (await readBodyBuffer(req)).toString("utf8"); -} - -function runNodeScript(scriptName) { - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [join(repoRoot, "tools", scriptName)], { - cwd: repoRoot, - stdio: ["ignore", "pipe", "pipe"], - }); - - let stdout = ""; - let stderr = ""; - child.stdout.on("data", (chunk) => { - stdout += chunk.toString(); - }); - child.stderr.on("data", (chunk) => { - stderr += chunk.toString(); - }); - child.on("error", reject); - child.on("close", (code) => { - if (code === 0) { - resolve({ stdout, stderr }); - } else { - reject(new Error(stderr || stdout || `${scriptName} exited with ${code}`)); - } - }); - }); -} - -function isUploadPayload(payload) { - return ( - payload && - typeof payload.fileName === "string" && - typeof payload.dataUrl === "string" && - (!payload.mimeType || typeof payload.mimeType === "string") && - (!payload.bucket || typeof payload.bucket === "string") - ); -} - -function sanitizePathSegment(value) { - const safe = value - .normalize("NFKD") - .replace(/[^\w.-]+/g, "-") - .replace(/^-+|-+$/g, "") - .toLowerCase(); - - return safe || "media"; -} - -function sanitizeUploadBucket(value) { - const segments = String(value || "media") - .split(/[\\/]+/) - .map((segment) => sanitizePathSegment(segment)) - .filter(Boolean) - .slice(0, 4); - - return segments.length ? segments.join("/") : "media"; -} - -function extensionFromMimeType(mimeType) { - const map = { - "image/svg+xml": ".svg", - "image/x-icon": ".ico", - "image/vnd.microsoft.icon": ".ico", - "image/png": ".png", - "image/jpeg": ".jpg", - "image/gif": ".gif", - "image/avif": ".avif", - "image/webp": ".webp", - "video/mp4": ".mp4", - "video/webm": ".webm", - "video/quicktime": ".mov", - "application/manifest+json": ".webmanifest", - "application/json": ".json", - "model/gltf-binary": ".glb", - "model/gltf+json": ".gltf", - "font/woff": ".woff", - "font/woff2": ".woff2", - }; - - return map[mimeType] || ""; -} - -function buildStoredFileName(fileName, mimeType) { - const extension = (extname(fileName) || extensionFromMimeType(mimeType) || ".bin").toLowerCase(); - const base = fileName.slice(0, extension ? -extension.length : undefined); - const safeBase = - base - .normalize("NFKD") - .replace(/[^\w.-]+/g, "-") - .replace(/^-+|-+$/g, "") - .toLowerCase() - .slice(0, 72) || "asset"; - - return `${safeBase}-${Date.now().toString(36)}${extension}`; -} - -async function saveUploadedAssetBuffer({ fileName, mimeType, bucket, fileBuffer }) { - if (typeof fileName !== "string" || !fileName || !Buffer.isBuffer(fileBuffer)) { - throw new Error("Некорректный файл загрузки"); - } - - const resolvedMimeType = mimeType || "application/octet-stream"; - const resolvedBucket = sanitizeUploadBucket(bucket || "media"); - const storedName = buildStoredFileName(fileName, resolvedMimeType); - const directory = join(uploadRoot, ...resolvedBucket.split("/")); - - await mkdir(directory, { recursive: true }); - await writeFile(join(directory, storedName), fileBuffer); - - return { - ok: true, - url: `./assets/uploads/${resolvedBucket}/${storedName}`, - fileName: storedName, - originalFileName: fileName, - mimeType: resolvedMimeType, - bucket: resolvedBucket, - }; -} - -async function saveUploadedAsset(payload) { - if (!isUploadPayload(payload)) { - throw new Error("Некорректный payload загрузки"); - } - - const match = /^data:([^;,]+)?(?:;[^,]*)?;base64,(.*)$/s.exec(payload.dataUrl); - - if (!match) { - throw new Error("Файл должен прийти data-url с base64"); - } - - const mimeType = payload.mimeType || match[1] || "application/octet-stream"; - const fileBuffer = Buffer.from(match[2], "base64"); - - return saveUploadedAssetBuffer({ - fileName: payload.fileName, - mimeType, - bucket: payload.bucket || "media", - fileBuffer, - }); -} - -function normalizeAssetRef(value) { - let url = String(value || "").trim(); - if (!url) return null; - - url = url.split("#")[0].split("?")[0].replace(/^\/+/, ""); - if (url.startsWith("./")) url = url.slice(2); - - try { - url = decodeURIComponent(url); - } catch { - // Keep the raw path when it contains a malformed escape sequence. - } - - if (!url.startsWith("assets/")) return null; - const normalized = normalize(url).replace(/\\/g, "/"); - if (!normalized.startsWith("assets/") || normalized.includes("../")) return null; - return normalized; -} - -function collectAssetRefsFromString(value, refs) { - for (const part of String(value || "").split(",")) { - const token = part.trim().split(/\s+/)[0]; - const ref = normalizeAssetRef(token); - if (ref) refs.add(ref); - } -} - -function collectAssetRefsFromValue(value, refs = new Set()) { - if (Array.isArray(value)) { - value.forEach((item) => collectAssetRefsFromValue(item, refs)); - return refs; - } - - if (value && typeof value === "object") { - Object.values(value).forEach((item) => collectAssetRefsFromValue(item, refs)); - return refs; - } - - if (typeof value === "string") { - collectAssetRefsFromString(value, refs); - } - - return refs; -} - -function collectAssetRefsFromText(text) { - const refs = new Set(); - const matches = String(text || "").matchAll(/(?:\.\/)?assets\/[^\s"'<>),]+/g); - - for (const match of matches) { - collectAssetRefsFromString(match[0], refs); - } - - return refs; -} - -async function buildMediaUsage() { - const [pageSource, knowledgeSource, indexSource, knowledgeIndexSource] = await Promise.all([ - readFile(pagePath, "utf8").catch(() => "{}"), - readFile(knowledgePath, "utf8").catch(() => "{}"), - readFile(join(repoRoot, "index.html"), "utf8").catch(() => ""), - readFile(join(repoRoot, "knowledge", "index.html"), "utf8").catch(() => ""), - ]); - const pageRefs = collectAssetRefsFromValue(JSON.parse(pageSource)); - collectAssetRefsFromValue(JSON.parse(knowledgeSource), pageRefs); - const renderedRefs = collectAssetRefsFromText(`${indexSource}\n${knowledgeIndexSource}`); - - return { pageRefs, renderedRefs }; -} - -function assetReferenceReplacementPairs(fromRef, toRef) { - const encodedFrom = encodeUrlPath(fromRef); - const encodedTo = encodeUrlPath(toRef); - const rawPairs = [ - [`./${encodedFrom}`, `./${encodedTo}`], - [`./${fromRef}`, `./${toRef}`], - [`/${encodedFrom}`, `/${encodedTo}`], - [`/${fromRef}`, `/${toRef}`], - [encodedFrom, encodedTo], - [fromRef, toRef], - ]; - const seen = new Set(); - - return rawPairs - .filter(([from]) => { - if (seen.has(from)) return false; - seen.add(from); - return true; - }) - .sort((left, right) => right[0].length - left[0].length); -} - -function replaceAssetReferencesInString(value, replacements) { - let nextValue = value; - - for (const replacement of replacements) { - for (const [fromVariant, toVariant] of assetReferenceReplacementPairs(replacement.from, replacement.to)) { - nextValue = nextValue.split(fromVariant).join(toVariant); - } - } - - return nextValue; -} - -function shouldScanReferenceFile(relativePath) { - const segments = pathSegments(relativePath); - if (segments.some((segment) => BROWSER_EXCLUDED_DIRS.has(segment))) return false; - return REFERENCE_TEXT_EXTENSIONS.has(extname(relativePath).toLowerCase()); -} - -async function applyAssetReferenceReplacements(replacements) { - const normalizedReplacements = replacements - .map(({ from, to }) => ({ from: normalizeAssetRef(from), to: normalizeAssetRef(to) })) - .filter((replacement) => replacement.from && replacement.to && replacement.from !== replacement.to); - - if (!normalizedReplacements.length) return []; - - const updatedFiles = []; - - async function walk(directory) { - const entries = await readdir(directory, { withFileTypes: true }); - - for (const entry of entries) { - if (BROWSER_EXCLUDED_DIRS.has(entry.name)) continue; - - const absolutePath = join(directory, entry.name); - const relativePath = relative(repoRoot, absolutePath).replace(/\\/g, "/"); - - if (entry.isDirectory()) { - await walk(absolutePath); - continue; - } - - if (!entry.isFile() || !shouldScanReferenceFile(relativePath)) continue; - - const source = await readFile(absolutePath, "utf8").catch(() => null); - if (source === null) continue; - - const nextSource = replaceAssetReferencesInString(source, normalizedReplacements); - if (nextSource === source) continue; - - await writeFile(absolutePath, nextSource); - updatedFiles.push(relativePath); - } - } - - await walk(repoRoot); - return updatedFiles.sort((left, right) => left.localeCompare(right, "ru", { numeric: true, sensitivity: "base" })); -} - -async function assetReferenceReplacementsForMove(relativePath, nextRelativePath, absolutePath, entryStat) { - const sourceRef = normalizeAssetRef(relativePath); - const targetRef = normalizeAssetRef(nextRelativePath); - - if (!sourceRef) return []; - if (!targetRef) { - throw new Error("Медиа из assets можно переносить только внутри assets, чтобы публичные ссылки оставались рабочими"); - } - - if (entryStat.isFile()) { - return [{ from: sourceRef, to: targetRef }]; - } - - if (!entryStat.isDirectory()) return []; - - const replacements = []; - - async function walk(directory) { - const entries = await readdir(directory, { withFileTypes: true }); - - for (const entry of entries) { - if (BROWSER_EXCLUDED_DIRS.has(entry.name)) continue; - - const entryAbsolutePath = join(directory, entry.name); - if (entry.isDirectory()) { - await walk(entryAbsolutePath); - continue; - } - - if (!entry.isFile()) continue; - - const entryRelativePath = relative(repoRoot, entryAbsolutePath).replace(/\\/g, "/"); - const entryRef = normalizeAssetRef(entryRelativePath); - if (!entryRef) continue; - - const relativeSuffix = entryRelativePath.slice(relativePath.length).replace(/^\/+/, ""); - const nextEntryRef = normalizeAssetRef(relativeSuffix ? `${nextRelativePath}/${relativeSuffix}` : nextRelativePath); - if (nextEntryRef) replacements.push({ from: entryRef, to: nextEntryRef }); - } - } - - await walk(absolutePath); - return replacements; -} - -function serializedAssetReplacements(replacements) { - return replacements.map(({ from, to }) => ({ - from, - to, - fromUrl: `./${encodeUrlPath(from)}`, - toUrl: `./${encodeUrlPath(to)}`, - })); -} - -function mediaKindFromExtension(extension) { - if ([".avif", ".gif", ".ico", ".jpeg", ".jpg", ".png", ".svg", ".webp"].includes(extension)) return "image"; - if ([".m4v", ".mov", ".mp4", ".webm"].includes(extension)) return "video"; - if ([".glb", ".gltf"].includes(extension)) return "model"; - if ([".woff", ".woff2"].includes(extension)) return "font"; - return "other"; -} - -function encodeUrlPath(value) { - return value - .split("/") - .map((part) => encodeURIComponent(part)) - .join("/") - .replaceAll("%2F", "/"); -} - -function buildMediaUrl(root, relativePath) { - return `${root.urlPrefix}/${encodeUrlPath(relativePath)}`; -} - -async function scanMediaRoot(root) { - const files = []; - - async function walk(directory) { - let entries = []; - - try { - entries = await readdir(directory, { withFileTypes: true }); - } catch { - return; - } - - for (const entry of entries) { - const filePath = join(directory, entry.name); - const relativePath = relative(root.rootPath, filePath).replace(/\\/g, "/"); - - if (!relativePath || relativePath.split("/").includes(".trash")) continue; - - if (entry.isDirectory()) { - await walk(filePath); - continue; - } - - if (!entry.isFile()) continue; - - const extension = extname(entry.name).toLowerCase(); - if (!MEDIA_EXTENSIONS.has(extension)) continue; - - const fileStat = await stat(filePath); - const url = buildMediaUrl(root, relativePath); - const normalizedRef = normalizeAssetRef(url); - - files.push({ - id: `${root.id}:${relativePath}`, - rootId: root.id, - rootLabel: root.label, - writable: root.writable, - name: entry.name, - directory: dirname(relativePath) === "." ? "" : dirname(relativePath).replace(/\\/g, "/"), - relativePath, - url, - normalizedRef, - extension: extension.slice(1), - kind: mediaKindFromExtension(extension), - size: fileStat.size, - mtimeMs: fileStat.mtimeMs, - }); - } - } - - await walk(root.rootPath); - return files; -} - -async function listMediaAssets() { - const [usage, rootFiles] = await Promise.all([buildMediaUsage(), Promise.all(mediaRoots.map(scanMediaRoot))]); - const files = rootFiles - .flat() - .map((file) => { - const rendered = usage.renderedRefs.has(file.normalizedRef); - const referenced = usage.pageRefs.has(file.normalizedRef); - const status = rendered ? "rendered" : referenced ? "referenced" : "unused"; - - return { - ...file, - usage: { - status, - referenced, - rendered, - label: status === "rendered" ? "В верстке" : status === "referenced" ? "В модели" : "Не используется", - }, - }; - }) - .sort((left, right) => right.mtimeMs - left.mtimeMs); - - return { - ok: true, - roots: mediaRoots.map(({ id, label, writable, urlPrefix }) => ({ id, label, writable, urlPrefix })), - files, - }; -} - -function safeBrowserRelativePath(value) { - let rawValue = String(value || "").trim().replace(/^\/+/, ""); - - try { - rawValue = decodeURIComponent(rawValue); - } catch { - // Keep the raw value when the browser sends a malformed escape sequence. - } - - rawValue = rawValue.replace(/\\/g, "/"); - const normalized = normalize(rawValue).replace(/\\/g, "/"); - - if (!normalized || normalized === ".") return ""; - if (normalized.startsWith("../") || normalized === ".." || normalized.includes("/../")) { - throw new Error("Некорректный путь"); - } - - return normalized; -} - -function resolveBrowserDirectory(value) { - const relativePath = safeBrowserRelativePath(value); - const absolutePath = join(repoRoot, relativePath); - const pathFromRoot = relative(repoRoot, absolutePath); - - if (pathFromRoot.startsWith("..") || pathFromRoot === "..") { - throw new Error("Путь вне проекта запрещён"); - } - - return { relativePath, absolutePath }; -} - -function pathSegments(value) { - return String(value || "") - .split("/") - .filter(Boolean); -} - -function ensureNoManagedDirectory(relativePath) { - const blockedSegment = pathSegments(relativePath).find((segment) => BROWSER_EXCLUDED_DIRS.has(segment)); - if (blockedSegment) { - throw new Error(`Операции внутри ${blockedSegment} запрещены`); - } -} - -function formatProjectSize(bytes) { - const value = Number(bytes); - if (!Number.isFinite(value) || value <= 0) return "0 МБ"; - - const gib = 1024 ** 3; - const mib = 1024 ** 2; - - if (value < gib) { - const mb = value / mib; - return `${mb >= 10 ? mb.toFixed(0) : mb.toFixed(1)} МБ`; - } - - const gb = value / gib; - return `${gb >= 10 ? gb.toFixed(0) : gb.toFixed(1)} ГБ`; -} - -async function calculateProjectSize() { - async function walk(directory) { - let total = 0; - const entries = await readdir(directory, { withFileTypes: true }); - - for (const entry of entries) { - if (BROWSER_EXCLUDED_DIRS.has(entry.name)) continue; - - const absolutePath = join(directory, entry.name); - if (entry.isSymbolicLink()) continue; - - if (entry.isDirectory()) { - total += await walk(absolutePath); - continue; - } - - if (!entry.isFile()) continue; - total += (await stat(absolutePath)).size; - } - - return total; - } - - const bytes = await walk(repoRoot); - return { - ok: true, - bytes, - label: formatProjectSize(bytes), - }; -} - -function resolveBrowserEntry(value) { - const relativePath = safeBrowserRelativePath(value); - if (!relativePath) { - throw new Error("Некорректный путь"); - } - - ensureNoManagedDirectory(relativePath); - - const absolutePath = join(repoRoot, relativePath); - const pathFromRoot = relative(repoRoot, absolutePath); - - if (pathFromRoot.startsWith("..") || pathFromRoot === "..") { - throw new Error("Путь вне проекта запрещён"); - } - - return { relativePath, absolutePath }; -} - -function safeEntryName(value) { - const name = String(value || "").trim(); - - if (!name || name === "." || name === "..") { - throw new Error("Введите корректное имя"); - } - - if (/[\\/]/.test(name) || BROWSER_EXCLUDED_DIRS.has(name)) { - throw new Error("Имя содержит запрещённые символы"); - } - - return name; -} - -async function ensurePathAvailable(absolutePath) { - try { - await stat(absolutePath); - } catch (error) { - if (error.code === "ENOENT") return; - throw error; - } - - throw new Error("Файл или папка с таким именем уже существует"); -} - -async function availableSiblingPath(directory, fileName) { - const extension = extname(fileName); - const baseName = fileName.slice(0, extension ? -extension.length : undefined); - let candidate = join(directory, fileName); - let index = 2; - - while (true) { - try { - await stat(candidate); - } catch (error) { - if (error.code === "ENOENT") return candidate; - throw error; - } - - candidate = join(directory, `${baseName}-${index}${extension}`); - index += 1; - } -} - -async function assertAssetFileCanMove(relativePath) { - const normalizedRef = normalizeAssetRef(relativePath); - if (!normalizedRef) return; - - const usage = await buildMediaUsage(); - if (usage.pageRefs.has(normalizedRef) || usage.renderedRefs.has(normalizedRef)) { - throw new Error("Файл используется в модели или текущей верстке"); - } -} - -function browserBreadcrumbs(relativePath) { - const breadcrumbs = [{ name: "NODEDC_SITE", path: "" }]; - const parts = relativePath.split("/").filter(Boolean); - let current = ""; - - for (const part of parts) { - current = current ? `${current}/${part}` : part; - breadcrumbs.push({ name: part, path: current }); - } - - return breadcrumbs; -} - -function publicUrlForBrowserPath(relativePath) { - if (!relativePath.startsWith("assets/")) return ""; - return `./${encodeUrlPath(relativePath)}`; -} - -async function browseProjectDirectory(pathValue) { - const { relativePath, absolutePath } = resolveBrowserDirectory(pathValue); - const usage = await buildMediaUsage(); - let entries = []; - - try { - const currentStat = await stat(absolutePath); - if (!currentStat.isDirectory()) { - throw new Error("Путь не является директорией"); - } - - const rawEntries = await readdir(absolutePath, { withFileTypes: true }); - - entries = await Promise.all( - rawEntries - .filter((entry) => !BROWSER_EXCLUDED_DIRS.has(entry.name)) - .map(async (entry) => { - const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name; - const entryAbsolutePath = join(absolutePath, entry.name); - const entryStat = await stat(entryAbsolutePath); - const isDirectory = entry.isDirectory(); - const extension = isDirectory ? "" : extname(entry.name).toLowerCase(); - const kind = isDirectory ? "folder" : mediaKindFromExtension(extension); - const url = isDirectory ? "" : publicUrlForBrowserPath(entryRelativePath); - const normalizedRef = url ? normalizeAssetRef(url) : null; - const rendered = normalizedRef ? usage.renderedRefs.has(normalizedRef) : false; - const referenced = normalizedRef ? usage.pageRefs.has(normalizedRef) : false; - const status = rendered ? "rendered" : referenced ? "referenced" : normalizedRef ? "unused" : "local"; - - return { - id: entryRelativePath || entry.name, - name: entry.name, - path: entryRelativePath, - directory: relativePath, - type: isDirectory ? "directory" : "file", - kind, - extension: extension ? extension.slice(1) : "", - size: isDirectory ? 0 : entryStat.size, - mtimeMs: entryStat.mtimeMs, - url, - selectable: Boolean(url), - writable: Boolean(normalizedRef && !normalizedRef.split("/").includes(".trash")), - usage: { - status, - referenced, - rendered, - label: - status === "rendered" - ? "В верстке" - : status === "referenced" - ? "В модели" - : status === "unused" - ? "Не используется" - : "Локальный файл", - }, - }; - }), - ); - } catch (error) { - if (error.code !== "ENOENT") throw error; - entries = []; - } - - entries.sort((left, right) => { - if (left.type !== right.type) return left.type === "directory" ? -1 : 1; - return left.name.localeCompare(right.name, "ru", { numeric: true, sensitivity: "base" }); - }); - - return { - ok: true, - path: relativePath, - breadcrumbs: browserBreadcrumbs(relativePath), - entries, - }; -} - -function resolveDeletableAssetFile(url) { - const normalizedRef = normalizeAssetRef(url); - if (!normalizedRef || !normalizedRef.startsWith("assets/") || normalizedRef.split("/").includes(".trash")) { - throw new Error("Удалять можно только файлы из assets"); - } - - const filePath = join(repoRoot, normalizedRef); - const pathFromAssets = relative(assetRoot, filePath); - if (pathFromAssets.startsWith("..") || pathFromAssets === "..") { - throw new Error("Некорректный путь файла"); - } - - return { normalizedRef, filePath }; -} - -function resolveDeletableAssetEntry(pathValue) { - const relativePath = safeBrowserRelativePath(pathValue); - if (!relativePath || relativePath === "assets" || !relativePath.startsWith("assets/") || pathSegments(relativePath).includes(".trash")) { - throw new Error("Удалять можно только файлы и папки внутри assets"); - } - - ensureNoManagedDirectory(relativePath); - - const absolutePath = join(repoRoot, relativePath); - const pathFromAssets = relative(assetRoot, absolutePath); - if (pathFromAssets.startsWith("..") || pathFromAssets === "..") { - throw new Error("Некорректный путь файла"); - } - - return { relativePath, absolutePath }; -} - -async function assertDirectoryCanDelete(directoryPath) { - const usage = await buildMediaUsage(); - - async function walk(currentPath) { - const entries = await readdir(currentPath, { withFileTypes: true }); - - for (const entry of entries) { - if (BROWSER_EXCLUDED_DIRS.has(entry.name)) continue; - - const entryPath = join(currentPath, entry.name); - if (entry.isDirectory()) { - await walk(entryPath); - continue; - } - - if (!entry.isFile()) continue; - - const entryRelativePath = relative(repoRoot, entryPath).replace(/\\/g, "/"); - const normalizedRef = normalizeAssetRef(entryRelativePath); - if (normalizedRef && (usage.pageRefs.has(normalizedRef) || usage.renderedRefs.has(normalizedRef))) { - throw new Error("В папке есть файлы, которые используются в модели или текущей верстке"); - } - } - } - - await walk(directoryPath); -} - -async function softDeleteMediaAsset(url) { - const { normalizedRef, filePath } = resolveDeletableAssetFile(url); - const usage = await buildMediaUsage(); - - if (usage.pageRefs.has(normalizedRef) || usage.renderedRefs.has(normalizedRef)) { - throw new Error("Файл используется в модели или текущей верстке"); - } - - const fileStat = await stat(filePath); - if (!fileStat.isFile()) { - throw new Error("Удалять можно только файлы"); - } - - const assetRelative = relative(assetRoot, filePath).replace(/\\/g, "/"); - const parsedExtension = extname(assetRelative); - const basePath = assetRelative.slice(0, parsedExtension ? -parsedExtension.length : undefined); - const trashPath = join(assetTrashRoot, `${basePath}-${Date.now().toString(36)}${parsedExtension}`); - - await mkdir(dirname(trashPath), { recursive: true }); - await rename(filePath, trashPath); - - return { - ok: true, - deleted: `./assets/${assetRelative}`, - trashPath: `assets/.trash/${relative(assetTrashRoot, trashPath).replace(/\\/g, "/")}`, - }; -} - -async function softDeleteProjectEntry(pathValue) { - const { relativePath, absolutePath } = resolveDeletableAssetEntry(pathValue); - const entryStat = await stat(absolutePath); - - if (entryStat.isFile()) { - await assertAssetFileCanMove(relativePath); - } else if (entryStat.isDirectory()) { - await assertDirectoryCanDelete(absolutePath); - } else { - throw new Error("Удалять можно только файл или папку"); - } - - const assetRelative = relative(assetRoot, absolutePath).replace(/\\/g, "/"); - const parsedExtension = entryStat.isFile() ? extname(assetRelative) : ""; - const basePath = assetRelative.slice(0, parsedExtension ? -parsedExtension.length : undefined); - const trashPath = join(assetTrashRoot, `${basePath}-${Date.now().toString(36)}${parsedExtension}`); - - await mkdir(dirname(trashPath), { recursive: true }); - await rename(absolutePath, trashPath); - - return { - ok: true, - deleted: relativePath, - type: entryStat.isDirectory() ? "directory" : "file", - trashPath: `assets/.trash/${relative(assetTrashRoot, trashPath).replace(/\\/g, "/")}`, - }; -} - -function originalNameFromTrashName(fileName) { - const extension = extname(fileName); - const baseName = fileName.slice(0, extension ? -extension.length : undefined); - return `${baseName.replace(/-[a-z0-9]{6,}$/i, "")}${extension}`; -} - -function trashOriginalRelativePath(store, trashRelativePath) { - const directory = dirname(trashRelativePath); - const restoredName = originalNameFromTrashName(trashRelativePath.split("/").pop() || "asset"); - const restoreRelative = directory === "." ? restoredName : `${directory}/${restoredName}`; - return relative(repoRoot, join(store.restoreBasePath, restoreRelative)).replace(/\\/g, "/"); -} - -async function scanTrashStore(store) { - const files = []; - - async function walk(directory) { - let entries = []; - - try { - entries = await readdir(directory, { withFileTypes: true }); - } catch { - return; - } - - for (const entry of entries) { - const absolutePath = join(directory, entry.name); - const trashRelativePath = relative(store.rootPath, absolutePath).replace(/\\/g, "/"); - - if (entry.isDirectory()) { - await walk(absolutePath); - continue; - } - - if (!entry.isFile()) continue; - - const extension = extname(entry.name).toLowerCase(); - const fileStat = await stat(absolutePath); - const url = `${store.urlPrefix}/${encodeUrlPath(trashRelativePath)}`; - const originalPath = trashOriginalRelativePath(store, trashRelativePath); - - files.push({ - id: `${store.id}:${trashRelativePath}`, - storeId: store.id, - name: entry.name, - originalName: originalNameFromTrashName(entry.name), - path: `${store.displayPrefix}/${trashRelativePath}`, - trashPath: `${store.displayPrefix}/${trashRelativePath}`, - originalPath, - directory: dirname(trashRelativePath) === "." ? "" : dirname(trashRelativePath).replace(/\\/g, "/"), - type: "file", - kind: mediaKindFromExtension(extension), - extension: extension ? extension.slice(1) : "", - size: fileStat.size, - mtimeMs: fileStat.mtimeMs, - url, - selectable: true, - writable: true, - restorable: true, - usage: { - status: "trash", - referenced: false, - rendered: false, - label: "Удалён", - }, - }); - } - } - - await walk(store.rootPath); - return files; -} - -async function listTrashAssets() { - const files = (await Promise.all(trashStores.map(scanTrashStore))) - .flat() - .sort((left, right) => right.mtimeMs - left.mtimeMs); - - return { - ok: true, - files, - }; -} - -function resolveTrashFile(trashPathValue) { - const relativePath = safeBrowserRelativePath(trashPathValue); - const store = trashStores.find( - (candidate) => relativePath === candidate.displayPrefix || relativePath.startsWith(`${candidate.displayPrefix}/`), - ); - - if (!store) { - throw new Error("Файл не найден в корзине"); - } - - const trashRelativePath = relativePath.slice(store.displayPrefix.length).replace(/^\/+/, ""); - if (!trashRelativePath || pathSegments(trashRelativePath).includes(".trash")) { - throw new Error("Некорректный путь корзины"); - } - - const absolutePath = join(store.rootPath, trashRelativePath); - const pathFromStore = relative(store.rootPath, absolutePath); - if (pathFromStore.startsWith("..") || pathFromStore === "..") { - throw new Error("Некорректный путь корзины"); - } - - return { store, trashRelativePath, absolutePath }; -} - -async function restoreTrashAsset(trashPathValue) { - const { store, trashRelativePath, absolutePath } = resolveTrashFile(trashPathValue); - const fileStat = await stat(absolutePath); - if (!fileStat.isFile()) { - throw new Error("Восстановить можно только файл"); - } - - const originalRelativePath = trashOriginalRelativePath(store, trashRelativePath); - const targetPath = await availableSiblingPath( - dirname(join(repoRoot, originalRelativePath)), - originalRelativePath.split("/").pop() || "asset", - ); - - await mkdir(dirname(targetPath), { recursive: true }); - await rename(absolutePath, targetPath); - - const restoredPath = relative(repoRoot, targetPath).replace(/\\/g, "/"); - return { - ok: true, - path: restoredPath, - url: publicUrlForBrowserPath(restoredPath), - }; -} - -async function pruneEmptyTrashParents(startPath, rootPath) { - let currentPath = dirname(startPath); - - while (currentPath !== rootPath && currentPath.startsWith(rootPath)) { - try { - const entries = await readdir(currentPath); - if (entries.length) return; - await rm(currentPath, { recursive: false, force: true }); - } catch { - return; - } - - currentPath = dirname(currentPath); - } -} - -async function deleteTrashAsset(trashPathValue) { - const { store, absolutePath } = resolveTrashFile(trashPathValue); - const fileStat = await stat(absolutePath); - if (!fileStat.isFile()) { - throw new Error("Окончательно удалить можно только файл"); - } - - await rm(absolutePath, { force: true }); - await pruneEmptyTrashParents(absolutePath, store.rootPath); - return { ok: true }; -} - -async function clearTrashAssets() { - await Promise.all(trashStores.map((store) => rm(store.rootPath, { recursive: true, force: true }))); - return { ok: true }; -} - -async function createProjectFolder(payload) { - const { relativePath, absolutePath } = resolveBrowserDirectory(payload?.path || ""); - ensureNoManagedDirectory(relativePath); - - const folderName = safeEntryName(payload?.name); - const targetPath = join(absolutePath, folderName); - const targetRelativePath = relativePath ? `${relativePath}/${folderName}` : folderName; - - ensureNoManagedDirectory(targetRelativePath); - await ensurePathAvailable(targetPath); - await mkdir(targetPath); - - return { - ok: true, - path: targetRelativePath, - }; -} - -async function renameProjectEntry(payload) { - const { relativePath, absolutePath } = resolveBrowserEntry(payload?.path || ""); - const entryStat = await stat(absolutePath); - const nextName = safeEntryName(payload?.name); - const parentPath = dirname(relativePath) === "." ? "" : dirname(relativePath).replace(/\\/g, "/"); - const nextRelativePath = parentPath ? `${parentPath}/${nextName}` : nextName; - const nextAbsolutePath = join(repoRoot, nextRelativePath); - - ensureNoManagedDirectory(nextRelativePath); - await ensurePathAvailable(nextAbsolutePath); - - const replacements = await assetReferenceReplacementsForMove(relativePath, nextRelativePath, absolutePath, entryStat); - await rename(absolutePath, nextAbsolutePath); - const updatedFiles = await applyAssetReferenceReplacements(replacements); - - return { - ok: true, - path: nextRelativePath, - type: entryStat.isDirectory() ? "directory" : "file", - url: publicUrlForBrowserPath(nextRelativePath), - replacements: serializedAssetReplacements(replacements), - updatedFiles, - }; -} - -async function moveProjectEntry(payload) { - const { relativePath, absolutePath } = resolveBrowserEntry(payload?.path || ""); - const { relativePath: targetRelativePath, absolutePath: targetAbsolutePath } = resolveBrowserDirectory(payload?.targetPath || ""); - const entryStat = await stat(absolutePath); - const targetStat = await stat(targetAbsolutePath); - - if (!entryStat.isFile() && !entryStat.isDirectory()) { - throw new Error("Переносить можно только файл или папку"); - } - - if (!targetStat.isDirectory()) { - throw new Error("Целевая директория не найдена"); - } - - ensureNoManagedDirectory(targetRelativePath); - - if (entryStat.isDirectory() && (targetRelativePath === relativePath || targetRelativePath.startsWith(`${relativePath}/`))) { - throw new Error("Нельзя перенести папку внутрь самой себя"); - } - - const entryName = relativePath.split("/").pop() || ""; - const nextRelativePath = targetRelativePath ? `${targetRelativePath}/${entryName}` : entryName; - const nextAbsolutePath = join(repoRoot, nextRelativePath); - - if (nextRelativePath === relativePath) { - return { - ok: true, - path: relativePath, - type: entryStat.isDirectory() ? "directory" : "file", - url: publicUrlForBrowserPath(relativePath), - replacements: [], - updatedFiles: [], - }; - } - - ensureNoManagedDirectory(nextRelativePath); - await ensurePathAvailable(nextAbsolutePath); - - const replacements = await assetReferenceReplacementsForMove(relativePath, nextRelativePath, absolutePath, entryStat); - await rename(absolutePath, nextAbsolutePath); - const updatedFiles = await applyAssetReferenceReplacements(replacements); - - return { - ok: true, - path: nextRelativePath, - type: entryStat.isDirectory() ? "directory" : "file", - url: publicUrlForBrowserPath(nextRelativePath), - replacements: serializedAssetReplacements(replacements), - updatedFiles, - }; -} - -async function duplicateProjectFile(payload) { - const { relativePath, absolutePath } = resolveBrowserEntry(payload?.path || ""); - const entryStat = await stat(absolutePath); - if (!entryStat.isFile() && !entryStat.isDirectory()) { - throw new Error("Дублировать можно только файл или папку"); - } - - const extension = entryStat.isFile() ? extname(relativePath) : ""; - const directory = dirname(absolutePath); - const baseName = relativePath.split("/").pop()?.slice(0, extension ? -extension.length : undefined) || "asset"; - const targetPath = await availableSiblingPath(directory, `${baseName}-copy${extension}`); - - if (entryStat.isDirectory()) { - await cp(absolutePath, targetPath, { recursive: true, errorOnExist: true }); - } else { - await copyFile(absolutePath, targetPath); - } - - const duplicatedPath = relative(repoRoot, targetPath).replace(/\\/g, "/"); - return { - ok: true, - path: duplicatedPath, - type: entryStat.isDirectory() ? "directory" : "file", - url: publicUrlForBrowserPath(duplicatedPath), - }; -} - -function isMultipartRequest(req) { - return String(req.headers["content-type"] || "").toLowerCase().startsWith("multipart/form-data"); -} - -function multipartBoundary(req) { - const contentType = String(req.headers["content-type"] || ""); - const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType); - return match?.[1] || match?.[2] || ""; -} - -function parseHeaderParams(value) { - const params = {}; - const parts = String(value || "").split(";"); - - for (const part of parts.slice(1)) { - const [rawKey, ...rawValueParts] = part.trim().split("="); - if (!rawKey || !rawValueParts.length) continue; - const rawValue = rawValueParts.join("=").trim(); - params[rawKey.toLowerCase()] = rawValue.replace(/^"|"$/g, ""); - } - - return params; -} - -function parseMultipartUpload(req, bodyBuffer) { - const boundary = multipartBoundary(req); - if (!boundary) { - throw new Error("Не найден multipart boundary"); - } - - const fields = {}; - let filePart = null; - const body = bodyBuffer.toString("latin1"); - const delimiter = `--${boundary}`; - - for (const rawPart of body.split(delimiter)) { - let part = rawPart; - if (!part || part === "--" || part === "--\r\n") continue; - if (part.startsWith("\r\n")) part = part.slice(2); - if (part.endsWith("--")) part = part.slice(0, -2); - if (part.endsWith("\r\n")) part = part.slice(0, -2); - - const headerEnd = part.indexOf("\r\n\r\n"); - if (headerEnd < 0) continue; - - const headerLines = part.slice(0, headerEnd).split("\r\n"); - const headers = Object.fromEntries( - headerLines - .map((line) => { - const separator = line.indexOf(":"); - if (separator < 0) return null; - return [line.slice(0, separator).trim().toLowerCase(), line.slice(separator + 1).trim()]; - }) - .filter(Boolean), - ); - const disposition = headers["content-disposition"]; - const params = parseHeaderParams(disposition); - const fieldName = params.name; - const value = part.slice(headerEnd + 4); - - if (!fieldName) continue; - - if (Object.prototype.hasOwnProperty.call(params, "filename")) { - filePart = { - fileName: params.filename || fields.fileName || "asset.bin", - mimeType: headers["content-type"] || fields.mimeType || "application/octet-stream", - fileBuffer: Buffer.from(value, "latin1"), - }; - continue; - } - - fields[fieldName] = Buffer.from(value, "latin1").toString("utf8"); - } - - if (!filePart) { - throw new Error("Файл не найден в multipart payload"); - } - - return { - fileName: fields.fileName || filePart.fileName, - mimeType: fields.mimeType || filePart.mimeType, - bucket: fields.bucket || "media", - fileBuffer: filePart.fileBuffer, - }; -} - -function safePublicPath(urlPath) { - const withoutQuery = decodeURIComponent(urlPath.split("?")[0]); - const requestPath = - withoutQuery === "/" - ? "/index.html" - : withoutQuery === "/admin" || withoutQuery === "/admin/" - ? "/admin/index.html" - : withoutQuery; - const normalized = normalize(requestPath).replace(/^(\.\.[/\\])+/, ""); - const absolute = join(repoRoot, normalized); - - if (!absolute.startsWith(repoRoot)) { - return null; - } - - return absolute; -} - -async function serveStatic(req, res) { - let filePath = safePublicPath(new URL(req.url, `http://${host}:${port}`).pathname); - if (!filePath) { - send(res, 403, "Forbidden"); - return; - } - - try { - let fileStat = await stat(filePath); - if (fileStat.isDirectory()) { - filePath = join(filePath, "index.html"); - fileStat = await stat(filePath); - } - - if (!fileStat.isFile()) { - send(res, 404, "Not found"); - return; - } - - const contentType = contentTypeForPath(filePath); - const range = req.headers.range; - - if (range) { - const match = /^bytes=(\d*)-(\d*)$/.exec(range); - - if (!match) { - res.writeHead(416, { - "cache-control": "no-store", - "content-range": `bytes */${fileStat.size}`, - }); - res.end(); - return; - } - - const start = match[1] ? Number(match[1]) : 0; - const end = match[2] ? Number(match[2]) : fileStat.size - 1; - - if ( - !Number.isInteger(start) || - !Number.isInteger(end) || - start < 0 || - end < start || - start >= fileStat.size - ) { - res.writeHead(416, { - "cache-control": "no-store", - "content-range": `bytes */${fileStat.size}`, - }); - res.end(); - return; - } - - const cappedEnd = Math.min(end, fileStat.size - 1); - sendStream(req, res, 206, createReadStream(filePath, { start, end: cappedEnd }), { - "content-type": contentType, - "content-length": cappedEnd - start + 1, - "content-range": `bytes ${start}-${cappedEnd}/${fileStat.size}`, - }); - return; - } - - sendStream(req, res, 200, createReadStream(filePath), { - "content-type": contentType, - "content-length": fileStat.size, - }); - } catch { - send(res, 404, "Not found"); - } -} - -const server = createServer(async (req, res) => { - try { - const url = new URL(req.url, `http://${host}:${port}`); - - if (req.method === "GET" && url.pathname === "/api/page/home") { - send(res, 200, await readFile(pagePath, "utf8"), "application/json; charset=utf-8"); - return; - } - - if (req.method === "GET" && url.pathname === "/api/knowledge") { - send(res, 200, await readFile(knowledgePath, "utf8"), "application/json; charset=utf-8"); - return; - } - - if (req.method === "GET" && url.pathname === "/api/landing-targets") { - const page = JSON.parse(await readFile(pagePath, "utf8")); - validateLandingTargets(page); - sendJson(res, 200, { ok: true, targets: landingTargetsForPage(page) }); - return; - } - - if (req.method === "GET" && url.pathname === "/api/block-templates/home") { - send(res, 200, await readFile(blockTemplatesPath, "utf8"), "application/json; charset=utf-8"); - return; - } - - if (req.method === "GET" && url.pathname === "/api/media/assets") { - sendJson(res, 200, await listMediaAssets()); - return; - } - - if (req.method === "GET" && url.pathname === "/api/media/browse") { - sendJson(res, 200, await browseProjectDirectory(url.searchParams.get("path") || "")); - return; - } - - if (req.method === "GET" && url.pathname === "/api/media/site-size") { - sendJson(res, 200, await calculateProjectSize()); - return; - } - - if (req.method === "GET" && url.pathname === "/api/media/trash") { - sendJson(res, 200, await listTrashAssets()); - return; - } - - if (req.method === "PUT" && url.pathname === "/api/page/home") { - const body = await readBody(req); - const parsed = JSON.parse(body); - validateLandingTargets(parsed); - await writeFile(pagePath, `${JSON.stringify(parsed, null, 2)}\n`); - sendJson(res, 200, { ok: true }); - return; - } - - if (req.method === "PUT" && url.pathname === "/api/knowledge") { - const body = await readBody(req); - const parsed = JSON.parse(body); - await writeFile(knowledgePath, `${JSON.stringify(parsed, null, 2)}\n`); - sendJson(res, 200, { ok: true }); - return; - } - - if (req.method === "POST" && url.pathname === "/api/render/home") { - const result = await runNodeScript("render-home.mjs"); - sendJson(res, 200, { ok: true, ...result }); - return; - } - - if (req.method === "POST" && url.pathname === "/api/render/knowledge") { - const result = await runNodeScript("render-knowledge.mjs"); - sendJson(res, 200, { ok: true, ...result }); - return; - } - - if (req.method === "POST" && url.pathname === "/api/upload/asset") { - const result = isMultipartRequest(req) - ? await saveUploadedAssetBuffer(parseMultipartUpload(req, await readBodyBuffer(req))) - : await saveUploadedAsset(JSON.parse(await readBody(req))); - sendJson(res, 200, result); - return; - } - - if (req.method === "POST" && url.pathname === "/api/media/delete") { - const payload = JSON.parse(await readBody(req)); - sendJson(res, 200, await softDeleteMediaAsset(payload.url)); - return; - } - - if (req.method === "POST" && url.pathname === "/api/media/delete-entry") { - const payload = JSON.parse(await readBody(req)); - sendJson(res, 200, await softDeleteProjectEntry(payload.path)); - return; - } - - if (req.method === "POST" && url.pathname === "/api/media/restore") { - const payload = JSON.parse(await readBody(req)); - sendJson(res, 200, await restoreTrashAsset(payload.trashPath)); - return; - } - - if (req.method === "POST" && url.pathname === "/api/media/trash/delete") { - const payload = JSON.parse(await readBody(req)); - sendJson(res, 200, await deleteTrashAsset(payload.trashPath)); - return; - } - - if (req.method === "POST" && url.pathname === "/api/media/trash/clear") { - sendJson(res, 200, await clearTrashAssets()); - return; - } - - if (req.method === "POST" && url.pathname === "/api/media/folder") { - const payload = JSON.parse(await readBody(req)); - sendJson(res, 200, await createProjectFolder(payload)); - return; - } - - if (req.method === "POST" && url.pathname === "/api/media/rename") { - const payload = JSON.parse(await readBody(req)); - sendJson(res, 200, await renameProjectEntry(payload)); - return; - } - - if (req.method === "POST" && url.pathname === "/api/media/move") { - const payload = JSON.parse(await readBody(req)); - sendJson(res, 200, await moveProjectEntry(payload)); - return; - } - - if (req.method === "POST" && url.pathname === "/api/media/duplicate") { - const payload = JSON.parse(await readBody(req)); - sendJson(res, 200, await duplicateProjectFile(payload)); - return; - } - - if (req.method === "POST" && url.pathname === "/api/extract/home") { - const result = await runNodeScript("extract-home-blocks.mjs"); - sendJson(res, 200, { ok: true, ...result }); - return; - } - - await serveStatic(req, res); - } catch (error) { - sendJson(res, 500, { ok: false, error: error.message }); - } -}); - -server.listen(port, host, () => { - console.log(`NODE.DC admin: http://${host}:${port}/admin/`); - console.log(`NODE.DC site: http://${host}:${port}/`); -});