feat: Implement infinite scroll for history view

This commit is contained in:
Simon Cordt
2025-09-24 20:55:02 +02:00
parent ac70a16cb3
commit da7ce9c3bd
2 changed files with 113 additions and 0 deletions
+1
View File
@@ -108,6 +108,7 @@
.pqueue-history-card__duration{font-size:.75rem;color:var(--muted-text,#a1a9b8)}
.pqueue-thumb-row{display:flex;flex-wrap:wrap;gap:.5rem}
.pqueue-thumb-wrap{position:relative;width:100px;height:100px;border-radius:8px;overflow:hidden;background:rgba(24,24,27,.6);border:1px solid rgba(82,82,91,.24);cursor:pointer}
.pqueue-history-sentinel{display:flex;align-items:center;justify-content:center;min-height:48px;opacity:.7}
.pqueue-thumb{width:100%;height:100%;object-fit:cover;display:block}
.pqueue-thumb-badge{position:absolute;right:6px;bottom:6px;padding:.15rem .5rem;border-radius:.6rem;background:rgba(15,23,42,.75);color:#fff;font-size:.68rem}
.pqueue-thumb-placeholder{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:var(--subtle-text,#94a3b8);height:100%;padding:.5rem;font-size:.75rem;gap:.35rem}
+112
View File
@@ -7,6 +7,14 @@
const API = {
getQueue: () => fetch("/api/pqueue").then((r) => r.json()),
getHistory: (limit = 50) => fetch(`/api/pqueue/history?limit=${limit}`).then((r) => r.json()),
getHistoryPaginated: (params = {}) => {
const url = new URL("/api/pqueue/history", window.location.origin);
Object.entries(params).forEach(([k, v]) => {
if (v === undefined || v === null || v === "") return;
url.searchParams.set(k, String(v));
});
return fetch(url.href).then((r) => r.json());
},
pause: () => fetch("/api/pqueue/pause", { method: "POST" }),
resume: () => fetch("/api/pqueue/resume", { method: "POST" }),
reorder: (order) =>
@@ -135,6 +143,13 @@
filters: {
pending: "",
},
historyIds: new Set(),
historyPaging: {
isLoading: false,
hasMore: true,
nextCursor: null,
params: { sort_by: "id", sort_dir: "desc", limit: 60 },
},
metrics: {
runningCount: 0,
queueCount: 0,
@@ -687,6 +702,9 @@
} else {
state.history.forEach((row) => grid.appendChild(UI.historyCard(row)));
}
const sentinel = UI.historySentinel();
state.dom.historySentinel = sentinel;
grid.appendChild(sentinel);
return UI.card("Recent history", {
icon: "ti ti-clock-bolt",
subtitle: state.metrics.historyCount ? `${state.metrics.historyCount} entries` : null,
@@ -694,6 +712,13 @@
});
},
historySentinel() {
const wrap = UI.el("div", { class: "pqueue-history-sentinel" });
const spinner = UI.icon("ti ti-loader-2", { size: "md", spin: true });
wrap.appendChild(spinner);
return wrap;
},
historyCard(row) {
const card = UI.el("article", { class: "pqueue-history-card", title: Format.tooltip(row) });
const header = UI.el("div", { class: "pqueue-history-card__header" }, [UI.statusBadge(row.status || "success"), UI.el("span", { class: "pqueue-history-card__time", text: row.completed_at ? Format.relative(row.completed_at) : Format.relative(row.created_at) })]);
@@ -799,6 +824,38 @@
});
},
ensureHistoryObserver() {
try {
if (!('IntersectionObserver' in window)) return;
const grid = state.dom.historyGrid;
const sentinel = state.dom.historySentinel;
if (!grid || !sentinel) return;
if (state.historyObserver) {
state.historyObserver.disconnect();
state.historyObserver = null;
}
// Initialize cursor from currently loaded list if not set
if (!state.historyPaging?.nextCursor && state.history?.length) {
const last = state.history[state.history.length - 1];
state.historyPaging.nextCursor = { id: last?.id, value: last?.id };
state.historyPaging.hasMore = true;
}
const obs = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
Events.loadMoreHistory();
});
}, { root: null, rootMargin: '600px 0px', threshold: 0 });
obs.observe(sentinel);
state.historyObserver = obs;
} catch (err) {
/* noop */
}
},
refreshFilter() {
const table = state.dom.pendingTable;
if (!table) return;
@@ -1167,6 +1224,7 @@
table.addEventListener("click", Events.handleTableClick);
table.addEventListener("keydown", Events.handleTableKey);
}
UI.ensureHistoryObserver();
},
async togglePause() {
@@ -1202,6 +1260,10 @@
manualRefresh() {
if (state.isRefreshing) return;
// Reset history paging on manual refresh
state.history = [];
state.historyIds = new Set();
state.historyPaging = { isLoading: false, hasMore: true, nextCursor: null, params: { sort_by: "id", sort_dir: "desc", limit: 60 } };
refresh();
},
@@ -1342,6 +1404,43 @@
}
},
async loadMoreHistory() {
try {
const paging = state.historyPaging;
if (!paging || paging.isLoading || !paging.hasMore) return;
paging.isLoading = true;
const params = { ...paging.params };
if (paging.nextCursor?.id != null) params.cursor_id = paging.nextCursor.id;
if (paging.nextCursor?.value != null) params.cursor_value = paging.nextCursor.value;
const result = await API.getHistoryPaginated(params);
const list = Array.isArray(result?.history) ? result.history : [];
const grid = state.dom.historyGrid;
const sentinel = state.dom.historySentinel;
if (grid && sentinel && list.length) {
const frag = document.createDocumentFragment();
for (const row of list) {
const id = row?.id;
if (id == null || state.historyIds.has(id)) continue;
state.historyIds.add(id);
frag.appendChild(UI.historyCard(row));
}
grid.insertBefore(frag, sentinel);
}
if (list.length) state.history.push(...list);
paging.nextCursor = result?.next_cursor || null;
paging.hasMore = !!result?.has_more;
// Hide sentinel when no more
if (!paging.hasMore && sentinel) sentinel.style.display = "none";
} catch (err) {
/* ignore load errors to avoid UI jank */
} finally {
if (state.historyPaging) state.historyPaging.isLoading = false;
}
},
async deleteSelected() {
if (!state.selectedPending.size) return;
if (!window.confirm(`Delete ${state.selectedPending.size} selected prompt${state.selectedPending.size === 1 ? "" : "s"}?`)) return;
@@ -1413,6 +1512,19 @@
});
state.lastUpdated = new Date().toISOString();
// Seed history paging cursors and ids
try {
state.historyIds = new Set();
state.history.forEach((row) => {
if (row && row.id != null) state.historyIds.add(row.id);
});
const last = state.history[state.history.length - 1];
state.historyPaging = state.historyPaging || { isLoading: false, hasMore: true, nextCursor: null, params: { sort_by: "id", sort_dir: "desc", limit: 60 } };
state.historyPaging.nextCursor = last ? { id: last.id, value: last.id } : null;
state.historyPaging.hasMore = true;
} catch (err) {
/* noop */
}
} catch (err) {
console.error("pqueue: refresh failed", err);
state.error = err?.message || "Failed to load persistent queue";