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:
357
public/js/history-tree.js
Normal file
357
public/js/history-tree.js
Normal file
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* history-tree.js — Дерево версий, свёртка групп, выбор и удаление
|
||||
*/
|
||||
|
||||
import { apiFetch } from "./api.js";
|
||||
import { getState, setCurrentVersionId, setHistoryVersions } from "./state.js";
|
||||
import {
|
||||
formatHistoryDate,
|
||||
versionFileName,
|
||||
resetResult,
|
||||
updateDownloadCurrent,
|
||||
showSourcePreview,
|
||||
clearSourcePreview,
|
||||
setJobStatus,
|
||||
} from "./ui.js";
|
||||
import { rebuildReferenceSelect, clearReference, getReferenceState } from "./reference.js";
|
||||
import { updateSuggestCodexButton } from "./suggest.js";
|
||||
|
||||
function historyItemBadge(version) {
|
||||
if (version.kind === "original") return "Исходник";
|
||||
if (version.kind === "upscale") return "Апскейл";
|
||||
return "Правка";
|
||||
}
|
||||
|
||||
function buildChildrenMap(versions) {
|
||||
const map = new Map();
|
||||
for (const version of versions) {
|
||||
const key = version.parentId || "";
|
||||
if (!map.has(key)) {
|
||||
map.set(key, []);
|
||||
}
|
||||
map.get(key).push(version);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function countSubtreeSize(id, childrenMap) {
|
||||
let count = 1;
|
||||
const children = childrenMap.get(id);
|
||||
if (children) {
|
||||
for (const child of children) {
|
||||
count += countSubtreeSize(child.id, childrenMap);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function renderTreeNode(version, depth, childrenMap, container) {
|
||||
const state = getState();
|
||||
const node = document.createElement("div");
|
||||
node.className = "history-node";
|
||||
node.style.setProperty("--depth", depth);
|
||||
|
||||
if (version.id === state.currentVersionId) {
|
||||
node.classList.add("is-current");
|
||||
}
|
||||
|
||||
const main = document.createElement("button");
|
||||
main.type = "button";
|
||||
main.className = "history-node-main";
|
||||
main.setAttribute("aria-label", version.label || "Версия");
|
||||
main.addEventListener("click", () => selectHistoryVersion(version));
|
||||
|
||||
const thumb = document.createElement("img");
|
||||
thumb.className = "history-node-thumb";
|
||||
thumb.src = version.url;
|
||||
thumb.alt = "";
|
||||
thumb.loading = "lazy";
|
||||
|
||||
const meta = document.createElement("span");
|
||||
meta.className = "history-node-meta";
|
||||
|
||||
const title = document.createElement("strong");
|
||||
title.className = "history-node-title";
|
||||
title.textContent = version.label || "Версия";
|
||||
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "history-node-badge";
|
||||
badge.textContent = historyItemBadge(version);
|
||||
|
||||
const date = document.createElement("span");
|
||||
date.className = "history-node-date";
|
||||
date.textContent = formatHistoryDate(version.createdAt);
|
||||
|
||||
meta.append(title, badge, date);
|
||||
|
||||
if (version.kind === "edit" && version.prompt) {
|
||||
const prompt = document.createElement("div");
|
||||
prompt.className = "history-node-prompt";
|
||||
prompt.textContent = version.prompt;
|
||||
prompt.title = version.prompt;
|
||||
meta.appendChild(prompt);
|
||||
}
|
||||
|
||||
main.append(thumb, meta);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "history-node-actions";
|
||||
|
||||
const download = document.createElement("a");
|
||||
download.className = "history-node-action history-download";
|
||||
download.href = version.url;
|
||||
download.download = versionFileName(version);
|
||||
download.textContent = "Скачать";
|
||||
download.addEventListener("click", (event) => event.stopPropagation());
|
||||
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "history-node-action history-delete";
|
||||
remove.textContent = "Удалить";
|
||||
remove.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
deleteVersion(version);
|
||||
});
|
||||
|
||||
actions.append(download, remove);
|
||||
node.append(main, actions);
|
||||
|
||||
if (childrenMap.has(version.id)) {
|
||||
const body = document.createElement("div");
|
||||
body.className = "history-node-children";
|
||||
for (const child of childrenMap.get(version.id)) {
|
||||
renderTreeNode(child, depth + 1, childrenMap, body);
|
||||
}
|
||||
node.appendChild(body);
|
||||
}
|
||||
|
||||
container.appendChild(node);
|
||||
}
|
||||
|
||||
export function renderHistory() {
|
||||
const list = document.querySelector("#history-list");
|
||||
const empty = document.querySelector("#history-empty");
|
||||
if (!list || !empty) return;
|
||||
|
||||
const state = getState();
|
||||
const versions = state.historyVersions;
|
||||
|
||||
list.querySelectorAll(".history-group").forEach((group) => group.remove());
|
||||
empty.hidden = versions.length > 0;
|
||||
|
||||
const childrenMap = buildChildrenMap(versions);
|
||||
const roots = versions
|
||||
.filter(
|
||||
(version) =>
|
||||
version.kind === "original" ||
|
||||
!version.parentId ||
|
||||
!childrenMap.has(version.parentId)
|
||||
)
|
||||
.sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || "")));
|
||||
|
||||
rebuildReferenceSelect();
|
||||
|
||||
for (const root of roots) {
|
||||
const group = document.createElement("div");
|
||||
group.className = "history-group";
|
||||
group.dataset.group = root.id;
|
||||
|
||||
const rootNode = document.createElement("div");
|
||||
rootNode.className = "history-node is-root";
|
||||
rootNode.style.setProperty("--depth", 0);
|
||||
|
||||
if (root.id === state.currentVersionId) {
|
||||
rootNode.classList.add("is-current");
|
||||
}
|
||||
|
||||
const toggle = document.createElement("button");
|
||||
toggle.type = "button";
|
||||
toggle.className = "history-node-toggle";
|
||||
toggle.setAttribute("aria-label", "Свернуть ветку");
|
||||
const collapsed = state.collapsedGroups.has(root.id);
|
||||
toggle.setAttribute("aria-expanded", String(!collapsed));
|
||||
toggle.textContent = collapsed ? "▸" : "▾";
|
||||
toggle.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
if (state.collapsedGroups.has(root.id)) {
|
||||
state.collapsedGroups.delete(root.id);
|
||||
} else {
|
||||
state.collapsedGroups.set(root.id, true);
|
||||
}
|
||||
renderHistory();
|
||||
});
|
||||
|
||||
const rootMain = document.createElement("button");
|
||||
rootMain.type = "button";
|
||||
rootMain.className = "history-node-main";
|
||||
rootMain.setAttribute("aria-label", root.label || "Версия");
|
||||
rootMain.addEventListener("click", () => selectHistoryVersion(root));
|
||||
|
||||
const thumb = document.createElement("img");
|
||||
thumb.className = "history-node-thumb";
|
||||
thumb.src = root.url;
|
||||
thumb.alt = "";
|
||||
thumb.loading = "lazy";
|
||||
|
||||
const meta = document.createElement("span");
|
||||
meta.className = "history-node-meta";
|
||||
|
||||
const title = document.createElement("strong");
|
||||
title.className = "history-node-title";
|
||||
title.textContent = root.label || "Версия";
|
||||
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "history-node-badge";
|
||||
badge.textContent = historyItemBadge(root);
|
||||
|
||||
const date = document.createElement("span");
|
||||
date.className = "history-node-date";
|
||||
date.textContent = formatHistoryDate(root.createdAt);
|
||||
|
||||
meta.append(title, badge, date);
|
||||
|
||||
if (root.kind === "edit" && root.prompt) {
|
||||
const prompt = document.createElement("div");
|
||||
prompt.className = "history-node-prompt";
|
||||
prompt.textContent = root.prompt;
|
||||
prompt.title = root.prompt;
|
||||
meta.appendChild(prompt);
|
||||
}
|
||||
|
||||
rootMain.append(thumb, meta);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "history-node-actions";
|
||||
|
||||
const download = document.createElement("a");
|
||||
download.className = "history-node-action history-download";
|
||||
download.href = root.url;
|
||||
download.download = versionFileName(root);
|
||||
download.textContent = "Скачать";
|
||||
download.addEventListener("click", (event) => event.stopPropagation());
|
||||
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "history-node-action history-delete";
|
||||
remove.textContent = "Удалить";
|
||||
remove.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
deleteVersion(root);
|
||||
});
|
||||
|
||||
actions.append(download, remove);
|
||||
rootNode.append(toggle, rootMain, actions);
|
||||
group.appendChild(rootNode);
|
||||
|
||||
const count = document.createElement("span");
|
||||
count.className = "history-group-count";
|
||||
const subtreeSize = countSubtreeSize(root.id, childrenMap);
|
||||
count.textContent = subtreeSize === 1 ? "1 версия" : `${subtreeSize} версий`;
|
||||
group.appendChild(count);
|
||||
|
||||
if (!collapsed) {
|
||||
const body = document.createElement("div");
|
||||
body.className = "history-group-body";
|
||||
const children = childrenMap.get(root.id);
|
||||
if (children) {
|
||||
for (const child of children) {
|
||||
renderTreeNode(child, 1, childrenMap, body);
|
||||
}
|
||||
}
|
||||
group.appendChild(body);
|
||||
}
|
||||
|
||||
list.appendChild(group);
|
||||
}
|
||||
}
|
||||
|
||||
export function selectHistoryVersion(version) {
|
||||
const state = getState();
|
||||
if (state.isBusy) return;
|
||||
|
||||
setCurrentVersionId(version.id);
|
||||
|
||||
resetResult();
|
||||
updateDownloadCurrent(version);
|
||||
showSourcePreview(
|
||||
version.url,
|
||||
version.label || "Версия",
|
||||
version.width && version.height ? `${version.width}×${version.height}` : "Готово к обработке"
|
||||
);
|
||||
renderHistory();
|
||||
updateSuggestCodexButton();
|
||||
setJobStatus("idle", "");
|
||||
|
||||
const editBtn = document.querySelector("#edit-button");
|
||||
const upscaleBtn = document.querySelector("#upscale-button");
|
||||
const promptInput = document.querySelector("#prompt");
|
||||
|
||||
if (upscaleBtn) upscaleBtn.disabled = false;
|
||||
if (editBtn) {
|
||||
editBtn.disabled = !promptInput?.value?.trim();
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshHistory(selectLatest = false) {
|
||||
try {
|
||||
const data = await apiFetch("/api/history", { cache: "no-store" });
|
||||
const versions = Array.isArray(data.versions) ? data.versions : [];
|
||||
setHistoryVersions(versions);
|
||||
|
||||
if (selectLatest && versions.length > 0) {
|
||||
const latest = versions[versions.length - 1];
|
||||
selectHistoryVersion(latest);
|
||||
} else {
|
||||
renderHistory();
|
||||
updateSuggestCodexButton();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Ошибка обновления истории:", err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteVersion(version) {
|
||||
if (!confirm("Удалить версию и все её ответвления?")) return;
|
||||
|
||||
try {
|
||||
const data = await apiFetch(`/api/history/${version.id}`, {
|
||||
method: "DELETE",
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const versions = Array.isArray(data.versions) ? data.versions : [];
|
||||
setHistoryVersions(versions);
|
||||
|
||||
const state = getState();
|
||||
state.collapsedGroups.delete(version.id);
|
||||
|
||||
const currentSurvives = versions.some((v) => v.id === state.currentVersionId);
|
||||
if (!currentSurvives) {
|
||||
const parent = version.parentId ? versions.find((v) => v.id === version.parentId) : null;
|
||||
if (parent) {
|
||||
selectHistoryVersion(parent);
|
||||
} else if (versions.length > 0) {
|
||||
setCurrentVersionId(null);
|
||||
resetResult();
|
||||
updateDownloadCurrent(null);
|
||||
clearSourcePreview();
|
||||
renderHistory();
|
||||
} else {
|
||||
setCurrentVersionId(null);
|
||||
resetResult();
|
||||
updateDownloadCurrent(null);
|
||||
clearSourcePreview();
|
||||
renderHistory();
|
||||
}
|
||||
} else {
|
||||
renderHistory();
|
||||
}
|
||||
|
||||
const refState = getReferenceState();
|
||||
if (refState?.source?.type === "history" && refState.source.versionId === version.id) {
|
||||
clearReference();
|
||||
}
|
||||
} catch (err) {
|
||||
setJobStatus("error", err.message || "Не удалось удалить версию.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user