fix: stale responses, double submits and leaked listeners

web/js/api-client.js wraps fetch with one failure path (ApiError for non-2xx,
success:false and non-JSON bodies) and latestOnly(), which aborts the previous
in-flight request and resolves superseded calls to STALE. The admin search and
recent-page loads share one guard, the gallery page load has its own, so fast
typing or clicking can no longer paint an older result over a newer one.

In-place prompt editing used a blur listener with {once:true} plus a keydown
listener added on every edit, which sent the PUT twice and stacked handlers.
The list now has one keydown/focusout pair bound at construction and
finishEdit() clears the edit state before touching the element, so a save is
sent exactly once. Mutations re-fetch the page currently shown (refreshList)
instead of calling search(), and delete, bulk delete, edit, restore and scan no
longer reload the whole page.

Pagination state is committed only after a successful fetch via the pure
ListState.nextPageState(current, response) in both admin and gallery, so a
failed or aborted page request leaves the current page intact. The gallery
lightbox removed its keydown handlers on a non-existent 'remove' event; a
MutationObserver now drops them when the modal leaves the document.

web/js/sse-stream.js reads server-sent events over fetch so POST-only progress
endpoints can stream.
This commit is contained in:
Vito Sansevero
2026-10-08 18:01:34 -07:00
parent 38fd581f09
commit 7b6b7ecf3d
10 changed files with 787 additions and 170 deletions
+168
View File
@@ -0,0 +1,168 @@
// Run with: node --test tests/js/
const test = require("node:test");
const assert = require("node:assert/strict");
const { createApiClient, ApiError, latestOnly, STALE } = require("../../web/js/api-client.js");
/** A fetch stand-in whose responses the test resolves by hand, in any order. */
function fakeFetch() {
const calls = [];
const impl = (url, init = {}) =>
new Promise((resolve, reject) => {
const call = { url, init, resolve, reject, aborted: false };
if (init.signal) {
init.signal.addEventListener("abort", () => {
call.aborted = true;
reject(new DOMException("The operation was aborted.", "AbortError"));
});
}
calls.push(call);
});
impl.calls = calls;
return impl;
}
function jsonResponse(status, body, { invalid = false } = {}) {
return {
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? "OK" : `Status ${status}`,
json: async () => {
if (invalid) throw new SyntaxError("Unexpected token");
return body;
},
};
}
test("get returns the parsed body of a successful response", async () => {
const fetch = fakeFetch();
const api = createApiClient(fetch);
const pending = api.get("/prompt_manager/recent?page=1");
fetch.calls[0].resolve(jsonResponse(200, { success: true, results: [1, 2] }));
assert.deepEqual(await pending, { success: true, results: [1, 2] });
assert.equal(fetch.calls[0].init.method, "GET");
});
test("get forwards an AbortSignal to fetch", async () => {
const fetch = fakeFetch();
const api = createApiClient(fetch);
const controller = new AbortController();
const pending = api.get("/x", { signal: controller.signal });
assert.equal(fetch.calls[0].init.signal, controller.signal);
controller.abort();
await assert.rejects(pending, (err) => err.name === "AbortError");
});
test("non-2xx responses throw ApiError carrying the status and server message", async () => {
const fetch = fakeFetch();
const api = createApiClient(fetch);
const pending = api.get("/x");
fetch.calls[0].resolve(jsonResponse(404, { success: false, error: "Prompt not found" }));
await assert.rejects(pending, (err) => {
assert.ok(err instanceof ApiError);
assert.ok(err instanceof Error);
assert.equal(err.status, 404);
assert.equal(err.message, "Prompt not found");
return true;
});
});
test("non-2xx responses without a JSON body fall back to the status text", async () => {
const fetch = fakeFetch();
const api = createApiClient(fetch);
const pending = api.get("/x");
fetch.calls[0].resolve(jsonResponse(500, null, { invalid: true }));
await assert.rejects(pending, (err) => err.status === 500 && err.message === "Status 500");
});
test("a 2xx body with success:false throws ApiError", async () => {
const fetch = fakeFetch();
const api = createApiClient(fetch);
const pending = api.post("/x", {});
fetch.calls[0].resolve(jsonResponse(200, { success: false, error: "Nope" }));
await assert.rejects(pending, (err) => err instanceof ApiError && err.status === 200 && err.message === "Nope");
});
test("a 2xx body that is not JSON throws ApiError", async () => {
const fetch = fakeFetch();
const api = createApiClient(fetch);
const pending = api.get("/x");
fetch.calls[0].resolve(jsonResponse(200, null, { invalid: true }));
await assert.rejects(pending, (err) => err instanceof ApiError && err.status === 200);
});
test("post, put and del send JSON bodies with the right method", async () => {
const fetch = fakeFetch();
const api = createApiClient(fetch);
const p1 = api.post("/a", { text: "x" });
const p2 = api.put("/b", { rating: 3 });
const p3 = api.del("/c");
assert.equal(fetch.calls[0].init.method, "POST");
assert.equal(fetch.calls[0].init.body, JSON.stringify({ text: "x" }));
assert.equal(fetch.calls[0].init.headers["Content-Type"], "application/json");
assert.equal(fetch.calls[1].init.method, "PUT");
assert.equal(fetch.calls[1].init.body, JSON.stringify({ rating: 3 }));
assert.equal(fetch.calls[2].init.method, "DELETE");
assert.equal(fetch.calls[2].init.body, undefined);
for (const c of fetch.calls) c.resolve(jsonResponse(200, { success: true }));
await Promise.all([p1, p2, p3]);
});
test("network failures propagate as ordinary errors", async () => {
const fetch = fakeFetch();
const api = createApiClient(fetch);
const pending = api.get("/x");
fetch.calls[0].reject(new TypeError("Failed to fetch"));
await assert.rejects(pending, (err) => err instanceof TypeError && !(err instanceof ApiError));
});
test("createApiClient without an argument uses the global fetch", async () => {
const original = globalThis.fetch;
globalThis.fetch = async () => jsonResponse(200, { success: true, via: "global" });
try {
assert.deepEqual(await createApiClient().get("/x"), { success: true, via: "global" });
} finally {
globalThis.fetch = original;
}
});
test("latestOnly: a newer call aborts the older one and the older result is STALE", async () => {
const fetch = fakeFetch();
const api = createApiClient(fetch);
const latest = latestOnly();
const first = latest((signal) => api.get("/search?q=a", { signal }));
const second = latest((signal) => api.get("/search?q=ab", { signal }));
assert.equal(fetch.calls[0].aborted, true, "first request aborted");
assert.equal(fetch.calls[1].aborted, false);
fetch.calls[1].resolve(jsonResponse(200, { success: true, q: "ab" }));
assert.deepEqual(await second, { success: true, q: "ab" });
assert.equal(await first, STALE);
});
test("latestOnly: an older response that arrives after a newer request is STALE even without abort", async () => {
const latest = latestOnly();
let resolveFirst;
const first = latest(() => new Promise((r) => { resolveFirst = r; }));
const second = latest(async () => "new");
assert.equal(await second, "new");
resolveFirst("old");
assert.equal(await first, STALE);
});
test("latestOnly: sequential calls each return their own result", async () => {
const latest = latestOnly();
assert.equal(await latest(async () => 1), 1);
assert.equal(await latest(async () => 2), 2);
});
test("latestOnly: errors from the current call propagate, errors from superseded calls do not", async () => {
const latest = latestOnly();
let rejectFirst;
const first = latest(() => new Promise((_, rej) => { rejectFirst = rej; }));
const second = latest(async () => { throw new Error("boom"); });
await assert.rejects(second, /boom/);
rejectFirst(new Error("old failure"));
assert.equal(await first, STALE);
});
+51
View File
@@ -0,0 +1,51 @@
// Run with: node --test tests/js/
const test = require("node:test");
const assert = require("node:assert/strict");
const { nextPageState, shouldSaveEdit, hasMorePages } = require("../../web/js/list-state.js");
test("hasMorePages follows pagination.has_more only while pages keep arriving", () => {
assert.equal(hasMorePages({ pagination: { has_more: true } }, 5000), true);
assert.equal(hasMorePages({ pagination: { has_more: false } }, 5000), false);
assert.equal(hasMorePages({ pagination: { has_more: true } }, 0), false, "an empty page ends the loop");
assert.equal(hasMorePages({}, 10), false, "no pagination block means a single page");
assert.equal(hasMorePages(null, 10), false);
});
const current = { page: 2, limit: 50, total: 120, totalPages: 3 };
test("nextPageState moves to the requested page once the fetch succeeded", () => {
assert.deepEqual(nextPageState(current, { page: 3, total: 120 }), { page: 3, limit: 50, total: 120, totalPages: 3 });
});
test("nextPageState clamps a page beyond the end when the total shrank", () => {
assert.deepEqual(nextPageState(current, { page: 3, total: 60 }), { page: 2, limit: 50, total: 60, totalPages: 2 });
});
test("nextPageState never goes below page 1 and has at least one page", () => {
assert.deepEqual(nextPageState(current, { page: 0, total: 0 }), { page: 1, limit: 50, total: 0, totalPages: 1 });
assert.equal(nextPageState(current, { page: -5, total: 10 }).page, 1);
});
test("nextPageState keeps the current page and total when the response omits them", () => {
assert.deepEqual(nextPageState(current, {}), current);
assert.deepEqual(nextPageState(current, { total: "many", page: "x" }), current);
});
test("nextPageState accepts a new limit and recomputes totalPages", () => {
assert.deepEqual(nextPageState(current, { page: 1, limit: 25, total: 120 }), { page: 1, limit: 25, total: 120, totalPages: 5 });
assert.equal(nextPageState(current, { limit: 0 }).limit, 50, "a zero limit is ignored");
});
test("nextPageState does not mutate its input", () => {
const before = { ...current };
nextPageState(current, { page: 1, total: 5 });
assert.deepEqual(current, before);
});
test("shouldSaveEdit is true only for a non-empty text that changed", () => {
assert.equal(shouldSaveEdit("old", "new"), true);
assert.equal(shouldSaveEdit("same", "same"), false);
assert.equal(shouldSaveEdit("same", " same \n"), false, "whitespace-only changes are not saved");
assert.equal(shouldSaveEdit("old", " "), false, "an emptied prompt is not saved");
assert.equal(shouldSaveEdit("old", null), false);
});
+118
View File
@@ -0,0 +1,118 @@
// Run with: node --test tests/js/
const test = require("node:test");
const assert = require("node:assert/strict");
const { parseSseChunk, connect } = require("../../web/js/sse-stream.js");
test("parseSseChunk splits complete events and keeps the unfinished tail", () => {
const { events, rest } = parseSseChunk('data: {"a":1}\n\ndata: {"b":2}\n\ndata: {"c"');
assert.deepEqual(events.map((e) => e.data), ['{"a":1}', '{"b":2}']);
assert.equal(rest, 'data: {"c"');
});
test("parseSseChunk joins multi-line data, reads event names and ignores comments", () => {
const { events, rest } = parseSseChunk(": keep-alive\nevent: progress\ndata: line1\ndata: line2\nid: 7\n\n");
assert.deepEqual(events, [{ event: "progress", data: "line1\nline2" }]);
assert.equal(rest, "");
});
test("parseSseChunk accepts CRLF line endings and a missing space after the colon", () => {
const { events } = parseSseChunk("data:x\r\n\r\ndata: y\r\n\r\n");
assert.deepEqual(events.map((e) => e.data), ["x", "y"]);
});
test("parseSseChunk returns nothing for an empty or whitespace buffer", () => {
assert.deepEqual(parseSseChunk(""), { events: [], rest: "" });
assert.deepEqual(parseSseChunk("\n\n"), { events: [], rest: "" });
});
function streamOf(chunks) {
const encoder = new TextEncoder();
return new ReadableStream({
start(controller) {
for (const c of chunks) controller.enqueue(encoder.encode(c));
controller.close();
},
});
}
function until(check, timeoutMs = 500) {
return new Promise((resolve, reject) => {
const started = Date.now();
(function poll() {
if (check()) return resolve();
if (Date.now() - started > timeoutMs) return reject(new Error("timed out"));
setTimeout(poll, 2);
})();
});
}
test("connect POSTs and delivers each event to onmessage, then onclose", async () => {
const seen = [];
let closed = false;
const calls = [];
const fetchImpl = async (url, init) => {
calls.push({ url, init });
return new Response(streamOf(['data: {"n":1}\n\ndata: {"n', '":2}\n\n']), { status: 200 });
};
const source = connect("/prompt_manager/autotag/start?x=1", { method: "POST", fetchImpl });
source.onmessage = (e) => seen.push(JSON.parse(e.data).n);
source.onclose = () => { closed = true; };
await until(() => closed);
assert.deepEqual(seen, [1, 2]);
assert.equal(calls[0].init.method, "POST");
assert.equal(calls[0].url, "/prompt_manager/autotag/start?x=1");
assert.equal(source.readyState, source.CLOSED);
});
test("connect reports a non-2xx response through onerror and never onmessage", async () => {
let error = null;
const fetchImpl = async () => new Response("nope", { status: 405 });
const source = connect("/x", { fetchImpl });
source.onmessage = () => assert.fail("no message expected");
source.onerror = (err) => { error = err; };
await until(() => error !== null);
assert.match(error.message, /405/);
assert.equal(source.readyState, source.CLOSED);
});
test("connect reports a network failure through onerror", async () => {
let error = null;
const source = connect("/x", { fetchImpl: async () => { throw new TypeError("Failed to fetch"); } });
source.onerror = (err) => { error = err; };
await until(() => error !== null);
assert.equal(error.message, "Failed to fetch");
});
test("close() aborts the request and suppresses later callbacks", async () => {
let aborted = false;
let errors = 0;
const fetchImpl = (url, init) =>
new Promise((resolve, reject) => {
init.signal.addEventListener("abort", () => {
aborted = true;
reject(new DOMException("aborted", "AbortError"));
});
resolve(new Response(new ReadableStream({ start() {} }), { status: 200 }));
});
const source = connect("/x", { fetchImpl });
source.onerror = () => errors++;
await until(() => source.readyState === source.OPEN);
source.close();
assert.equal(aborted, true);
assert.equal(source.readyState, source.CLOSED);
await new Promise((r) => setTimeout(r, 10));
assert.equal(errors, 0);
});
test("connect defaults to GET and the global fetch", async () => {
const original = globalThis.fetch;
let init = null;
globalThis.fetch = async (_url, i) => { init = i; return new Response(streamOf([]), { status: 200 }); };
try {
const source = connect("/x");
await until(() => source.readyState === source.CLOSED);
assert.equal(init.method, "GET");
} finally {
globalThis.fetch = original;
}
});
+8
View File
@@ -6,6 +6,9 @@
<title>PromptManager - Admin Dashboard</title>
<script src="/prompt_manager/js/theme-bridge.js"></script>
<script src="/prompt_manager/js/html-escape.js"></script>
<script src="/prompt_manager/js/api-client.js"></script>
<script src="/prompt_manager/js/list-state.js"></script>
<script src="/prompt_manager/js/sse-stream.js"></script>
<script src="/prompt_manager/js/data-actions.js"></script>
<link rel="stylesheet" href="lib/tailwind/comfyui-theme.css">
<link rel="stylesheet" href="lib/tailwind/styles.css">
@@ -444,6 +447,11 @@
<p class="text-xs text-pm-muted mt-1">Get your key from <a href="https://civitai.com/user/account" target="_blank" class="text-pm-accent hover:underline">civitai.com/user/account</a></p>
</div>
<label class="mt-1 flex items-center gap-1.5 text-xs text-pm-secondary cursor-pointer">
<input type="checkbox" id="clearCivitaiKey" class="w-3.5 h-3.5 text-pm-accent bg-pm-input border-pm rounded focus:ring-pm-accent">
<span>Clear the stored key on save</span>
</label>
<button id="loraImportBtn" type="button"
class="mt-1 flex items-center gap-1 text-xs text-pm-accent hover:text-pm-accent-hover">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+3
View File
@@ -6,6 +6,9 @@
<title>PromptManager - Gallery</title>
<script src="/prompt_manager/js/theme-bridge.js"></script>
<script src="/prompt_manager/js/html-escape.js"></script>
<script src="/prompt_manager/js/api-client.js"></script>
<script src="/prompt_manager/js/list-state.js"></script>
<script src="/prompt_manager/js/sse-stream.js"></script>
<script src="/prompt_manager/js/data-actions.js"></script>
<link rel="stylesheet" href="lib/tailwind/comfyui-theme.css">
<link rel="stylesheet" href="lib/tailwind/styles.css">
+120 -113
View File
@@ -21,6 +21,12 @@
this.tagsPage = null;
this.api = ApiClient.createApiClient();
// Search and page loads share one guard: whichever list request is newest wins
this.listRequest = ApiClient.latestOnly();
// The prompt currently edited in place, if any: { promptId, element, originalText }
this.activeEdit = null;
// Elements rendered with data-action="..." (prompt cards, film strips, log files,
// review chips) are dispatched here instead of through inline onclick handlers,
// so user-provided strings never land inside JavaScript source.
@@ -130,6 +136,12 @@
document.addEventListener("click", (e) => this.handleActionClick(e));
document.addEventListener("error", (e) => this.applyImageFallback(e.target), true);
// In-place prompt editing: one keydown/focusout pair for the whole list, so
// listeners are not stacked per edit and a save is sent exactly once
const results = document.getElementById("resultsList");
results.addEventListener("keydown", (e) => this.handleEditKeydown(e));
results.addEventListener("focusout", (e) => this.handleEditFocusOut(e));
// Auto-search on filter changes
["searchCategory", "searchFolder"].forEach((id) => {
document.getElementById(id).addEventListener("change", () => this.search());
@@ -378,37 +390,41 @@
select.value = current;
}
/**
* Load one page of recent prompts. The page is committed to this.pagination only
* after the server answered; a newer list request supersedes one still in flight.
*/
async loadRecentPrompts(page = 1) {
this.listMode = "recent";
const url = PromptListSort.buildRecentUrl({
page,
limit: this.pagination.limit,
sort: this.currentSort(),
});
try {
this.listMode = "recent";
this.pagination.currentPage = page;
const response = await fetch(PromptListSort.buildRecentUrl({
page,
limit: this.pagination.limit,
sort: this.currentSort(),
}));
if (response.ok) {
const data = await response.json();
if (data.success) {
this.prompts = data.results;
this.pagination = {
...this.pagination,
total: data.pagination.total,
totalPages: data.pagination.total_pages,
currentPage: data.pagination.page
};
this.renderPrompts();
this.updatePaginationControls();
// Don't update stats from local data as it's only a subset of prompts
}
}
const data = await this.listRequest((signal) => this.api.get(url, { signal }));
if (data === ApiClient.STALE) return; // a newer list request owns the loading state
const next = ListState.nextPageState(
{ page: this.pagination.currentPage, limit: this.pagination.limit, total: this.pagination.total },
{ page: data.pagination.page, total: data.pagination.total },
);
this.prompts = data.results;
this.pagination = { ...this.pagination, currentPage: next.page, total: next.total, totalPages: next.totalPages };
this.renderPrompts();
this.updatePaginationControls();
document.getElementById("resultsTitle").textContent = "Recent Prompts";
} catch (error) {
console.error("Recent prompts error:", error);
} finally {
document.getElementById("loadingState").classList.add("hidden");
document.getElementById("resultsList").classList.remove("hidden");
document.getElementById("paginationControls").classList.remove("hidden");
this.showNotification("Failed to load prompts", "error");
}
this.showListLoaded();
document.getElementById("paginationControls").classList.remove("hidden");
}
showListLoaded() {
document.getElementById("loadingState").classList.add("hidden");
document.getElementById("resultsList").classList.remove("hidden");
}
updateLocalStats() {
@@ -454,33 +470,34 @@
params.append("limit", "100");
this.listMode = "search";
const response = await fetch(`/prompt_manager/search?${PromptListSort.withSort(params, this.currentSort())}`);
if (response.ok) {
const data = await response.json();
if (data.success) {
this.prompts = data.results;
this.renderPrompts();
document.getElementById("resultsTitle").textContent = "Search Results";
}
}
const url = `/prompt_manager/search?${PromptListSort.withSort(params, this.currentSort())}`;
const data = await this.listRequest((signal) => this.api.get(url, { signal }));
if (data === ApiClient.STALE) return; // a newer list request owns the loading state
this.prompts = data.results;
this.renderPrompts();
document.getElementById("resultsTitle").textContent = "Search Results";
} catch (error) {
this.showNotification("Search failed", "error");
console.error("Search error:", error);
} finally {
document.getElementById("loadingState").classList.add("hidden");
document.getElementById("resultsList").classList.remove("hidden");
}
this.showListLoaded();
}
currentSort() {
return PromptListSort.normalizeSort(document.getElementById("sortBy")?.value);
}
/** Re-fetch the list currently shown, e.g. after the sort changes. */
/** Re-fetch the list currently shown from its first page, e.g. after the sort changes. */
reloadPrompts() {
return this.listMode === "search" ? this.search() : this.loadRecentPrompts(1);
}
/** Re-fetch the page currently shown, e.g. after a prompt was edited, tagged or deleted. */
refreshList() {
return this.listMode === "search" ? this.search() : this.loadRecentPrompts(this.pagination.currentPage);
}
handleActionClick(e) {
const target = e.target.closest("[data-action]");
if (target) DataActions.dispatch(target.dataset, this.actionHandlers, target);
@@ -1145,7 +1162,7 @@
this.hideModal("individualTagModal");
// Refresh both stats and results
await this.loadStatistics();
this.search();
this.refreshList();
}
} catch (error) {
this.showNotification("Failed to add tags", "error");
@@ -1164,82 +1181,79 @@
this.showNotification("Tag removed", "success");
// Refresh both stats and results
await this.loadStatistics();
this.search();
this.refreshList();
}
} catch (error) {
this.showNotification("Failed to remove tag", "error");
}
}
async editPrompt(promptId) {
editPrompt(promptId) {
const promptElement = document.querySelector(`[data-id="${promptId}"].prompt-text`);
if (!promptElement) return;
if (this.activeEdit) this.finishEdit(false);
const originalText = promptElement.textContent;
this.activeEdit = { promptId, element: promptElement, originalText: promptElement.textContent };
promptElement.contentEditable = true;
promptElement.focus();
promptElement.classList.add("bg-pm-surface", "border", "border-pm-accent", "rounded-pm-sm", "p-3");
}
const saveEdit = async () => {
const newText = promptElement.textContent.trim();
if (newText !== originalText && newText) {
try {
const response = await fetch(`/prompt_manager/prompts/${promptId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: newText }),
});
handleEditKeydown(e) {
if (!this.activeEdit || e.target !== this.activeEdit.element) return;
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
this.finishEdit(true);
} else if (e.key === "Escape") {
this.finishEdit(false);
}
}
if (response.ok) {
this.showNotification("Prompt updated", "success");
// Refresh the page to show updated content
setTimeout(() => {
this.loadStatistics();
setTimeout(() => window.location.reload(), 500);
}, 1000);
} else {
throw new Error("Update failed");
}
} catch (error) {
this.showNotification("Failed to update prompt", "error");
promptElement.textContent = originalText;
}
}
promptElement.contentEditable = false;
promptElement.classList.remove("bg-pm-surface", "border", "border-pm-accent", "rounded-pm-sm", "p-3");
};
handleEditFocusOut(e) {
if (this.activeEdit && e.target === this.activeEdit.element) this.finishEdit(true);
}
promptElement.addEventListener("blur", saveEdit, { once: true });
promptElement.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
saveEdit();
}
if (e.key === "Escape") {
promptElement.textContent = originalText;
promptElement.contentEditable = false;
promptElement.classList.remove("bg-pm-surface", "border", "border-pm-accent", "rounded-pm-sm", "p-3");
}
});
/**
* End the in-place edit exactly once. The edit state is cleared before the element
* changes, so the focusout caused by contentEditable=false cannot save a second time.
*/
async finishEdit(save) {
const edit = this.activeEdit;
if (!edit) return;
this.activeEdit = null;
const { element, originalText, promptId } = edit;
const newText = element.textContent.trim();
element.contentEditable = false;
element.classList.remove("bg-pm-surface", "border", "border-pm-accent", "rounded-pm-sm", "p-3");
if (!save || !ListState.shouldSaveEdit(originalText, newText)) {
element.textContent = originalText;
return;
}
try {
await this.api.put(`/prompt_manager/prompts/${promptId}`, { text: newText });
this.showNotification("Prompt updated", "success");
await this.loadStatistics();
this.refreshList();
} catch (error) {
console.error("Update failed:", error);
this.showNotification("Failed to update prompt", "error");
element.textContent = originalText;
}
}
async deletePrompt(promptId) {
if (!confirm("Are you sure you want to delete this prompt?")) return;
try {
const response = await fetch(`/prompt_manager/delete/${promptId}`, {
method: "DELETE",
});
if (response.ok) {
this.showNotification("Prompt deleted", "success");
// Refresh stats and reload page
setTimeout(() => {
this.loadStatistics();
setTimeout(() => window.location.reload(), 500);
}, 1000);
}
await this.api.del(`/prompt_manager/delete/${promptId}`);
this.showNotification("Prompt deleted", "success");
await this.loadStatistics();
this.refreshList();
} catch (error) {
console.error("Delete failed:", error);
this.showNotification("Failed to delete prompt", "error");
}
}
@@ -1273,7 +1287,7 @@
this.hideModal("bulkTagModal");
// Refresh both stats and results
await this.loadStatistics();
this.search();
this.refreshList();
}
} catch (error) {
this.showNotification("Failed to add tags", "error");
@@ -1299,7 +1313,7 @@
this.hideModal("bulkCategoryModal");
// Refresh both stats and results
await this.loadStatistics();
this.search();
this.refreshList();
}
} catch (error) {
this.showNotification("Failed to set category", "error");
@@ -1320,11 +1334,9 @@
if (response.ok) {
this.showNotification(`${this.selectedPrompts.size} prompts deleted`, "success");
// Refresh stats and reload page
setTimeout(() => {
this.loadStatistics();
setTimeout(() => window.location.reload(), 500);
}, 1000);
this.selectedPrompts.clear();
await this.loadStatistics();
this.refreshList();
}
} catch (error) {
this.showNotification("Failed to delete prompts", "error");
@@ -1832,7 +1844,7 @@
if (data.success) {
this.showNotification('✅ Test image link created successfully!', 'success');
// Refresh the gallery to show the test image
setTimeout(() => this.search(), 1000);
setTimeout(() => this.refreshList(), 1000);
} else {
throw new Error(data.result?.message || 'Test failed');
}
@@ -1939,10 +1951,8 @@
);
this.hideModal('restoreModal');
// Refresh the interface to show new data
setTimeout(() => {
window.location.reload();
}, 2000);
// Refresh the interface to show the restored data
await this.loadInitialData();
} else {
throw new Error(data.error || 'Restore failed');
}
@@ -2157,10 +2167,7 @@
// Refresh the statistics immediately
this.loadStatistics();
// Also reload the page after a short delay to ensure everything is fully updated
setTimeout(() => {
window.location.reload();
}, 1000);
this.refreshList();
}, 2000);
}
@@ -3102,7 +3109,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
this.showNotification('Prompt added successfully!', 'success');
this.hideModal('addPromptModal');
// Refresh the prompts list
this.search();
this.refreshList();
// Reload categories and tags in case new ones were added
this.loadCategories();
this.loadTags();
@@ -3498,7 +3505,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
this.autoTagState.eventSource.close();
this.hideModal("autoTagProgressModal");
this.showNotification(`Auto tagging complete! Applied tags to ${data.tagged || 0} prompts.`, 'success');
this.search(); // Refresh the prompt list
this.refreshList(); // Refresh the prompt list
} else if (data.type === 'error') {
this.autoTagState.eventSource.close();
this.hideModal("autoTagProgressModal");
@@ -3635,7 +3642,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
if (this.autoTagState.reviewIndex >= this.autoTagState.reviewImages.length) {
this.hideModal("autoTagReviewModal");
this.showNotification('Review complete!', 'success');
this.search();
this.refreshList();
return;
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Small JSON API client for the admin and gallery pages.
*
* Every method resolves with the parsed body of a successful response and
* throws ApiError(status, message) for a non-2xx status, a `success: false`
* body or a body that is not JSON, so callers have one failure path.
*
* latestOnly() guards list requests against out-of-order responses: each new
* call aborts the previous in-flight request and a superseded call resolves
* to STALE instead of its (outdated) result.
*
* Loadable in the browser (window.ApiClient) and in Node for tests.
*/
(function (root) {
"use strict";
class ApiError extends Error {
constructor(status, message) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
/** Result of a latestOnly() call that was superseded by a newer one. */
const STALE = Symbol("stale response");
/**
* @param {typeof fetch} [fetchImpl] - defaults to the global fetch (bound to globalThis)
*/
function createApiClient(fetchImpl) {
const doFetch = fetchImpl || ((...args) => globalThis.fetch(...args));
async function request(method, url, { body, signal } = {}) {
const init = { method, headers: {} };
if (body !== undefined) {
init.headers["Content-Type"] = "application/json";
init.body = JSON.stringify(body);
}
if (signal) init.signal = signal;
const response = await doFetch(url, init);
let data = null;
let parsed = true;
try {
data = await response.json();
} catch (_) {
parsed = false;
}
const serverMessage = data && typeof data === "object" ? data.error || data.message : undefined;
if (!response.ok) {
throw new ApiError(response.status, serverMessage || response.statusText || `HTTP ${response.status}`);
}
if (!parsed) throw new ApiError(response.status, "Invalid JSON response");
if (data && typeof data === "object" && data.success === false) {
throw new ApiError(response.status, serverMessage || "Request failed");
}
return data;
}
return {
get: (url, { signal } = {}) => request("GET", url, { signal }),
post: (url, body, { signal } = {}) => request("POST", url, { body, signal }),
put: (url, body, { signal } = {}) => request("PUT", url, { body, signal }),
del: (url, { signal } = {}) => request("DELETE", url, { signal }),
};
}
/**
* @returns {(run: (signal: AbortSignal) => Promise<*>) => Promise<*>} wrapper that
* aborts the previous call's signal and resolves superseded calls to STALE
*/
function latestOnly() {
let current = null;
return async function (run) {
if (current) current.abort();
const controller = new AbortController();
current = controller;
try {
const result = await run(controller.signal);
return controller === current ? result : STALE;
} catch (error) {
if (controller !== current) return STALE;
throw error;
}
};
}
const api = { createApiClient, latestOnly, ApiError, STALE };
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.ApiClient = api;
})(typeof window !== "undefined" ? window : globalThis);
+59 -57
View File
@@ -10,6 +10,10 @@
this.expandedFolders = new Set();
this.thumbSize = 'md'; // sm, md, lg
this.api = ApiClient.createApiClient();
// Only the newest page request may update the grid
this.imagesRequest = ApiClient.latestOnly();
// Elements rendered with data-action="..." are dispatched here instead of through
// inline onclick handlers, so file names and URLs never land inside JavaScript source.
this.actionHandlers = {
@@ -141,9 +145,8 @@
// Folder tree events
document.getElementById('clearFolderFilterBtn').addEventListener('click', () => {
this.currentSubfolder = '';
this.currentPage = 1;
this.renderFolderTree();
this.loadImages();
this.loadImages(1);
});
// Resize handle for folder panel
@@ -244,9 +247,8 @@
// Click to filter
row.addEventListener('click', () => {
this.currentSubfolder = fullPath;
this.currentPage = 1;
this.renderFolderTree();
this.loadImages();
this.loadImages(1);
});
container.appendChild(row);
@@ -265,9 +267,8 @@
allRow.innerHTML = '<span class="inline-block w-4 mr-1"></span><span>All Images</span>';
allRow.addEventListener('click', () => {
this.currentSubfolder = '';
this.currentPage = 1;
this.renderFolderTree();
this.loadImages();
this.loadImages(1);
});
container.appendChild(allRow);
@@ -298,34 +299,34 @@
});
}
async loadImages() {
/**
* Load one page of images. `page` is committed to this.currentPage only after the
* server answered, and a newer request supersedes an older one still in flight.
*/
async loadImages(page = this.currentPage) {
this.showLoading();
const offset = (page - 1) * this.limit;
let url = `/prompt_manager/images/output?limit=${this.limit}&offset=${offset}`;
if (this.currentSubfolder) {
url += `&subfolder=${encodeURIComponent(this.currentSubfolder)}`;
}
try {
const offset = (this.currentPage - 1) * this.limit;
let url = `/prompt_manager/images/output?limit=${this.limit}&offset=${offset}`;
if (this.currentSubfolder) {
url += `&subfolder=${encodeURIComponent(this.currentSubfolder)}`;
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
this.images = data.images;
this.total = data.total;
this.updateStats();
this.renderGallery();
this.updatePagination();
document.getElementById('loadingStatus').textContent = 'Loaded';
} else {
throw new Error(data.error || 'Failed to load images');
}
const data = await this.imagesRequest((signal) => this.api.get(url, { signal }));
if (data === ApiClient.STALE) return;
const next = ListState.nextPageState(
{ page: this.currentPage, limit: this.limit, total: this.total },
{ page, total: data.total },
);
this.currentPage = next.page;
this.total = next.total;
this.images = data.images;
this.updateStats();
this.renderGallery();
this.updatePagination();
document.getElementById('loadingStatus').textContent = 'Loaded';
} catch (error) {
console.error('Error loading images:', error);
this.showError(error.message);
@@ -766,14 +767,18 @@
}
});
// Close on Escape key
// Close on Escape key; the listener is dropped once the modal leaves the
// document, whichever way it was closed
const escapeHandler = (e) => {
if (e.key === 'Escape') {
modal.remove();
document.removeEventListener('keydown', escapeHandler);
}
if (e.key === 'Escape') modal.remove();
};
document.addEventListener('keydown', escapeHandler);
const closeObserver = new MutationObserver(() => {
if (modal.isConnected) return;
document.removeEventListener('keydown', escapeHandler);
closeObserver.disconnect();
});
closeObserver.observe(document.body, { childList: true });
document.body.appendChild(modal);
@@ -870,14 +875,18 @@
}
});
// Close on Escape key
// Close on Escape key; the listener is dropped once the modal leaves the
// document, whichever way it was closed
const escapeHandler = (e) => {
if (e.key === 'Escape') {
modal.remove();
document.removeEventListener('keydown', escapeHandler);
}
if (e.key === 'Escape') modal.remove();
};
document.addEventListener('keydown', escapeHandler);
const closeObserver = new MutationObserver(() => {
if (modal.isConnected) return;
document.removeEventListener('keydown', escapeHandler);
closeObserver.disconnect();
});
closeObserver.observe(document.body, { childList: true });
document.body.appendChild(modal);
@@ -1358,10 +1367,13 @@
};
document.addEventListener('keydown', keyHandler);
// Clean up on modal close
modal.addEventListener('remove', () => {
// 'remove' is not a DOM event; watch for the modal leaving the document instead
const navObserver = new MutationObserver(() => {
if (modal.isConnected) return;
document.removeEventListener('keydown', keyHandler);
navObserver.disconnect();
});
navObserver.observe(document.body, { childList: true });
// Initial button state
updateImage(this.currentImageIndex);
@@ -2306,8 +2318,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
changeLimit(newLimit) {
this.limit = newLimit;
this.currentPage = 1;
this.loadImages();
this.loadImages(1);
}
updatePagination() {
@@ -2331,18 +2342,12 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
}
previousPage() {
if (this.currentPage > 1) {
this.currentPage--;
this.loadImages();
}
if (this.currentPage > 1) this.loadImages(this.currentPage - 1);
}
nextPage() {
const totalPages = Math.ceil(this.total / this.limit);
if (this.currentPage < totalPages) {
this.currentPage++;
this.loadImages();
}
if (this.currentPage < totalPages) this.loadImages(this.currentPage + 1);
}
goToPage(page) {
@@ -2353,10 +2358,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
return;
}
if (page !== this.currentPage) {
this.currentPage = page;
this.loadImages();
}
if (page !== this.currentPage) this.loadImages(page);
}
async checkThumbnailsAtStartup() {
+50
View File
@@ -0,0 +1,50 @@
/**
* Pure state helpers for the paginated prompt and image lists.
*
* Loadable in the browser (window.ListState) and in Node for tests.
*/
(function (root) {
"use strict";
function nonNegativeInt(value) {
return Number.isInteger(value) && value >= 0 ? value : null;
}
/**
* Pagination state after a page fetch succeeded. Callers keep their current
* state untouched while the request is in flight and only adopt the result.
*
* @param {{page: number, limit: number, total: number}} current
* @param {{page?: number, limit?: number, total?: number}} response - the page
* that was requested and the total the server reported
* @returns {{page: number, limit: number, total: number, totalPages: number}}
*/
function nextPageState(current, response) {
const limit = Number.isInteger(response.limit) && response.limit > 0 ? response.limit : current.limit;
const total = nonNegativeInt(response.total) ?? current.total;
const totalPages = Math.max(1, Math.ceil(total / limit));
const requested = Number.isInteger(response.page) ? response.page : current.page;
const page = Math.min(Math.max(1, requested), totalPages);
return { page, limit, total, totalPages };
}
/** Whether an in-place prompt edit changed the text to something worth saving. */
function shouldSaveEdit(originalText, newText) {
if (typeof newText !== "string") return false;
const trimmed = newText.trim();
return trimmed !== "" && trimmed !== String(originalText ?? "").trim();
}
/**
* Whether another page should be fetched from a capped list endpoint such as
* /prompt_manager/images/all: the server said has_more and the last page was not empty.
*/
function hasMorePages(response, receivedCount) {
const pagination = response && response.pagination;
return Boolean(pagination && pagination.has_more) && receivedCount > 0;
}
const api = { nextPageState, shouldSaveEdit, hasMorePages };
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.ListState = api;
})(typeof window !== "undefined" ? window : globalThis);
+117
View File
@@ -0,0 +1,117 @@
/**
* Server-sent events over fetch.
*
* EventSource can only GET. The auto-tag download/start endpoints are POST
* (they have side effects), so their progress streams are read with fetch and
* a small parser. `connect()` returns an object with the EventSource surface
* the pages already use: onmessage, onerror, onclose, readyState, close().
*
* Loadable in the browser (window.SseStream) and in Node for tests.
*/
(function (root) {
"use strict";
const CONNECTING = 0;
const OPEN = 1;
const CLOSED = 2;
/**
* Parse complete events out of a buffer of SSE text.
* @param {string} buffer
* @returns {{events: Array<{data: string, event?: string}>, rest: string}} parsed
* events and the trailing, still incomplete block
*/
function parseSseChunk(buffer) {
const normalized = buffer.replace(/\r\n/g, "\n");
const blocks = normalized.split("\n\n");
const rest = blocks.pop();
const events = [];
for (const block of blocks) {
const event = parseBlock(block);
if (event) events.push(event);
}
return { events, rest };
}
function parseBlock(block) {
const data = [];
let name;
for (const line of block.split("\n")) {
if (line === "" || line.startsWith(":")) continue;
const colon = line.indexOf(":");
const field = colon === -1 ? line : line.slice(0, colon);
let value = colon === -1 ? "" : line.slice(colon + 1);
if (value.startsWith(" ")) value = value.slice(1);
if (field === "data") data.push(value);
else if (field === "event") name = value;
}
if (data.length === 0) return null;
const event = { data: data.join("\n") };
if (name !== undefined) event.event = name;
return event;
}
/**
* @param {string} url
* @param {{method?: string, body?: string|FormData, fetchImpl?: typeof fetch}} [options]
* @returns {{onmessage: Function|null, onerror: Function|null, onclose: Function|null,
* readyState: number, close: Function, CONNECTING: number, OPEN: number, CLOSED: number}}
*/
function connect(url, { method = "GET", body, fetchImpl } = {}) {
const doFetch = fetchImpl || ((...args) => globalThis.fetch(...args));
const controller = new AbortController();
const source = {
onmessage: null,
onerror: null,
onclose: null,
readyState: CONNECTING,
CONNECTING,
OPEN,
CLOSED,
close() {
if (source.readyState === CLOSED) return;
source.readyState = CLOSED;
controller.abort();
},
};
(async () => {
try {
const init = { method, headers: { Accept: "text/event-stream" }, signal: controller.signal };
if (body !== undefined) init.body = body;
const response = await doFetch(url, init);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
if (source.readyState === CLOSED) return;
source.readyState = OPEN;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const { value, done } = await reader.read();
if (source.readyState === CLOSED) return;
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
const { events, rest } = parseSseChunk(buffer);
buffer = rest;
for (const event of events) {
if (source.readyState === CLOSED) return;
if (source.onmessage) source.onmessage(event);
}
if (done) break;
}
source.readyState = CLOSED;
if (source.onclose) source.onclose();
} catch (error) {
if (source.readyState === CLOSED) return; // closed by the caller
source.readyState = CLOSED;
if (source.onerror) source.onerror(error);
}
})();
return source;
}
const api = { parseSseChunk, connect, CONNECTING, OPEN, CLOSED };
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.SseStream = api;
})(typeof window !== "undefined" ? window : globalThis);