From dc49acaa437ab788458c13ded2b8b73a68ad498d Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Mon, 6 Jul 2026 11:55:08 +0300 Subject: [PATCH] Handle unloaded site workspace in CMS --- README.md | 4 ++ admin/admin.css | 42 +++++++++++++++ admin/admin.js | 117 +++++++++++++++++++++++++++++++++++----- admin/index.html | 17 +++--- server/admin-server.mjs | 56 ++++++++++++++++++- 5 files changed, 214 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 498bbd8..5e09ebf 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,10 @@ Пароль хостинга не хранится в JSON. В конфиге указывается имя переменной окружения, например `MCHOST_PASSWORD`. +CMS редактирует локальный workspace сайта, а внешний хостинг является целью публикации. На Synology текущий NODE.DC workspace монтируется в контейнер как `/sites/nodedc` и физически лежит в `/volume1/docker/dc-cms/sites/nodedc`. + +Если workspace ещё не загружен, админка открывается без вкладок сайта и показывает состояние подключения. После загрузки `content/`, `templates/`, `tools/` и нужных assets появляются режимы администрирования, SEO и Knowledge. + ## Локальный запуск ```bash diff --git a/admin/admin.css b/admin/admin.css index 1adc89b..0dd407a 100644 --- a/admin/admin.css +++ b/admin/admin.css @@ -368,6 +368,48 @@ body[data-file-picker-active="true"] .editor { stroke-linejoin: round; } +.workspace-card-actions { + display: inline-flex; + margin-left: auto; + align-items: center; + gap: 0.34rem; +} + +.workspace-action-button { + display: grid; + width: 2rem; + height: 2rem; + place-items: center; + flex: 0 0 auto; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: var(--launcher-radius-circle); + background: rgba(255, 255, 255, 0.055); + color: rgba(255, 255, 255, 0.8); + font-size: 1.04rem; + line-height: 1; +} + +.workspace-action-button svg { + width: 0.92rem; + height: 0.92rem; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.workspace-action-button--plus { + font-size: 1.18rem; + font-weight: 650; +} + +.workspace-action-button:hover { + border-color: rgba(255, 255, 255, 0.3); + background: rgba(255, 255, 255, 0.1); + color: var(--text-primary); +} + .admin-panel-close:hover { border-color: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.07); diff --git a/admin/admin.js b/admin/admin.js index 315fb33..9863346 100644 --- a/admin/admin.js +++ b/admin/admin.js @@ -150,6 +150,7 @@ const el = { modeContent: document.querySelector("#admin-mode-content"), modeSeo: document.querySelector("#admin-mode-seo"), modeKnowledge: document.querySelector("#admin-mode-knowledge"), + modeNav: document.querySelector(".nodedc-expanded-nav-group"), sidebarTitle: document.querySelector("#sidebar-title"), workspaceName: document.querySelector("#workspace-name"), workspaceRole: document.querySelector("#workspace-role"), @@ -158,6 +159,7 @@ const el = { panelTitle: document.querySelector("#content-panel-title"), panelNote: document.querySelector("#content-panel-note"), regions: document.querySelector("#regions"), + addProject: document.querySelector("#add-project"), projectSettings: document.querySelector("#project-settings"), addBlock: document.querySelector("#add-block"), reload: document.querySelector("#reload"), @@ -582,6 +584,14 @@ function projectRole() { return state.projectConfig?.role || "Администратор сайта"; } +function siteStatus() { + return state.projectConfig?.site || { ready: false, missingFiles: [] }; +} + +function siteReady() { + return Boolean(siteStatus().ready); +} + function applyProjectConfigSurface() { if (el.workspaceName) el.workspaceName.textContent = projectName(); if (el.workspaceRole && !isProjectSettingsSelected()) { @@ -693,6 +703,13 @@ function ensureSeoModel() { } function setAdminMode(nextMode) { + if (!siteReady() && !isProjectSettingsSelected()) { + state.mode = "content"; + updateAdminLayoutState({ mode: state.mode }); + renderSiteUnavailable(); + return; + } + const mode = nextMode === "seo" || nextMode === "knowledge" ? nextMode : "content"; state.mode = mode; if (isProjectSettingsSelected()) { @@ -714,8 +731,10 @@ function setAdminMode(nextMode) { function updateAdminModeSurface() { const isSeo = state.mode === "seo"; const isKnowledge = state.mode === "knowledge"; + const hasSite = siteReady(); document.body.dataset.adminMode = state.mode; + el.modeNav?.classList.toggle("hidden", !hasSite); 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); @@ -736,14 +755,24 @@ function updateAdminModeSurface() { if (el.addBlock) { el.addBlock.title = isKnowledge ? "Добавить раздел в корень базы знаний" : "Добавить блок"; el.addBlock.setAttribute("aria-label", el.addBlock.title); + el.addBlock.disabled = !hasSite; + } + if (el.addProject) { + el.addProject.title = "Добавить сайт"; + el.addProject.setAttribute("aria-label", "Добавить сайт"); } if (el.save) { - el.save.textContent = isKnowledge ? "Сохранить базу" : "Сохранить модель"; + el.save.textContent = "Сохранить"; el.save.title = isKnowledge ? "Записать дерево в content/knowledge.json" : "Записать текущую модель в content/pages/home.json"; + el.save.disabled = !hasSite && !isProjectSettingsSelected(); } if (el.render) { - el.render.textContent = isKnowledge ? "Обновить /knowledge/" : "Обновить index.html"; + el.render.textContent = "Опубликовать"; el.render.title = isKnowledge ? "Сохранить дерево и пересобрать публичный раздел /knowledge/" : "Сохранить модель и пересобрать публичный index.html"; + el.render.disabled = !hasSite && !isProjectSettingsSelected(); + } + if (el.reload) { + el.reload.disabled = !hasSite && !isProjectSettingsSelected(); } } @@ -1796,7 +1825,7 @@ function openTemplateModal() { function closeTemplateModal() { el.templateModal.classList.add("hidden"); - el.addBlock.focus(); + el.addBlock?.focus(); } function closeDeleteModal(confirmed = false) { @@ -1996,6 +2025,43 @@ function renderProjectRuntimeLine(label, value) { return row; } +function renderEmptyState({ icon = "ND", title, body, stateName = "" }) { + el.empty.dataset.state = stateName; + el.empty.innerHTML = ` +
${escapeHtml(icon)}
+
+

