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, FAVORITE_WRITE_COUNTS, FAVORITE_COUNT_DELAY_MS } from './state.mjs'; import { mountWorkspaceTheme } from './theme.mjs'; import { mountImages, onFavoriteChange } 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, pageHeading } from './heading.mjs'; import { closeWorkspaceDialog } from './workspace-dialogs.mjs'; import { createSavedViews, openSaveViewDialog, openEditViewDialog, savedViewHash } from './saved-views.mjs'; import { createActivityStore } from './activity-store.mjs'; import { mountActivityPage, mountActivityTopbar } from './activity.mjs'; import { mountActivityJobView } from './activity-job-view.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 savedNav = document.getElementById('saved-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 (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(); const sidebar = document.querySelector('aside.sidebar'); // The route key a nav link carries in `data-route`: a saved view's is `saved/`. const routeKey = route => route.id === 'saved' ? `saved/${route.savedId}` : 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; /* D50 */ no "Needs review" row. The first row is // the concept's Favorites (star icon, count), kept across every re-read so its count stays (D51). const [favoritesLink] = navLinks([['favorites', 'star']]); savedNav.replaceChildren(favoritesLink); const savedViews = createSavedViews(api, scope.signal); scope.cleanup(savedViews.subscribe(({ views }) => { savedNav.replaceChildren(favoritesLink, ...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(); /** 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) return; 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')); }, [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))); // 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)); 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; currentKey = routeKey(route); markCurrent(); if (route.id === 'saved') { openSavedView(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') { await savedViews.load(); 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); mountEditAction(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} = renderHeading(main, {title:route.label}); 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 */ The open view's Edit (maintainer ruling): first in the heading's actions. 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 mountEditAction(saved, view) { // renderPage has just written the heading. const edit = createButton('Edit'); pageHeading(main).actions.prepend(edit); view.listen(edit, 'click', () => openEditViewDialog({ scope: view, trigger: edit, 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}`); } })); } function retitle(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 = name; document.title = `${name} · Prompt Manager`; } function renderPage(route, view, saved = 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 }); if (!firstRender) h1.focus(); firstRender = false; document.getElementById('workspace-breadcrumb').textContent = routeTitle(route); if (route.id === 'images' || route.id === 'favorites') { mountNewPromptAction(main, api, view); images = mountImages(main, api, view, {savedState:saved?.state, favorites:route.id === 'favorites', 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/` 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 (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(); });