Add CMS site upload and SFTP publish

This commit is contained in:
DCCONSTRUCTIONS
2026-07-06 14:02:52 +03:00
parent cfee9d0c19
commit 808d867dd7
8 changed files with 737 additions and 40 deletions
+16
View File
@@ -704,8 +704,10 @@ body[data-file-picker-active="true"] .editor {
.dirty-state {
display: inline-flex;
min-width: 13.5rem;
min-height: 2rem;
align-items: center;
justify-content: center;
border-radius: var(--launcher-radius-circle);
background: rgba(255, 255, 255, 0.055);
color: var(--text-muted);
@@ -970,6 +972,20 @@ body:not([data-admin-mode="knowledge"]) #knowledge-form {
grid-column: 1 / -1;
}
.project-archive-field {
grid-column: 1 / -1;
}
.project-archive-control {
display: flex;
min-height: 2.72rem;
align-items: center;
}
.project-archive-button {
min-width: 13rem;
}
.secret-input-wrap {
position: relative;
display: grid;
+121 -27
View File
@@ -165,6 +165,7 @@ const el = {
reload: document.querySelector("#reload"),
save: document.querySelector("#save"),
render: document.querySelector("#render"),
publish: document.querySelector("#publish"),
dirtyState: document.querySelector("#dirty-state"),
status: document.querySelector("#status"),
empty: document.querySelector("#empty-state"),
@@ -640,13 +641,6 @@ function syncProjectConfigFromForm() {
async function saveProjectSettings() {
syncProjectConfigFromForm();
const passwordEnv = String(state.projectConfig?.deploy?.passwordEnv || "").trim();
if (passwordEnv && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(passwordEnv)) {
setByPath(state.projectConfig, "deploy.passwordEnv", "MCHOST_PASSWORD");
renderProjectSettings();
setStatus("FTP-пароль не сохраняем в модель. Поле сброшено к имени переменной MCHOST_PASSWORD.");
return;
}
const response = await fetch("/api/project/config", {
method: "PUT",
@@ -681,6 +675,29 @@ async function checkProjectConnection() {
setStatus(`Проект доступен. Размер локального сайта: ${size?.label || "не рассчитан"}.`);
}
async function publishProject() {
const response = await fetch("/api/project/publish", { method: "POST" });
const payload = await response.json().catch(() => null);
if (!response.ok || !payload?.ok) {
throw new Error(payload?.error || "Не удалось опубликовать проект");
}
setStatus(publishStatusText(payload).trim() || "Проект опубликован.");
}
function publishStatusText(payload) {
const publish = payload?.publish;
if (!publish) return "";
const uploaded = Number(publish.uploadedFiles || 0).toLocaleString("ru-RU");
const skipped = Number(publish.skippedFiles || 0).toLocaleString("ru-RU");
const total = Number(publish.totalFiles || 0).toLocaleString("ru-RU");
const volume = publish.uploadedLabel || "0 МБ";
return ` Опубликовано на ${publish.host}: загружено ${uploaded}/${total}, пропущено ${skipped}, объем ${volume}.`;
}
function ensurePageSeo() {
if (!state.page) return;
state.page.seo = {
@@ -779,6 +796,10 @@ function updateAdminModeSurface() {
el.render.title = isKnowledge ? "Сохранить дерево и пересобрать публичный раздел /knowledge/" : "Сохранить модель и пересобрать публичный index.html";
el.render.disabled = !hasSite && !isProjectSettingsSelected();
}
if (el.publish) {
el.publish.classList.add("hidden");
el.publish.disabled = true;
}
if (el.reload) {
el.reload.disabled = !hasSite && !isProjectSettingsSelected();
}
@@ -1992,14 +2013,14 @@ function createProjectField({ path, label, type = "text", placeholder = "", opti
}
control.dataset.projectPath = path;
control.addEventListener("input", () => {
const onProjectFieldChange = () => {
setProjectConfigValue(path, normalizeProjectInputValue(control));
markDirty("Настройки проекта изменены. Нажмите «Сохранить настройки».");
});
control.addEventListener("change", () => {
setProjectConfigValue(path, normalizeProjectInputValue(control));
markDirty("Настройки проекта изменены. Нажмите «Сохранить настройки».");
});
if (path === "deploy.enabled") renderProjectSettings();
};
control.addEventListener("input", onProjectFieldChange);
control.addEventListener("change", onProjectFieldChange);
if (hint && type !== "checkbox") {
const note = document.createElement("small");
@@ -2048,6 +2069,62 @@ function createProjectSettingsCard({ kicker, title, note = "", fields = [], runt
return card;
}
async function uploadSiteArchive(file) {
if (!file) return;
const formData = new FormData();
formData.append("file", file, file.name);
setStatus(`Загружаем архив сайта: ${file.name}...`);
const response = await fetch("/api/project/upload-site", {
method: "POST",
body: formData,
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload?.ok) {
throw new Error(payload?.error || "Не удалось загрузить архив сайта");
}
state.projectConfig = await loadProjectConfig();
renderProjectSettings();
setStatus(`Архив сайта загружен: ${Number(payload.files || 0).toLocaleString("ru-RU")} файлов, workspace ${payload.size?.label || payload.label || "обновлен"}.`);
}
function renderProjectArchiveUpload() {
const field = document.createElement("div");
const title = document.createElement("span");
const badge = document.createElement("span");
const body = document.createElement("div");
const button = document.createElement("button");
const input = document.createElement("input");
const note = document.createElement("small");
field.className = "field-control project-archive-field";
title.textContent = "Архив сайта";
badge.className = "field-kind";
badge.textContent = "ZIP";
body.className = "project-archive-control";
button.className = "ghost-btn project-archive-button";
button.type = "button";
button.textContent = "Загрузить архив сайта";
input.type = "file";
input.accept = ".zip,application/zip";
input.className = "hidden";
note.className = "field-hint";
note.textContent = "ZIP должен содержать content/pages/home.json, content/knowledge.json, templates и tools рендера.";
button.addEventListener("click", () => input.click());
input.addEventListener("change", () => {
const file = input.files?.[0];
input.value = "";
uploadSiteArchive(file).catch((error) => setStatus(error.message));
});
body.append(button, input);
field.append(title, badge, body, note);
return field;
}
function renderProjectRuntimeLine(label, value) {
const row = document.createElement("div");
const code = document.createElement("code");
@@ -2119,37 +2196,46 @@ function renderProjectSettings() {
el.render.textContent = "Проверить проект";
el.render.title = "Проверить доступ к модели, базе знаний и файлам проекта";
}
if (el.publish) {
const enabled = Boolean(state.projectConfig?.deploy?.enabled);
el.publish.classList.toggle("hidden", !enabled);
el.publish.disabled = !enabled || !siteReady() || state.projectConfigDirty;
el.publish.title = state.projectConfigDirty
? "Сначала сохраните настройки проекта"
: enabled
? "Опубликовать текущую копию сайта на хостинг"
: "Включите деплой в настройках проекта";
}
const projectCard = createProjectSettingsCard({
kicker: "Проект",
title: "Подключенный сайт",
note: "Эти поля описывают сайт, который сейчас обслуживает CMS. Локальный путь используется сервером, публичный URL нужен для будущего деплоя и ссылок.",
note: "Проект хранится в workspace CMS. Архив сайта загружается один раз и дальше редактируется через админку.",
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/" }),
renderProjectArchiveUpload(),
],
});
const deployCard = createProjectSettingsCard({
kicker: "Хостинг",
title: "Публикация на McHost",
note: "Для McHost обычно используются FTP host вида aXXXX.ftp.mchost.ru, порт 21 для FTP или 22 для SFTP и папка сайта httpdocs. Пароль не пишем в JSON: passwordEnv хранит имя переменной окружения сервиса.",
kicker: "Публикация",
title: "Деплой на хостинг",
note: "Укажите доступ к хостингу. Пароль сохраняется на сервере в зашифрованном виде и не возвращается в браузер.",
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: "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: "Имя переменной пароля",
path: "deploy.password",
label: "Пароль",
type: "password",
placeholder: "MCHOST_PASSWORD",
hint: "Не вводите сюда сам FTP-пароль. Укажите имя переменной окружения сервиса, например MCHOST_PASSWORD.",
placeholder: state.projectConfig?.deploy?.passwordConfigured ? "Пароль сохранен. Введите новый для замены" : "Введите пароль",
hint: state.projectConfig?.deploy?.passwordConfigured
? "Пароль уже сохранен на сервере. Поле пустое, потому что секрет не возвращается в браузер."
: "Введите пароль один раз и нажмите «Сохранить настройки».",
}),
createProjectField({ path: "deploy.remotePath", label: "Папка сайта на хостинге", placeholder: "httpdocs" }),
],
@@ -5481,7 +5567,9 @@ async function renderKnowledge() {
}
setDirty(false);
setStatus(payload.stdout ? `База знаний сохранена. ${payload.stdout}` : "База знаний сохранена и пересобрана.");
setStatus(
`${payload.stdout ? `База знаний сохранена. ${payload.stdout}` : "База знаний сохранена и пересобрана."}${publishStatusText(payload)}`,
);
}
async function loadPage() {
@@ -5546,7 +5634,9 @@ async function renderHome() {
}
setDirty(false);
setStatus(payload.stdout ? `Модель сохранена. ${payload.stdout}` : "Модель сохранена, index.html пересобран.");
setStatus(
`${payload.stdout ? `Модель сохранена. ${payload.stdout}` : "Модель сохранена, index.html пересобран."}${publishStatusText(payload)}`,
);
}
function moveSelected(delta) {
@@ -5856,6 +5946,10 @@ el.render.addEventListener("click", () => {
const action = state.mode === "knowledge" ? renderKnowledge : renderHome;
action().catch((error) => setStatus(error.message));
});
el.publish?.addEventListener("click", () => {
if (!isProjectSettingsSelected()) return;
publishProject().catch((error) => setStatus(error.message));
});
el.moveUp.addEventListener("click", () => moveSelected(-1));
el.moveDown.addEventListener("click", () => moveSelected(1));
el.duplicate.addEventListener("click", duplicateSelected);
+1
View File
@@ -88,6 +88,7 @@
<button id="reload" class="icon-btn" type="button" title="Перезагрузить данные"></button>
<button id="save" class="primary-btn" type="button" title="Записать текущую модель в content/pages/home.json">Сохранить модель</button>
<button id="render" class="ghost-btn" type="button" title="Сохранить модель и пересобрать публичный index.html">Обновить index.html</button>
<button id="publish" class="ghost-btn hidden" type="button" title="Опубликовать сайт на хостинг">Опубликовать</button>
</div>
</header>