Add CMS site upload and SFTP publish
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user