Review of #249 C2: - Load more and Retry focus the first appended image, as the concept does (app.js loadMoreImages). Focus moves without scrolling so the visible cards keep their place (bounded-DOM anchor), and only while focus is still on the pressed control: a viewer or a field the user moved to keeps it. When a last page adds nothing and the button hides, focus goes to the last image, never to the page; a failed page focuses Retry. - The concept's empty state beside the grid: .empty "No matching images. Try another search or category." - D17: the caption date shows only under Newest first (", YYYY" outside the current year) and is hidden under Oldest first and the name and rating sorts. - en-US number grouping in the embedded count too. - Refine "Folder" defaults to the concept's "All output folders". It is not narrowed by Source: the concept has no Source field, and the list holds every source's folders, so any pair can be chosen in one dialog. - The shared toast moves to toast.mjs; library-editor re-exports it. - The D32 overlay (extreme zoom) wraps the layout segmented control and the continuation row instead of pushing them out of the 390px column. - The legacy gallery checks moved to Overview's "Open existing image gallery" card (D29). Mapping additions: grid with no results -> .empty concept copy beside the grid; Load more press -> focus the first new image (concept).
374 lines
20 KiB
JavaScript
374 lines
20 KiB
JavaScript
/**
|
||
* Native recipe editor dialogs: New prompt, Edit recipe metadata, Create text
|
||
* revision, Add a negative variant, and the pair archive confirmation.
|
||
*
|
||
* Markup, labels, order and native datalists follow the approved concept. All
|
||
* reads, writes, receipts and recovery live in library-editor-state.mjs; this
|
||
* module only renders `controller.state` and forwards user actions.
|
||
*/
|
||
import { createButton } from '../shared/toolkit/core.js';
|
||
import { createLibraryEditorController } from './library-editor-state.mjs';
|
||
|
||
const TITLES = {new: 'New prompt', metadata: 'Edit recipe metadata', revision: 'Create text revision', negative: 'Add a negative variant'};
|
||
const RATINGS = [['5', '5 / 5'], ['4', '4 / 5'], ['3', '3 / 5'], ['2', '2 / 5'], ['1', '1 / 5'], ['0', 'Unrated']];
|
||
const FIELD_IDS = {title: 'recipe-title', positive_text: 'recipe-positive', negative_text: 'recipe-negative', category: 'recipe-category',
|
||
tags: 'recipe-tags', rating: 'recipe-rating', notes: 'recipe-notes'};
|
||
const SUBMITTABLE = new Set(['ready', 'write-error']);
|
||
const NEGATIVE_HELP = 'Leave empty for an explicitly empty negative. The positive stays unchanged.';
|
||
const NO_PAIR = 'Archive needs a selected saved negative variant.';
|
||
const ALREADY_ARCHIVED = 'This negative variant is already archived.';
|
||
const REVIEW = 'Loaded the latest saved values. Review your entries, then save.';
|
||
const REVIEW_ARCHIVE = 'Loaded the latest saved values. Review them, then archive.';
|
||
const REFRESH = ['Reload Library', 'refresh'];
|
||
// The concept has no discard confirmation (its Cancel and × simply close), so the state says it inline:
|
||
// every exit (Reload Library, Cancel, × or Escape) drops the draft.
|
||
const DISCARDS = 'Leaving this dialog discards your entries.';
|
||
// The toast lives in the shell module; re-exported for the pages that already import it from here.
|
||
export { showWorkspaceToast } from './toast.mjs';
|
||
|
||
function el(tag, text, className) {
|
||
const node = document.createElement(tag);
|
||
if (text !== undefined) node.textContent = text;
|
||
if (className) node.className = className;
|
||
return node;
|
||
}
|
||
|
||
/**
|
||
* Inline state copy and its one recovery action; `archive` selects the pair-archive
|
||
* wording. After a restore only New prompt may re-read: every other mode names an
|
||
* owner by its old numeric ID, so it offers only the Library refresh exit, which
|
||
* discards the draft.
|
||
*/
|
||
function statusOf(state, archive, mode = null) {
|
||
const nothing = archive ? 'Nothing was archived.' : 'Nothing was saved.';
|
||
const message = state.error?.message;
|
||
switch (state.phase) {
|
||
case 'initializing': return {text: 'Loading the latest saved values…'};
|
||
case 'initialization-error': return {text: `The latest saved values could not be loaded. ${nothing}`, action: ['Retry', 'retryInitialization']};
|
||
case 'saving': return {text: archive ? 'Archiving… The dialog stays open until the server answers.' : 'Saving… The dialog stays open until the server answers.'};
|
||
case 'write-error': return {text: `${archive ? 'Not archived' : 'Not saved'}: ${message} Your entries are kept.`};
|
||
case 'conflict': return {text: archive ? 'This negative variant changed since it was loaded. Reload it and review it before archiving.'
|
||
: 'This prompt changed since it was loaded. Reload it and review your entries before saving.', action: ['Reload', 'reload']};
|
||
case 'storage-changed': return mode === 'new' ? {text: `Prompt storage was restored since this dialog opened. ${nothing}`, action: ['Reload Library state', 'reload']}
|
||
: {text: `Prompt storage was restored since this dialog opened. ${nothing}${archive ? '' : ` ${DISCARDS}`}`, action: REFRESH};
|
||
case 'unknown-outcome': return {text: archive ? 'Archive could not be confirmed. Retry sends the same archive once.'
|
||
: mode === 'metadata' ? 'Save could not be confirmed. Your entries are kept; Retry reloads the latest saved values for review and sends nothing.'
|
||
: 'Save could not be confirmed. Your entries are kept; Retry sends the same save once.', action: ['Retry', 'retryUnconfirmed']};
|
||
case 'saved-refresh-error':
|
||
// A refresh that found restored storage cannot recover by re-reading; only reloading the Library view can.
|
||
if (state.error?.code === 'storage-changed') {
|
||
return {text: `${archive ? 'Archived' : 'Saved'}; Library refresh failed because prompt storage was restored. Reload the Library to see current data.`, action: REFRESH};
|
||
}
|
||
return {text: archive ? 'Archived; Library refresh failed.' : 'Saved; Library refresh failed.', action: ['Retry refresh', 'retryReconciliation']};
|
||
case 'ready': return message ? {text: message} : state.reviewRequired ? {text: archive ? REVIEW_ARCHIVE : REVIEW} : null;
|
||
default: return null;
|
||
}
|
||
}
|
||
|
||
/** One persistent inline status in the concept's .warning-note style, so live updates are announced. */
|
||
function statusLine() {
|
||
const note = el('p', '', 'warning-note');
|
||
note.id = 'library-editor-status';
|
||
note.setAttribute('role', 'status');
|
||
note.hidden = true;
|
||
return {note, set(text) {
|
||
if (note.textContent !== (text || '')) note.textContent = text || '';
|
||
note.hidden = !text;
|
||
}};
|
||
}
|
||
/** Re-parent only on a real change: moving a focused button would drop its focus. */
|
||
function setChildren(parent, nodes) {
|
||
const current = [...parent.children];
|
||
if (current.length !== nodes.length || current.some((node, index) => node !== nodes[index])) parent.replaceChildren(...nodes);
|
||
}
|
||
|
||
/** Saving or re-reading disables or removes the focused action; focus would drop to the body. */
|
||
const SETTLING = new Set(['saving', 'initializing']);
|
||
const restoredStorage = state => state.phase === 'storage-changed' || (state.phase === 'saved-refresh-error' && state.error?.code === 'storage-changed');
|
||
/**
|
||
* The action that held focus when a write or re-read began gets it back once that
|
||
* settles; when it is gone or disabled (Save after an unconfirmed write), focus moves
|
||
* to the first usable fallback, the recovery action first, and never to the body.
|
||
*/
|
||
function restoreFocus(button, state, fallbacks) {
|
||
if (!button || SETTLING.has(state.phase)) return button;
|
||
if (state.phase !== 'complete' && (!document.activeElement || document.activeElement === document.body)) {
|
||
[button, ...fallbacks].find(node => node?.isConnected && !node.disabled)?.focus();
|
||
}
|
||
return null;
|
||
}
|
||
/** Remember a focused dialog action while the phase settles. */
|
||
function settlingFocus(actions, state, current) {
|
||
return SETTLING.has(state.phase) && actions.contains(document.activeElement) ? document.activeElement : current;
|
||
}
|
||
|
||
function field(label, control, id) {
|
||
const wrapper = el('div', undefined, 'field');
|
||
const caption = el('label', label);
|
||
caption.htmlFor = id;
|
||
control.id = id;
|
||
wrapper.append(caption, control);
|
||
return wrapper;
|
||
}
|
||
function datalist(id, values) {
|
||
const list = el('datalist');
|
||
list.id = id;
|
||
list.append(...values.map(value => Object.assign(document.createElement('option'), {value})));
|
||
return list;
|
||
}
|
||
|
||
/**
|
||
* `onReconciled(result, {outcome, draft})` receives the reconciled result with the
|
||
* controller's outcome and submitted draft. `onRefreshRequired()` is the explicit
|
||
* exit when prompt storage was restored; the dialog closes first.
|
||
*/
|
||
export function createLibraryEditor({api, parentScope, onCommitted = () => {}, onReconciled = () => {},
|
||
onRefreshRequired = () => window.location.reload()}) {
|
||
let session = null, disposed = false;
|
||
const dialog = el('dialog', undefined, 'library-editor');
|
||
dialog.setAttribute('aria-labelledby', 'library-editor-title');
|
||
const header = el('div', undefined, 'dialog-header');
|
||
const title = el('h2');
|
||
title.id = 'library-editor-title';
|
||
const close = createButton('×');
|
||
close.setAttribute('aria-label', 'Close dialog');
|
||
const body = el('div');
|
||
header.append(title, close);
|
||
dialog.append(header, body);
|
||
|
||
const busy = () => session?.controller.state.phase === 'saving';
|
||
close.addEventListener('click', () => finish());
|
||
dialog.addEventListener('cancel', event => {
|
||
event.preventDefault();
|
||
if (!busy()) finish();
|
||
});
|
||
// A browser may still force-close a modal; treat that as a dismissal without claiming rollback.
|
||
dialog.addEventListener('close', () => {if (!dialog.open) finish();});
|
||
|
||
function finish() {
|
||
if (!session) return;
|
||
const {controller, trigger, views} = session;
|
||
session = null;
|
||
views.abort();
|
||
controller.dispose();
|
||
if (dialog.open) dialog.close();
|
||
dialog.remove();
|
||
if (trigger?.isConnected) trigger.focus();
|
||
}
|
||
|
||
function show(heading) {
|
||
title.textContent = heading;
|
||
close.disabled = false;
|
||
if (!dialog.isConnected) document.body.append(dialog);
|
||
if (!dialog.open) dialog.showModal();
|
||
close.focus();
|
||
}
|
||
|
||
/** The one recovery action for a phase, created once per view and reused across renders. */
|
||
function recoveryButton(cache, action, signal) {
|
||
if (!action) return null;
|
||
const [label, method] = action;
|
||
if (!cache.has(label)) {
|
||
const control = createButton(label);
|
||
control.addEventListener('click', () => {
|
||
const {controller} = session;
|
||
if (method === 'refresh') {finish(); onRefreshRequired(); return;}
|
||
if (method === 'reload') controller.reloadOwner({keepDraft: true});
|
||
else controller[method]();
|
||
}, {signal});
|
||
cache.set(label, control);
|
||
}
|
||
return cache.get(label);
|
||
}
|
||
|
||
function controllerFor(mode, positiveId, pairId) {
|
||
const controller = createLibraryEditorController({api, mode, positiveId, pairId, onCommitted,
|
||
onReconciled: result => onReconciled(result, {outcome: controller.state.outcome, draft: controller.state.draft}),
|
||
onChange: state => session?.render(state)});
|
||
return controller;
|
||
}
|
||
|
||
function editorView({mode, positiveId, pairId, draft}) {
|
||
const views = new AbortController();
|
||
const controller = controllerFor(mode, positiveId, pairId);
|
||
const negativeOnly = mode === 'negative';
|
||
const readOnly = mode === 'metadata';
|
||
const form = el('form');
|
||
const controls = {};
|
||
const textarea = (name, required = false) => {
|
||
const node = el('textarea');
|
||
node.required = required;
|
||
node.readOnly = readOnly && (name === 'positive_text' || name === 'negative_text');
|
||
return controls[name] = node;
|
||
};
|
||
const input = (name, required = false) => {
|
||
const node = el('input');
|
||
node.required = required;
|
||
return controls[name] = node;
|
||
};
|
||
if (!negativeOnly) {
|
||
form.append(field('Title', input('title', true), FIELD_IDS.title),
|
||
field('Positive prompt', textarea('positive_text', true), FIELD_IDS.positive_text));
|
||
}
|
||
form.append(field(negativeOnly ? 'Negative prompt' : 'Negative prompt (optional)', textarea('negative_text'), FIELD_IDS.negative_text));
|
||
const categories = datalist('categories', []), tags = datalist('tag-options', []);
|
||
if (!negativeOnly) {
|
||
const columns = el('div', undefined, 'two-col');
|
||
const category = input('category'), tagInput = input('tags');
|
||
category.setAttribute('list', 'categories');
|
||
tagInput.setAttribute('list', 'tag-options');
|
||
columns.append(field('Category', category, FIELD_IDS.category), field('Tags, separated by commas', tagInput, FIELD_IDS.tags));
|
||
const rating = controls.rating = el('select');
|
||
for (const [value, text] of RATINGS) rating.add(new Option(text, value));
|
||
form.append(columns, categories, tags, field('Rating', rating, FIELD_IDS.rating), field('Notes', textarea('notes'), FIELD_IDS.notes));
|
||
} else form.append(el('p', NEGATIVE_HELP, 'small'));
|
||
const line = statusLine(), recovery = new Map();
|
||
const actions = el('div', undefined, 'dialog-actions');
|
||
const revise = createButton('Create text revision'), archive = createButton('Archive…', 'danger');
|
||
const cancel = createButton('Cancel'), save = createButton('Save', 'ca-primary');
|
||
save.type = 'submit';
|
||
form.append(line.note, actions);
|
||
|
||
for (const [name, node] of Object.entries(controls)) {
|
||
node.addEventListener(node.tagName === 'SELECT' ? 'change' : 'input', () => {
|
||
controller.setField(name, name === 'rating' && node.value === '0' ? null : node.value);
|
||
}, {signal: views.signal});
|
||
}
|
||
form.addEventListener('submit', event => {
|
||
event.preventDefault();
|
||
controller.submit();
|
||
}, {signal: views.signal});
|
||
cancel.addEventListener('click', () => finish(), {signal: views.signal});
|
||
revise.addEventListener('click', () => open({mode: 'revision', positiveId, pairId, trigger: session.trigger}), {signal: views.signal});
|
||
archive.addEventListener('click', () => archiveView(), {signal: views.signal});
|
||
if (!negativeOnly) loadVocabulary(tags, views.signal);
|
||
|
||
let categoryKey = null, refocus = null;
|
||
function render(state) {
|
||
for (const [name, node] of Object.entries(controls)) {
|
||
const value = name === 'rating' ? String(state.draft.rating ?? 0) : state.draft[name];
|
||
if (node.value !== value) node.value = value;
|
||
}
|
||
const key = state.categories.join('\n');
|
||
if (key !== categoryKey) {
|
||
categoryKey = key;
|
||
categories.replaceChildren(...datalist('', state.categories).children);
|
||
}
|
||
const saving = state.phase === 'saving';
|
||
refocus = settlingFocus(actions, state, refocus);
|
||
save.disabled = !SUBMITTABLE.has(state.phase);
|
||
cancel.disabled = close.disabled = saving;
|
||
// After a restore the numeric IDs may name another prompt; only the Library reload exit stays.
|
||
revise.disabled = saving || restoredStorage(state);
|
||
const pairProblem = readOnly && !state.pair && pairId === null ? NO_PAIR : state.pair?.archived ? ALREADY_ARCHIVED : null;
|
||
archive.disabled = !SUBMITTABLE.has(state.phase) || !state.pair || state.pair.archived;
|
||
const status = statusOf(state, false, mode);
|
||
line.set(status?.text ?? pairProblem);
|
||
if (!status && pairProblem) archive.setAttribute('aria-describedby', line.note.id);
|
||
else archive.removeAttribute('aria-describedby');
|
||
const retry = recoveryButton(recovery, status?.action, views.signal);
|
||
setChildren(actions, [...(retry ? [retry] : []), ...(readOnly ? [revise, archive] : []), cancel, save]);
|
||
refocus = restoreFocus(refocus, state, [retry, save]);
|
||
if (state.phase === 'complete') finish();
|
||
}
|
||
|
||
body.replaceChildren(form);
|
||
return {controller, views, render, start() {
|
||
if (draft) for (const [name, value] of Object.entries(draft)) controller.setField(name, value);
|
||
render(controller.state);
|
||
controller.initialize();
|
||
}};
|
||
}
|
||
|
||
async function loadVocabulary(list, signal) {
|
||
try {
|
||
const result = await api.get('/prompt_manager/tags', {signal, cache: 'no-store'});
|
||
if (signal.aborted || !Array.isArray(result.tags)) return;
|
||
list.replaceChildren(...datalist('', result.tags.filter(tag => typeof tag === 'string').map(tag => `${tag},`)).children);
|
||
} catch {
|
||
// Tag suggestions are auxiliary vocabulary; the field stays fully usable without them.
|
||
}
|
||
}
|
||
|
||
/** Swap the open Edit dialog to the pair archive confirmation, reusing its controller. */
|
||
function archiveView() {
|
||
const {controller, trigger} = session;
|
||
// An Edit save's failure or validation message is not about the pair; the confirmation starts clean.
|
||
const inherited = controller.state.error;
|
||
session.views.abort();
|
||
const views = new AbortController();
|
||
const block = el('section', undefined, 'prompt-block prompt-negative');
|
||
block.setAttribute('aria-label', 'Negative prompt');
|
||
const blockHeading = el('div', undefined, 'prompt-block-heading');
|
||
blockHeading.append(el('h3', 'Negative prompt'));
|
||
const negative = el('div');
|
||
block.append(blockHeading, negative);
|
||
const links = el('p', undefined, 'small');
|
||
const restore = el('p', 'You can restore it from Show archived in Negative variants.', 'small');
|
||
const line = statusLine(), recovery = new Map();
|
||
let refocus = null;
|
||
const actions = el('div', undefined, 'dialog-actions');
|
||
const reread = createButton('Retry');
|
||
reread.addEventListener('click', () => controller.refreshPair(), {signal: views.signal});
|
||
const cancel = createButton('Cancel'), confirm = createButton('Archive', 'danger');
|
||
cancel.addEventListener('click', () => finish(), {signal: views.signal});
|
||
confirm.addEventListener('click', () => controller.setArchive(true), {signal: views.signal});
|
||
let shownNegative = null;
|
||
|
||
function render(state) {
|
||
const {pair} = state;
|
||
if (pair && pair.negative_text !== shownNegative) {
|
||
shownNegative = pair.negative_text;
|
||
negative.replaceChildren(shownNegative === '' ? el('p', 'Empty negative prompt', 'prompt-empty') : el('pre', shownNegative, 'prompt-text'));
|
||
}
|
||
// Counts come only from the controller's fresh exact-ID pair read.
|
||
const counted = state.pairRead === 'fresh' && Boolean(pair), readFailed = state.pairRead === 'error';
|
||
if (counted) {
|
||
const images = pair.image_count, runs = pair.run_count;
|
||
links.textContent = `${images} image${images === 1 ? '' : 's'} and ${runs} recorded run${runs === 1 ? '' : 's'} stay linked.`;
|
||
}
|
||
const status = state.error && state.error === inherited ? null : statusOf(state, true);
|
||
line.set(status?.text ?? (readFailed ? 'Linked images and recorded runs could not be read. Nothing was archived.'
|
||
: counted ? null : 'Checking linked images and recorded runs…'));
|
||
const retry = recoveryButton(recovery, status?.action, views.signal) ?? (readFailed ? reread : null);
|
||
links.hidden = !counted;
|
||
const saving = state.phase === 'saving';
|
||
refocus = settlingFocus(actions, state, refocus);
|
||
confirm.disabled = !counted || !SUBMITTABLE.has(state.phase);
|
||
cancel.disabled = close.disabled = saving;
|
||
setChildren(actions, [...(retry ? [retry] : []), cancel, confirm]);
|
||
refocus = restoreFocus(refocus, state, [retry, confirm]);
|
||
if (state.phase === 'complete') finish();
|
||
}
|
||
|
||
body.replaceChildren(block, links, restore, line.note, actions);
|
||
session = {controller, trigger, views, render};
|
||
show('Archive negative variant');
|
||
controller.refreshPair();
|
||
render(controller.state);
|
||
}
|
||
|
||
function open({mode, positiveId = null, pairId = null, trigger = null, draft = null}) {
|
||
if (disposed) return;
|
||
const previous = session;
|
||
if (previous) {
|
||
// Swap the same open dialog's content, as the concept does; the old controller stops here.
|
||
previous.views.abort();
|
||
previous.controller.dispose();
|
||
}
|
||
const view = editorView({mode, positiveId, pairId, draft});
|
||
session = {controller: view.controller, trigger: trigger ?? previous?.trigger ?? null, views: view.views, render: view.render};
|
||
show(TITLES[mode]);
|
||
view.start();
|
||
}
|
||
|
||
function dispose() {
|
||
if (disposed) return;
|
||
finish();
|
||
disposed = true;
|
||
}
|
||
if (parentScope.signal.aborted) dispose();
|
||
else parentScope.signal.addEventListener('abort', dispose, {once: true});
|
||
return {open, dispose};
|
||
}
|