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:
@@ -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