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