v2: модульный монолит — очередь, персистентность, безопасность, SSE, тесты
Some checks failed
test / test (push) Failing after 24s
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:
123
public/js/upload.js
Normal file
123
public/js/upload.js
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user