v2: модульный монолит — очередь, персистентность, безопасность, SSE, тесты
Some checks failed
test / test (push) Failing after 24s

Реализация целевого дизайна (docs/architecture/target-design.md):
- src/: модульная структура (config, logger, errors, http, core: auth/history/jobs/engines/comfy/codex/images)
- Очередь заданий: FIFO, кониурентность codex=1 / comfy-upscale=2, MAX_QUEUED_JOBS=5, JSONL-журнал и восстановление после рестарта (running → interrupted, upscale requeue)
- Персистентные сессии (sha256-хеши токенов), scrypt-хеш пароля, rate-limit входа, Origin-проверка, magic-byte валидация аплоадов
- Атомарная запись манифеста истории, каскадное удаление, URL с фактическим расширением + 302-алиас /image.jpg, миграция history/ → data/history
- Даунскейл sharp до 1024px перед Codex + масштабирование области референса
- SSE /api/events с фолбэком на polling, фронтенд переведён на ES-модули (public/js/)
- Тесты node:test + supertest (27), CI workflow test.yml, Docker/деплой: том kadr-data
- Документация: README, docs/architecture/
This commit is contained in:
2026-08-27 12:52:27 +07:00
parent 1c36a21e0c
commit 401969cfdb
68 changed files with 9368 additions and 4085 deletions

123
public/js/upload.js Normal file
View File

@@ -0,0 +1,123 @@
/**
* upload.js — Загрузка и импорт фотографий через drag-and-drop или file picker
*/
import { apiFetch } from "./api.js";
import { getState, setBusy } from "./state.js";
import { setJobStatus, isHeicFile, ACCEPTED_TYPES, MAX_FILE_SIZE } from "./ui.js";
import { refreshHistory } from "./history-tree.js";
export async function importPhoto(file) {
const form = new FormData();
form.append("photo", file, file.name);
const data = await apiFetch("/api/history/import", {
method: "POST",
body: form,
cache: "no-store",
});
return data.version;
}
export async function handleFiles(fileList) {
const files = Array.from(fileList || []).filter((file) => {
const fileType = String(file.type || "").toLowerCase();
if (!ACCEPTED_TYPES.has(fileType) && !isHeicFile(file)) {
setJobStatus("error", "Поддерживаются только фотографии JPEG, PNG, WebP или HEIC.");
return false;
}
if (file.size > MAX_FILE_SIZE) {
setJobStatus("error", "Файл слишком большой. Максимальный размер — 25 МБ.");
return false;
}
return true;
});
if (files.length === 0) return;
setBusy(true);
for (let i = 0; i < files.length; i++) {
const file = files[i];
setJobStatus(
"progress",
files.length > 1
? `Загрузка ${i + 1} из ${files.length}`
: "Загружаем фотографию в историю…"
);
try {
await importPhoto(file);
} catch (error) {
setBusy(false);
setJobStatus(
"error",
error instanceof Error ? error.message : "Не удалось загрузить фотографию."
);
return;
}
}
await refreshHistory(true);
setJobStatus("idle", "");
setBusy(false);
}
export function initUpload() {
const dropZone = document.querySelector("#drop-zone");
const photoInput = document.querySelector("#photo-input");
if (dropZone && photoInput) {
dropZone.addEventListener("click", () => {
const state = getState();
if (!state.isBusy) {
photoInput.click();
}
});
dropZone.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
const state = getState();
if (!state.isBusy) {
photoInput.click();
}
}
});
photoInput.addEventListener("change", (event) => {
handleFiles(event.target.files);
photoInput.value = "";
});
const preventDefaults = (e) => {
e.preventDefault();
e.stopPropagation();
};
["dragenter", "dragover", "dragleave", "drop"].forEach((evt) => {
dropZone.addEventListener(evt, preventDefaults, false);
document.body.addEventListener(evt, preventDefaults, false);
});
["dragenter", "dragover"].forEach((evt) => {
dropZone.addEventListener(evt, () => dropZone.classList.add("dragover"), false);
});
["dragleave", "drop"].forEach((evt) => {
dropZone.addEventListener(evt, () => dropZone.classList.remove("dragover"), false);
});
dropZone.addEventListener("drop", (event) => {
const state = getState();
if (!state.isBusy && event.dataTransfer?.files) {
handleFiles(event.dataTransfer.files);
}
});
}
}