${escapeHtml(title)}

+

${escapeHtml(body)}

+
+ `; +} + +function renderSiteUnavailable() { + updateAdminModeSurface(); + applyProjectConfigSurface(); + el.regions.innerHTML = ""; + el.form.classList.add("hidden"); + el.knowledgeForm.classList.add("hidden"); + el.projectSettingsForm.classList.add("hidden"); + el.empty.classList.remove("hidden"); + + const missing = siteStatus().missingFiles || []; + const missingText = missing.length ? ` Сейчас отсутствуют: ${missing.join(", ")}.` : ""; + const root = siteStatus().root || state.projectConfig?.runtime?.siteRoot || ""; + const rootText = root ? ` Локальная папка: ${root}.` : ""; + + renderEmptyState({ + icon: "DC", + title: "Сайт недоступен", + body: `Проверьте настройки подключения к хостингу или загрузите архив сайта в локальный workspace CMS.${missingText}${rootText}`, + stateName: "site-unavailable", + }); + if (el.editorHeading) el.editorHeading.textContent = "DC CMS"; + el.selectedRegion.textContent = `DC CMS / ${projectName()} / Подключение`; + el.selectedTitle.textContent = "Подключите сайт"; + setDirty(false); +} + function renderProjectSettings() { updateAdminModeSurface(); el.empty.classList.add("hidden"); @@ -2038,17 +2104,17 @@ function renderProjectSettings() { const deployCard = createProjectSettingsCard({ kicker: "Хостинг", - title: "Публикация на McHost / Synology", - note: "Пароль не пишем в JSON. В поле passwordEnv указывается имя переменной окружения сервиса, где будет лежать пароль.", + title: "Публикация на McHost", + note: "Для McHost обычно используются FTP host вида aXXXX.ftp.mchost.ru, порт 21 для FTP или 22 для SFTP и папка сайта httpdocs. Пароль не пишем в 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.host", label: "FTP / SFTP host", placeholder: "a0000.ftp.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" }), + createProjectField({ path: "deploy.remotePath", label: "Папка сайта на хостинге", placeholder: "httpdocs" }), ], }); @@ -2081,6 +2147,11 @@ function renderEditor() { if (!block) { el.empty.classList.remove("hidden"); el.form.classList.add("hidden"); + renderEmptyState({ + icon: "ND", + title: "Выберите блок слева", + body: "Статика, hero, форма и секции страницы разделены на рабочие группы. Сначала редактируем typed-поля, сырой JSON оставлен как технический fallback.", + }); el.selectedRegion.textContent = "NODE.DC / Выберите блок"; el.selectedTitle.textContent = isSeo ? "SEO-слой" : "Контентный слой"; renderLandingTargetOptions(); @@ -2124,6 +2195,11 @@ function renderEditor() { function renderAll() { try { + if (!siteReady() && !isProjectSettingsSelected()) { + renderSiteUnavailable(); + return; + } + if (isProjectSettingsSelected()) { if (state.mode === "knowledge" && state.knowledge) { ensureKnowledgeSelection(); @@ -5327,6 +5403,11 @@ function renderKnowledgeAll() { } async function loadKnowledge() { + if (!siteReady()) { + renderSiteUnavailable(); + return; + } + const response = await fetch("/api/knowledge"); if (!response.ok) throw new Error(await response.text()); state.knowledge = await response.json(); @@ -5367,13 +5448,20 @@ async function renderKnowledge() { } async function loadPage() { - const [projectConfig, pageResponse, templatesResponse] = await Promise.all([ - loadProjectConfig(), - fetch("/api/page/home"), - fetch("/api/block-templates/home"), - ]); - + const projectConfig = await loadProjectConfig(); state.projectConfig = projectConfig; + state.knowledge = null; + + if (!siteReady()) { + state.page = null; + state.templates = []; + state.selected = null; + renderSiteUnavailable(); + setStatus("Сайт не загружен в локальный workspace CMS."); + return; + } + + const [pageResponse, templatesResponse] = await Promise.all([fetch("/api/page/home"), fetch("/api/block-templates/home")]); if (!pageResponse.ok) throw new Error(await pageResponse.text()); if (!templatesResponse.ok) throw new Error(await templatesResponse.text()); @@ -5514,6 +5602,7 @@ for (const input of [el.id, el.label, el.anchor, el.enabled]) { el.modeContent?.addEventListener("click", () => setAdminMode("content")); el.modeSeo?.addEventListener("click", () => setAdminMode("seo")); el.modeKnowledge?.addEventListener("click", () => setAdminMode("knowledge")); +el.addProject?.addEventListener("click", openProjectSettings); el.projectSettings?.addEventListener("click", openProjectSettings); el.json.addEventListener("blur", () => { @@ -5525,7 +5614,7 @@ el.json.addEventListener("blur", () => { syncBlockFromJson(); }); -el.addBlock.addEventListener("click", () => { +el.addBlock?.addEventListener("click", () => { if (state.mode === "knowledge") { addKnowledgeNode(null); return; diff --git a/admin/index.html b/admin/index.html index 7584109..b21f7bb 100644 --- a/admin/index.html +++ b/admin/index.html @@ -55,19 +55,22 @@
NODE.DC

Администрирование

- - +
NODE.DC
Администратор сайта
+
+ + +
diff --git a/server/admin-server.mjs b/server/admin-server.mjs index ef495f9..55d4570 100644 --- a/server/admin-server.mjs +++ b/server/admin-server.mjs @@ -1,5 +1,5 @@ import { createServer } from "node:http"; -import { createReadStream } from "node:fs"; +import { createReadStream, statSync } 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"; @@ -117,6 +117,15 @@ const REFERENCE_TEXT_EXTENSIONS = new Set([ ".xml", ]); +const REQUIRED_SITE_FILES = [ + { key: "page", label: "content/pages/home.json", path: pagePath }, + { key: "knowledge", label: "content/knowledge.json", path: knowledgePath }, + { key: "blockTemplates", label: "content/block-templates/home.json", path: blockTemplatesPath }, + { key: "renderHome", label: "tools/render-home.mjs", path: join(repoRoot, "tools", "render-home.mjs") }, + { key: "renderKnowledge", label: "tools/render-knowledge.mjs", path: join(repoRoot, "tools", "render-knowledge.mjs") }, + { key: "homeShell", label: "templates/pages/home/shell.html", path: join(repoRoot, "templates", "pages", "home", "shell.html") }, +]; + const MIME = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", @@ -206,6 +215,42 @@ function trailingSlashUrl(value) { return trimmed.endsWith("/") ? trimmed : `${trimmed}/`; } +function fileReady(filePath) { + try { + return statSync(filePath).isFile(); + } catch { + return false; + } +} + +function currentSiteStatus() { + const files = REQUIRED_SITE_FILES.map((item) => ({ + key: item.key, + label: item.label, + exists: fileReady(item.path), + })); + const missingFiles = files.filter((item) => !item.exists).map((item) => item.label); + + return { + ready: missingFiles.length === 0, + root: repoRoot, + requiredFiles: files, + missingFiles, + }; +} + +function ensureSiteReady(res) { + const site = currentSiteStatus(); + if (site.ready) return true; + sendJson(res, 409, { + ok: false, + code: "SITE_NOT_READY", + error: "Сайт не загружен в локальный workspace CMS.", + site, + }); + return false; +} + const authConfig = { enabled: boolEnv(process.env.CMS_AUTH_ENABLED, false), baseUrl: String(process.env.CMS_BASE_URL || "").trim(), @@ -773,6 +818,7 @@ function publicProjectConfig() { configPath: projectConfigPath, siteRootChangesRequireRestart: true, }, + site: currentSiteStatus(), }; } @@ -2165,16 +2211,19 @@ const server = createServer(async (req, res) => { } if (req.method === "GET" && url.pathname === "/api/page/home") { + if (!ensureSiteReady(res)) return; send(res, 200, await readFile(pagePath, "utf8"), "application/json; charset=utf-8"); return; } if (req.method === "GET" && url.pathname === "/api/knowledge") { + if (!ensureSiteReady(res)) return; send(res, 200, await readFile(knowledgePath, "utf8"), "application/json; charset=utf-8"); return; } if (req.method === "GET" && url.pathname === "/api/landing-targets") { + if (!ensureSiteReady(res)) return; const page = JSON.parse(await readFile(pagePath, "utf8")); validateLandingTargets(page); sendJson(res, 200, { ok: true, targets: landingTargetsForPage(page) }); @@ -2182,6 +2231,7 @@ const server = createServer(async (req, res) => { } if (req.method === "GET" && url.pathname === "/api/block-templates/home") { + if (!ensureSiteReady(res)) return; send(res, 200, await readFile(blockTemplatesPath, "utf8"), "application/json; charset=utf-8"); return; } @@ -2207,6 +2257,7 @@ const server = createServer(async (req, res) => { } if (req.method === "PUT" && url.pathname === "/api/page/home") { + if (!ensureSiteReady(res)) return; const body = await readBody(req); const parsed = JSON.parse(body); validateLandingTargets(parsed); @@ -2216,6 +2267,7 @@ const server = createServer(async (req, res) => { } if (req.method === "PUT" && url.pathname === "/api/knowledge") { + if (!ensureSiteReady(res)) return; const body = await readBody(req); const parsed = JSON.parse(body); await writeFile(knowledgePath, `${JSON.stringify(parsed, null, 2)}\n`); @@ -2224,12 +2276,14 @@ const server = createServer(async (req, res) => { } if (req.method === "POST" && url.pathname === "/api/render/home") { + if (!ensureSiteReady(res)) return; const result = await runNodeScript("render-home.mjs"); sendJson(res, 200, { ok: true, ...result }); return; } if (req.method === "POST" && url.pathname === "/api/render/knowledge") { + if (!ensureSiteReady(res)) return; const result = await runNodeScript("render-knowledge.mjs"); sendJson(res, 200, { ok: true, ...result }); return;