fix(workspace): Overview cards show the sidebar's counts (#549)
Closes #547
This commit is contained in:
+37
-21
@@ -1,6 +1,6 @@
|
||||
import '../api-client.js';
|
||||
import { createButton, createScope } from '../shared/toolkit/core.js';
|
||||
import { routes, mountRouter, summarizeStats, routeTitle, loadNavCounts, navigateTo, COLLECTION_ROUTE,
|
||||
import { routes, mountRouter, summarizeOverviewCounts, OVERVIEW_COUNT_ROUTES, routeTitle, loadNavCounts, navigateTo, COLLECTION_ROUTE,
|
||||
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES, FAVORITE_WRITE_COUNTS, FAVORITE_COUNT_DELAY_MS, REVIEW_WRITE_COUNTS,
|
||||
JOB_END_COUNTS } from './state.mjs';
|
||||
import { mountWorkspaceTheme } from './theme.mjs';
|
||||
@@ -153,9 +153,13 @@ function mountWorkspace() {
|
||||
countRequests.get(id)?.abort();
|
||||
const request = new AbortController();
|
||||
countRequests.set(id, request);
|
||||
// A failed count reads as loading again while it is retried; a known one stays until replaced.
|
||||
if (knownCounts.get(id) === null) { knownCounts.delete(id); notifyCounts(); }
|
||||
// Each badge lands as its own read settles; a count that failed to refresh is removed, never left stale.
|
||||
loadNavCounts(api, request.signal, (_, count) => {
|
||||
if (!request.signal.aborted) showCount(id, count);
|
||||
if (request.signal.aborted) return;
|
||||
countRequests.delete(id);
|
||||
showCount(id, count);
|
||||
}, [id]);
|
||||
}
|
||||
}
|
||||
@@ -163,7 +167,15 @@ function mountWorkspace() {
|
||||
const link = sidebar.querySelector(`nav [data-route="${id}"]`);
|
||||
link?.querySelector('.nav-count')?.remove();
|
||||
if (link && Number.isSafeInteger(count)) link.append(element('span', count.toLocaleString(), 'nav-count'));
|
||||
knownCounts.set(id, count);
|
||||
notifyCounts();
|
||||
}
|
||||
/** #547: Overview shows these same counts, so a card and its badge cannot differ, and no read runs twice. */
|
||||
const knownCounts = new Map();
|
||||
const countListeners = new Set();
|
||||
function notifyCounts() { for (const listener of countListeners) listener(); }
|
||||
/** Re-read the given counts unless a read is already in flight. */
|
||||
function freshenCounts(ids) { refreshCounts(ids.filter(id => !countRequests.has(id))); }
|
||||
/** A count a page has just read itself: it replaces the badge, and an older read in flight no longer lands. */
|
||||
function setCount(id, count) {
|
||||
countRequests.get(id)?.abort();
|
||||
@@ -499,34 +511,38 @@ function mountWorkspace() {
|
||||
}
|
||||
tools.append(cards);
|
||||
main.append(tools);
|
||||
async function loadStats() {
|
||||
const status = element('p', 'Loading library statistics…');
|
||||
status.setAttribute('role', 'status');
|
||||
stats.replaceChildren(status);
|
||||
try {
|
||||
const result = await api.get('/prompt_manager/stats', { signal: view.signal });
|
||||
if (view.signal.aborted) return;
|
||||
const values = summarizeStats(result.stats);
|
||||
function showStats() {
|
||||
const { cards, pending, failed, empty } = summarizeOverviewCounts(knownCounts);
|
||||
const parts = [];
|
||||
if (cards.some(([, count]) => count !== null)) {
|
||||
const list = element('dl', undefined, 'stat-grid');
|
||||
for (const [label, count] of values) {
|
||||
for (const [label, count] of cards) {
|
||||
const stat = element('div', undefined, 'stat');
|
||||
stat.append(element('dt', label), element('dd', count.toLocaleString()));
|
||||
stat.append(element('dt', label), element('dd', count === null ? '–' : count.toLocaleString()));
|
||||
list.append(stat);
|
||||
}
|
||||
stats.replaceChildren(list);
|
||||
if (values.every(([, count]) => count === 0)) {
|
||||
stats.append(element('p', 'Your library is empty. Add a prompt in the prompt library or configure sources in System.', 'empty-state'));
|
||||
}
|
||||
} catch (error) {
|
||||
if (view.signal.aborted) return;
|
||||
parts.push(list);
|
||||
}
|
||||
if (pending) {
|
||||
const status = element('p', 'Loading library statistics…');
|
||||
status.setAttribute('role', 'status');
|
||||
parts.push(status);
|
||||
} else if (failed) {
|
||||
const alert = element('p', 'Library statistics could not be loaded. Your saved data has not changed.');
|
||||
alert.setAttribute('role', 'alert');
|
||||
const retry = createButton('Retry');
|
||||
view.listen(retry, 'click', loadStats);
|
||||
stats.replaceChildren(alert, retry);
|
||||
view.listen(retry, 'click', () => refreshCounts(OVERVIEW_COUNT_ROUTES.filter(id => knownCounts.get(id) === null)));
|
||||
parts.push(alert, retry);
|
||||
} else if (empty) {
|
||||
parts.push(element('p', 'Your library is empty. Add a prompt in the prompt library or configure sources in System.', 'empty-state'));
|
||||
}
|
||||
stats.replaceChildren(...parts);
|
||||
}
|
||||
loadStats();
|
||||
countListeners.add(showStats);
|
||||
view.cleanup(() => countListeners.delete(showStats));
|
||||
showStats();
|
||||
// A visit reads them again, as a generation since the last read changes the sidebar's badges too.
|
||||
freshenCounts(OVERVIEW_COUNT_ROUTES);
|
||||
}
|
||||
scope.cleanup(mountRouter(window, render));
|
||||
scope.listen(window, 'pagehide', () => scope.dispose());
|
||||
|
||||
@@ -95,15 +95,6 @@ export function resolveRoute(hash) {
|
||||
return routes.find(route => hash === `#/${route.id}`) || routes[0];
|
||||
}
|
||||
|
||||
export function summarizeStats(stats) {
|
||||
return [['Prompts', 'total_prompts'], ['Images', 'total_images'], ['Tags', 'total_tags']]
|
||||
.map(([label, key]) => {
|
||||
const count = stats?.[key];
|
||||
if (!Number.isSafeInteger(count) || count < 0) throw new Error('Invalid library statistics');
|
||||
return [label, count];
|
||||
});
|
||||
}
|
||||
|
||||
// The sidebar's real counts (D27): one bounded `limit=1` read each, whose `total` is the count. Favorites (D51) is the
|
||||
// Favorites page's own listing, so its count is what that page lists.
|
||||
const NAV_COUNT_SOURCES = Object.freeze([
|
||||
@@ -158,6 +149,18 @@ export async function loadNavCounts(api, signal, onCount = () => {}, ids = NAV_C
|
||||
return new Map([...summarizeNavCounts(pages)].filter(([id]) => ids.includes(id)));
|
||||
}
|
||||
|
||||
// #547: each Overview card is the count of the page it names, the same number as that page's sidebar badge.
|
||||
const OVERVIEW_CARDS = Object.freeze([['Prompts', 'library'], ['Images', 'images'], ['Tags', 'tags']]);
|
||||
export const OVERVIEW_COUNT_ROUTES = Object.freeze(OVERVIEW_CARDS.map(([, id]) => id));
|
||||
|
||||
/** Overview's state from the shell's counts: no entry is a read not settled yet, null a read that failed. */
|
||||
export function summarizeOverviewCounts(counts) {
|
||||
const cards = OVERVIEW_CARDS.map(([label, id]) => [label, counts.get(id) ?? null]);
|
||||
const pending = OVERVIEW_COUNT_ROUTES.some(id => !counts.has(id));
|
||||
const failed = OVERVIEW_COUNT_ROUTES.some(id => counts.get(id) === null);
|
||||
return { cards, pending, failed, empty: !pending && cards.every(([, count]) => count === 0) };
|
||||
}
|
||||
|
||||
/** Fired on the window to render the current route again, where setting the same hash fires no `hashchange`. */
|
||||
const ROUTE_REFRESH_EVENT = 'prompt-manager:route-refresh';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user