376 lines
20 KiB
JavaScript
376 lines
20 KiB
JavaScript
/**
|
||
* Needs review (#249 PR F): the concept's Metadata review page (docs/concepts/workspace/app.js `renderReview`
|
||
* a749–760; style.css s407–415, s796–814 and their breakpoints) over the review controller (review-state.mjs).
|
||
*
|
||
* - Heading "Metadata review", its line, and **Open activity** (`#/activity`).
|
||
* - `.notice`: "N images need a decision" and the concept's sentence; with an empty queue, D86 (plan D63, ruling R6)
|
||
* "No images need a decision" and the scan/new-generation sentence, and nothing else.
|
||
* - `.gallery-layout`: the queue's concept media cards (image-card.mjs; the image opens the shared viewer, Info selects
|
||
* the item, Tag opens the shared Tag image dialog with the review scope note), then `section.surface` "Resolve the
|
||
* association": filename, Tag image, Link to a prompt…, Sampler context, the chosen path's prompt blocks and the
|
||
* actions Leave for later, Leave unresolved and Apply association. Each opens the next item (#536, overturning R7).
|
||
* - D179: Leave unresolved is a button, not the select's last option; a disabled option names the server's reason.
|
||
* - D180: with nothing to apply the select, the prompt blocks and Apply are gone: the panel says why and Link to a
|
||
* prompt… is the primary action.
|
||
* - States the concept lacks (loading, a failed read, a refused or unanswered decision, a limited or unavailable file)
|
||
* are text: `p.warning-note` in the panel, or the page's status line (D119).
|
||
* - The open page re-reads its queue on each `change` of the review change stream (`/prompt_manager/review/events`,
|
||
* F2): items a scan or the monitor added, and decisions made in other tabs. Each `ready` (the first one, and one
|
||
* after the browser reconnects a dropped stream) names the queue's version, and the page re-reads only when the
|
||
* queue it shows is older (F3: one read per visit). A re-read keeps the depth Load more had reached. Nothing polls.
|
||
* - The stream lives only while the tab is visible: a hidden tab closes it and a visible one opens it again, so
|
||
* background tabs hold no connection (I4); the reopened stream's `ready` reads only when the queue changed meanwhile.
|
||
* A stream the browser gave up on (a 503 or a proxy error on its reconnect) is opened again after 3 s, doubling to
|
||
* 30 s; while the stream is down the page says so in a quiet status line (D120, I3).
|
||
* - A re-read refreshes each card whose media, target or availability changed (M6). Cards show the Images listing's
|
||
* thumbnail when the server knows one (M10).
|
||
* - The SAVED VIEWS row's count follows the total the page shows (`onTotal`), so a change costs no count read.
|
||
* - D119: the queue cards carry no selection checkbox and no star (this page has no selection bar, and the queue
|
||
* carries no favorite state); more than 40 items add the Images page's `.infinite-continuation` Load more after the
|
||
* layout; the toasts "Association accepted." and "Left unresolved." stand in for the concept's prototype toast.
|
||
*/
|
||
import { createButton } from '../shared/toolkit/core.js';
|
||
import { createReviewController, COPY, noticeTitle } from './review-state.mjs';
|
||
import { createImageCard } from './image-card.mjs';
|
||
import { openPromptLink } from './review-link.mjs';
|
||
import { isTagButton, openImageTagging } from './image-tagging.mjs';
|
||
import { mountPromptBlocks } from './prompt-blocks.mjs';
|
||
import { createPromptSender } from './prompt-sender.mjs';
|
||
import { createMediaViewer } from './media-viewer.mjs';
|
||
import { createInspectionController } from './inspection-state.mjs';
|
||
import { createWorkspaceIcon } from './icons.mjs';
|
||
import { field } from './workspace-dialogs.mjs';
|
||
import { pageHeading } from './heading.mjs';
|
||
import { navigateTo } from './state.mjs';
|
||
import { noun } from './plural.mjs';
|
||
import { showWorkspaceToast } from './toast.mjs';
|
||
import { imageKey } from './images-state.mjs';
|
||
|
||
export const REVIEW_EVENTS_URL = '/prompt_manager/review/events';
|
||
// I3: reopening a stream the browser closed for good waits this long, doubling up to the maximum.
|
||
export const REOPEN_FIRST_MS = 3000;
|
||
export const REOPEN_MAX_MS = 30000;
|
||
// D120: the page's line while the change stream is down (the concept has no such state).
|
||
export const NOT_LIVE = 'Live updates are paused: changes made elsewhere appear once the connection returns.';
|
||
const openEvents = url => new EventSource(window.ApiClient.resolveUrl(url));
|
||
|
||
function el(tag, text, className) {
|
||
const node = document.createElement(tag);
|
||
if (text !== undefined) node.textContent = text;
|
||
if (className) node.className = className;
|
||
return node;
|
||
}
|
||
function setText(node, text) {
|
||
if (node.textContent !== text) node.textContent = text;
|
||
}
|
||
function setDisabled(button, disabled) {
|
||
if (disabled) button.setAttribute('aria-disabled', 'true');
|
||
else button.removeAttribute('aria-disabled');
|
||
}
|
||
const isDisabled = button => button.getAttribute('aria-disabled') === 'true';
|
||
|
||
/** A queue item as the card, the viewer and the Tag dialog read a listed image. */
|
||
export function imageOf(item) {
|
||
const target = item.target ?? {};
|
||
return { filename: item.filename, root_index: target.root_index, root_scope: target.root_scope, path: target.path,
|
||
url: item.url, thumbnail_url: item.thumbnail_url ?? null, is_video: item.is_video === true,
|
||
media_type: item.is_video ? 'video' : 'image' };
|
||
}
|
||
|
||
/** True when a re-read changed what a card shows or can open (M6). */
|
||
const cardChanged = (before, after) => before.url !== after.url || before.thumbnail_url !== after.thumbnail_url ||
|
||
before.unavailable !== after.unavailable || JSON.stringify(before.target) !== JSON.stringify(after.target);
|
||
|
||
/** The queue version a `ready` event names, or null (the page then re-reads). */
|
||
export function readyVersion(event) {
|
||
try {
|
||
const version = JSON.parse(event?.data ?? 'null');
|
||
return version && typeof version === 'object' ? version : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Mount the page under the shell's heading. `onDecision()` runs after a recorded decision and `onTotal(total)` each
|
||
* time the pending total the page shows changes (the shell's Needs review count); `controller` and `connect` (the
|
||
* change stream's EventSource) are injectable for tests.
|
||
*/
|
||
export function mountReview(main, { api, scope, onDecision = () => {}, onTotal,
|
||
connect = openEvents, controller = createReviewController({ api }) }) {
|
||
scope.cleanup(() => controller.dispose());
|
||
const heading = pageHeading(main);
|
||
heading.h1.textContent = COPY.title;
|
||
heading.description.textContent = COPY.description;
|
||
const openActivity = createButton('Open activity');
|
||
heading.actions.append(openActivity);
|
||
scope.listen(openActivity, 'click', () => navigateTo('#/activity'));
|
||
|
||
const status = el('p', COPY.loading, 'empty-state');
|
||
status.setAttribute('role', 'status');
|
||
const retryLoad = createButton('Retry');
|
||
const notice = el('div', undefined, 'notice');
|
||
const noticeCopy = el('div');
|
||
const noticeTitleNode = el('h2');
|
||
const noticeText = el('p');
|
||
noticeCopy.append(noticeTitleNode, noticeText);
|
||
notice.append(noticeCopy);
|
||
|
||
const layout = el('div', undefined, 'gallery-layout');
|
||
const grid = el('div', undefined, 'image-grid review-queue');
|
||
grid.setAttribute('role', 'list');
|
||
grid.setAttribute('aria-label', 'Review queue');
|
||
const panel = el('section', undefined, 'surface review-panel');
|
||
const panelTitle = el('h2', COPY.panelTitle);
|
||
panelTitle.id = 'review-panel-title';
|
||
panel.setAttribute('aria-labelledby', panelTitle.id);
|
||
const filename = el('p', '', 'small');
|
||
const tagRow = el('div', undefined, 'row-actions spaced-15');
|
||
const tagButton = createButton('');
|
||
tagButton.append(createWorkspaceIcon('tagImage'), ' Tag image');
|
||
const linkButton = createButton('Link to a prompt…');
|
||
linkButton.classList.add('review-link-open');
|
||
tagRow.append(tagButton, linkButton);
|
||
const select = el('select');
|
||
const contextField = field('Sampler context', select, 'review-context');
|
||
const unreadable = el('div', undefined, 'review-unreadable'); // D180
|
||
const warning = el('p', '', 'warning-note'); // D119: the states the concept lacks
|
||
warning.setAttribute('role', 'status');
|
||
const blocks = el('div');
|
||
const actions = el('div', undefined, 'dialog-actions');
|
||
const later = createButton('Leave for later');
|
||
const leaveUnresolved = createButton('Leave unresolved');
|
||
leaveUnresolved.classList.add('review-leave-unresolved');
|
||
const apply = createButton('Apply association', 'ca-primary');
|
||
actions.append(later, leaveUnresolved, apply);
|
||
panel.append(panelTitle, filename, tagRow, contextField, unreadable, warning, blocks, actions);
|
||
layout.append(grid, panel);
|
||
|
||
const live = el('p', NOT_LIVE, 'small review-live'); // D120: the change stream is down
|
||
live.setAttribute('role', 'status');
|
||
live.hidden = true;
|
||
heading.description.after(live);
|
||
|
||
const continuation = el('div', undefined, 'infinite-continuation'); // D119: Load more past 40 items
|
||
const moreText = el('p', '', 'small');
|
||
const more = createButton('Load more images');
|
||
continuation.append(moreText, more);
|
||
|
||
const sender = createPromptSender({ scope });
|
||
const prompts = mountPromptBlocks(blocks, { prompts: null, sender, parentScope: scope, includePairAction: false,
|
||
onRetry: () => { if (controller.state.selectedId) controller.select(controller.state.selectedId); } });
|
||
const inspection = createInspectionController({ api });
|
||
scope.cleanup(() => inspection.dispose());
|
||
const viewer = createMediaViewer({ api, container: main, parentScope: scope, inspectionController: inspection,
|
||
sender, onSelect: image => inspection.select(image) });
|
||
|
||
const cards = new Map();
|
||
let shownItems = null, shownSelected, shownOptions = null, shownPrompts, shownDetail, shownUnreadable = null, shownDecisions = 0;
|
||
|
||
function createCard(item) {
|
||
// D119: no selection checkbox and no star on a queue card.
|
||
const card = createImageCard(imageOf(item), { media: Boolean(item.url) });
|
||
card.dataset.reviewId = item.id;
|
||
card.item = item;
|
||
if (!item.url) setDisabled(card.querySelector('.image-open'), true);
|
||
if (!item.target) setDisabled(card.querySelector('.image-card-actions button:last-child'), true);
|
||
cards.set(item.id, card);
|
||
return card;
|
||
}
|
||
|
||
function renderCards(state) {
|
||
if (state.items !== shownItems) {
|
||
shownItems = state.items;
|
||
const ids = new Set(state.items.map(item => item.id));
|
||
for (const [id, card] of cards) if (!ids.has(id)) { card.remove(); cards.delete(id); }
|
||
state.items.forEach((item, index) => {
|
||
let card = cards.get(item.id);
|
||
if (card && cardChanged(card.item, item)) {
|
||
// M6: a root came or went: the card's media, Open and Tag follow the re-read.
|
||
const fresh = createCard(item);
|
||
card.replaceWith(fresh);
|
||
card = fresh;
|
||
} else if (card) card.item = item;
|
||
if (!card) card = createCard(item);
|
||
if (grid.children[index] !== card) grid.insertBefore(card, grid.children[index] ?? null);
|
||
});
|
||
shownSelected = undefined;
|
||
}
|
||
if (state.selectedId !== shownSelected) {
|
||
shownSelected = state.selectedId;
|
||
for (const [id, card] of cards) card.classList.toggle('selected', id === state.selectedId);
|
||
}
|
||
}
|
||
|
||
/** D180: nothing to apply, so say why and lead with the picker. */
|
||
function renderUnreadable(state) {
|
||
const none = state.detailStatus === 'ready' && !state.choice;
|
||
contextField.hidden = blocks.hidden = apply.hidden = none;
|
||
linkButton.classList.toggle('ca-primary', none && Boolean(state.detail?.target));
|
||
unreadable.hidden = !state.unreadable;
|
||
if (state.unreadable === shownUnreadable) return;
|
||
shownUnreadable = state.unreadable;
|
||
if (!state.unreadable) { unreadable.replaceChildren(); return; }
|
||
const { count, reasons } = state.unreadable;
|
||
unreadable.replaceChildren(el('p', COPY.unreadable(count)),
|
||
...reasons.map(({ reason, count: paths }) => el('p', `${reason} (${paths} ${noun(paths, 'path')})`, 'small')));
|
||
}
|
||
|
||
function renderPanel(state) {
|
||
const item = state.items.find(entry => entry.id === state.selectedId);
|
||
setText(filename, item?.filename ?? '');
|
||
setDisabled(tagButton, !item?.target || state.phase === 'applying');
|
||
setDisabled(linkButton, !item?.target || state.detailStatus !== 'ready' || state.phase !== 'idle');
|
||
renderUnreadable(state);
|
||
if (state.options !== shownOptions) {
|
||
shownOptions = state.options;
|
||
select.replaceChildren(...state.options.map(option => {
|
||
const node = el('option', option.label);
|
||
node.value = option.value;
|
||
node.disabled = option.disabled;
|
||
return node;
|
||
}));
|
||
}
|
||
if (state.choice !== null && select.value !== state.choice) select.value = state.choice;
|
||
select.disabled = state.detailStatus !== 'ready' || state.phase === 'applying';
|
||
setText(warning, state.warning);
|
||
warning.hidden = !state.warning;
|
||
if (state.prompts !== shownPrompts || state.detailStatus !== shownDetail) {
|
||
shownPrompts = state.prompts;
|
||
shownDetail = state.detailStatus;
|
||
if (state.detailStatus === 'ready' && state.prompts) prompts.setState({ prompts: state.prompts });
|
||
else if (state.detailStatus === 'ready') prompts.setState({ prompts: null });
|
||
else prompts.setState({ loading: state.detailStatus === 'loading', error: state.detailStatus === 'error' });
|
||
}
|
||
setDisabled(apply, state.detailStatus !== 'ready' || state.phase !== 'idle' || !state.choice);
|
||
setDisabled(leaveUnresolved, state.detailStatus !== 'ready' || state.phase !== 'idle');
|
||
setDisabled(later, state.phase === 'applying');
|
||
}
|
||
|
||
function render(state) {
|
||
if (scope.signal.aborted) return;
|
||
if (state.decisions !== shownDecisions) {
|
||
shownDecisions = state.decisions;
|
||
showWorkspaceToast(state.toast);
|
||
onDecision();
|
||
}
|
||
const ready = state.status === 'ready';
|
||
if (!ready) {
|
||
setText(status, state.status === 'error' ? COPY.loadFailed : COPY.loading);
|
||
status.setAttribute('role', state.status === 'error' ? 'alert' : 'status');
|
||
if (!status.isConnected) main.append(status);
|
||
if (state.status === 'error' && !retryLoad.isConnected) main.append(retryLoad);
|
||
if (state.status !== 'error') retryLoad.remove();
|
||
if (state.status === 'error' || !state.items.length) { notice.remove(); layout.remove(); continuation.remove(); }
|
||
return;
|
||
}
|
||
status.remove();
|
||
retryLoad.remove();
|
||
setText(noticeTitleNode, noticeTitle(state.total));
|
||
setText(noticeText, state.total ? COPY.notice : COPY.empty); // D86
|
||
if (!notice.isConnected) main.append(notice);
|
||
if (!state.items.length) { layout.remove(); continuation.remove(); return; }
|
||
if (!layout.isConnected) notice.after(layout);
|
||
renderCards(state);
|
||
renderPanel(state);
|
||
if (state.nextCursor) {
|
||
setText(moreText, `${state.items.length.toLocaleString('en-US')} of ${state.total.toLocaleString('en-US')} images`);
|
||
setDisabled(more, state.more === 'loading');
|
||
if (state.more === 'error') setText(moreText, COPY.moreFailed);
|
||
if (!continuation.isConnected) layout.after(continuation);
|
||
} else continuation.remove();
|
||
}
|
||
|
||
scope.listen(retryLoad, 'click', () => controller.load());
|
||
scope.listen(more, 'click', () => { if (!isDisabled(more)) controller.loadMore(); });
|
||
scope.listen(select, 'change', () => controller.choose(select.value));
|
||
scope.listen(apply, 'click', () => { if (!isDisabled(apply)) controller.apply(); });
|
||
scope.listen(later, 'click', () => { if (!isDisabled(later)) controller.leave(); });
|
||
scope.listen(leaveUnresolved, 'click', () => { if (!isDisabled(leaveUnresolved)) controller.leaveUnresolved(); });
|
||
scope.listen(linkButton, 'click', () => {
|
||
const item = controller.state.items.find(entry => entry.id === controller.state.selectedId);
|
||
if (isDisabled(linkButton) || !item?.target) return;
|
||
openPromptLink({ api, scope, controller, filename: item.filename, trigger: linkButton });
|
||
});
|
||
scope.listen(tagButton, 'click', () => {
|
||
const item = controller.state.items.find(entry => entry.id === controller.state.selectedId);
|
||
if (isDisabled(tagButton) || !item?.target) return;
|
||
openImageTagging({ api, scope, image: imageOf(item), source: 'review', trigger: tagButton });
|
||
});
|
||
scope.listen(grid, 'click', event => {
|
||
const card = event.target.closest('article[data-review-id]');
|
||
if (!card) return;
|
||
const { item } = card;
|
||
const button = event.target.closest('button');
|
||
if (!button || isDisabled(button)) return;
|
||
if (isTagButton(button)) {
|
||
openImageTagging({ api, scope, image: imageOf(item), source: 'review', trigger: button });
|
||
return;
|
||
}
|
||
if (button.matches('.image-open')) {
|
||
const items = controller.state.items.filter(entry => entry.url).map(imageOf);
|
||
viewer.open({ items, selectedKey: imageKey(imageOf(item)), trigger: button });
|
||
return;
|
||
}
|
||
// Info selects the item, as the concept's `inspect` does on this page (app.js a1244–1248).
|
||
controller.select(item.id);
|
||
// D189: at one column the panel cannot stay beside the queue, so Info brings it into view.
|
||
if (getComputedStyle(panel).position === 'static') panel.scrollIntoView({ block: 'nearest' });
|
||
});
|
||
|
||
let shownTotal = null;
|
||
const reportTotal = state => {
|
||
if (state.status !== 'ready' || state.total === shownTotal) return;
|
||
shownTotal = state.total;
|
||
onTotal?.(state.total);
|
||
};
|
||
scope.cleanup(controller.subscribe(render));
|
||
scope.cleanup(controller.subscribe(reportTotal));
|
||
render(controller.state);
|
||
controller.load();
|
||
connectEvents({ scope, connect, controller, live });
|
||
return { controller };
|
||
}
|
||
|
||
/**
|
||
* The change stream while the page is mounted: open while the tab is visible (I4), reopened with a bounded backoff
|
||
* after the browser gives up on it (I3), with `live` shown while it is down.
|
||
*/
|
||
function connectEvents({ scope, connect, controller, live }) {
|
||
let events = null, timer = null, delay = REOPEN_FIRST_MS;
|
||
const setLive = up => { live.hidden = up; };
|
||
function close() {
|
||
clearTimeout(timer);
|
||
timer = null;
|
||
events?.close();
|
||
events = null;
|
||
}
|
||
function open() {
|
||
if (events || scope.signal.aborted || document.hidden) return;
|
||
const source = events = connect(REVIEW_EVENTS_URL);
|
||
source.addEventListener('ready', event => {
|
||
if (source !== events || scope.signal.aborted) return;
|
||
delay = REOPEN_FIRST_MS;
|
||
setLive(true);
|
||
controller.sync(readyVersion(event));
|
||
});
|
||
source.addEventListener('change', () => { if (source === events && !scope.signal.aborted) controller.refresh(); });
|
||
source.addEventListener('error', () => {
|
||
if (source !== events || scope.signal.aborted) return;
|
||
setLive(false);
|
||
// The browser retries a dropped connection itself; one it closed for good is reopened here.
|
||
if (source.readyState !== 2) return; // EventSource.CLOSED
|
||
events = null;
|
||
source.close();
|
||
timer = setTimeout(() => { timer = null; open(); }, delay);
|
||
delay = Math.min(delay * 2, REOPEN_MAX_MS);
|
||
});
|
||
}
|
||
scope.listen(document, 'visibilitychange', () => {
|
||
if (document.hidden) close();
|
||
else open();
|
||
});
|
||
scope.cleanup(close);
|
||
open();
|
||
}
|