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:
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user