diff --git a/TESTING.md b/TESTING.md index e35e371..477d4b5 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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. diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index 7b94702..8969889 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -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); +}); diff --git a/tests/js/workspace-images-category.test.js b/tests/js/workspace-images-category.test.js index ba7f3aa..641d3fa 100644 --- a/tests/js/workspace-images-category.test.js +++ b/tests/js/workspace-images-category.test.js @@ -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); +}); diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index 4ca07c9..b7208fb 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -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, diff --git a/web/js/workspace/images-category.mjs b/web/js/workspace/images-category.mjs index 6bcba8c..c305c68 100644 --- a/web/js/workspace/images-category.mjs +++ b/web/js/workspace/images-category.mjs @@ -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; +} diff --git a/web/js/workspace/images-selection.mjs b/web/js/workspace/images-selection.mjs new file mode 100644 index 0000000..ec985c6 --- /dev/null +++ b/web/js/workspace/images-selection.mjs @@ -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, + }; +} diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 885a7d4..fe8afca 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -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); diff --git a/web/js/workspace/workspace-dialogs.mjs b/web/js/workspace/workspace-dialogs.mjs index 11228fb..606e9e2 100644 --- a/web/js/workspace/workspace-dialogs.mjs +++ b/web/js/workspace/workspace-dialogs.mjs @@ -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());