- A list read superseded by a newer one (the 300 ms count read, a restore) no longer shows "could not be loaded": the collection and saved-view pages wait for the list to settle. - A listing that finds the open collection gone shows the deleted page once the list no longer holds it; when the list read fails or still holds it, the page keeps the listing's error and Retry instead of staying blank, and each failed read is reported again. - The missing-members note follows every list read and goes away at zero. - A rename that settles after its Edit dialog closed leaves any dialog opened since alone. - The deleted-collection, list-failed and saved-view failure pages focus their title after a route change, as every page does. Part of #331
494 lines
26 KiB
JavaScript
494 lines
26 KiB
JavaScript
import '../api-client.js';
|
||
import { createButton, createScope } from '../shared/toolkit/core.js';
|
||
import { routes, mountRouter, summarizeStats, routeTitle, loadNavCounts, navigateTo, COLLECTION_ROUTE,
|
||
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES, FAVORITE_WRITE_COUNTS, FAVORITE_COUNT_DELAY_MS, REVIEW_WRITE_COUNTS } from './state.mjs';
|
||
import { mountWorkspaceTheme } from './theme.mjs';
|
||
import { forgetImagesVisit, mountImages, onFavoriteChange } from './images.mjs';
|
||
import { forgetLibraryVisit, mountLibrary, mountNewPromptAction, onLibraryWrite } from './library.mjs';
|
||
import { mountVocabulary } from './vocabulary.mjs';
|
||
import { createWorkspaceIcon } from './icons.mjs';
|
||
import { bindCanvasLink, canvasHref } from './topbar.mjs';
|
||
import { renderHeading, pageHeading } from './heading.mjs';
|
||
import { closeWorkspaceDialog } from './workspace-dialogs.mjs';
|
||
import { createSavedViews, openSaveViewDialog, openEditViewDialog, savedViewHash } from './saved-views.mjs';
|
||
import { createCollections, collectionHash, openEditCollectionDialog, missingNote, DELETED_PAGE_TEXT, OPEN_IMAGES_LINK,
|
||
LIST_FAILED_TEXT } from './collections.mjs';
|
||
import { createActivityStore } from './activity-store.mjs';
|
||
import { mountActivityPage, mountActivityTopbar } from './activity.mjs';
|
||
import { mountActivityJobView } from './activity-job-view.mjs';
|
||
import { mountInspect } from './inspect.mjs';
|
||
import { mountReview } from './review.mjs';
|
||
import { createPromptSender } from './prompt-sender.mjs';
|
||
import { mountSystem } from './system.mjs';
|
||
import { onRestoreFinished, onRestoreHandedBack, showRestoreReview } from './system-backups.mjs';
|
||
import { mountLibraryBanner } from './library-location.mjs';
|
||
import { detectJxlSupport } from './thumbnail-support.mjs';
|
||
|
||
const { createApiClient, resolveUrl } = window.ApiClient;
|
||
|
||
function element(tag, text, className) {
|
||
const node = document.createElement(tag);
|
||
if (text !== undefined) node.textContent = text;
|
||
if (className) node.className = className;
|
||
return node;
|
||
}
|
||
|
||
function legacyLink(route) {
|
||
const link = element('a', route.action, 'workspace-link');
|
||
// A native card (System, Q12) links its workspace hash; a legacy tool's page goes through the mount prefix.
|
||
link.href = route.native ? route.href : resolveUrl(route.href);
|
||
return link;
|
||
}
|
||
|
||
function mountWorkspace() {
|
||
const scope = createScope();
|
||
const api = createApiClient();
|
||
const main = document.getElementById('workspace-main');
|
||
const primaryNav = document.getElementById('primary-nav');
|
||
const savedNav = document.getElementById('saved-nav');
|
||
const collectionsNav = document.getElementById('collections-nav');
|
||
const systemNav = document.getElementById('system-nav');
|
||
scope.listen(document.querySelector('.skip'), 'click', event => {
|
||
event.preventDefault();
|
||
main.focus();
|
||
});
|
||
// D4: the concept's Theme select; "System" keeps following ComfyUI, then the OS.
|
||
const theme = mountWorkspaceTheme({ root: document.body, scope });
|
||
// #317: learn once whether this browser shows JPEG XL thumbnails (cards fall back to originals otherwise).
|
||
detectJxlSupport();
|
||
const preference = document.getElementById('theme');
|
||
preference.value = theme.preference;
|
||
scope.listen(preference, 'change', () => theme.setPreference(preference.value));
|
||
// D6: ComfyUI ↗ opens the canvas; on Images with a resolved selection it sends that pair (a send is not an Activity job).
|
||
let images = null;
|
||
scope.cleanup(bindCanvasLink(document.querySelector('.topbar .canvas-link'), {
|
||
href: canvasHref(resolveUrl, window.location.pathname),
|
||
getSelectedPair: () => images?.selectedPair?.() ?? null,
|
||
sender: {open: (roles, trigger) => images?.sender.open(roles, trigger)},
|
||
}));
|
||
// The concept's nav(): icon, label text, then a `.nav-count` only for a loaded real count.
|
||
const navLinks = ids => ids.map(([id, icon]) => {
|
||
const route = routes.find(item => item.id === id);
|
||
const link = element('a');
|
||
link.append(createWorkspaceIcon(icon), document.createTextNode(route.label));
|
||
link.href = `#/${route.id}`;
|
||
link.dataset.route = route.id;
|
||
return link;
|
||
});
|
||
primaryNav.replaceChildren(...navLinks([['images', 'images'], ['library', 'library'], ['tags', 'tags'], ['inspect', 'upload']]));
|
||
// The concept's tools-and-system group: Activity, System, then (D28) the production Overview hub.
|
||
systemNav.replaceChildren(...navLinks([['activity', 'activity'], ['system', 'settings'], ['overview', 'overview']]));
|
||
// Activity (#249 Activity-1): one store of this session's jobs feeds the top-bar button and pill, the sidebar
|
||
// row's count and the Activity page, on every route.
|
||
const activity = createActivityStore({ api, scope, connect: url => new EventSource(resolveUrl(url)) });
|
||
mountActivityTopbar({ store: activity, scope });
|
||
activity.load();
|
||
// D129: a node-folder library is offered a move (or "Keep it here") above every page.
|
||
mountLibraryBanner(document.getElementById('library-banner'), { api, activity, scope });
|
||
const sidebar = document.querySelector('aside.sidebar');
|
||
// The route key a nav link carries in `data-route`: a saved view's is `saved/<id>`, a collection's `collection/<id>`.
|
||
const routeKey = route => route.id === 'saved' ? `saved/${route.savedId}`
|
||
: route.id === 'collection' ? `collection/${route.collectionId}` : route.id;
|
||
let currentKey = null;
|
||
const markCurrent = () => {
|
||
for (const link of sidebar.querySelectorAll('nav [data-route]')) {
|
||
if (link.dataset.route === currentKey) link.setAttribute('aria-current', 'page');
|
||
else link.removeAttribute('aria-current');
|
||
}
|
||
};
|
||
// SAVED VIEWS (#249 D16): the concept's saved-nav rows (search icon, name, no count), loaded with the shell and
|
||
// re-read after every change. /* D49 */ no "PROPOSED" badge. The first rows are the concept's Favorites (star icon,
|
||
// count, D51) and Needs review (review icon, the exact pending count, ruling R5), kept across every re-read so
|
||
// their counts stay.
|
||
const [favoritesLink, reviewLink] = navLinks([['favorites', 'star'], ['review', 'review']]);
|
||
savedNav.replaceChildren(favoritesLink, reviewLink);
|
||
const savedViews = createSavedViews(api, scope.signal);
|
||
scope.cleanup(savedViews.subscribe(({ views }) => {
|
||
savedNav.replaceChildren(favoritesLink, reviewLink, ...views.map(view => {
|
||
const link = element('a');
|
||
link.append(createWorkspaceIcon('search'), document.createTextNode(view.name));
|
||
link.href = savedViewHash(view.id);
|
||
link.dataset.route = `saved/${view.id}`;
|
||
return link;
|
||
}));
|
||
markCurrent();
|
||
}));
|
||
savedViews.load();
|
||
// COLLECTIONS (#331 /* D136 */): the concept's collections-nav rows (folder icon, name, the listed-member count once
|
||
// read), without the "PROPOSED" badge. The label shows only while a collection is listed. A failed read keeps the
|
||
// last rows; a collection page says the list could not be loaded.
|
||
const collectionsLabel = collectionsNav.previousElementSibling;
|
||
const collections = createCollections(api, scope.signal);
|
||
scope.cleanup(collections.subscribe(({ collections: listed }) => {
|
||
collectionsLabel.hidden = listed.length === 0;
|
||
collectionsNav.replaceChildren(...listed.map(item => {
|
||
const link = element('a');
|
||
link.append(createWorkspaceIcon('folder'), document.createTextNode(item.name));
|
||
if (Number.isSafeInteger(item.count)) link.append(element('span', item.count.toLocaleString(), 'nav-count'));
|
||
link.href = collectionHash(item.id);
|
||
link.dataset.route = `collection/${item.id}`;
|
||
return link;
|
||
}));
|
||
markCurrent();
|
||
}));
|
||
collections.load();
|
||
/** The toolbar's Save view on a page: the concept's name dialog, then the store saves the page's full state. */
|
||
const saveView = (page, view) => (trigger, state) => openSaveViewDialog({ scope: view, trigger, q: state.q,
|
||
onSave: name => savedViews.create(page, name, state) });
|
||
// One read per count, so a write that refreshes Library and Tags never supersedes an Images read in flight.
|
||
const countRequests = new Map();
|
||
function refreshCounts(ids) {
|
||
for (const id of ids) {
|
||
countRequests.get(id)?.abort();
|
||
const request = new AbortController();
|
||
countRequests.set(id, request);
|
||
// 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);
|
||
}, [id]);
|
||
}
|
||
}
|
||
function showCount(id, count) {
|
||
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'));
|
||
}
|
||
/** 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();
|
||
countRequests.delete(id);
|
||
showCount(id, count);
|
||
}
|
||
scope.cleanup(() => { for (const request of countRequests.values()) request.abort(); });
|
||
// A confirmed Library or vocabulary write cannot change the Images count, so only Library and Tags are re-read.
|
||
scope.cleanup(onLibraryWrite(() => refreshCounts(LIBRARY_WRITE_COUNTS)));
|
||
// Starring or unstarring anywhere (Images, Favorites, Library media) re-reads the Favorites count (D51), once
|
||
// after a run of quick toggles settles rather than once per click (#249 M1).
|
||
let favoriteCountTimer = 0;
|
||
scope.cleanup(onFavoriteChange(() => {
|
||
clearTimeout(favoriteCountTimer);
|
||
favoriteCountTimer = setTimeout(() => refreshCounts(FAVORITE_WRITE_COUNTS), FAVORITE_COUNT_DELAY_MS);
|
||
}));
|
||
scope.cleanup(() => clearTimeout(favoriteCountTimer));
|
||
// A metadata scan that ends may have added items to Needs review: re-read its count once per ended scan.
|
||
const runningScans = new Set();
|
||
scope.cleanup(activity.subscribe(() => {
|
||
let ended = false;
|
||
for (const job of activity.jobs()) {
|
||
if (job.kind !== 'scan') continue;
|
||
if (job.state === 'running') runningScans.add(job.id);
|
||
else if (runningScans.delete(job.id)) ended = true;
|
||
}
|
||
if (ended) refreshCounts(REVIEW_WRITE_COUNTS);
|
||
}));
|
||
refreshCounts(NAV_COUNT_ROUTES);
|
||
// S9: a finished restore replaced the library. The cached visits are dropped, the counts and saved views read
|
||
// again, and the open page is shown again from the restored data; the Activity job view the restore opened stays.
|
||
scope.cleanup(onRestoreFinished(activity, () => {
|
||
forgetImagesVisit();
|
||
forgetLibraryVisit();
|
||
refreshCounts(NAV_COUNT_ROUTES);
|
||
savedViews.load();
|
||
collections.load();
|
||
if (currentKey !== 'activity') navigateTo(window.location.hash);
|
||
}));
|
||
// S9 I1: a restore this page started that could not run yet (other requests kept reading) hands its review back:
|
||
// it opens again over the page shown now, with Restore (again) and Discard; leaving it any other way discards it.
|
||
scope.cleanup(onRestoreHandedBack(activity, review => {
|
||
if (viewScope && !viewScope.signal.aborted) showRestoreReview({ api, store: activity, scope: viewScope, ...review, again: true });
|
||
}));
|
||
let viewScope;
|
||
let firstRender = true;
|
||
// D62: a page whose teardown must wait (Inspect, while a video is still starting) holds the next route until it
|
||
// settles; the latest route wins.
|
||
let leaveGuard = null, renderToken = 0;
|
||
scope.cleanup(() => viewScope?.dispose());
|
||
|
||
// The mounted System page: another of its sections is shown in place, keeping the shell and the drafts.
|
||
let system = null;
|
||
|
||
function render(route) {
|
||
const token = ++renderToken;
|
||
const hold = leaveGuard?.();
|
||
if (hold) {
|
||
hold.then(() => { if (token === renderToken && !scope.signal.aborted) renderNow(route); });
|
||
return;
|
||
}
|
||
renderNow(route);
|
||
}
|
||
|
||
function renderNow(route) {
|
||
leaveGuard = null;
|
||
// A shared page dialog never stays open over the next route.
|
||
closeWorkspaceDialog();
|
||
if (route.id === 'system' && system && currentKey === 'system') {
|
||
system.show(route.section);
|
||
return;
|
||
}
|
||
system = null;
|
||
viewScope?.dispose();
|
||
images = null;
|
||
viewScope = createScope();
|
||
const view = viewScope;
|
||
currentKey = routeKey(route);
|
||
markCurrent();
|
||
if (route.id === 'saved') {
|
||
openSavedView(route, view);
|
||
return;
|
||
}
|
||
if (route.id === 'collection') {
|
||
openCollectionPage(route, view);
|
||
return;
|
||
}
|
||
renderPage(route, view);
|
||
}
|
||
|
||
/** A saved view opens its page with its state; the heading, breadcrumb and title are the view's name. */
|
||
async function openSavedView(route, view) {
|
||
main.replaceChildren();
|
||
await savedViews.settled();
|
||
if (view.signal.aborted) return;
|
||
// A view saved since the list loaded (another tab) is read once more before the link counts as gone.
|
||
if (!savedViews.find(route.savedId) && savedViews.status === 'ready') {
|
||
// settled(), not load()'s own promise: a newer read (a restore, another change) can supersede this one.
|
||
savedViews.load();
|
||
await savedViews.settled();
|
||
if (view.signal.aborted) return;
|
||
}
|
||
const saved = savedViews.find(route.savedId);
|
||
if (saved) {
|
||
const page = routes.find(item => item.id === saved.page);
|
||
renderPage({...page, label:saved.name, title:saved.name}, view, saved);
|
||
mountSavedViewEdit(saved, view);
|
||
return;
|
||
}
|
||
if (savedViews.status === 'ready') {
|
||
// A removed or unknown view: the shell's default page, without a history entry for the dead link.
|
||
window.location.replace('#/images');
|
||
return;
|
||
}
|
||
const {description, h1} = renderHeading(main, {title:route.label});
|
||
focusTitle(h1);
|
||
description.textContent = 'Saved views could not be loaded. Your saved data has not changed.';
|
||
description.setAttribute('role', 'alert');
|
||
const retry = createButton('Retry');
|
||
view.listen(retry, 'click', () => {
|
||
savedViews.load();
|
||
openSavedView(route, view);
|
||
});
|
||
main.append(retry);
|
||
}
|
||
|
||
// /* D51 */ An open sidebar item's Edit (maintainer ruling): first in the heading's actions; `open(trigger)` opens
|
||
// its dialog. Saved views and collections (D137) share it.
|
||
function mountEditAction(view, open) {
|
||
// renderPage has just written the heading.
|
||
const edit = createButton('Edit');
|
||
pageHeading(main).actions.prepend(edit);
|
||
view.listen(edit, 'click', () => open(edit));
|
||
}
|
||
|
||
// A rename re-reads the list and retitles the open page; a delete re-reads it and replaces the dead route with the
|
||
// view's plain page.
|
||
function mountSavedViewEdit(saved, view) {
|
||
mountEditAction(view, trigger => openEditViewDialog({ scope: view, trigger, view: saved,
|
||
onRename: async name => {
|
||
const renamed = await savedViews.rename(saved.id, name);
|
||
if (view.signal.aborted) return;
|
||
saved = renamed;
|
||
retitle(renamed.name);
|
||
},
|
||
onDelete: async () => {
|
||
await savedViews.remove(saved.id);
|
||
window.location.replace(`#/${saved.page}`);
|
||
} }));
|
||
}
|
||
|
||
/** `name` becomes the h1; the breadcrumb and title too unless the page keeps its own (a collection's "Collection"). */
|
||
function retitle(name, title = name) {
|
||
// Only the mounted view retitles (its rename returns once aborted), so its heading is present.
|
||
pageHeading(main).h1.textContent = name;
|
||
document.getElementById('workspace-breadcrumb').textContent = title;
|
||
document.title = `${title} · Prompt Manager`;
|
||
}
|
||
|
||
/**
|
||
* #331 D137: `#/collection/<id>` is Images filtered to the collection, its name the h1, "Collection" the breadcrumb
|
||
* and title (concept a1186–1189), with Edit and the note about members the sources do not list now. An unknown id
|
||
* is read once more (another tab may have made it), then shows the deleted-collection page.
|
||
*/
|
||
async function openCollectionPage(route, view) {
|
||
main.replaceChildren();
|
||
await collections.settled();
|
||
if (view.signal.aborted) return;
|
||
if (!collections.find(route.collectionId) && collections.status === 'ready') {
|
||
// settled(), not load()'s own promise: the 300 ms count read or a restore can supersede this one.
|
||
collections.load();
|
||
await collections.settled();
|
||
if (view.signal.aborted) return;
|
||
}
|
||
let collection = collections.find(route.collectionId);
|
||
const showAgain = () => { if (!view.signal.aborted) navigateTo(window.location.hash); };
|
||
if (collection) {
|
||
renderPage({...COLLECTION_ROUTE, label:collection.name, title:routeTitle(COLLECTION_ROUTE)}, view, null, {collection:collection.id,
|
||
// The listing found the collection gone meanwhile: the list is read again and, once it no longer lists
|
||
// the collection, the page is shown again (the deleted-collection page). A failed read, or a list that
|
||
// still holds it, leaves the listing's own error and Retry (images.mjs).
|
||
onCollectionMissing:async () => {
|
||
collections.load();
|
||
await collections.settled();
|
||
if (collections.status === 'ready' && !collections.find(route.collectionId)) showAgain();
|
||
}});
|
||
// MC3: the note follows every read of the list for the page's life; none while nothing is missing.
|
||
const note = element('p', '', 'small');
|
||
const showNote = () => {
|
||
const text = missingNote(collections.find(route.collectionId)?.missing);
|
||
note.textContent = text;
|
||
if (!text) note.remove();
|
||
else if (!note.isConnected) pageHeading(main)?.description.after(note);
|
||
};
|
||
showNote();
|
||
view.cleanup(collections.subscribe(showNote));
|
||
// The dialog's Delete question uses the latest stored count.
|
||
mountEditAction(view, trigger => openEditCollectionDialog({ scope: view, trigger,
|
||
collection: collection = {...collection, ...collections.find(collection.id), name:collection.name},
|
||
onRename: async name => {
|
||
const renamed = await collections.rename(collection.id, name);
|
||
if (view.signal.aborted) return;
|
||
collection = {...collection, ...renamed};
|
||
retitle(renamed.name, routeTitle(COLLECTION_ROUTE));
|
||
},
|
||
onDelete: async () => {
|
||
await collections.delete(collection.id);
|
||
window.location.replace('#/images');
|
||
},
|
||
onGone: () => collections.settled().then(showAgain) }));
|
||
return;
|
||
}
|
||
const {description, h1} = renderHeading(main, {title:route.label});
|
||
focusTitle(h1);
|
||
document.title = `${routeTitle(route)} · Prompt Manager`;
|
||
document.getElementById('workspace-breadcrumb').textContent = routeTitle(route);
|
||
if (collections.status === 'ready') {
|
||
// Deleted, or never existed: say so, with the way back to Images.
|
||
description.textContent = DELETED_PAGE_TEXT;
|
||
const link = element('a', OPEN_IMAGES_LINK, 'workspace-link');
|
||
link.href = '#/images';
|
||
main.append(link);
|
||
return;
|
||
}
|
||
description.textContent = LIST_FAILED_TEXT;
|
||
description.setAttribute('role', 'alert');
|
||
const retry = createButton('Retry');
|
||
view.listen(retry, 'click', () => {
|
||
collections.load();
|
||
openCollectionPage(route, view);
|
||
});
|
||
main.append(retry);
|
||
}
|
||
|
||
/** The concept focuses its page title on a hashchange navigation (app.js 1168), never on the first render. */
|
||
function focusTitle(h1) {
|
||
if (!firstRender) h1.focus();
|
||
firstRender = false;
|
||
}
|
||
|
||
function renderPage(route, view, saved = null, {collection = null, onCollectionMissing = null} = {}) {
|
||
document.title = `${routeTitle(route)} · Prompt Manager`;
|
||
// The concept's heading(); each view fills its description and actions.
|
||
// The concept focuses its page title on a hashchange navigation (app.js 1168); the skip link focuses `main`.
|
||
const { description, h1 } = renderHeading(main, { title: route.label });
|
||
focusTitle(h1);
|
||
document.getElementById('workspace-breadcrumb').textContent = routeTitle(route);
|
||
if (route.id === 'images' || route.id === 'favorites' || route.id === 'collection') {
|
||
mountNewPromptAction(main, api, view);
|
||
images = mountImages(main, api, view, {savedState:saved?.state, favorites:route.id === 'favorites',
|
||
collection, onCollectionMissing, onSaveView:saveView('images', view), activity});
|
||
return;
|
||
}
|
||
if (route.id === 'library') {
|
||
mountLibrary(main, api, view, {positiveId:route.positiveId, savedState:saved?.state, onSaveView:saveView('library', view)});
|
||
return;
|
||
}
|
||
if (route.id === 'tags') {
|
||
mountVocabulary(main, api, view);
|
||
return;
|
||
}
|
||
if (route.id === 'activity') {
|
||
// D56: `#/activity/<id>` is that job's own view; `#/activity` lists the session's jobs.
|
||
if (route.jobId) mountActivityJobView(main, { store: activity, scope: view, api, jobId: route.jobId });
|
||
else mountActivityPage(main, { store: activity, scope: view });
|
||
return;
|
||
}
|
||
if (route.id === 'review') {
|
||
// Needs review (ruling R5): a recorded decision re-reads the row's count, and the open page's total is the
|
||
// count (F3: a change the page hears of costs no count read).
|
||
mountReview(main, { api, scope: view, onDecision: () => refreshCounts(REVIEW_WRITE_COUNTS),
|
||
onTotal: total => setCount('review', total) });
|
||
return;
|
||
}
|
||
if (route.id === 'system') {
|
||
// The concept's System (app.js renderSystem): its heading description, section nav and Save row.
|
||
description.textContent = route.description;
|
||
system = mountSystem(main, api, view, { section: route.section, activity });
|
||
return;
|
||
}
|
||
if (route.id === 'inspect') {
|
||
// The concept's Inspect a file (app.js renderMetadata): a local file, read only in this browser.
|
||
description.textContent = route.description;
|
||
({ leaveGuard } = mountInspect(main, { parentScope: view, api, sender: createPromptSender({ scope: view }) }));
|
||
return;
|
||
}
|
||
description.textContent = 'A home for your prompts, images and creative history.';
|
||
const stats = element('section', undefined, 'overview-stats');
|
||
stats.setAttribute('aria-label', 'Library statistics');
|
||
main.append(stats);
|
||
const tools = element('section', undefined, 'surface');
|
||
tools.append(element('h2', 'Continue creating'), element('p', 'Your existing tools remain available while the new workspaces take shape.'));
|
||
const cards = element('div', undefined, 'tool-grid');
|
||
for (const target of routes.filter(item => item.href)) {
|
||
const card = element('article', undefined, 'tool-card');
|
||
card.append(element('h3', target.label), element('p', target.description, 'small'), legacyLink(target));
|
||
cards.append(card);
|
||
}
|
||
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);
|
||
const list = element('dl', undefined, 'stat-grid');
|
||
for (const [label, count] of values) {
|
||
const stat = element('div', undefined, 'stat');
|
||
stat.append(element('dt', label), element('dd', 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;
|
||
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);
|
||
}
|
||
}
|
||
loadStats();
|
||
}
|
||
scope.cleanup(mountRouter(window, render));
|
||
scope.listen(window, 'pagehide', () => scope.dispose());
|
||
return () => scope.dispose();
|
||
}
|
||
|
||
mountWorkspace();
|
||
window.addEventListener('pageshow', event => {
|
||
if (event.persisted) mountWorkspace();
|
||
});
|