Handle unloaded site workspace in CMS

This commit is contained in:
DCCONSTRUCTIONS 2026-07-06 11:55:08 +03:00
parent 33ab11ab08
commit dc49acaa43
5 changed files with 214 additions and 22 deletions

View File

@ -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

View File

@ -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);

View File

@ -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 = `
<div class="empty-icon">${escapeHtml(icon)}</div>
<div>
<h3>${escapeHtml(title)}</h3>
<p>${escapeHtml(body)}</p>
</div>
`;
}
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;

View File

@ -55,19 +55,22 @@
<div class="section-kicker">NODE.DC</div>
<h1 id="sidebar-title">Администрирование</h1>
</div>
<button id="project-settings" class="admin-panel-close project-settings-button" type="button" aria-label="Настройки проекта" title="Настройки проекта">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M12 8.2a3.8 3.8 0 1 1 0 7.6 3.8 3.8 0 0 1 0-7.6Z" />
<path d="M19.4 13.5c.06-.48.06-.98 0-1.5l2-1.55-1.9-3.3-2.35.95a8.2 8.2 0 0 0-1.3-.75L15.5 4.8h-3.8l-.35 2.55c-.46.2-.9.45-1.3.75L7.7 7.15l-1.9 3.3L7.8 12a8.5 8.5 0 0 0 0 1.5l-2 1.55 1.9 3.3 2.35-.95c.4.3.84.55 1.3.75l.35 2.55h3.8l.35-2.55c.46-.2.9-.45 1.3-.75l2.35.95 1.9-3.3-2-1.55Z" />
</svg>
</button>
<button id="add-block" class="admin-panel-close add-block-button" type="button" aria-label="Добавить блок">+</button>
<button id="add-project" class="admin-panel-close add-block-button" type="button" aria-label="Добавить сайт" title="Добавить сайт">+</button>
<div class="workspace-card admin-panel-client-select">
<div class="workspace-logo admin-panel-client-select__icon">DC</div>
<div>
<div id="workspace-name" class="workspace-name">NODE.DC</div>
<div id="workspace-role" class="workspace-role">Администратор сайта</div>
</div>
<div class="workspace-card-actions">
<button id="project-settings" class="workspace-action-button" type="button" aria-label="Настройки проекта" title="Настройки проекта">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M12 8.2a3.8 3.8 0 1 1 0 7.6 3.8 3.8 0 0 1 0-7.6Z" />
<path d="M19.4 13.5c.06-.48.06-.98 0-1.5l2-1.55-1.9-3.3-2.35.95a8.2 8.2 0 0 0-1.3-.75L15.5 4.8h-3.8l-.35 2.55c-.46.2-.9.45-1.3.75L7.7 7.15l-1.9 3.3L7.8 12a8.5 8.5 0 0 0 0 1.5l-2 1.55 1.9 3.3 2.35-.95c.4.3.84.55 1.3.75l.35 2.55h3.8l.35-2.55c.46-.2.9-.45 1.3-.75l2.35.95 1.9-3.3-2-1.55Z" />
</svg>
</button>
<button id="add-block" class="workspace-action-button workspace-action-button--plus" type="button" aria-label="Добавить блок" title="Добавить блок">+</button>
</div>
</div>
</div>
<div id="regions" class="regions"></div>

View File

@ -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;