- Drop the invented Refine note; every field is real (D14). - Ignore a late close event once the shell was reopened or replaced, so an action that opens the next dialog keeps it open. - Dialog openers require the page scope; disposing it closes and removes the shell unless a newer page's dialog is open. Every route change closes the shared dialog before the next page mounts. - Clear filters also reports category '' so the page clears the toolbar category, as the concept does. - The toolbar requires its scope and falls back to the first sort for an unknown value; dialog callbacks are validated up front. Refs #249
180 lines
8.8 KiB
JavaScript
180 lines
8.8 KiB
JavaScript
import '../api-client.js';
|
|
import { createButton, createScope } from '../shared/toolkit/core.js';
|
|
import { routes, mountRouter, summarizeStats, routeTitle, isHandoff, loadNavCounts,
|
|
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES } from './state.mjs';
|
|
import { mountWorkspaceTheme } from './theme.mjs';
|
|
import { mountImages } from './images.mjs';
|
|
import { mountLibrary, mountNewPromptAction, onLibraryWrite } from './library.mjs';
|
|
import { mountVocabulary } from './vocabulary.mjs';
|
|
import { createWorkspaceIcon } from './icons.mjs';
|
|
import { bindCanvasLink, canvasHref } from './topbar.mjs';
|
|
import { renderHeading } from './heading.mjs';
|
|
import { closeWorkspaceDialog } from './workspace-dialogs.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');
|
|
link.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 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 });
|
|
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 (D5: no Activity).
|
|
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']]));
|
|
// D28: System, then the production Overview hub, in the concept's tools-and-system group.
|
|
systemNav.replaceChildren(...navLinks([['system', 'settings'], ['overview', 'overview']]));
|
|
const sidebar = document.querySelector('aside.sidebar');
|
|
// 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) return;
|
|
const link = primaryNav.querySelector(`[data-route="${id}"]`);
|
|
link?.querySelector('.nav-count')?.remove();
|
|
if (link && Number.isSafeInteger(count)) link.append(element('span', count.toLocaleString(), 'nav-count'));
|
|
}, [id]);
|
|
}
|
|
}
|
|
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)));
|
|
refreshCounts(NAV_COUNT_ROUTES);
|
|
let viewScope;
|
|
let firstRender = true;
|
|
scope.cleanup(() => viewScope?.dispose());
|
|
|
|
function render(route) {
|
|
// A shared page dialog never stays open over the next route.
|
|
closeWorkspaceDialog();
|
|
viewScope?.dispose();
|
|
images = null;
|
|
viewScope = createScope();
|
|
const view = viewScope;
|
|
for (const link of sidebar.querySelectorAll('nav [data-route]')) {
|
|
if (link.dataset.route === route.id) link.setAttribute('aria-current', 'page');
|
|
else link.removeAttribute('aria-current');
|
|
}
|
|
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 });
|
|
if (!firstRender) h1.focus();
|
|
firstRender = false;
|
|
document.getElementById('workspace-breadcrumb').textContent = routeTitle(route);
|
|
if (route.id === 'images') {
|
|
mountNewPromptAction(main, api, view);
|
|
images = mountImages(main, api, view);
|
|
return;
|
|
}
|
|
if (route.id === 'library') {
|
|
mountLibrary(main, api, view, {positiveId:route.positiveId});
|
|
return;
|
|
}
|
|
if (route.id === 'tags') {
|
|
mountVocabulary(main, api, view);
|
|
return;
|
|
}
|
|
if (isHandoff(route)) {
|
|
description.textContent = route.description;
|
|
const panel = element('section', undefined, 'surface handoff');
|
|
panel.append(element('h2', 'Your current tools are ready'),
|
|
element('p', 'This workspace is being introduced in stages. Continue in the existing tool with your saved data.'),
|
|
legacyLink(route));
|
|
main.append(panel);
|
|
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();
|
|
});
|