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:
+13
-5
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user