From 43a96c9f7fb91e5fbab87c38de4f748786b938dd Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Mon, 6 Jul 2026 00:33:27 +0300 Subject: [PATCH] Initial DC CMS extraction --- .dockerignore | 10 + .gitignore | 11 + Dockerfile | 17 + README.md | 64 + admin/admin.css | 2995 +++++++++ admin/admin.js | 5751 +++++++++++++++++ admin/index.html | 351 + admin/nodedc-logo.svg | 1 + infra/.env.example | 52 + infra/.env.synology.example | 52 + infra/authentik/bootstrap-cms.py | 320 + infra/authentik/custom-templates/.gitkeep | 1 + .../custom-templates/base/header_js.html | 948 +++ .../custom-templates/base/skeleton.html | 50 + .../branding/nodedc-login.css | 737 +++ .../authentik/custom-templates/if/admin.html | 16 + infra/docker-compose.yml | 144 + infra/reverse-proxy/Caddyfile | 39 + infra/scripts/bootstrap-authentik.sh | 9 + package-lock.json | 24 + package.json | 13 + projects/nodedc.json | 18 + server/admin-server.mjs | 2221 +++++++ 23 files changed, 13844 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 admin/admin.css create mode 100644 admin/admin.js create mode 100644 admin/index.html create mode 100644 admin/nodedc-logo.svg create mode 100644 infra/.env.example create mode 100644 infra/.env.synology.example create mode 100644 infra/authentik/bootstrap-cms.py create mode 100644 infra/authentik/custom-templates/.gitkeep create mode 100644 infra/authentik/custom-templates/base/header_js.html create mode 100644 infra/authentik/custom-templates/base/skeleton.html create mode 100644 infra/authentik/custom-templates/branding/nodedc-login.css create mode 100644 infra/authentik/custom-templates/if/admin.html create mode 100644 infra/docker-compose.yml create mode 100644 infra/reverse-proxy/Caddyfile create mode 100755 infra/scripts/bootstrap-authentik.sh create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 projects/nodedc.json create mode 100644 server/admin-server.mjs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9a85124 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.DS_Store +node_modules +npm-debug.log* +__pycache__/ +*.pyc +.env +*.local +infra/.env +.cms-smoke.png diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e037678 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.DS_Store +node_modules/ +npm-debug.log* +__pycache__/ +*.pyc + +.env +*.local +infra/.env + +.cms-smoke.png diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..81a5df7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM node:24-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install --omit=dev + +COPY admin ./admin +COPY projects ./projects +COPY server ./server + +ENV HOST=0.0.0.0 +ENV PORT=8090 + +EXPOSE 8090 + +CMD ["npm", "run", "admin"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..899aec7 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# DC CMS + +Отдельный сервис администрирования сайтов DCTOUCH / NODE.DC. + +## Первый подключенный проект + +`projects/nodedc.json` подключает существующий сайт: + +- `siteRoot`: локальный путь к репозиторию сайта. +- `previewUrl`: локальный адрес предпросмотра. +- `publicUrl`: публичный домен сайта. +- `deploy`: настройки будущей публикации на хостинг. + +Пароль хостинга не хранится в JSON. В конфиге указывается имя переменной окружения, например `MCHOST_PASSWORD`. + +## Локальный запуск + +```bash +PORT=8210 npm run admin +``` + +Админка будет доступна на `http://127.0.0.1:8210/admin/`, а подключенный сайт на `http://127.0.0.1:8210/`. + +## Запуск с Authentik + +Полная локальная связка лежит в `infra/`: CMS, отдельный Authentik, Postgres и Caddy reverse proxy. Секреты хранятся только в локальном `infra/.env`; файл игнорируется git и Docker build context. + +Для локальных доменов нужны host aliases: + +```bash +127.0.0.1 cms.local.nodedc cms-auth.local.nodedc cms-auth-admin.local.nodedc +``` + +Старт: + +```bash +docker compose --env-file infra/.env -f infra/docker-compose.yml up -d --build +infra/scripts/bootstrap-authentik.sh +``` + +Адреса: + +- CMS: `http://cms.local.nodedc:8210/admin/` +- Authentik login: `http://cms-auth.local.nodedc:8210/` +- Authentik admin: `http://cms-auth-admin.local.nodedc:8210/if/admin/` + +Bootstrap создает OIDC-приложение `dc-cms`, группы `dc-cms:*` и стартового администратора из `infra/.env`. После первичного входа временный пароль нужно заменить в Authentik. + +## Synology + +Для Synology используется отдельный env-шаблон `infra/.env.synology.example`. Рабочий файл должен лежать рядом как `infra/.env.synology` и не должен попадать в artifact. + +Публичные host routes: + +- `cms.dcserve.ru` -> CMS. +- `auth.dcserve.ru` -> Authentik login/OIDC issuer. +- `auth-admin.dcserve.ru` -> Authentik admin, опционально и лучше не открывать публично без необходимости. + +DNS-записи должны вести на внешний IP NAS. В Synology Reverse Proxy: + +- `cms.dcserve.ru` -> `http://172.22.0.222:9918` +- `auth.dcserve.ru` -> `http://172.22.0.222:9919` + +Оба target-порта ведут в один Caddy контейнер внутри compose-проекта `dc-cms`; Caddy разводит запросы по Host. diff --git a/admin/admin.css b/admin/admin.css new file mode 100644 index 0000000..1adc89b --- /dev/null +++ b/admin/admin.css @@ -0,0 +1,2995 @@ +: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; +} + +.project-settings-button { + right: calc(var(--admin-control-inset) + (var(--admin-control-ring) * 0.9) + 0.45rem); + width: calc(var(--admin-control-ring) * 0.85); + height: calc(var(--admin-control-ring) * 0.85); +} + +.project-settings-button svg { + width: 1rem; + height: 1rem; + fill: none; + stroke: currentColor; + stroke-width: 1.7; + stroke-linecap: round; + stroke-linejoin: round; +} + +.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; +} + +.project-settings-editor { + display: grid; + min-height: 0; + overflow: auto; + gap: 1rem; + padding: 0 0.2rem 5rem; + scrollbar-color: rgba(255, 255, 255, 0.25) transparent; +} + +.project-settings-hint { + margin: 0; + max-width: 58rem; + color: var(--text-muted); + font-size: 0.85rem; + line-height: 1.45; +} + +.project-settings-actions { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; + justify-content: flex-end; +} + +.project-settings-runtime { + display: grid; + gap: 0.45rem; + color: var(--text-muted); + font-size: 0.76rem; + line-height: 1.35; +} + +.project-settings-runtime code { + display: inline-block; + max-width: 100%; + overflow: hidden; + padding: 0.18rem 0.38rem; + border-radius: 0.42rem; + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.76); + text-overflow: ellipsis; + vertical-align: bottom; +} + +.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 new file mode 100644 index 0000000..315fb33 --- /dev/null +++ b/admin/admin.js @@ -0,0 +1,5751 @@ +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, + projectConfig: null, + projectConfigDirty: false, + 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"), + workspaceName: document.querySelector("#workspace-name"), + 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"), + projectSettings: document.querySelector("#project-settings"), + 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"), + projectSettingsForm: document.querySelector("#project-settings-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.projectSettingsForm.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 isProjectSettingsSelected() { + return state.selected?.kind === "project-settings"; +} + +function projectName() { + return state.projectConfig?.name || "NODE.DC"; +} + +function projectRole() { + return state.projectConfig?.role || "Администратор сайта"; +} + +function applyProjectConfigSurface() { + if (el.workspaceName) el.workspaceName.textContent = projectName(); + if (el.workspaceRole && !isProjectSettingsSelected()) { + el.workspaceRole.textContent = + state.mode === "knowledge" ? "Редактор материалов" : state.mode === "seo" ? "SEO-режим сайта" : projectRole(); + } +} + +async function loadProjectConfig() { + const response = await fetch("/api/project/config"); + const payload = await response.json().catch(() => null); + + if (!response.ok || !payload) { + throw new Error(payload?.error || "Не удалось загрузить настройки проекта"); + } + + state.projectConfig = payload; + state.projectConfigDirty = false; + applyProjectConfigSurface(); + return payload; +} + +function projectConfigValue(path) { + return getByPath(state.projectConfig || {}, path); +} + +function setProjectConfigValue(path, value) { + if (!state.projectConfig) state.projectConfig = {}; + setByPath(state.projectConfig, path, value); + state.projectConfigDirty = true; +} + +function normalizeProjectInputValue(input) { + if (input.type === "checkbox") return input.checked; + if (input.type === "number") return Number(input.value || 0); + return input.value.trim(); +} + +function syncProjectConfigFromForm() { + if (!el.projectSettingsForm || !state.projectConfig) return; + + for (const input of el.projectSettingsForm.querySelectorAll("[data-project-path]")) { + setByPath(state.projectConfig, input.dataset.projectPath, normalizeProjectInputValue(input)); + } +} + +async function saveProjectSettings() { + syncProjectConfigFromForm(); + const response = await fetch("/api/project/config", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(state.projectConfig), + }); + const payload = await response.json().catch(() => null); + + if (!response.ok || !payload?.ok) { + throw new Error(payload?.error || "Не удалось сохранить настройки проекта"); + } + + state.projectConfig = payload.project; + state.projectConfigDirty = false; + setDirty(false); + applyProjectConfigSurface(); + renderProjectSettings(); + setStatus("Настройки проекта сохранены."); +} + +async function checkProjectConnection() { + const [pageResponse, knowledgeResponse, sizeResponse] = await Promise.all([ + fetch("/api/page/home"), + fetch("/api/knowledge"), + fetch("/api/media/site-size"), + ]); + + if (!pageResponse.ok) throw new Error(`home.json: ${await pageResponse.text()}`); + if (!knowledgeResponse.ok) throw new Error(`knowledge.json: ${await knowledgeResponse.text()}`); + + const size = sizeResponse.ok ? await sizeResponse.json() : null; + setStatus(`Проект доступен. Размер локального сайта: ${size?.label || "не рассчитан"}.`); +} + +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; + if (isProjectSettingsSelected()) { + state.selected = mode === "knowledge" ? null : { kind: "static" }; + } + 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-режим сайта" : projectRole(); + } + 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.projectSettingsForm.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 createProjectField({ path, label, type = "text", placeholder = "", options = [], hint = "" }) { + const field = document.createElement("label"); + const title = document.createElement("span"); + const badge = document.createElement("span"); + const control = options.length ? document.createElement("select") : document.createElement("input"); + const value = projectConfigValue(path); + + field.className = type === "checkbox" ? "toggle-row" : "field-control"; + title.textContent = label; + + if (type === "checkbox") { + control.type = "checkbox"; + control.checked = Boolean(value); + field.append(control, title); + } else { + if (options.length) { + for (const optionValue of options) { + const option = document.createElement("option"); + option.value = optionValue; + option.textContent = optionValue; + control.append(option); + } + control.value = value || options[0] || ""; + } else { + control.type = type; + control.value = value ?? ""; + control.placeholder = placeholder; + } + + badge.className = "field-kind"; + badge.textContent = options.length ? "SELECT" : type === "number" ? "NUMBER" : "TEXT"; + field.append(title, badge, control); + } + + control.dataset.projectPath = path; + control.addEventListener("input", () => { + setProjectConfigValue(path, normalizeProjectInputValue(control)); + markDirty("Настройки проекта изменены. Нажмите «Сохранить настройки»."); + }); + control.addEventListener("change", () => { + setProjectConfigValue(path, normalizeProjectInputValue(control)); + markDirty("Настройки проекта изменены. Нажмите «Сохранить настройки»."); + }); + + if (hint && type !== "checkbox") { + const note = document.createElement("small"); + note.className = "field-hint"; + note.textContent = hint; + field.append(note); + } + + return field; +} + +function createProjectSettingsCard({ kicker, title, note = "", fields = [], runtime = false, actions = null }) { + const card = document.createElement("section"); + const head = document.createElement("div"); + const copy = document.createElement("div"); + const kickerNode = document.createElement("div"); + const titleNode = document.createElement("h3"); + const body = document.createElement("div"); + + card.className = "settings-card"; + head.className = "card-head"; + copy.append(kickerNode, titleNode); + kickerNode.className = "section-kicker"; + kickerNode.textContent = kicker; + titleNode.textContent = title; + head.append(copy); + + if (note) { + const noteNode = document.createElement("p"); + noteNode.className = "project-settings-hint"; + noteNode.textContent = note; + head.append(noteNode); + } + + body.className = runtime ? "project-settings-runtime" : "field-grid"; + fields.forEach((field) => body.append(field)); + card.append(head, body); + + if (actions) { + const foot = document.createElement("div"); + foot.className = "project-settings-actions"; + foot.append(...actions); + card.append(foot); + } + + return card; +} + +function renderProjectRuntimeLine(label, value) { + const row = document.createElement("div"); + const code = document.createElement("code"); + row.append(`${label}: `, code); + code.textContent = value || "не задано"; + return row; +} + +function renderProjectSettings() { + updateAdminModeSurface(); + el.empty.classList.add("hidden"); + el.form.classList.add("hidden"); + el.knowledgeForm.classList.add("hidden"); + el.projectSettingsForm.classList.remove("hidden"); + el.projectSettingsForm.innerHTML = ""; + + if (!state.projectConfig) { + el.projectSettingsForm.innerHTML = '
Загружаем настройки проекта...
'; + loadProjectConfig().then(renderProjectSettings).catch((error) => setStatus(error.message)); + return; + } + + if (el.workspaceRole) el.workspaceRole.textContent = "Настройки подключения"; + if (el.editorHeading) el.editorHeading.textContent = "Настройки CMS"; + el.selectedRegion.textContent = `DC CMS / ${projectName()} / Подключение`; + el.selectedTitle.textContent = "Проект и деплой"; + if (el.save) { + el.save.textContent = "Сохранить настройки"; + el.save.title = "Записать настройки проекта в projects/nodedc.json"; + } + if (el.render) { + el.render.textContent = "Проверить проект"; + el.render.title = "Проверить доступ к модели, базе знаний и файлам проекта"; + } + + const projectCard = createProjectSettingsCard({ + kicker: "Проект", + title: "Подключенный сайт", + note: "Эти поля описывают сайт, который сейчас обслуживает CMS. Локальный путь используется сервером, публичный URL нужен для будущего деплоя и ссылок.", + fields: [ + createProjectField({ path: "name", label: "Название проекта" }), + createProjectField({ path: "role", label: "Роль в сайдбаре" }), + createProjectField({ path: "siteRoot", label: "Локальный путь сайта", placeholder: "../NODEDC_SITE" }), + createProjectField({ path: "previewUrl", label: "Preview URL", placeholder: "http://127.0.0.1:8210/" }), + createProjectField({ path: "publicUrl", label: "Публичный домен", placeholder: "https://nodedc.ru/" }), + ], + }); + + const deployCard = createProjectSettingsCard({ + kicker: "Хостинг", + title: "Публикация на McHost / Synology", + note: "Пароль не пишем в JSON. В поле passwordEnv указывается имя переменной окружения сервиса, где будет лежать пароль.", + fields: [ + createProjectField({ path: "deploy.enabled", label: "Включить деплой", type: "checkbox" }), + createProjectField({ path: "deploy.deployOnRender", label: "Деплоить после рендера", type: "checkbox" }), + createProjectField({ path: "deploy.method", label: "Метод", options: ["sftp", "ftp"] }), + createProjectField({ path: "deploy.host", label: "Хост", placeholder: "server.mchost.ru" }), + createProjectField({ path: "deploy.port", label: "Порт", type: "number", placeholder: "22" }), + createProjectField({ path: "deploy.username", label: "Логин" }), + createProjectField({ path: "deploy.passwordEnv", label: "Переменная пароля", placeholder: "MCHOST_PASSWORD" }), + createProjectField({ path: "deploy.remotePath", label: "Папка сайта на хостинге", placeholder: "/public_html/nodedc.ru" }), + ], + }); + + const runtime = state.projectConfig.runtime || {}; + const runtimeCard = createProjectSettingsCard({ + kicker: "Runtime", + title: "Текущая связка", + runtime: true, + fields: [ + renderProjectRuntimeLine("CMS root", runtime.cmsRoot), + renderProjectRuntimeLine("Site root", runtime.siteRoot), + renderProjectRuntimeLine("Config", runtime.configPath), + renderProjectRuntimeLine("Важно", runtime.siteRootChangesRequireRestart ? "изменение siteRoot применится после перезапуска CMS" : ""), + ], + }); + + el.projectSettingsForm.append(projectCard, deployCard, runtimeCard); +} + +function renderEditor() { + updateAdminModeSurface(); + el.knowledgeForm.classList.add("hidden"); + el.projectSettingsForm.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 (isProjectSettingsSelected()) { + if (state.mode === "knowledge" && state.knowledge) { + ensureKnowledgeSelection(); + renderKnowledgeRegions(); + } else { + renderRegions(); + } + renderProjectSettings(); + return; + } + 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.projectSettingsForm.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 [projectConfig, pageResponse, templatesResponse] = await Promise.all([ + loadProjectConfig(), + fetch("/api/page/home"), + fetch("/api/block-templates/home"), + ]); + + state.projectConfig = projectConfig; + 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»."); +} + +function openProjectSettings() { + state.selected = { kind: "project-settings" }; + renderAll(); +} + +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.projectSettings?.addEventListener("click", openProjectSettings); + +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", () => { + if (isProjectSettingsSelected()) { + loadProjectConfig().then(renderProjectSettings).then(() => setStatus("Настройки проекта перезагружены.")).catch((error) => setStatus(error.message)); + return; + } + + const action = state.mode === "knowledge" ? loadKnowledge : loadPage; + action().catch((error) => setStatus(error.message)); +}); +el.save.addEventListener("click", () => { + if (isProjectSettingsSelected()) { + saveProjectSettings().catch((error) => setStatus(error.message)); + return; + } + + const action = state.mode === "knowledge" ? saveKnowledge : savePage; + action().catch((error) => setStatus(error.message)); +}); +el.render.addEventListener("click", () => { + if (isProjectSettingsSelected()) { + checkProjectConnection().catch((error) => setStatus(error.message)); + return; + } + + 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 new file mode 100644 index 0000000..7584109 --- /dev/null +++ b/admin/index.html @@ -0,0 +1,351 @@ + + + + + + NODE.DC Admin + + + + +
+
+
+
+ + + +
+
+ + +
+
+
+ + + + Профиль + + + +
+
+
+
+
+
+ + +
+
+
+

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

+ +

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

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

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

+

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

+
+
+ + + + + + +

+      
+
+ + + + + + + + + diff --git a/admin/nodedc-logo.svg b/admin/nodedc-logo.svg new file mode 100644 index 0000000..92b19d8 --- /dev/null +++ b/admin/nodedc-logo.svg @@ -0,0 +1 @@ + diff --git a/infra/.env.example b/infra/.env.example new file mode 100644 index 0000000..6277300 --- /dev/null +++ b/infra/.env.example @@ -0,0 +1,52 @@ +# domains +CMS_DOMAIN=cms.local.nodedc +CMS_AUTH_DOMAIN=cms-auth.local.nodedc +CMS_AUTH_ADMIN_DOMAIN=cms-auth-admin.local.nodedc +CMS_PUBLIC_URL=http://cms.local.nodedc:8210 +CMS_AUTH_PUBLIC_URL=http://cms-auth.local.nodedc:8210 +CMS_AUTH_ADMIN_PUBLIC_URL=http://cms-auth-admin.local.nodedc:8210 +CMS_HTTP_BIND=127.0.0.1:8210 +CMS_AUTH_HTTP_BIND=127.0.0.1:8211 +CMS_AUTH_ADMIN_HTTP_BIND=127.0.0.1:8212 +CMS_EXTERNAL_SCHEME=http + +# mounted NODE.DC site for local development +CMS_NODEDC_SITE_PATH=../../NODEDC_SITE + +# authentik image +AUTHENTIK_IMAGE=ghcr.io/goauthentik/server +AUTHENTIK_TAG=2026.2.2 +AUTHENTIK_ERROR_REPORTING__ENABLED=false +AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS=127.0.0.0/8,172.16.0.0/12 + +# authentik database +PG_DB=authentik +PG_USER=authentik +PG_PASS=replace-with-random-secret + +# authentik first-start bootstrap +AUTHENTIK_SECRET_KEY=replace-with-random-secret +AUTHENTIK_BOOTSTRAP_EMAIL=dcctouch@gmail.com +AUTHENTIK_BOOTSTRAP_PASSWORD=replace-with-temporary-password +AUTHENTIK_BOOTSTRAP_TOKEN=replace-with-random-token + +# CMS bootstrap user. Used by infra/scripts/bootstrap-authentik.sh. +CMS_BOOTSTRAP_ADMIN_EMAIL=dcctouch@gmail.com +CMS_BOOTSTRAP_ADMIN_PASSWORD=replace-with-temporary-password +CMS_BOOTSTRAP_ADMIN_NAME=DCTOUCH CMS Admin + +# CMS OIDC application. bootstrap-authentik.sh creates the matching provider. +CMS_AUTH_ENABLED=true +CMS_BASE_URL=http://cms.local.nodedc:8210 +CMS_OIDC_ISSUER=http://cms-auth.local.nodedc:8210/application/o/dc-cms/ +CMS_OIDC_CLIENT_ID=dc-cms +CMS_OIDC_CLIENT_SECRET=replace-with-random-secret +CMS_OIDC_REDIRECT_URI=http://cms.local.nodedc:8210/auth/callback +CMS_OIDC_LOGGED_OUT_REDIRECT_URI=http://cms.local.nodedc:8210/auth/logged-out +CMS_LOGOUT_URI=http://cms.local.nodedc:8210/auth/logout +CMS_REQUIRED_GROUPS=dc-cms:owner,dc-cms:admin,dc-cms:editor,dc-cms:publisher + +# CMS app session +CMS_SESSION_SECRET=replace-with-random-secret +COOKIE_DOMAIN= +COOKIE_SECURE=false diff --git a/infra/.env.synology.example b/infra/.env.synology.example new file mode 100644 index 0000000..b671e88 --- /dev/null +++ b/infra/.env.synology.example @@ -0,0 +1,52 @@ +# public domains routed by Synology Reverse Proxy to 172.22.0.222:9918 +CMS_DOMAIN=cms.dcserve.ru +CMS_AUTH_DOMAIN=auth.dcserve.ru +CMS_AUTH_ADMIN_DOMAIN=auth-admin.dcserve.ru +CMS_PUBLIC_URL=https://cms.dcserve.ru +CMS_AUTH_PUBLIC_URL=https://auth.dcserve.ru +CMS_AUTH_ADMIN_PUBLIC_URL=https://auth-admin.dcserve.ru +CMS_HTTP_BIND=172.22.0.222:9918 +CMS_AUTH_HTTP_BIND=172.22.0.222:9919 +CMS_AUTH_ADMIN_HTTP_BIND=127.0.0.1:9920 +CMS_EXTERNAL_SCHEME=https + +# mounted NODE.DC site root on Synology +CMS_NODEDC_SITE_PATH=/volume1/docker/dc-cms/sites/nodedc + +# authentik image +AUTHENTIK_IMAGE=ghcr.io/goauthentik/server +AUTHENTIK_TAG=2026.2.2 +AUTHENTIK_ERROR_REPORTING__ENABLED=false +AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS=127.0.0.0/8,172.16.0.0/12 + +# authentik database +PG_DB=authentik +PG_USER=authentik +PG_PASS=replace-with-random-secret + +# authentik first-start bootstrap +AUTHENTIK_SECRET_KEY=replace-with-random-secret +AUTHENTIK_BOOTSTRAP_EMAIL=dcctouch@gmail.com +AUTHENTIK_BOOTSTRAP_PASSWORD=replace-with-temporary-password +AUTHENTIK_BOOTSTRAP_TOKEN=replace-with-random-token + +# CMS bootstrap user. Used by infra/scripts/bootstrap-authentik.sh. +CMS_BOOTSTRAP_ADMIN_EMAIL=dcctouch@gmail.com +CMS_BOOTSTRAP_ADMIN_PASSWORD=replace-with-temporary-password +CMS_BOOTSTRAP_ADMIN_NAME=DCTOUCH CMS Admin + +# CMS OIDC application. bootstrap-authentik.sh creates the matching provider. +CMS_AUTH_ENABLED=true +CMS_BASE_URL=https://cms.dcserve.ru +CMS_OIDC_ISSUER=https://auth.dcserve.ru/application/o/dc-cms/ +CMS_OIDC_CLIENT_ID=dc-cms +CMS_OIDC_CLIENT_SECRET=replace-with-random-secret +CMS_OIDC_REDIRECT_URI=https://cms.dcserve.ru/auth/callback +CMS_OIDC_LOGGED_OUT_REDIRECT_URI=https://cms.dcserve.ru/auth/logged-out +CMS_LOGOUT_URI=https://cms.dcserve.ru/auth/logout +CMS_REQUIRED_GROUPS=dc-cms:owner,dc-cms:admin,dc-cms:editor,dc-cms:publisher + +# CMS app session +CMS_SESSION_SECRET=replace-with-random-secret +COOKIE_DOMAIN= +COOKIE_SECURE=true diff --git a/infra/authentik/bootstrap-cms.py b/infra/authentik/bootstrap-cms.py new file mode 100644 index 0000000..8ab811c --- /dev/null +++ b/infra/authentik/bootstrap-cms.py @@ -0,0 +1,320 @@ +from os import environ + +from django.db import transaction + +from authentik.brands.models import Brand +from authentik.common.oauth.constants import SubModes +from authentik.core.models import Application, Group, User +from authentik.crypto.models import CertificateKeyPair +from authentik.flows.models import Flow, FlowStageBinding +from authentik.policies.models import PolicyBinding +from authentik.providers.oauth2.models import ( + ClientTypes, + IssuerMode, + OAuth2LogoutMethod, + OAuth2Provider, + RedirectURI, + RedirectURIMatchingMode, + ScopeMapping, +) +from authentik.stages.identification.models import IdentificationStage +from authentik.stages.password.models import PasswordStage + + +GROUP_SPECS = [ + ("dc-cms:owner", False), + ("dc-cms:admin", False), + ("dc-cms:editor", False), + ("dc-cms:publisher", False), + ("dc-cms:site:nodedc:admin", False), +] + +CMS_APP_SPEC = { + "slug": "dc-cms", + "name": "DC CMS", + "provider_name": "DC CMS OIDC", + "client_id_env": "CMS_OIDC_CLIENT_ID", + "client_secret_env": "CMS_OIDC_CLIENT_SECRET", + "redirect_uri_env": "CMS_OIDC_REDIRECT_URI", + "logged_out_redirect_uri_env": "CMS_OIDC_LOGGED_OUT_REDIRECT_URI", + "launch_url_env": "CMS_BASE_URL", + "launch_url": "http://cms.local.nodedc:8210", + "logout_uri_env": "CMS_LOGOUT_URI", + "logout_uri": "http://cms.local.nodedc:8210/auth/logout", + "groups": [ + "dc-cms:owner", + "dc-cms:admin", + "dc-cms:editor", + "dc-cms:publisher", + "dc-cms:site:nodedc:admin", + ], + "description": "Standalone CMS for NODE.DC websites and SEO knowledge bases.", +} + + +def required_env(name): + value = environ.get(name, "").strip() + if not value: + raise RuntimeError(f"{name} is required") + return value + + +def optional_env(name, default=""): + return environ.get(name, default).strip() + + +def ensure_group(name, is_superuser=False): + group, _ = Group.objects.get_or_create(name=name) + group.is_superuser = is_superuser + group.save() + return group + + +def ensure_groups(): + groups = {} + for name, is_superuser in GROUP_SPECS: + groups[name] = ensure_group(name, is_superuser) + return groups + + +def ensure_admin_user(groups): + admin_email = ( + environ.get("CMS_BOOTSTRAP_ADMIN_EMAIL", "").strip() + or environ.get("AUTHENTIK_BOOTSTRAP_EMAIL", "").strip() + ) + if not admin_email: + return None + + user = User.objects.filter(email__iexact=admin_email).first() or User.objects.filter( + username=admin_email + ).first() + if user is None: + user = User(username=admin_email, email=admin_email, name=admin_email, type="internal") + + user.username = admin_email + user.email = admin_email + user.name = environ.get("CMS_BOOTSTRAP_ADMIN_NAME", "DCTOUCH CMS Admin").strip() or admin_email + user.is_active = True + user.type = "internal" + + admin_password = ( + environ.get("CMS_BOOTSTRAP_ADMIN_PASSWORD", "") + or environ.get("AUTHENTIK_BOOTSTRAP_PASSWORD", "") + ) + if admin_password: + user.set_password(admin_password) + user.save() + + authentik_admins, _ = Group.objects.get_or_create(name="authentik Admins") + if not authentik_admins.is_superuser: + authentik_admins.is_superuser = True + authentik_admins.save(update_fields=["is_superuser"]) + user.groups.add(authentik_admins) + + for group in groups.values(): + user.groups.add(group) + return user + + +def ensure_groups_scope_mapping(): + mapping, _ = ScopeMapping.objects.get_or_create( + name="DC CMS OAuth Mapping: groups", + defaults={ + "scope_name": "groups", + "description": "Adds Authentik group names to DC CMS OIDC tokens.", + "expression": 'return {"groups": [group.name for group in request.user.groups.all()]}', + }, + ) + mapping.scope_name = "groups" + mapping.description = "Adds Authentik group names to DC CMS OIDC tokens." + mapping.expression = 'return {"groups": [group.name for group in request.user.groups.all()]}' + mapping.save() + return mapping + + +def ensure_profile_scope_mapping(): + expression = """ +attributes = request.user.attributes or {} +display_name = request.user.name or request.user.username +name_parts = display_name.split(" ", 1) +avatar_url = attributes.get("picture") or attributes.get("avatar_url") or attributes.get("avatar") + +return { + "name": display_name, + "given_name": name_parts[0] if name_parts else display_name, + "family_name": name_parts[1] if len(name_parts) > 1 else "", + "preferred_username": request.user.username, + "nickname": request.user.username, + "picture": avatar_url, + "avatar_url": avatar_url, +} +""".strip() + mapping, _ = ScopeMapping.objects.get_or_create( + name="DC CMS OAuth Mapping: profile context", + defaults={ + "scope_name": "profile", + "description": "Adds normalized DC CMS profile claims to OIDC tokens.", + "expression": expression, + }, + ) + mapping.scope_name = "profile" + mapping.description = "Adds normalized DC CMS profile claims to OIDC tokens." + mapping.expression = expression + mapping.save() + return mapping + + +def default_scope_mappings(): + scope_names = ["openid", "email", "offline_access"] + mappings = list(ScopeMapping.objects.filter(scope_name__in=scope_names)) + mappings.append(ensure_profile_scope_mapping()) + mappings.append(ensure_groups_scope_mapping()) + return mappings + + +def ensure_cms_brand(): + auth_domain = environ.get("CMS_AUTH_DOMAIN", "cms-auth.local.nodedc").strip() or "cms-auth.local.nodedc" + authentication_flow = Flow.objects.get(slug="default-authentication-flow") + invalidation_flow = Flow.objects.get(slug="default-invalidation-flow") + + authentication_flow.name = "DC CMS authentication" + authentication_flow.title = "Управление сайтами." + authentication_flow.layout = "stacked" + authentication_flow.background = "" + authentication_flow.save() + + identification_stage = IdentificationStage.objects.get( + name="default-authentication-identification" + ) + password_stage = PasswordStage.objects.get(name="default-authentication-password") + password_stage.allow_show_password = True + password_stage.save() + identification_stage.user_fields = ["email"] + identification_stage.password_stage = password_stage + identification_stage.show_matched_user = False + identification_stage.enable_remember_me = False + identification_stage.save() + FlowStageBinding.objects.filter(target=authentication_flow, stage=password_stage).delete() + + brand = Brand.objects.filter(domain=auth_domain).first() + if brand is None: + brand = Brand(domain=auth_domain) + + Brand.objects.exclude(brand_uuid=brand.brand_uuid).update(default=False) + brand.default = True + brand.domain = auth_domain + brand.branding_title = "DC CMS" + brand.branding_logo = "" + brand.branding_favicon = "" + brand.branding_custom_css = "" + brand.flow_authentication = authentication_flow + brand.flow_invalidation = invalidation_flow + brand.attributes = { + **(brand.attributes or {}), + "settings": { + **((brand.attributes or {}).get("settings") or {}), + "locale": "ru", + "theme": { + **(((brand.attributes or {}).get("settings") or {}).get("theme") or {}), + "base": "dark", + }, + }, + } + brand.save() + return brand + + +def ensure_provider(spec, mappings): + authorization_flow = Flow.objects.get(slug="default-provider-authorization-implicit-consent") + invalidation_flow = Flow.objects.get(slug="default-invalidation-flow") + signing_key = ( + CertificateKeyPair.objects.filter(name="authentik Self-signed Certificate").first() + or CertificateKeyPair.objects.first() + ) + + if signing_key is None: + raise RuntimeError("No Authentik CertificateKeyPair exists for OIDC signing") + + provider = OAuth2Provider.objects.filter(name=spec["provider_name"]).first() + if provider is None: + provider = OAuth2Provider(name=spec["provider_name"]) + + provider.name = spec["provider_name"] + provider.client_type = ClientTypes.CONFIDENTIAL + provider.client_id = required_env(spec["client_id_env"]) + provider.client_secret = required_env(spec["client_secret_env"]) + redirect_uri_values = [required_env(spec["redirect_uri_env"])] + logged_out_redirect_uri = optional_env(spec.get("logged_out_redirect_uri_env", ""), "") + + if logged_out_redirect_uri: + redirect_uri_values.append(logged_out_redirect_uri) + + provider.redirect_uris = [ + RedirectURI(RedirectURIMatchingMode.STRICT, redirect_uri) + for redirect_uri in dict.fromkeys(redirect_uri_values) + ] + provider.logout_uri = optional_env(spec.get("logout_uri_env", ""), spec["logout_uri"]) + provider.logout_method = OAuth2LogoutMethod.FRONTCHANNEL + provider.include_claims_in_id_token = True + provider.sub_mode = SubModes.USER_UUID + provider.issuer_mode = IssuerMode.PER_PROVIDER + provider.authorization_flow = authorization_flow + provider.invalidation_flow = invalidation_flow + provider.signing_key = signing_key + provider.save() + provider.property_mappings.set(mappings) + return provider + + +def ensure_application(spec, provider, groups): + application = Application.objects.filter(slug=spec["slug"]).first() + if application is None: + application = Application(slug=spec["slug"], name=spec["name"]) + + application.name = spec["name"] + application.slug = spec["slug"] + application.group = "DC CMS" + application.provider = provider + application.meta_launch_url = optional_env(spec.get("launch_url_env", ""), spec["launch_url"]) + application.meta_description = spec["description"] + application.meta_publisher = "DCTOUCH" + application.open_in_new_tab = False + application.policy_engine_mode = "any" + application.save() + + PolicyBinding.objects.filter(target=application).exclude( + group__name__in=spec["groups"] + ).delete() + for order, group_name in enumerate(spec["groups"]): + binding = PolicyBinding.objects.filter(target=application, group=groups[group_name]).first() + if binding is None: + binding = PolicyBinding(target=application, group=groups[group_name]) + binding.enabled = True + binding.negate = False + binding.timeout = 30 + binding.failure_result = False + binding.order = order + binding.save() + + return application + + +@transaction.atomic +def main(): + brand = ensure_cms_brand() + groups = ensure_groups() + user = ensure_admin_user(groups) + mappings = default_scope_mappings() + provider = ensure_provider(CMS_APP_SPEC, mappings) + application = ensure_application(CMS_APP_SPEC, provider, groups) + summary = { + "groups": list(groups), + "admin_user": user.email if user else None, + "brand": brand.domain, + "application": application.slug, + "provider": provider.name, + } + print(summary) + + +main() diff --git a/infra/authentik/custom-templates/.gitkeep b/infra/authentik/custom-templates/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/infra/authentik/custom-templates/.gitkeep @@ -0,0 +1 @@ + diff --git a/infra/authentik/custom-templates/base/header_js.html b/infra/authentik/custom-templates/base/header_js.html new file mode 100644 index 0000000..bb926de --- /dev/null +++ b/infra/authentik/custom-templates/base/header_js.html @@ -0,0 +1,948 @@ +{% load i18n %} +{% get_current_language as LANGUAGE_CODE %} + + + +{% with request_host=request.get_host %} +{% if request_host|slice:":11" != "auth-admin." and request_host|slice:":15" != "cms-auth-admin." and request_host|slice:":9" != "id-admin." and request_host|slice:":12" != "172.22.0.222" %} +{% if request.path|slice:":9" == "/if/flow/" or request.path|slice:":7" == "/flows/" or request.path|slice:":7" == "/login/" %} + + + + + +{% endif %} +{% endif %} +{% endwith %} + + + +{% with request_host=request.get_host %} +{% if request_host|slice:":11" != "auth-admin." and request_host|slice:":15" != "cms-auth-admin." and request_host|slice:":9" != "id-admin." and request_host|slice:":12" != "172.22.0.222" %} +{% if request.path|slice:":9" == "/if/flow/" or request.path|slice:":7" == "/flows/" or request.path|slice:":7" == "/login/" %} + +{% endif %} +{% endif %} +{% endwith %} diff --git a/infra/authentik/custom-templates/base/skeleton.html b/infra/authentik/custom-templates/base/skeleton.html new file mode 100644 index 0000000..5925745 --- /dev/null +++ b/infra/authentik/custom-templates/base/skeleton.html @@ -0,0 +1,50 @@ +{% load static %} +{% load i18n %} +{% load authentik_core %} +{% get_current_language as LANGUAGE_CODE %} + + + + + + + {# Darkreader breaks the site regardless of theme as its not compatible with webcomponents, and we default to a dark theme based on preferred colour-scheme #} + + {% block title %}{% trans title|default:brand.branding_title %}{% endblock %} + + + + {% block head_before %} + {% endblock %} + + {% include "base/theme.html" %} + + {% with request_host=request.get_host %} + {% if request_host|slice:":11" != "auth-admin." and request_host|slice:":15" != "cms-auth-admin." and request_host|slice:":9" != "id-admin." and request_host|slice:":12" != "172.22.0.222" %} + {% if request.path|slice:":9" == "/if/flow/" or request.path|slice:":7" == "/flows/" or request.path|slice:":7" == "/login/" %} + + {% else %} + + {% endif %} + {% else %} + + {% endif %} + {% endwith %} + + {% block head %} + {% endblock %} + {% for key, value in html_meta.items %} + + {% endfor %} + + + {% block body %} + {% endblock %} + {% block scripts %} + {% endblock %} + + diff --git a/infra/authentik/custom-templates/branding/nodedc-login.css b/infra/authentik/custom-templates/branding/nodedc-login.css new file mode 100644 index 0000000..c63a5d2 --- /dev/null +++ b/infra/authentik/custom-templates/branding/nodedc-login.css @@ -0,0 +1,737 @@ +:root, +:host { + --ak-global--primary: #f4f4f5; + --ak-global--primary-hover: #ffffff; + --ak-global--background: #0e0f10; + --ak-global--background-image: none !important; + --ak-c-login--MaxWidth: 28rem; + --ak-c-login--spacer: 2.2rem; + --ak-c-login__main--BackgroundColor: transparent; + --ak-c-login__main--Color: #f3f3f3; + --ak-c-login__main--BoxShadow: none; + --ak-c-login__footer--PaddingBlock: 0; + --pf-global--primary-color--100: #f4f4f5; + --pf-global--primary-color--200: #ffffff; + --pf-v4-global--palette--blue-300: #f4f4f5; + --ak-global--palette--blue-300: #f4f4f5; + --pf-global--active-color--100: #f4f4f5; + --pf-global--link--Color: #f4f4f5; + --pf-global--link--Color--hover: #ffffff; + --pf-global--BackgroundColor--100: #0e0f10; + --pf-global--BackgroundColor--light-100: transparent; + --pf-global--Color--100: #f3f3f3; + --pf-global--Color--200: #9d9da3; + --pf-global--BorderRadius--lg: 1.9rem; + --pf-global--BorderRadius--sm: 1.15rem; + --pf-v5-global--BorderRadius--lg: 1.9rem; + --pf-v5-global--BorderRadius--sm: 1.15rem; + --pf-v6-global--BorderRadius--large: 1.9rem; + --pf-c-form-control--BackgroundColor: rgba(255, 255, 255, 0.04); + --pf-c-form-control--BorderColor: transparent; + --pf-c-form-control--BorderBottomColor: transparent; + --pf-v5-c-form-control--BackgroundColor: rgba(255, 255, 255, 0.04); + --pf-v5-c-form-control--BorderColor: transparent; + --pf-v5-c-form-control--BorderBottomColor: transparent; + --nodedc-auth-bg: #0e0f10; + --nodedc-auth-card-bg: rgba(9, 9, 12, 0.84); + --nodedc-auth-primary: #f4f4f5; + --nodedc-auth-primary-hover: #ffffff; + --nodedc-auth-on-primary: rgb(11, 17, 23); + --nodedc-auth-text-primary: #e4e6e7; + --nodedc-auth-text-secondary: #cacdce; + --nodedc-auth-text-placeholder: #959a9d; + color-scheme: dark; +} + +html, +body, +.pf-c-login { + background: var(--nodedc-auth-bg) !important; + color: var(--nodedc-auth-text-primary) !important; + font-family: Inter, "Inter var", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important; + font-synthesis: none; + -webkit-font-smoothing: antialiased; + text-rendering: geometricPrecision; +} + +:host { + background: transparent !important; + color: inherit !important; + font-family: Inter, "Inter var", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important; + font-synthesis: none; + -webkit-font-smoothing: antialiased; + text-rendering: geometricPrecision; +} + +:host(.pf-c-login) { + display: flex !important; + min-width: 100vw !important; + min-height: 100vh !important; + align-items: center !important; + justify-content: center !important; + background: var(--nodedc-auth-bg) !important; + color: var(--nodedc-auth-text-primary) !important; +} + +body::before, +.pf-c-login::before { + background: none !important; +} + +body:not(.nodedc-auth-enhanced)::after { + content: ""; + display: block; + position: fixed; + top: 1.7875rem; + left: calc(1.9375rem - 2px); + width: 7.25rem; + height: 1.79rem; + background: center / contain no-repeat url("data:image/svg+xml,%3Csvg id='nodedc-logo' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 220.82 54.55'%3E%3Cpath fill='%23e2e1e1' d='M52.8 23.61 46.92 33.76 41.05 23.61H52.8m18-10.39H23.06l23.86 41.33Z'/%3E%3Cpolygon fill='%23e2e1e1' points='31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13'/%3E%3Cpath fill='%23dbdbdb' d='M116.35 18.49V1h1.27l10.34 15V1h1.33v17.49H128l-10.34-15v15Zm24.08.15c-4.79 0-8.16-3.72-8.16-8.89S135.64.86 140.43.86s8.17 3.72 8.17 8.89-3.35 8.89-8.17 8.89Zm0-1.25c4 0 6.79-3.17 6.79-7.64s-2.77-7.64-6.79-7.64-6.77 3.17-6.77 7.64 2.78 7.64 6.77 7.64ZM151.6 18.49V1h5.1c5.54 0 8.79 3.42 8.79 8.74s-3.25 8.74-8.79 8.74Zm1.4-1.25h3.75c4.77 0 7.42-2.92 7.42-7.49s-2.65-7.49-7.42-7.49H153ZM168.49 1h10.77v1.26h-9.42v6.67h7.89v1.25h-7.89v7.06h9.74v1.25h-11.09Zm20.39 17.49V1H194c5.54 0 8.79 3.42 8.79 8.74s-3.25 8.74-8.79 8.74Zm1.35-1.25H194c4.77 0 7.41-2.92 7.41-7.49S198.75 2.26 194 2.26h-3.75Zm14.92-7.49c0-5.24 3.19-8.89 8.11-8.89a6.8 6.8 0 0 1 7.1 5.52h-1.43a5.54 5.54 0 0 0-5.74-4.27c-4.05 0-6.64 3.17-6.64 7.64s2.54 7.64 6.59 7.64a5.46 5.46 0 0 0 5.74-4.29h1.43c-.75 3.52-3.4 5.54-7.15 5.54-4.89 0-8.01-3.59-8.01-8.89Z'/%3E%3C/svg%3E"); + image-rendering: auto; + z-index: 20; + pointer-events: none; +} + +.nodedc-auth-logo { + position: fixed; + display: block; + top: 1.7875rem; + left: calc(1.9375rem - 2px); + width: 7.25rem; + height: 1.79rem; + z-index: 20; + line-height: 0; + text-decoration: none; + pointer-events: auto; + color: var(--nodedc-auth-text-primary); +} + +.nodedc-auth-logo svg { + display: block; + width: 100%; + height: 100%; + overflow: visible; + shape-rendering: geometricPrecision; + text-rendering: geometricPrecision; +} + +.pf-c-page__drawer, +.pf-c-drawer, +.pf-c-drawer__main, +.pf-c-drawer__content, +.pf-c-drawer__body { + background: transparent !important; +} + +.pf-c-drawer__body { + display: flex !important; + min-height: 100vh !important; + align-items: center !important; + justify-content: center !important; + padding: 0 !important; +} + +ak-flow-executor.pf-c-login::before, +:host(.pf-c-login)::before { + display: none !important; +} + +ak-flow-executor.pf-c-login { + display: flex !important; + width: 100% !important; + min-height: 100vh !important; + position: relative !important; + align-items: center !important; + justify-content: center !important; + padding-right: 0 !important; + box-sizing: border-box !important; +} + +ak-brand-links, +ak-flow-inspector, +ak-locale-context, +ak-locale-switcher, +ak-locale-select, +ak-flow-executor [slot="footer"], +.pf-c-login__footer, +.pf-c-login__header, +.pf-c-login__main-header.pf-c-brand, +.pf-c-form > p:first-child { + display: none !important; +} + +ak-flow-executor::part(locale-select), +ak-flow-executor::part(footer), +ak-flow-executor::part(branding) { + display: none !important; +} + +ak-flow-executor::part(main), +.pf-c-login__main { + position: fixed !important; + top: 50% !important; + left: 50% !important; + right: auto !important; + transform: translate(-50%, -50%) !important; + width: min(100%, 28rem) !important; + max-width: 28rem !important; + min-height: 0 !important; + margin: 0 !important; + padding: 2.2rem !important; + box-sizing: border-box !important; + overflow: hidden !important; + border: 0 !important; + outline: none !important; + box-shadow: none !important; + border-radius: 1.9rem !important; + background: transparent !important; + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + justify-content: flex-start !important; +} + +body.nodedc-auth-card-ready ak-flow-executor::part(main), +body.nodedc-auth-card-ready .pf-c-login__main { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.048) 0%, rgba(255, 255, 255, 0.015) 100%), + var(--nodedc-auth-card-bg) !important; + -webkit-backdrop-filter: blur(40px) !important; + backdrop-filter: blur(40px) !important; +} + +ak-flow-card, +ak-stage-identification, +ak-stage-password { + display: block !important; + width: 100% !important; + background: transparent !important; +} + +.pf-c-login__main-body, +.pf-c-form, +.pf-c-form__group, +.pf-c-form__group-control, +.pf-c-input-group, +.pf-c-input-group__item, +.pf-c-card, +.pf-c-card__body, +.pf-c-empty-state, +.pf-c-empty-state__content, +.pf-c-empty-state__body, +.pf-v5-c-empty-state, +.pf-v5-c-empty-state__content, +.pf-v5-c-empty-state__body, +.pf-v6-c-empty-state, +.pf-v6-c-empty-state__content, +.pf-v6-c-empty-state__body { + background: transparent !important; + box-shadow: none !important; + border: 0 !important; +} + +.pf-c-login__main-header { + display: block !important; + width: 100% !important; + min-width: 0 !important; + padding: 0 0 1.75rem !important; +} + +.pf-c-login__main-header .pf-c-title, +.pf-c-title.pf-m-3xl, +h1.pf-c-title { + display: block !important; + width: 100% !important; + max-width: 22rem !important; + margin: 0 !important; + color: var(--nodedc-auth-text-primary) !important; + font-size: 2rem !important; + line-height: 2.5rem !important; + font-weight: 650 !important; + letter-spacing: -0.045em !important; + text-wrap: balance !important; + white-space: normal !important; + word-break: normal !important; + overflow-wrap: normal !important; + hyphens: none !important; + overflow: visible !important; + line-clamp: unset !important; + -webkit-line-clamp: unset !important; + -webkit-box-orient: initial !important; + text-transform: none !important; +} + +.pf-c-login__main-header::after { + content: "Вход в панель управления сайтами."; + display: block; + max-width: 22rem; + margin-top: 0.75rem; + color: var(--nodedc-auth-text-placeholder); + font-size: 1.9rem; + line-height: 2.5rem; + font-weight: 650; + letter-spacing: -0.045em; + text-transform: none; +} + +.pf-c-login__main-header[data-nodedc-permission-denied="true"]::after, +body.nodedc-auth-permission-denied .pf-c-login__main-header::after { + content: attr(data-nodedc-denied-subtitle) !important; + margin-top: 0; +} + +body.nodedc-auth-permission-denied .pf-c-login__main-header::after { + content: "Доступ запрещён." !important; +} + +.pf-c-login__main-header[data-nodedc-permission-denied="true"] .pf-c-title, +.pf-c-login__main-header[data-nodedc-permission-denied="true"] .pf-c-title.pf-m-3xl, +.pf-c-login__main-header[data-nodedc-permission-denied="true"] h1.pf-c-title, +body.nodedc-auth-permission-denied .pf-c-login__main-header .pf-c-title, +body.nodedc-auth-permission-denied .pf-c-title.pf-m-3xl, +body.nodedc-auth-permission-denied h1.pf-c-title { + display: none !important; +} + +body.nodedc-auth-permission-denied ak-flow-executor::part(main), +body.nodedc-auth-permission-denied .pf-c-login__main { + background: transparent !important; + border: 0 !important; + outline: 0 !important; + box-shadow: none !important; + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; +} + +.pf-c-login__main-body { + padding: 0 !important; +} + +.pf-c-form { + display: flex !important; + flex-direction: column !important; + gap: 1.05rem !important; +} + +.pf-c-form__group, +ak-flow-input-password.pf-c-form__group { + margin: 0 !important; +} + +.pf-c-form__label, +.pf-c-form__label-text, +label { + color: var(--nodedc-auth-text-placeholder) !important; + font-size: 0.75rem !important; + line-height: 1rem !important; + font-weight: 400 !important; + letter-spacing: -0.01em !important; + text-transform: none !important; +} + +.pf-c-form__label-required { + display: none !important; +} + +label[for="ak-identifier-input"], +label[for="ak-stage-identification-password"] { + font-size: 0 !important; +} + +label[for="ak-identifier-input"] .pf-c-form__label-text, +label[for="ak-identifier-input"] .pf-c-form__label-required, +label[for="ak-stage-identification-password"] .pf-c-form__label-text, +label[for="ak-stage-identification-password"] .pf-c-form__label-required { + display: none !important; +} + +label[for="ak-identifier-input"]::after { + content: "Эл. почта"; + font-size: 0.75rem; + color: var(--nodedc-auth-text-placeholder); + font-weight: 400; +} + +label[for="ak-stage-identification-password"]::after { + content: "Пароль"; + font-size: 0.75rem; + color: var(--nodedc-auth-text-placeholder); + font-weight: 400; +} + +.pf-c-form-control, +.pf-c-input-group .pf-c-form-control, +input.pf-c-form-control { + width: 100% !important; + min-height: 3rem !important; + padding: 0 1rem !important; + border: 0 !important; + border-bottom: 0 !important; + outline: none !important; + box-shadow: none !important; + border-radius: 1.15rem !important; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%), + rgba(255, 255, 255, 0.03) !important; + -webkit-backdrop-filter: blur(18px); + backdrop-filter: blur(18px); + color: var(--nodedc-auth-text-primary) !important; + font-size: 0.875rem !important; + font-style: normal !important; + font-weight: 400 !important; + text-transform: none !important; + caret-color: var(--nodedc-auth-primary) !important; +} + +.pf-c-form-control::placeholder, +input.pf-c-form-control::placeholder { + color: var(--nodedc-auth-text-placeholder) !important; + font-style: normal !important; + opacity: 1 !important; +} + +.pf-c-form-control:focus, +input.pf-c-form-control:focus { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%), + rgba(255, 255, 255, 0.03) !important; + border: 0 !important; + border-bottom: 0 !important; + outline: none !important; + box-shadow: none !important; +} + +.pf-c-form-control:-webkit-autofill, +.pf-c-form-control:-webkit-autofill:hover, +.pf-c-form-control:-webkit-autofill:focus, +input.pf-c-form-control:-webkit-autofill, +input.pf-c-form-control:-webkit-autofill:hover, +input.pf-c-form-control:-webkit-autofill:focus { + border: 0 !important; + outline: none !important; + -webkit-text-fill-color: var(--nodedc-auth-text-primary) !important; + caret-color: var(--nodedc-auth-primary) !important; + transition: background-color 999999s ease-in-out 0s !important; + box-shadow: inset 0 0 0 1000px rgba(255, 255, 255, 0.03) !important; + -webkit-box-shadow: inset 0 0 0 1000px rgba(255, 255, 255, 0.03) !important; +} + +.pf-c-input-group { + position: relative !important; + display: block !important; + border: 0 !important; + box-shadow: none !important; +} + +.pf-c-form__group:has(#ak-identifier-input) { + position: relative !important; +} + +.pf-c-input-group .pf-c-button.pf-m-control { + position: absolute !important; + top: 0.5rem !important; + right: 0.75rem !important; + display: grid !important; + width: 2rem !important; + min-width: 2rem !important; + height: 2rem !important; + place-items: center !important; + padding: 0 !important; + border: 0 !important; + border-radius: 999px !important; + background: transparent !important; + color: var(--nodedc-auth-text-placeholder) !important; + box-shadow: none !important; + text-decoration: none !important; + appearance: none !important; + -webkit-appearance: none !important; +} + +.pf-c-input-group .pf-c-button.pf-m-control::before, +.pf-c-input-group .pf-c-button.pf-m-control::after, +.pf-c-input-group .pf-c-button.pf-m-control:hover::before, +.pf-c-input-group .pf-c-button.pf-m-control:hover::after, +.pf-c-input-group .pf-c-button.pf-m-control:focus::before, +.pf-c-input-group .pf-c-button.pf-m-control:focus::after { + display: none !important; + border: 0 !important; + box-shadow: none !important; + content: none !important; +} + +.pf-c-input-group .pf-c-button.pf-m-control:hover, +.pf-c-input-group .pf-c-button.pf-m-control:focus, +.pf-c-input-group .pf-c-button.pf-m-control:active { + border: 0 !important; + outline: none !important; + box-shadow: none !important; + background: transparent !important; + text-decoration: none !important; +} + +.pf-c-input-group .pf-c-button.pf-m-control i, +.nodedc-auth-clear-input svg { + width: 1.25rem !important; + height: 1.25rem !important; + color: var(--nodedc-auth-text-placeholder) !important; + opacity: 0.9 !important; +} + +.pf-c-input-group input.pf-c-form-control { + padding-right: 3rem !important; +} + +.nodedc-auth-clear-input { + position: absolute !important; + right: 0.75rem !important; + bottom: 0.5rem !important; + display: none !important; + width: 2rem !important; + height: 2rem !important; + padding: 0 !important; + place-items: center !important; + border: 0 !important; + border-radius: 999px !important; + background: transparent !important; + color: var(--nodedc-auth-text-placeholder) !important; + box-shadow: none !important; + cursor: pointer !important; +} + +.nodedc-auth-clear-input[data-visible="true"] { + display: grid !important; +} + +.pf-c-button.pf-m-primary, +button.pf-m-primary, +button[type="submit"], +.pf-c-form .pf-c-button.pf-m-primary { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + width: 100% !important; + min-height: 3.25rem !important; + margin-top: 1.25rem !important; + border: 0 !important; + outline: none !important; + box-shadow: none !important; + border-radius: 1.25rem !important; + background: var(--nodedc-auth-primary) !important; + color: var(--nodedc-auth-on-primary) !important; + font-size: 0.95rem !important; + font-weight: 600 !important; + line-height: 1 !important; + letter-spacing: -0.02em !important; + text-align: center !important; + text-transform: none !important; +} + +.pf-c-button.pf-m-primary:hover, +button.pf-m-primary:hover, +button[type="submit"]:hover { + background: var(--nodedc-auth-primary-hover) !important; + color: var(--nodedc-auth-on-primary) !important; +} + +.nodedc-auth-request-access { + display: inline-flex !important; + justify-content: center !important; + width: 100% !important; + margin-top: -0.35rem !important; + color: var(--nodedc-auth-primary) !important; + font-size: 0.75rem !important; + line-height: 1rem !important; + font-weight: 650 !important; + letter-spacing: -0.01em !important; + text-decoration: none !important; +} + +.nodedc-auth-request-access:hover, +.nodedc-auth-request-access:focus { + color: var(--nodedc-auth-primary-hover) !important; + text-decoration: none !important; +} + +.pf-c-alert, +.pf-c-alert.pf-m-inline { + border: 0 !important; + border-radius: 1rem !important; + margin: 0 0 1.15rem !important; + padding: 0 !important; + background: transparent !important; + color: var(--nodedc-auth-primary) !important; + box-shadow: none !important; +} + +.pf-c-alert__icon, +.pf-c-alert__title, +.pf-c-alert__description { + color: var(--nodedc-auth-primary) !important; + font-size: 0.75rem !important; + line-height: 1rem !important; + font-weight: 600 !important; +} + +body.nodedc-auth-permission-denied .pf-c-alert, +body.nodedc-auth-permission-denied .pf-v5-c-alert, +body.nodedc-auth-permission-denied .pf-c-alert.pf-m-inline { + display: none !important; +} + +a, +.pf-c-button.pf-m-link, +button.pf-m-link { + color: var(--nodedc-auth-primary) !important; + font-size: 0.75rem !important; + line-height: 1rem !important; + font-weight: 600 !important; + text-decoration: none !important; +} + +body.nodedc-auth-submitting ak-flow-executor::part(main), +body.nodedc-auth-loading ak-flow-executor::part(main), +body.nodedc-auth-submitting .pf-c-login__main, +body.nodedc-auth-loading .pf-c-login__main, +body.nodedc-auth-submitting .pf-c-empty-state, +body.nodedc-auth-loading .pf-c-empty-state, +body.nodedc-auth-submitting .pf-c-spinner, +body.nodedc-auth-loading .pf-c-spinner, +body.nodedc-auth-submitting [role="progressbar"] { + opacity: 0 !important; + visibility: hidden !important; + pointer-events: none !important; +} + +body.nodedc-auth-loading [role="progressbar"] { + opacity: 0 !important; + visibility: hidden !important; + pointer-events: none !important; +} + +.pf-c-spinner, +.pf-v5-c-spinner, +[role="progressbar"], +ak-spinner { + --pf-c-spinner--Color: var(--nodedc-auth-primary) !important; + --pf-v5-c-spinner--Color: var(--nodedc-auth-primary) !important; + --pf-c-spinner__clipper--after--BoxShadowColor: var(--nodedc-auth-primary) !important; + --pf-c-spinner__lead-ball--after--BackgroundColor: var(--nodedc-auth-primary) !important; + --pf-c-spinner__tail-ball--after--BackgroundColor: var(--nodedc-auth-primary) !important; + color: var(--nodedc-auth-primary) !important; +} + +#ak-placeholder.ak-c-placeholder, +.ak-c-placeholder[slot="placeholder"] { + position: fixed !important; + top: 50% !important; + left: 50% !important; + display: block !important; + width: 2.5rem !important; + height: 2.5rem !important; + min-width: 2.5rem !important; + min-height: 2.5rem !important; + margin: 0 !important; + padding: 0 !important; + border: 0 !important; + border-radius: 999px !important; + background: transparent !important; + box-shadow: none !important; + opacity: 1 !important; + visibility: visible !important; + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + transform: translate(-50%, -50%) !important; + overflow: visible !important; +} + +#ak-placeholder.ak-c-placeholder .pf-c-spinner, +.ak-c-placeholder[slot="placeholder"] .pf-c-spinner, +#ak-placeholder.ak-c-placeholder [role="progressbar"], +.ak-c-placeholder[slot="placeholder"] [role="progressbar"] { + opacity: 0 !important; + visibility: hidden !important; +} + +#ak-placeholder.ak-c-placeholder::before, +.ak-c-placeholder[slot="placeholder"]::before { + content: ""; + position: absolute; + inset: 0; + border: 2px solid rgba(255, 255, 255, 0.22); + border-top-color: var(--nodedc-auth-primary); + border-radius: 999px; + animation: nodedc-auth-placeholder-spin 0.72s linear infinite; + pointer-events: none; +} + +body.nodedc-auth-submitting::before, +body.nodedc-auth-loading::before { + content: ""; + position: fixed; + top: 50%; + left: 50%; + z-index: 1200; + width: 2.5rem; + height: 2.5rem; + border: 2px solid rgba(255, 255, 255, 0.22); + border-top-color: var(--nodedc-auth-primary); + border-radius: 999px; + transform: translate(-50%, -50%); + animation: nodedc-auth-loader-spin 0.72s linear infinite; + pointer-events: none; +} + +@keyframes nodedc-auth-loader-spin { + from { + transform: translate(-50%, -50%) rotate(0deg); + } + + to { + transform: translate(-50%, -50%) rotate(360deg); + } +} + +@keyframes nodedc-auth-placeholder-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +ak-library-application, +ak-application-wizard, +ak-user-interface, +.pf-c-page__sidebar, +.pf-c-page__header, +.pf-c-nav, +.pf-c-brand { + color-scheme: dark; +} + +@media (max-width: 640px) { + ak-flow-executor.pf-c-login::before, + :host(.pf-c-login)::before { + display: none !important; + } + + body::after, + .nodedc-auth-logo { + top: 1.7875rem; + left: calc(1.9375rem - 2px); + width: 7.25rem; + height: 1.79rem; + } + + ak-flow-executor::part(main), + .pf-c-login__main { + position: fixed !important; + top: 50% !important; + right: auto !important; + left: 50% !important; + transform: translate(-50%, -50%) !important; + width: calc(100vw - 2rem) !important; + padding: 1.5rem !important; + } +} diff --git a/infra/authentik/custom-templates/if/admin.html b/infra/authentik/custom-templates/if/admin.html new file mode 100644 index 0000000..1dda875 --- /dev/null +++ b/infra/authentik/custom-templates/if/admin.html @@ -0,0 +1,16 @@ +{% extends "base/skeleton.html" %} + +{% load authentik_core %} + +{% block head %} + +{% include "base/header_js.html" %} +{% endblock %} + +{% block body %} + + + + {% include "base/placeholder.html" %} + +{% endblock %} diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml new file mode 100644 index 0000000..5ae3795 --- /dev/null +++ b/infra/docker-compose.yml @@ -0,0 +1,144 @@ +name: dc-cms + +services: + reverse-proxy: + image: caddy:2-alpine + restart: unless-stopped + env_file: + - .env + ports: + - "${CMS_HTTP_BIND:-127.0.0.1:8210}:80" + - "${CMS_AUTH_HTTP_BIND:-127.0.0.1:8211}:80" + - "${CMS_AUTH_ADMIN_HTTP_BIND:-127.0.0.1:8212}:80" + volumes: + - ./reverse-proxy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + depends_on: + cms-app: + condition: service_started + authentik-server: + condition: service_started + networks: + cms-edge: + aliases: + - ${CMS_DOMAIN:-cms.local.nodedc} + - ${CMS_AUTH_DOMAIN:-cms-auth.local.nodedc} + - ${CMS_AUTH_ADMIN_DOMAIN:-cms-auth-admin.local.nodedc} + + cms-app: + build: + context: .. + restart: unless-stopped + env_file: + - .env + environment: + HOST: 0.0.0.0 + PORT: 8090 + SITE_ROOT: /sites/nodedc + CMS_AUTH_ENABLED: ${CMS_AUTH_ENABLED:-true} + CMS_OIDC_ISSUER: ${CMS_OIDC_ISSUER:?cms oidc issuer required} + CMS_OIDC_CLIENT_ID: ${CMS_OIDC_CLIENT_ID:?cms oidc client id required} + CMS_OIDC_CLIENT_SECRET: ${CMS_OIDC_CLIENT_SECRET:?cms oidc client secret required} + CMS_OIDC_REDIRECT_URI: ${CMS_OIDC_REDIRECT_URI:?cms oidc redirect uri required} + CMS_OIDC_LOGGED_OUT_REDIRECT_URI: ${CMS_OIDC_LOGGED_OUT_REDIRECT_URI:?cms oidc logged out redirect uri required} + CMS_REQUIRED_GROUPS: ${CMS_REQUIRED_GROUPS:-dc-cms:owner,dc-cms:admin,dc-cms:editor,dc-cms:publisher} + CMS_SESSION_SECRET: ${CMS_SESSION_SECRET:?cms session secret required} + COOKIE_SECURE: ${COOKIE_SECURE:-false} + COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} + expose: + - "8090" + volumes: + - ../projects:/app/projects + - ${CMS_NODEDC_SITE_PATH:-../../NODEDC_SITE}:/sites/nodedc + depends_on: + authentik-server: + condition: service_started + networks: + - cms-edge + + postgresql-authentik: + image: docker.io/library/postgres:16-alpine + restart: unless-stopped + env_file: + - .env + environment: + POSTGRES_DB: ${PG_DB:-authentik} + POSTGRES_PASSWORD: ${PG_PASS:?database password required} + POSTGRES_USER: ${PG_USER:-authentik} + healthcheck: + test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s + volumes: + - authentik-database:/var/lib/postgresql/data + networks: + - cms-edge + + authentik-server: + image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.2} + command: server + restart: unless-stopped + env_file: + - .env + environment: + AUTHENTIK_POSTGRESQL__HOST: postgresql-authentik + AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik} + AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS:?database password required} + AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik} + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required} + AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS: ${AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS:-127.0.0.0/8,172.16.0.0/12} + AUTHENTIK_ERROR_REPORTING__ENABLED: ${AUTHENTIK_ERROR_REPORTING__ENABLED:-false} + AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL:-} + AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD:-} + AUTHENTIK_BOOTSTRAP_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN:-} + depends_on: + postgresql-authentik: + condition: service_healthy + expose: + - "9000" + - "9443" + shm_size: 512mb + volumes: + - authentik-data:/data + - ./authentik/custom-templates:/templates:ro + networks: + - cms-edge + + authentik-worker: + image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.2} + command: worker + restart: unless-stopped + user: root + env_file: + - .env + environment: + AUTHENTIK_POSTGRESQL__HOST: postgresql-authentik + AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik} + AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS:?database password required} + AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik} + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required} + AUTHENTIK_ERROR_REPORTING__ENABLED: ${AUTHENTIK_ERROR_REPORTING__ENABLED:-false} + depends_on: + postgresql-authentik: + condition: service_healthy + shm_size: 512mb + volumes: + - authentik-data:/data + - authentik-certs:/certs + - ./authentik/custom-templates:/templates:ro + - ./authentik/bootstrap-cms.py:/bootstrap/bootstrap-cms.py:ro + networks: + - cms-edge + +networks: + cms-edge: + +volumes: + authentik-database: + authentik-data: + authentik-certs: + caddy-data: + caddy-config: diff --git a/infra/reverse-proxy/Caddyfile b/infra/reverse-proxy/Caddyfile new file mode 100644 index 0000000..2d8c41d --- /dev/null +++ b/infra/reverse-proxy/Caddyfile @@ -0,0 +1,39 @@ +{ + auto_https off +} + +http://{$CMS_DOMAIN:cms.local.nodedc} { + reverse_proxy cms-app:8090 { + header_up Host {host} + header_up X-Forwarded-Proto {$CMS_EXTERNAL_SCHEME:http} + header_up X-Forwarded-For {remote_host} + } +} + +http://{$CMS_AUTH_DOMAIN:cms-auth.local.nodedc} { + @auth_admin path /if/admin /if/admin/* + redir @auth_admin {$CMS_AUTH_ADMIN_PUBLIC_URL}{uri} 302 + + @auth_root path / + redir @auth_root {$CMS_PUBLIC_URL}/ 302 + + @auth_user_dashboard path /if/user /if/user/* + redir @auth_user_dashboard {$CMS_PUBLIC_URL}/ 302 + + reverse_proxy authentik-server:9000 { + header_up Host {host} + header_up X-Forwarded-Proto {$CMS_EXTERNAL_SCHEME:http} + header_up X-Forwarded-For {remote_host} + } +} + +http://{$CMS_AUTH_ADMIN_DOMAIN:cms-auth-admin.local.nodedc} { + @admin_root path / + redir @admin_root /if/admin/ 302 + + reverse_proxy authentik-server:9000 { + header_up Host {host} + header_up X-Forwarded-Proto {$CMS_EXTERNAL_SCHEME:http} + header_up X-Forwarded-For {remote_host} + } +} diff --git a/infra/scripts/bootstrap-authentik.sh b/infra/scripts/bootstrap-authentik.sh new file mode 100755 index 0000000..e7612d9 --- /dev/null +++ b/infra/scripts/bootstrap-authentik.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INFRA_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +cd "${INFRA_DIR}" + +docker compose --env-file .env -f docker-compose.yml exec -T authentik-worker python /bootstrap/bootstrap-cms.py diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9e78699 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,24 @@ +{ + "name": "dc-cms", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dc-cms", + "version": "0.1.0", + "dependencies": { + "jose": "^6.2.3" + } + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6d904a0 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "dc-cms", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "admin": "node server/admin-server.mjs", + "start": "node server/admin-server.mjs" + }, + "dependencies": { + "jose": "^6.2.3" + } +} diff --git a/projects/nodedc.json b/projects/nodedc.json new file mode 100644 index 0000000..8bfcfbe --- /dev/null +++ b/projects/nodedc.json @@ -0,0 +1,18 @@ +{ + "id": "nodedc", + "name": "NODE.DC", + "role": "Администратор сайта", + "siteRoot": "../NODEDC_SITE", + "previewUrl": "http://127.0.0.1:8210/", + "publicUrl": "https://nodedc.ru/", + "deploy": { + "enabled": false, + "method": "sftp", + "host": "", + "port": 22, + "username": "", + "passwordEnv": "MCHOST_PASSWORD", + "remotePath": "", + "deployOnRender": false + } +} diff --git a/server/admin-server.mjs b/server/admin-server.mjs new file mode 100644 index 0000000..9ba62b0 --- /dev/null +++ b/server/admin-server.mjs @@ -0,0 +1,2221 @@ +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, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; +import { dirname } from "node:path"; +import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { createRemoteJWKSet, jwtVerify } from "jose"; + +const cmsRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +const port = Number(process.env.PORT || 8090); +const host = process.env.HOST || "127.0.0.1"; +const activeProjectId = process.env.CMS_PROJECT || "nodedc"; +const projectConfigPath = join(cmsRoot, "projects", `${activeProjectId}.json`); + +async function loadProjectConfig() { + const source = await readFile(projectConfigPath, "utf8"); + return JSON.parse(source); +} + +function resolveCmsPath(value) { + const rawPath = String(value || "").trim(); + if (!rawPath) return cmsRoot; + return resolve(cmsRoot, rawPath); +} + +let projectConfig = await loadProjectConfig(); +const repoRoot = resolveCmsPath(process.env.SITE_ROOT || projectConfig.siteRoot || "../NODEDC_SITE"); +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 boolEnv(value, fallback = false) { + if (value === undefined || value === null || value === "") return fallback; + return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase()); +} + +function csvEnv(value) { + return String(value || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function trailingSlashUrl(value) { + const trimmed = String(value || "").trim(); + if (!trimmed) return ""; + return trimmed.endsWith("/") ? trimmed : `${trimmed}/`; +} + +const authConfig = { + enabled: boolEnv(process.env.CMS_AUTH_ENABLED, false), + baseUrl: String(process.env.CMS_BASE_URL || "").trim(), + issuer: trailingSlashUrl(process.env.CMS_OIDC_ISSUER), + clientId: String(process.env.CMS_OIDC_CLIENT_ID || "").trim(), + clientSecret: String(process.env.CMS_OIDC_CLIENT_SECRET || "").trim(), + redirectUri: String(process.env.CMS_OIDC_REDIRECT_URI || "").trim(), + loggedOutRedirectUri: String(process.env.CMS_OIDC_LOGGED_OUT_REDIRECT_URI || "").trim(), + scope: String(process.env.CMS_OIDC_SCOPE || "openid email profile groups offline_access").trim(), + requiredGroups: csvEnv(process.env.CMS_REQUIRED_GROUPS || "dc-cms:owner,dc-cms:admin"), + sessionSecret: String(process.env.CMS_SESSION_SECRET || "dc-cms-local-session-secret").trim(), + sessionCookie: String(process.env.CMS_SESSION_COOKIE || "dc_cms_session").trim(), + stateCookie: String(process.env.CMS_STATE_COOKIE || "dc_cms_oidc_state").trim(), + cookieDomain: String(process.env.COOKIE_DOMAIN || "").trim(), + cookieSecure: boolEnv(process.env.COOKIE_SECURE, false), + sessionTtlMs: Number(process.env.CMS_SESSION_TTL_MS || 12 * 60 * 60 * 1000), + loginTtlMs: Number(process.env.CMS_LOGIN_TTL_MS || 10 * 60 * 1000), +}; + +const pendingLogins = new Map(); +const sessions = new Map(); +let discoveryCache = null; +let discoveryPromise = null; +let jwksCache = null; +let jwksUriCache = ""; + +function randomToken(byteLength = 32) { + return randomBytes(byteLength).toString("base64url"); +} + +function codeChallengeForVerifier(verifier) { + return createHash("sha256").update(verifier).digest("base64url"); +} + +function signatureForValue(value) { + return createHmac("sha256", authConfig.sessionSecret).update(value).digest("base64url"); +} + +function safeEqual(left, right) { + const leftBuffer = Buffer.from(String(left || "")); + const rightBuffer = Buffer.from(String(right || "")); + return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer); +} + +function signValue(value) { + return `${value}.${signatureForValue(value)}`; +} + +function verifySignedValue(value) { + const rawValue = String(value || ""); + const separatorIndex = rawValue.lastIndexOf("."); + if (separatorIndex <= 0) return ""; + + const payload = rawValue.slice(0, separatorIndex); + const signature = rawValue.slice(separatorIndex + 1); + return safeEqual(signature, signatureForValue(payload)) ? payload : ""; +} + +function parseCookies(req) { + const cookies = {}; + for (const part of String(req.headers.cookie || "").split(";")) { + const index = part.indexOf("="); + if (index === -1) continue; + const name = part.slice(0, index).trim(); + const value = part.slice(index + 1).trim(); + if (!name) continue; + + try { + cookies[name] = decodeURIComponent(value); + } catch { + cookies[name] = value; + } + } + return cookies; +} + +function serializeCookie(name, value, options = {}) { + const parts = [`${name}=${encodeURIComponent(value)}`, "Path=/", "SameSite=Lax"]; + if (options.httpOnly !== false) parts.push("HttpOnly"); + if (authConfig.cookieSecure) parts.push("Secure"); + if (authConfig.cookieDomain) parts.push(`Domain=${authConfig.cookieDomain}`); + if (Number.isFinite(options.maxAge)) parts.push(`Max-Age=${Math.trunc(options.maxAge)}`); + if (options.expires) parts.push(`Expires=${options.expires.toUTCString()}`); + return parts.join("; "); +} + +function appendCookie(res, cookie) { + const current = res.getHeader("set-cookie"); + if (!current) { + res.setHeader("set-cookie", cookie); + return; + } + + res.setHeader("set-cookie", Array.isArray(current) ? [...current, cookie] : [current, cookie]); +} + +function setCookie(res, name, value, options = {}) { + appendCookie(res, serializeCookie(name, value, options)); +} + +function clearCookie(res, name) { + appendCookie(res, serializeCookie(name, "", { maxAge: 0, expires: new Date(0) })); +} + +function requestOrigin(req) { + const forwardedProto = String(req.headers["x-forwarded-proto"] || "").split(",")[0].trim(); + const forwardedHost = String(req.headers["x-forwarded-host"] || "").split(",")[0].trim(); + const protocol = forwardedProto || (authConfig.cookieSecure ? "https" : "http"); + const requestHost = forwardedHost || req.headers.host || `${host}:${port}`; + return `${protocol}://${requestHost}`; +} + +function publicBaseUrl(req) { + return authConfig.baseUrl || requestOrigin(req); +} + +function callbackUri(req) { + return authConfig.redirectUri || new URL("/auth/callback", publicBaseUrl(req)).toString(); +} + +function sanitizeReturnTo(value) { + const rawValue = String(value || "").trim(); + if (!rawValue || rawValue.startsWith("//")) return "/admin/"; + if (/^https?:\/\//i.test(rawValue)) return "/admin/"; + return rawValue.startsWith("/") ? rawValue : "/admin/"; +} + +function sendRedirect(res, target, status = 302) { + res.writeHead(status, { + location: target, + "cache-control": "no-store", + }); + res.end(); +} + +function authLoginUrl(req, returnTo) { + const loginUrl = new URL("/auth/login", publicBaseUrl(req)); + loginUrl.searchParams.set("returnTo", sanitizeReturnTo(returnTo)); + return loginUrl.toString(); +} + +function assertAuthConfigured() { + if (!authConfig.issuer || !authConfig.clientId || !authConfig.clientSecret) { + throw new Error("CMS Authentik OIDC не настроен: проверьте CMS_OIDC_ISSUER, CMS_OIDC_CLIENT_ID и CMS_OIDC_CLIENT_SECRET"); + } +} + +async function loadOidcDiscovery() { + assertAuthConfigured(); + if (discoveryCache) return discoveryCache; + if (!discoveryPromise) { + discoveryPromise = (async () => { + const wellKnownUrl = new URL(".well-known/openid-configuration", authConfig.issuer); + const response = await fetch(wellKnownUrl); + const payload = await response.json().catch(() => null); + if (!response.ok || !payload) { + throw new Error(`Не удалось загрузить OIDC discovery: ${response.status}`); + } + discoveryCache = payload; + return discoveryCache; + })().finally(() => { + discoveryPromise = null; + }); + } + return discoveryPromise; +} + +function jwksForDiscovery(discovery) { + if (!jwksCache || jwksUriCache !== discovery.jwks_uri) { + jwksUriCache = discovery.jwks_uri; + jwksCache = createRemoteJWKSet(new URL(discovery.jwks_uri)); + } + return jwksCache; +} + +async function exchangeAuthorizationCode(code, codeVerifier, redirectUri) { + const discovery = await loadOidcDiscovery(); + const body = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }); + const credentials = Buffer.from(`${encodeURIComponent(authConfig.clientId)}:${encodeURIComponent(authConfig.clientSecret)}`).toString("base64"); + const response = await fetch(discovery.token_endpoint, { + method: "POST", + headers: { + authorization: `Basic ${credentials}`, + "content-type": "application/x-www-form-urlencoded", + accept: "application/json", + }, + body, + }); + const payload = await response.json().catch(() => null); + + if (!response.ok || !payload) { + throw new Error(payload?.error_description || payload?.error || `OIDC token exchange failed: ${response.status}`); + } + return { discovery, tokens: payload }; +} + +function normalizeGroups(value) { + if (Array.isArray(value)) return value.map((item) => String(item || "").trim()).filter(Boolean); + if (typeof value === "string") return value.split(/[,\n]/).map((item) => item.trim()).filter(Boolean); + return []; +} + +function userFromClaims(claims) { + const groups = normalizeGroups(claims.groups || claims.group || claims["https://authentik.io/groups"]); + return { + sub: String(claims.sub || ""), + email: String(claims.email || ""), + name: String(claims.name || claims.preferred_username || claims.email || "CMS user"), + username: String(claims.preferred_username || claims.nickname || claims.email || claims.sub || ""), + picture: String(claims.picture || claims.avatar_url || ""), + groups, + }; +} + +async function verifyTokens(tokens, pendingLogin) { + if (!tokens.id_token) { + throw new Error("OIDC provider did not return id_token"); + } + + const discovery = await loadOidcDiscovery(); + const issuer = discovery.issuer || authConfig.issuer.replace(/\/?$/, "/"); + const { payload } = await jwtVerify(tokens.id_token, jwksForDiscovery(discovery), { + issuer, + audience: authConfig.clientId, + }); + + if (pendingLogin.nonce && payload.nonce !== pendingLogin.nonce) { + throw new Error("OIDC nonce mismatch"); + } + + return userFromClaims(payload); +} + +function cleanupAuthStores() { + const now = Date.now(); + for (const [state, pendingLogin] of pendingLogins) { + if (now - pendingLogin.createdAt > authConfig.loginTtlMs) pendingLogins.delete(state); + } + for (const [sessionId, session] of sessions) { + if (session.expiresAt <= now) sessions.delete(sessionId); + } +} + +function createSession(user, tokens) { + const sessionId = randomToken(); + const now = Date.now(); + sessions.set(sessionId, { + id: sessionId, + user, + idToken: String(tokens.id_token || ""), + createdAt: now, + expiresAt: now + authConfig.sessionTtlMs, + }); + return sessionId; +} + +function currentSessionFromRequest(req) { + const cookieValue = parseCookies(req)[authConfig.sessionCookie]; + const sessionId = verifySignedValue(cookieValue); + if (!sessionId) return null; + const session = sessions.get(sessionId); + if (!session || session.expiresAt <= Date.now()) { + sessions.delete(sessionId); + return null; + } + return session; +} + +function sessionHasAccess(session) { + if (!authConfig.requiredGroups.length) return true; + const userGroups = new Set(session.user.groups || []); + return authConfig.requiredGroups.some((group) => userGroups.has(group)); +} + +function sendAuthRequired(req, res, url) { + const returnTo = `${url.pathname}${url.search}`; + if (url.pathname.startsWith("/api/")) { + sendJson(res, 401, { ok: false, error: "Auth required", loginUrl: authLoginUrl(req, returnTo) }); + return; + } + + if (req.method === "GET" || req.method === "HEAD") { + sendRedirect(res, authLoginUrl(req, returnTo)); + return; + } + + sendJson(res, 401, { ok: false, error: "Auth required" }); +} + +function sendAccessDenied(res, url) { + if (url.pathname.startsWith("/api/")) { + sendJson(res, 403, { ok: false, error: "Forbidden" }); + return; + } + send(res, 403, "Forbidden"); +} + +async function startLogin(req, res, url) { + cleanupAuthStores(); + const discovery = await loadOidcDiscovery(); + const state = randomToken(); + const nonce = randomToken(); + const codeVerifier = randomToken(48); + const redirectUri = callbackUri(req); + const returnTo = sanitizeReturnTo(url.searchParams.get("returnTo")); + + pendingLogins.set(state, { + nonce, + codeVerifier, + redirectUri, + returnTo, + createdAt: Date.now(), + }); + + setCookie(res, authConfig.stateCookie, signValue(state), { + maxAge: Math.floor(authConfig.loginTtlMs / 1000), + }); + + const authUrl = new URL(discovery.authorization_endpoint); + authUrl.searchParams.set("client_id", authConfig.clientId); + authUrl.searchParams.set("redirect_uri", redirectUri); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("scope", authConfig.scope); + authUrl.searchParams.set("state", state); + authUrl.searchParams.set("nonce", nonce); + authUrl.searchParams.set("code_challenge", codeChallengeForVerifier(codeVerifier)); + authUrl.searchParams.set("code_challenge_method", "S256"); + sendRedirect(res, authUrl.toString()); +} + +async function completeLogin(req, res, url) { + cleanupAuthStores(); + const error = url.searchParams.get("error"); + if (error) { + send(res, 401, url.searchParams.get("error_description") || error); + return; + } + + const state = url.searchParams.get("state") || ""; + const code = url.searchParams.get("code") || ""; + const cookieState = verifySignedValue(parseCookies(req)[authConfig.stateCookie]); + clearCookie(res, authConfig.stateCookie); + + if (!state || !code || state !== cookieState) { + send(res, 400, "Invalid OIDC callback state"); + return; + } + + const pendingLogin = pendingLogins.get(state); + pendingLogins.delete(state); + if (!pendingLogin) { + send(res, 400, "OIDC login request expired"); + return; + } + + const { tokens } = await exchangeAuthorizationCode(code, pendingLogin.codeVerifier, pendingLogin.redirectUri); + const user = await verifyTokens(tokens, pendingLogin); + const sessionId = createSession(user, tokens); + setCookie(res, authConfig.sessionCookie, signValue(sessionId), { + maxAge: Math.floor(authConfig.sessionTtlMs / 1000), + }); + sendRedirect(res, pendingLogin.returnTo); +} + +async function logout(req, res) { + const session = currentSessionFromRequest(req); + if (session) sessions.delete(session.id); + clearCookie(res, authConfig.sessionCookie); + clearCookie(res, authConfig.stateCookie); + + const fallbackRedirect = authConfig.loggedOutRedirectUri || new URL("/auth/logged-out", publicBaseUrl(req)).toString(); + + try { + const discovery = await loadOidcDiscovery(); + if (discovery.end_session_endpoint) { + const logoutUrl = new URL(discovery.end_session_endpoint); + logoutUrl.searchParams.set("client_id", authConfig.clientId); + logoutUrl.searchParams.set("post_logout_redirect_uri", fallbackRedirect); + if (session?.idToken) logoutUrl.searchParams.set("id_token_hint", session.idToken); + sendRedirect(res, logoutUrl.toString()); + return; + } + } catch { + // Local logout must keep working even when Authentik is temporarily unavailable. + } + + sendRedirect(res, fallbackRedirect); +} + +async function handleAuthRoute(req, res, url) { + if (req.method === "GET" && url.pathname === "/healthz") { + sendJson(res, 200, { ok: true, service: "dc-cms", authEnabled: authConfig.enabled }); + return true; + } + + if (req.method === "GET" && url.pathname === "/api/me") { + const session = currentSessionFromRequest(req); + if (!authConfig.enabled) { + sendJson(res, 200, { ok: true, authEnabled: false, user: null }); + return true; + } + if (!session) { + sendAuthRequired(req, res, url); + return true; + } + if (!sessionHasAccess(session)) { + sendAccessDenied(res, url); + return true; + } + sendJson(res, 200, { ok: true, authEnabled: true, user: session.user }); + return true; + } + + if (!authConfig.enabled) return false; + + if (req.method === "GET" && url.pathname === "/auth/login") { + await startLogin(req, res, url); + return true; + } + + if (req.method === "GET" && url.pathname === "/auth/callback") { + await completeLogin(req, res, url); + return true; + } + + if ((req.method === "GET" || req.method === "POST") && url.pathname === "/auth/logout") { + await logout(req, res); + return true; + } + + if (req.method === "GET" && url.pathname === "/auth/logged-out") { + sendRedirect(res, authLoginUrl(req, "/admin/")); + return true; + } + + return false; +} + +function authorizeRequest(req, res, url) { + if (!authConfig.enabled) return true; + + cleanupAuthStores(); + const session = currentSessionFromRequest(req); + if (!session) { + clearCookie(res, authConfig.sessionCookie); + sendAuthRequired(req, res, url); + return false; + } + + if (!sessionHasAccess(session)) { + sendAccessDenied(res, url); + return false; + } + + session.expiresAt = Date.now() + authConfig.sessionTtlMs; + req.cmsUser = session.user; + return true; +} + +function publicProjectConfig() { + return { + ...projectConfig, + id: projectConfig.id || activeProjectId, + runtime: { + cmsRoot, + siteRoot: repoRoot, + configPath: projectConfigPath, + siteRootChangesRequireRestart: true, + }, + }; +} + +function normalizeProjectConfig(payload) { + const nextDeploy = { + enabled: Boolean(payload?.deploy?.enabled), + method: String(payload?.deploy?.method || "sftp").trim() || "sftp", + host: String(payload?.deploy?.host || "").trim(), + port: Number(payload?.deploy?.port || (payload?.deploy?.method === "ftp" ? 21 : 22)), + username: String(payload?.deploy?.username || "").trim(), + passwordEnv: String(payload?.deploy?.passwordEnv || "").trim(), + remotePath: String(payload?.deploy?.remotePath || "").trim(), + deployOnRender: Boolean(payload?.deploy?.deployOnRender), + }; + + return { + id: String(payload?.id || projectConfig.id || activeProjectId).trim() || activeProjectId, + name: String(payload?.name || "NODE.DC").trim() || "NODE.DC", + role: String(payload?.role || "Администратор сайта").trim() || "Администратор сайта", + siteRoot: String(payload?.siteRoot || projectConfig.siteRoot || "../NODEDC_SITE").trim() || "../NODEDC_SITE", + previewUrl: String(payload?.previewUrl || "").trim(), + publicUrl: String(payload?.publicUrl || "").trim(), + deploy: nextDeploy, + }; +} + +async function saveProjectConfig(payload) { + const nextConfig = normalizeProjectConfig(payload); + await writeFile(projectConfigPath, `${JSON.stringify(nextConfig, null, 2)}\n`); + projectConfig = nextConfig; + return publicProjectConfig(); +} + +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 isAdminAsset = normalized === "/admin/index.html" || normalized.startsWith("/admin/"); + const root = isAdminAsset ? cmsRoot : repoRoot; + const absolute = join(root, normalized); + + const pathFromRoot = relative(root, absolute); + if (pathFromRoot.startsWith("..") || pathFromRoot === "..") { + 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 (await handleAuthRoute(req, res, url)) { + return; + } + + if (!authorizeRequest(req, res, url)) { + return; + } + + if (req.method === "GET" && url.pathname === "/api/project/config") { + sendJson(res, 200, publicProjectConfig()); + return; + } + + if (req.method === "PUT" && url.pathname === "/api/project/config") { + const payload = JSON.parse(await readBody(req)); + sendJson(res, 200, { ok: true, project: await saveProjectConfig(payload) }); + return; + } + + 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(`DC CMS admin: http://${host}:${port}/admin/`); + console.log(`Project site: http://${host}:${port}/`); + console.log(`Project root: ${repoRoot}`); +});