refactor(images): move the selection bar into images-selection.mjs and harden its actions

- images.mjs is back under 800 lines; the bar, its checked set, Export metadata and Set category live in images-selection.mjs.
- Busy flags release in finally; the dead guards are gone and the category targets are taken when the modal dialog opens.
- The category write no longer carries the page signal; leaving Images mid-write drops the saved visit so the return reloads.
- The categories and the listing reload together after Set category.
- The Category field stops at the server's 100 characters; the server's updated_count is a tested pure helper.
- New browser tests: leaving mid-export and mid-write, Blob URL revoke, updated_count 0 and a 400 rejection.
- Additive coverage baseline entries for images-category, images-export and images-selection.
- TESTING.md D8 records the dialog deviations and these rulings.
This commit is contained in:
Vito Sansevero
2026-10-20 15:37:51 -07:00
parent 4a5dade10d
commit 963c671747
8 changed files with 295 additions and 125 deletions
+13 -5
View File
@@ -512,15 +512,23 @@ Recorded deviations on this page (spec §8):
`prompt-manager-images-metadata.json` (`format`, `version: 1`, `exported_at`, and the
inspector's own "Download metadata JSON" envelopes in listing order); the button is
`aria-disabled` and ignores a second press while it runs, the selection is kept, and
the toast names the unread images (none read: no download). Set category opens the
the toast names the unread images (none read: no download); leaving Images aborts the
reads with no download and no toast. Set category opens the
concept's `simpleInput` dialog ("Categorize selected images", `input#action-input`
labelled Category, Cancel / Apply) and posts the selection's distinct linked positive
ids to `/prompt_manager/bulk/category` (images have no category; the filter matches
the linked positive's). With no linked image checked it posts nothing and opens no
dialog; an empty value keeps the dialog open; success reloads the categories and the
listing (clearing the selection, focus to the heading); failure keeps the selection.
The route rejects anything but 1-1000 positive integer ids (deduplicated) and a
category that fails `validate_category`. `images selection bar matches
dialog; an empty value keeps the dialog open; the field stops at the server's 100
characters; success reloads the categories and the listing together (clearing the
selection, focus to the heading, and, as any replaced collection does, the inspector
moves to the first listed image); failure keeps the selection. The write carries no
page signal: leaving Images while it runs lets it finish without a toast, and the return
loads the collection afresh instead of the saved visit. Three deviations from the
concept's `simpleInput`: the field starts empty (the concept pre-fills its demo
"Landscapes"), the action reads "Apply" (not "Apply in prototype"), and the
nothing-linked check runs on the Set category press, so no dialog opens. The route
rejects anything but 1-1000 positive integer ids within SQLite's INTEGER range
(deduplicated) and a category that fails `validate_category`. `images selection bar matches
the concept` compares the bar and the checked card with one card checked; the omitted
actions flow again from the concept's own boxes, checked against the concept page with
them removed at runtime.
+77
View File
@@ -1721,6 +1721,13 @@ async function bundleOf(download) {
}
test('Export metadata downloads one bundle of the checked images in listing order and keeps the selection', async ({ page }) => {
// Record the Blob URLs the page creates and revokes, to prove the download's URL is released.
await page.addInitScript(() => {
const blobs = window.__blobs = { created: [], revoked: [] };
const create = URL.createObjectURL.bind(URL), revoke = URL.revokeObjectURL.bind(URL);
URL.createObjectURL = value => { const url = create(value); blobs.created.push(url); return url; };
URL.revokeObjectURL = url => { blobs.revoked.push(url); revoke(url); };
});
await page.goto(workspace);
await checkBoth(page);
const keys = await cards(page).evaluateAll(nodes => nodes.map(node => node.dataset.imageKey));
@@ -1749,6 +1756,10 @@ test('Export metadata downloads one bundle of the checked images in listing orde
await expect(exportButton(page)).not.toHaveAttribute('aria-disabled', 'true');
expect(reads).toHaveLength(2);
expect(downloads).toHaveLength(1);
// The bundle's one Blob URL is revoked once the download started.
await expect.poll(() => page.evaluate(() => window.__blobs)).toEqual({ created: [expect.stringMatching(/^blob:/)], revoked: [expect.stringMatching(/^blob:/)] });
const blobs = await page.evaluate(() => window.__blobs);
expect(blobs.revoked).toEqual(blobs.created);
// The selection is kept after an export.
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(2);
@@ -1865,3 +1876,69 @@ test('Set category reports a failure, keeps the selection and ignores a second p
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(2);
});
test('leaving Images during an export downloads nothing and shows no toast', async ({ page }) => {
await page.goto(workspace);
await checkBoth(page);
const held = [];
await page.route(inspectRoute, route => { held.push(route); });
const downloads = [];
page.on('download', download => downloads.push(download));
await exportButton(page).click();
await expect.poll(() => held.length).toBe(2);
await page.evaluate(() => { window.location.hash = '/overview'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
// The reads were aborted with the page; answering them now changes nothing.
for (const route of held) await route.continue().catch(() => {});
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150)));
expect(downloads).toHaveLength(0);
await expect(toast(page)).not.toContainText('etadata');
});
test('leaving Images during Set category finishes the write, and the return reloads the collection', async ({ page }) => {
await page.goto(workspace);
await checkBoth(page);
const held = [];
await page.route(categoryRoute, route => { held.push(route); });
await categoryButton(page).click();
await categoryDialog(page).getByLabel('Category', { exact: true }).fill('Landscapes');
await categoryDialog(page).getByRole('button', { name: 'Apply', exact: true }).click();
await expect.poll(() => held.length).toBe(1);
await page.evaluate(() => { window.location.hash = '/overview'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
// The write is not aborted with the page: the server still receives and answers it.
const answered = page.waitForResponse(response => response.url().endsWith('/prompt_manager/bulk/category'));
await held[0].continue();
expect((await answered).ok()).toBe(true);
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150)));
await expect(toast(page)).not.toContainText('Category');
// The saved visit predates the write, so the return loads the collection afresh: no stale selection or categories.
await page.evaluate(() => { window.location.hash = '/images'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await expect(cards(page)).toHaveCount(2);
await expect(bulkbar(page)).toBeEmpty();
await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(0);
await expect(page.getByRole('combobox', { name: 'Filter by category', exact: true }).locator('option')).toContainText(['Landscapes']);
});
test('Set category limits the field to the server length, shows the server count, and a rejection keeps the selection', async ({ page }) => {
await page.goto(workspace);
await checkBoth(page);
const answers = [
{ status: 400, json: { success: false, error: 'Category cannot exceed 100 characters' } },
{ status: 200, json: { success: true, updated_count: 0 } },
];
await page.route(categoryRoute, route => route.fulfill(answers.shift()));
for (const expected of ['Category could not be set. Try again.', 'Category set on 0 prompts.']) {
await categoryButton(page).click();
const field = categoryDialog(page).getByLabel('Category', { exact: true });
await expect(field).toHaveAttribute('maxlength', '100');
await field.fill('Landscapes');
await categoryDialog(page).getByRole('button', { name: 'Apply', exact: true }).click();
await expect(toast(page)).toHaveText(expected);
if (answers.length) await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
}
// The success reload clears the selection.
await expect(bulkbar(page)).toBeEmpty();
expect(answers).toHaveLength(0);
});
@@ -37,3 +37,17 @@ test('the dialog and failure copy is the plan\'s', async () => {
assert.equal(CATEGORY_FAILED, 'Category could not be set. Try again.');
assert.ok(Object.isFrozen(CATEGORY_DIALOG));
});
test('the updated count is the server\'s when it is a count, else the number of prompts posted', async () => {
const {updatedCount} = await source;
assert.equal(updatedCount({updated_count:0}, 3), 0);
assert.equal(updatedCount({updated_count:2}, 3), 2);
for (const result of [{}, {updated_count:-1}, {updated_count:1.5}, {updated_count:'2'}, null, undefined]) {
assert.equal(updatedCount(result, 3), 3, JSON.stringify(result));
}
});
test('the category field is limited to the server\'s 100 characters', async () => {
const {CATEGORY_MAX_LENGTH} = await source;
assert.equal(CATEGORY_MAX_LENGTH, 100);
});
+18
View File
@@ -197,6 +197,24 @@
"functions": 98,
"statements": 98
},
"workspace/images-category.mjs": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"workspace/images-export.mjs": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"workspace/images-selection.mjs": {
"lines": 98,
"branches": 92,
"functions": 98,
"statements": 98
},
"workspace/images-state.mjs": {
"lines": 98,
"branches": 98,
+8
View File
@@ -7,6 +7,8 @@ import { countOf, noun } from './plural.mjs';
export const CATEGORY_DIALOG = Object.freeze({title:'Categorize selected images', label:'Category', action:'Apply'});
export const CATEGORY_NONE_LINKED = 'None of the selected images has a linked prompt.';
export const CATEGORY_FAILED = 'Category could not be set. Try again.';
// The server's limit (`validate_category`): the field stops there instead of failing on Apply.
export const CATEGORY_MAX_LENGTH = 100;
function linkedPositive(image) {
const link = image?.linked;
@@ -32,3 +34,9 @@ export function categoryOutcome(prompts, skipped) {
if (!skipped) return done;
return `${done} ${countOf(skipped, 'selected image')} had no linked prompt and ${noun(skipped, 'was', 'were')} skipped.`;
}
/** The prompts the server reports changed; without a count, the number of prompts posted. */
export function updatedCount(result, posted) {
const count = result?.updated_count;
return Number.isSafeInteger(count) && count >= 0 ? count : posted;
}
+148
View File
@@ -0,0 +1,148 @@
import { createButton } from '../shared/toolkit/core.js';
import { imageKey, toggleSelection, selectionLabel } from './images-state.mjs';
import { inspectPath, toInspectionRecord } from './inspection-state.mjs';
import { BUNDLE_FILENAME, metadataBundle, collectImagesMetadata, exportOutcome } from './images-export.mjs';
import { CATEGORY_DIALOG, CATEGORY_FAILED, CATEGORY_MAX_LENGTH, CATEGORY_NONE_LINKED, categoryOutcome, categoryTargets, updatedCount } from './images-category.mjs';
import { openInputDialog } from './workspace-dialogs.mjs';
import { showWorkspaceToast } from './toast.mjs';
const CHECKBOX = '.image-overlays input[type="checkbox"]';
const CATEGORY_URL = '/prompt_manager/bulk/category';
function el(tag, text, className) {
const element = document.createElement(tag);
if (text !== undefined) element.textContent = text;
if (className) element.className = className;
return element;
}
/**
* The Images page's checked cards and the concept's selection bar (#249 D8; app.js renderImages, updateBulk and the
* bulk handlers). `element` is the `#bulkbar` the page places between its filter row and gallery layout: empty while
* nothing is checked, else "N selected" and the real actions in the concept's order. The page renders each card's
* checkbox from `isChecked`; this module follows the boxes through the grid's `change` events.
*
* - `getItems()` is the listing in its order; `checked` restores a saved visit's selection.
* - `onCategorySet()` reloads the categories and the listing after a successful Set category (the reload clears the
* selection through `clear`); focus then moves to `focusFallback` if it was on the hidden action.
*/
export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, checked: initial = Object.freeze(new Set()) }) {
let checked = initial;
let exporting = false, categorizing = false;
const element = el('div');
element.id = 'bulkbar';
const bulk = el('div', undefined, 'bulkbar'), count = el('strong');
const setCategory = createButton('Set category');
scope.listen(setCategory, 'click', () => openCategory(setCategory));
const exportMetadata = createButton('Export metadata');
scope.listen(exportMetadata, 'click', () => exportChecked(exportMetadata));
const clearButton = createButton('Clear');
// Clear hides the bar it sits in, so focus moves to the page heading instead of falling to the page.
scope.listen(clearButton, 'click', () => { clear(); focusFallback.focus({ preventScroll: true }); });
bulk.append(count, setCategory, exportMetadata, clearButton);
/** The count updates in place: the same bar and `strong`, its text written only when it changes. */
function render() {
if (!checked.size) { bulk.remove(); return; }
const label = selectionLabel(checked.size);
if (count.textContent !== label) count.textContent = label;
if (bulk.parentNode !== element) element.append(bulk);
}
function clear() {
if (!checked.size) return;
checked = Object.freeze(new Set());
for (const box of grid.querySelectorAll(CHECKBOX)) box.checked = false;
render();
}
function checkedImages() {
return getItems().filter(image => checked.has(imageKey(image)));
}
// Every card carries its image key; its only form control is the checkbox, so a toggle never opens or inspects.
scope.listen(grid, 'change', event => {
const box = event.target;
if (!box.matches(CHECKBOX)) return;
checked = toggleSelection(checked, box.closest('article[data-image-key]').dataset.imageKey, box.checked);
render();
});
/** Busy until it settles: `aria-disabled` now, released in a `finally` so a throw cannot leave it stuck. */
function busy(button) {
button.setAttribute('aria-disabled', 'true');
return () => button.removeAttribute('aria-disabled');
}
/**
* Export metadata: the checked images' inspector envelopes in listing order, as one JSON download. A second press
* while it runs is ignored and the selection is kept. Leaving the page aborts the reads: no download, no toast.
*/
async function exportChecked(button) {
if (exporting) return;
exporting = true;
const release = busy(button);
try {
const { envelopes, failed } = await collectImagesMetadata(checkedImages(),
async image => toInspectionRecord(await api.get(inspectPath(image), { signal: scope.signal }), image));
if (scope.signal.aborted) return;
if (envelopes.length) downloadJson(BUNDLE_FILENAME, metadataBundle(envelopes, new Date().toISOString()));
showWorkspaceToast(exportOutcome(envelopes.length, envelopes.length + failed));
} finally {
exporting = false;
release();
}
}
/** A Blob, an anchor and a deferred revoke, as the concept's exportSample does. */
function downloadJson(filename, value) {
const url = URL.createObjectURL(new Blob([JSON.stringify(value, null, 2)], { type: 'application/json' }));
let timer, released = false;
const release = () => { if (released) return; released = true; clearTimeout(timer); URL.revokeObjectURL(url); };
scope.cleanup(release);
const anchor = el('a'); anchor.href = url; anchor.download = filename;
element.append(anchor); anchor.click(); anchor.remove();
timer = setTimeout(release, 1000);
}
/**
* Set category (app.js bulk-category → simpleInput): images have no category, so Apply sets it on the selection's
* distinct linked positives, taken when the (modal) dialog opens. With no linked image there is nothing to change:
* a toast says so and no dialog opens.
*/
function openCategory(button) {
if (categorizing) return;
const targets = categoryTargets(checkedImages());
if (!targets.promptIds.length) { showWorkspaceToast(CATEGORY_NONE_LINKED); return; }
openInputDialog({ scope, trigger: button, title: CATEGORY_DIALOG.title, label: CATEGORY_DIALOG.label,
action: CATEGORY_DIALOG.action, maxLength: CATEGORY_MAX_LENGTH, onApply: category => applyCategory(button, targets, category) });
}
/**
* The write carries no page signal: once sent it finishes even if the page is left (`writing` then tells the page
* not to save a visit that predates it). A failure keeps the selection; a success reloads through `onCategorySet`.
*/
async function applyCategory(button, { promptIds, skipped }, category) {
categorizing = true;
const release = busy(button);
let result = null;
try {
result = await api.post(CATEGORY_URL, { prompt_ids: promptIds, category });
} catch {
// Reported below; the selection stays as it was.
} finally {
categorizing = false;
release();
}
if (scope.signal.aborted) return;
if (!result) { showWorkspaceToast(CATEGORY_FAILED); return; }
showWorkspaceToast(categoryOutcome(updatedCount(result, promptIds.length), skipped));
const focused = document.activeElement === button;
onCategorySet();
// The reload hides the bar with the focused action in it, as Clear does: focus moves to the page heading.
if (focused) focusFallback.focus({ preventScroll: true });
}
render();
return {
element,
isChecked: key => checked.has(key),
checked: () => checked,
clear,
/** Whether a Set category write is still in flight. */
writing: () => categorizing,
};
}
+14 -119
View File
@@ -1,9 +1,9 @@
import { createScope, createButton } from '../shared/toolkit/core.js';
import { createImageStream, withItem, imageKey, toggleSelection, selectionLabel, countLine, captionDate, scopeLabel, cardTitle, cardSubtitle, mediaRatio } from './images-state.mjs';
import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, cardTitle, cardSubtitle, mediaRatio } from './images-state.mjs';
import { layoutImages, visibleImages, gridMetrics, MINIMUM_CARD_WIDTH } from './image-layout.mjs';
import { mountFileInspector } from './file-inspector.mjs';
import { createInspectionController, inspectPath, toInspectionRecord } from './inspection-state.mjs';
import { BUNDLE_FILENAME, metadataBundle, collectImagesMetadata, exportOutcome } from './images-export.mjs';
import { createInspectionController } from './inspection-state.mjs';
import { mountSelectionBar } from './images-selection.mjs';
import { createPromptSender } from './prompt-sender.mjs';
import { promptRoles } from './prompt-blocks.mjs';
import { createWorkspaceIcon } from './icons.mjs';
@@ -11,8 +11,7 @@ import { createMediaViewer } from './media-viewer.mjs';
import { noun } from './plural.mjs';
import { pageHeading } from './heading.mjs';
import { mountToolbar } from './toolbar.mjs';
import { CATEGORY_DIALOG, CATEGORY_FAILED, CATEGORY_NONE_LINKED, categoryOutcome, categoryTargets } from './images-category.mjs';
import { openImportDialog, openInputDialog, openRefineDialog } from './workspace-dialogs.mjs';
import { openImportDialog, openRefineDialog } from './workspace-dialogs.mjs';
import { showWorkspaceToast } from './toast.mjs';
function el(tag, text, className) {
@@ -107,12 +106,11 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
let roots = [], folders = [];
const rendered = new Map();
const itemIndexes = new Map();
// The checked cards (#249 D8): image keys, kept across appended batches and cleared with the collection.
let checked = visit?.checked || Object.freeze(new Set());
const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...visit?.filters };
filters.tags = [...filters.tags];
scope.cleanup(() => {
if (!embedded) lastVisit = state ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked, scrollY: window.scrollY } : null;
// A Set category write still in flight may change what the saved collection shows: the return reloads instead.
if (!embedded) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
cancelAnimationFrame(restoreFrame);
});
main.classList.add('images-main');
@@ -178,24 +176,6 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
filterRow.append(chip, group);
}
main.append(filterRow);
// The concept's selection bar (app.js renderImages, updateBulk), between the filter row and the gallery layout.
// One bar and count, mounted while anything is checked, so the count updates in place.
// Only the Images page builds it: the Library-embedded gallery has no selection.
const bulkbar = embedded ? null : el('div');
let bulk, bulkCount;
if (bulkbar) {
bulkbar.id = 'bulkbar';
bulk = el('div', undefined, 'bulkbar'); bulkCount = el('strong');
const setCategory = createButton('Set category');
scope.listen(setCategory, 'click', () => openCategory(setCategory));
const exportMetadata = createButton('Export metadata');
scope.listen(exportMetadata, 'click', () => exportChecked(exportMetadata));
const clear = createButton('Clear');
// Clear hides the bar it sits in, so focus moves to the page heading instead of falling to the page.
scope.listen(clear, 'click', () => { clearChecked(); focusFallback.focus({preventScroll:true}); });
bulk.append(bulkCount, setCategory, exportMetadata, clear);
main.append(bulkbar);
}
const body = el('div', undefined, 'gallery-layout image-workspace');
const collection = el('section', undefined, 'gallery-body');
collection.setAttribute('aria-label', 'Images');
@@ -231,6 +211,11 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
details.setAttribute('aria-label', 'Image details');
details.append(el('h2', 'Image details'), el('p', 'Select an image to inspect it.'));
body.append(collection, details);
// The checked cards and the concept's selection bar (#249 D8), between the filter row and the gallery layout. The
// selection is kept across appended batches and cleared with the collection; the Library-embedded gallery has none.
const bar = embedded ? null : mountSelectionBar({scope, api, grid, getItems:() => state.items, focusFallback,
checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); }});
if (bar) main.append(bar.element);
main.append(body);
const mediaViewer = createMediaViewer({container:main,parentScope:scope,inspectionController:inspection,sender,
onSelect:image => {if (selection !== imageKey(image)) inspect(image);else inspection.select(image);},
@@ -288,9 +273,9 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
// D8: the selection checkbox, then D7: the star, wherever the listing reports favorites (neither on
// Library-embedded media, which has no selection bar).
const overlays = el('div', undefined, 'image-overlays');
if (bulkbar) {
if (bar) {
const label = el('label'), box = el('input');
box.type = 'checkbox'; box.checked = checked.has(key);
box.type = 'checkbox'; box.checked = bar.isChecked(key);
box.setAttribute('aria-label', `Select ${image.filename}`);
label.append(box);
overlays.append(label);
@@ -353,95 +338,6 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
deliverFavorite(key, saved ? favorite : !favorite);
showWorkspaceToast(saved ? FAVORITE_DONE[favorite] : FAVORITE_FAILED[favorite]);
}
/** The concept's bar: empty while nothing is checked, else "N selected" and the real actions in its order. */
function renderBulk() {
if (!bulkbar) return;
if (!checked.size) { bulk.remove(); return; }
setText(bulkCount, selectionLabel(checked.size));
if (bulk.parentNode !== bulkbar) bulkbar.append(bulk);
}
/**
* Export metadata: the checked images' inspector envelopes in listing order, as one JSON download. The button is
* busy until it settles and ignores a second press; the selection is kept.
*/
let exporting = false;
async function exportChecked(button) {
if (exporting) return;
exporting = true;
button.setAttribute('aria-disabled', 'true');
const images = checkedImages();
const {envelopes, failed} = await collectImagesMetadata(images,
async image => toInspectionRecord(await api.get(inspectPath(image), {signal:scope.signal}), image));
if (scope.signal.aborted) return;
exporting = false;
button.removeAttribute('aria-disabled');
if (envelopes.length) downloadJson(BUNDLE_FILENAME, metadataBundle(envelopes, new Date().toISOString()));
showWorkspaceToast(exportOutcome(envelopes.length, envelopes.length + failed));
}
/**
* Set category (app.js bulk-category → simpleInput): images have no category, so Apply sets it on the selection's
* distinct linked positives, then reloads the categories and the listing, which clears the selection. With no
* linked image there is nothing to change: a toast says so and no dialog opens. A failure keeps the selection.
*/
let categorizing = false;
function openCategory(button) {
if (categorizing) return;
if (!categoryTargets(checkedImages()).promptIds.length) { showWorkspaceToast(CATEGORY_NONE_LINKED); return; }
openInputDialog({scope, trigger:button, title:CATEGORY_DIALOG.title, label:CATEGORY_DIALOG.label,
action:CATEGORY_DIALOG.action, onApply:category => applyCategory(button, category)});
}
async function applyCategory(button, category) {
if (categorizing) return;
const {promptIds, skipped} = categoryTargets(checkedImages());
if (!promptIds.length) { showWorkspaceToast(CATEGORY_NONE_LINKED); return; }
categorizing = true;
button.setAttribute('aria-disabled', 'true');
let result = null;
try {
result = await api.post('/prompt_manager/bulk/category', {prompt_ids:promptIds, category}, {signal:scope.signal});
} catch {
// Reported below; the selection stays as it was.
}
if (scope.signal.aborted) return;
categorizing = false;
button.removeAttribute('aria-disabled');
if (!result) { showWorkspaceToast(CATEGORY_FAILED); return; }
const updated = Number.isSafeInteger(result.updated_count) && result.updated_count >= 0 ? result.updated_count : promptIds.length;
showWorkspaceToast(categoryOutcome(updated, skipped));
await loadCategories();
if (scope.signal.aborted) return;
const focused = document.activeElement === button;
reset();
// The reload hides the bar with the focused action in it, as Clear does: focus moves to the page heading.
if (focused) focusFallback.focus({preventScroll:true});
}
function checkedImages() {
return state.items.filter(image => checked.has(imageKey(image)));
}
/** A Blob, an anchor and a deferred revoke, as the concept's exportSample does. */
function downloadJson(filename, value) {
const url = URL.createObjectURL(new Blob([JSON.stringify(value, null, 2)], {type:'application/json'}));
let timer, released = false;
const release = () => { if (released) return; released = true; clearTimeout(timer); URL.revokeObjectURL(url); };
scope.cleanup(release);
const anchor = el('a'); anchor.href = url; anchor.download = filename;
main.append(anchor); anchor.click(); anchor.remove();
timer = setTimeout(release, 1000);
}
function clearChecked() {
if (!checked.size) return;
checked = Object.freeze(new Set());
for (const box of grid.querySelectorAll('.image-overlays input[type="checkbox"]')) box.checked = false;
renderBulk();
}
scope.listen(grid, 'change', event => {
const box = event.target;
if (!box.matches('.image-overlays input[type="checkbox"]')) return;
const key = box.closest('article[data-image-key]')?.dataset.imageKey;
if (key === undefined) return;
checked = toggleSelection(checked, key, box.checked);
renderBulk();
});
scope.listen(grid, 'click', event => {
const card = event.target.closest('article[data-image-key]');
if (!card) return;
@@ -671,7 +567,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
selection = null;
focusAfterLoad = null;
// The ruled selection lifetime: a replaced collection may no longer hold the checked files.
clearChecked();
bar?.clear();
for (const video of details.querySelectorAll('video')) releaseVideo(video);
updateScope();
stream.reset(filters, {load:!recovering});
@@ -808,7 +704,6 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
// The page's one discovery alert is written once both first loads settled (#249 final review m6).
if (!embedded) Promise.all([loadOptions(), loadCategories()]).then(() => { if (!scope.signal.aborted) renderLoadStatus(); });
if (visit) {
renderBulk();
renderState(stream.state);
const selected = stream.state.items.find(image => imageKey(image) === selection);
if (selected) inspect(selected);
+3 -1
View File
@@ -263,8 +263,9 @@ export function openCopyFallback(text, { scope, trigger = null } = {}) {
/**
* The concept's `simpleInput`: one labelled `input#action-input` (starting at `value`), then Cancel and the primary
* `action`. The action reports the trimmed value and closes; an empty value keeps the dialog open on its field.
* A positive `maxLength` limits the field.
*/
export function openInputDialog({ scope, title, label, action, value = '', onApply, trigger = null }) {
export function openInputDialog({ scope, title, label, action, value = '', maxLength, onApply, trigger = null }) {
requireScope(scope);
requireCallbacks({ onApply });
for (const [name, text] of Object.entries({ title, label, action })) {
@@ -272,6 +273,7 @@ export function openInputDialog({ scope, title, label, action, value = '', onApp
}
const input = el('input');
input.value = typeof value === 'string' ? value : '';
if (Number.isSafeInteger(maxLength) && maxLength > 0) input.maxLength = maxLength;
const cancel = createButton('Cancel');
const apply = createButton(action, 'ca-primary');
cancel.addEventListener('click', () => finish());