feat(images): favorite images from the card
This commit is contained in:
+5
-1
@@ -528,7 +528,11 @@ Favorites store (D7 = A): `PUT` and `DELETE /prompt_manager/images/favorite` wit
|
||||
root or a malformed body, and 403 for traversal, an absolute or drive path, NUL, a
|
||||
non-media name, or a key over the stored bound. PUT needs a listed file (404
|
||||
otherwise). DELETE also clears a path that is no longer listed. Send the DELETE
|
||||
body as `application/json`.
|
||||
body as `application/json` (`api.del(url, {body})` in the shared API client).
|
||||
The Images card star (#249 C5) toggles optimistically, allows one request per file
|
||||
at a time, and on any failure restores the previous state and announces it in the
|
||||
shared toast. The browser fixtures clear `image_favorites` on reset, and the
|
||||
concept fixture stars the concept's favorites (each other prompt's first image).
|
||||
|
||||
- The table is `image_favorites(root_key, relative_path, created_at)`. `root_key`
|
||||
is the root's real path (`os.path.realpath`), case-normalized with `/`
|
||||
|
||||
@@ -132,6 +132,7 @@ def create_app(root: Path) -> web.Application:
|
||||
) -> None:
|
||||
with api.db.model.get_connection() as conn:
|
||||
for table in (
|
||||
"image_favorites",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
|
||||
@@ -89,7 +89,7 @@ test('image selection is shown on the selected card', async ({ page }) => {
|
||||
await selected.click();
|
||||
await expect(cards(page).last()).toHaveClass(/\bselected\b/);
|
||||
await expect(cards(page).first()).not.toHaveClass(/\bselected\b/);
|
||||
await expect(cards(page).locator('[aria-pressed]')).toHaveCount(0);
|
||||
await expect(cards(page).locator('.image-card-actions [aria-pressed]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('closing details preview restores its trigger focus', async ({ page }) => {
|
||||
@@ -1013,9 +1013,10 @@ async function cardInventory(page, concept) {
|
||||
const walk = new Function(`return (${walkSource})()`)();
|
||||
return nodes.map(card => {
|
||||
const copy = card.cloneNode(true);
|
||||
// D7: the star arrives with C5; D8: no selection checkbox; D9: no tag button until image annotations.
|
||||
if (concept) for (const node of copy.querySelectorAll('.image-overlays > *, [aria-label^="Tag or auto-tag "]')) node.remove();
|
||||
// D8: no selection checkbox; D9: no tag button until image annotations. The D7 star is compared.
|
||||
if (concept) for (const node of copy.querySelectorAll('.image-overlays > label, [aria-label^="Tag or auto-tag "]')) node.remove();
|
||||
const open = card.querySelector('.image-open'), img = open.querySelector('img');
|
||||
const star = card.querySelector('.image-overlays > button.star-button');
|
||||
return { filename: open.getAttribute('aria-label').replace(/^Open /, ''),
|
||||
// The concept's alt is the card title plus its demo-only " · sample illustration N" (m5).
|
||||
alt: img.alt.replace(/ · sample illustration \d+$/, ''),
|
||||
@@ -1023,6 +1024,7 @@ async function cardInventory(page, concept) {
|
||||
title: card.querySelector('.media-info h3').textContent, subtitle: card.querySelector('.media-info p').textContent,
|
||||
ratio: card.style.getPropertyValue('--media-ratio').replace(/\s/g, ''),
|
||||
inspect: card.querySelector('.image-card-actions > button').getAttribute('aria-label'),
|
||||
star: star && { text: star.textContent, pressed: star.getAttribute('aria-pressed'), label: star.getAttribute('aria-label') },
|
||||
paths: walk(copy, `article.${[...card.classList].filter(value => value !== 'selected').sort().join('.')}`, []) };
|
||||
});
|
||||
}, { walkSource: CARD_PATHS.toString(), concept });
|
||||
@@ -1044,10 +1046,11 @@ test('card matches the concept structure', async ({ page, request }) => {
|
||||
if (!reference) continue;
|
||||
compared++;
|
||||
// D10 title (the linked positive's, also for pair links), D11 "W × H · PNG · Seed N", the concept's
|
||||
// --media-ratio, image alt and size attributes, Inspect label and markup.
|
||||
// --media-ratio, image alt and size attributes, Inspect label, the D7 star (the fixture stars the concept's) and markup.
|
||||
expect(card, card.filename).toEqual(reference);
|
||||
}
|
||||
expect(compared).toBeGreaterThan(5);
|
||||
expect(production.filter(card => card.star?.pressed === 'true').length).toBeGreaterThan(0);
|
||||
// D10/D20: a pair-linked file's inspector heading is the positive's title, as the concept panel's.
|
||||
const pairLinked = 'alpine_00001.png';
|
||||
expect(concept.get(pairLinked)).toBeTruthy();
|
||||
@@ -1183,6 +1186,9 @@ async function zoomedGallery(page, request, { cssZoom }) {
|
||||
});
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-open`)).toBeFocused();
|
||||
// The concept's card order: image, the D7 star, then Inspect.
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-overlays .star-button`)).toBeFocused();
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-card-actions button`)).toBeFocused();
|
||||
await page.evaluate(() => {
|
||||
@@ -1244,3 +1250,127 @@ for (const motion of ['no-preference', 'reduce']) {
|
||||
expect(await img.evaluate(n => getComputedStyle(n).transitionDuration)).toBe(motion === 'reduce' ? '0s' : '0.2s');
|
||||
});
|
||||
}
|
||||
|
||||
// D7 = A: the concept's star (app.js mediaCard, actions.favorite) against the PR B favorites store.
|
||||
const favoriteRoute = '**/prompt_manager/images/favorite';
|
||||
const star = (card, filename) => card.getByRole('button', { name: `Favorite ${filename}`, exact: true });
|
||||
const toast = page => page.locator('#workspace-toast');
|
||||
const listedFavorites = async request => Object.fromEntries((await (await request.get('/prompt_manager/images/output?limit=40&mode=workspace')).json())
|
||||
.images.map(image => [`${image.root_index}:${image.path}`, image.favorite]));
|
||||
async function expectStar(button, favorite) {
|
||||
await expect(button).toHaveText(favorite ? '★' : '☆');
|
||||
await expect(button).toHaveAttribute('aria-pressed', String(favorite));
|
||||
}
|
||||
|
||||
test('favorite persists across reload', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/reset', { data: { include_video: true } })).toBeOK();
|
||||
const sent = [];
|
||||
page.on('request', item => {
|
||||
if (new URL(item.url()).pathname === '/prompt_manager/images/favorite') {
|
||||
sent.push({ method: item.method(), type: item.headers()['content-type'], body: item.postDataJSON() });
|
||||
}
|
||||
});
|
||||
await page.goto(workspace);
|
||||
const input = page.locator('article[data-image-key="1:shared/same.png"]');
|
||||
const video = page.locator('article[data-image-key="0:clip.webm"]');
|
||||
await expect(input).toBeVisible(); await expect(video).toBeVisible();
|
||||
// The star sits in the concept's overlay on image and video cards alike.
|
||||
await expect(input.locator('.media-art > .image-overlays > button.star-button')).toHaveCount(1);
|
||||
await expect(video.locator('.media-art > .image-overlays > button.star-button')).toHaveCount(1);
|
||||
await expectStar(star(input, 'same.png'), false);
|
||||
await expectStar(star(video, 'clip.webm'), false);
|
||||
const selected = await page.locator('article.selected').getAttribute('data-image-key');
|
||||
expect(selected).not.toBe('1:shared/same.png');
|
||||
|
||||
await star(input, 'same.png').click();
|
||||
await expectStar(star(input, 'same.png'), true);
|
||||
await expect(toast(page)).toHaveText('Added to favorites.');
|
||||
// The star neither opens the viewer nor inspects the card.
|
||||
await expect(page.getByRole('dialog', { name: 'Image preview', exact: true })).toHaveCount(0);
|
||||
await expect(page.locator('article.selected')).toHaveAttribute('data-image-key', selected);
|
||||
await expect(star(input, 'same.png')).toBeFocused();
|
||||
|
||||
// Keyboard: Enter stars the video, Space clears it again.
|
||||
await star(video, 'clip.webm').focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expectStar(star(video, 'clip.webm'), true);
|
||||
await expect(toast(page)).toHaveText('Added to favorites.');
|
||||
await page.keyboard.press(' ');
|
||||
await expectStar(star(video, 'clip.webm'), false);
|
||||
await expect(toast(page)).toHaveText('Removed from favorites.');
|
||||
await expect(page.getByRole('dialog', { name: 'Image preview', exact: true })).toHaveCount(0);
|
||||
await expect.poll(() => sent.length).toBe(3);
|
||||
expect(sent).toEqual([
|
||||
{ method: 'PUT', type: 'application/json', body: { root_index: 1, path: 'shared/same.png' } },
|
||||
{ method: 'PUT', type: 'application/json', body: { root_index: 0, path: 'clip.webm' } },
|
||||
{ method: 'DELETE', type: 'application/json', body: { root_index: 0, path: 'clip.webm' } },
|
||||
]);
|
||||
// Favorites are keyed by source: the same file name in the other root stays unstarred.
|
||||
await expect.poll(() => listedFavorites(request)).toMatchObject({ '1:shared/same.png': true, '0:shared/same.png': false, '0:clip.webm': false });
|
||||
|
||||
await page.reload();
|
||||
await expectStar(star(input, 'same.png'), true);
|
||||
await expectStar(star(page.locator('article[data-image-key="0:shared/same.png"]'), 'same.png'), false);
|
||||
await expectStar(star(video, 'clip.webm'), false);
|
||||
await star(input, 'same.png').click();
|
||||
await expectStar(star(input, 'same.png'), false);
|
||||
await expect(toast(page)).toHaveText('Removed from favorites.');
|
||||
await expect.poll(() => listedFavorites(request)).toMatchObject({ '1:shared/same.png': false });
|
||||
await page.reload();
|
||||
await expectStar(star(input, 'same.png'), false);
|
||||
|
||||
// Leaving Images and coming back restores the browsing state with the current star.
|
||||
await star(input, 'same.png').click();
|
||||
await expect(toast(page)).toHaveText('Added to favorites.');
|
||||
await page.evaluate(() => { window.location.hash = '/overview'; });
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await page.evaluate(() => { window.location.hash = '/images'; });
|
||||
await expectStar(star(input, 'same.png'), true);
|
||||
});
|
||||
|
||||
test('favorite failure keeps the previous state and announces it', async ({ page, request }) => {
|
||||
await page.goto(workspace);
|
||||
const card = page.locator('article[data-image-key="0:shared/same.png"]');
|
||||
const button = star(card, 'same.png');
|
||||
await expectStar(button, false);
|
||||
const held = [];
|
||||
await page.route(favoriteRoute, route => { held.push(route); });
|
||||
// Optimistic while in flight; a second press during the request sends nothing more.
|
||||
await button.click();
|
||||
await expectStar(button, true);
|
||||
await button.click();
|
||||
await expect.poll(() => held.length).toBe(1);
|
||||
await page.waitForTimeout(150);
|
||||
expect(held.length).toBe(1);
|
||||
await held[0].fulfill({ status: 500, json: { success: false, error: 'Favorite update failed' } });
|
||||
await expectStar(button, false);
|
||||
await expect(toast(page)).toHaveText('Favorite could not be added. Try again.');
|
||||
expect((await listedFavorites(request))['0:shared/same.png']).toBe(false);
|
||||
|
||||
// Removal failure keeps the star; a network failure is announced the same way.
|
||||
await page.unroute(favoriteRoute);
|
||||
await button.click();
|
||||
await expectStar(button, true);
|
||||
await expect(toast(page)).toHaveText('Added to favorites.');
|
||||
await page.route(favoriteRoute, route => route.abort('failed'));
|
||||
await button.click();
|
||||
await expectStar(button, true);
|
||||
await expect(toast(page)).toHaveText('Favorite could not be removed. Try again.');
|
||||
await page.unroute(favoriteRoute);
|
||||
await page.reload();
|
||||
await expectStar(star(card, 'same.png'), true);
|
||||
expect((await listedFavorites(request))['0:shared/same.png']).toBe(true);
|
||||
|
||||
// A failure that lands after leaving Images still restores the star the user comes back to, and is announced.
|
||||
const late = [];
|
||||
await page.route(favoriteRoute, route => { late.push(route); });
|
||||
await star(card, 'same.png').click();
|
||||
await expectStar(star(card, 'same.png'), false);
|
||||
await expect.poll(() => late.length).toBe(1);
|
||||
await page.evaluate(() => { window.location.hash = '/overview'; });
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await late[0].fulfill({ status: 500, json: { success: false, error: 'Favorite update failed' } });
|
||||
await expect(toast(page)).toHaveText('Favorite could not be removed. Try again.');
|
||||
await page.evaluate(() => { window.location.hash = '/images'; });
|
||||
await expectStar(star(card, 'same.png'), true);
|
||||
});
|
||||
|
||||
@@ -352,9 +352,14 @@ for (const [width, height] of [[390, 844], [1600, 1100]]) for (const layout of [
|
||||
await expect(page.getByRole('dialog', { name: 'Image preview', exact: true })).toBeVisible();
|
||||
await expect(page.locator('.image-preview > header h2')).toHaveText(filename);
|
||||
await page.keyboard.press('Escape');
|
||||
// The concept's card order: image, the D7 star (hidden in List, as the concept hides its overlays), then Inspect.
|
||||
const star = longCard.getByRole('button', { name: `Favorite ${filename}`, exact: true });
|
||||
await longCard.locator('.image-open').focus(); await page.keyboard.press('Tab');
|
||||
if (layout !== 'List') { await expect(star).toBeFocused(); await page.keyboard.press('Tab'); }
|
||||
await expect(longCard.getByRole('button', { name: `Inspect ${filename}`, exact: true })).toBeFocused();
|
||||
await page.keyboard.press('Shift+Tab'); await expect(longCard.locator('.image-open')).toBeFocused();
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
if (layout !== 'List') { await expect(star).toBeFocused(); await page.keyboard.press('Shift+Tab'); }
|
||||
await expect(longCard.locator('.image-open')).toBeFocused();
|
||||
await page.keyboard.press('Enter'); await expect(page.locator('dialog[open]')).toHaveCount(1);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect.poll(() => containedImageContent(page)).toEqual([]);
|
||||
|
||||
@@ -165,6 +165,7 @@ _TABLES = (
|
||||
"execution_contexts",
|
||||
"execution_prompt_snapshots",
|
||||
"execution_context_texts",
|
||||
"image_favorites",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
@@ -176,6 +177,11 @@ _TABLES = (
|
||||
)
|
||||
|
||||
|
||||
def concept_starred(prompt_index: int, round_index: int) -> bool:
|
||||
"""The concept's favorites (app.js ``starred``): each other prompt's first image."""
|
||||
return round_index == 0 and prompt_index % 2 == 0
|
||||
|
||||
|
||||
def concept_seed(prompt_index: int, round_index: int) -> int:
|
||||
"""The concept's sample seed (app.js:185)."""
|
||||
return 42001 + prompt_index * 100 + round_index
|
||||
@@ -356,6 +362,10 @@ def seed_workspace_concept(
|
||||
folder = root / "output" / "concepts"
|
||||
folder.mkdir()
|
||||
roots = snapshot_output_roots("posix", str(root / "output"), str(root / "temp"))
|
||||
# The API package needs the host stubs the server installs before importing it.
|
||||
from py.api.images import _favorite_relative_key, _favorite_root_key
|
||||
|
||||
favorite_root = _favorite_root_key(list(api._get_all_output_dirs())[0])
|
||||
total = len(CONCEPT_PROMPTS) * CONCEPT_IMAGE_ROUNDS + extra_images
|
||||
rounds = math.ceil(total / len(CONCEPT_PROMPTS))
|
||||
now = time.time()
|
||||
@@ -376,6 +386,10 @@ def seed_workspace_concept(
|
||||
image_id = db.link_image_to_prompt(owner["positive_id"], str(path))
|
||||
pair_id = owner["pair_ids"][variant]
|
||||
db.link_image_to_pair(pair_id, image_id, {"source": "concept fixture"})
|
||||
if concept_starred(prompt_index, round_index):
|
||||
db.set_image_favorite(
|
||||
favorite_root, _favorite_relative_key(f"concepts/{name}")
|
||||
)
|
||||
context = RecipeContext(
|
||||
"3", _snapshot(prompt.positive), _snapshot(negative), "inferred"
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@ EDITOR_METADATA = {
|
||||
"notes": "Keep the soft light.",
|
||||
}
|
||||
_TABLES = (
|
||||
"image_favorites",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
|
||||
@@ -112,6 +112,7 @@ def seed_workspace_visual(api: PromptManagerAPI, root: Path) -> None:
|
||||
"""Replace only the owned browser harness records and media roots."""
|
||||
with api.db.model.get_connection() as conn:
|
||||
for table in (
|
||||
"image_favorites",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
|
||||
@@ -79,6 +79,7 @@ UNUSED_TAG = "unused_vocabulary"
|
||||
MAX_EXTRA_TAGS = 300
|
||||
MAX_EXTRA_CATEGORIES = 200
|
||||
_TABLES = (
|
||||
"image_favorites",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
|
||||
@@ -163,6 +163,19 @@ test("post, put and del send JSON bodies with the right method", async () => {
|
||||
await Promise.all([p1, p2, p3]);
|
||||
});
|
||||
|
||||
test("del sends an optional JSON body", async () => {
|
||||
const fetch = fakeFetch();
|
||||
const api = createApiClient(fetch);
|
||||
const controller = new AbortController();
|
||||
const pending = api.del("/prompt_manager/images/favorite", { body: { root_index: 1, path: "a/b.png" }, signal: controller.signal });
|
||||
assert.equal(fetch.calls[0].init.method, "DELETE");
|
||||
assert.equal(fetch.calls[0].init.body, JSON.stringify({ root_index: 1, path: "a/b.png" }));
|
||||
assert.equal(fetch.calls[0].init.headers["Content-Type"], "application/json");
|
||||
assert.equal(fetch.calls[0].init.signal, controller.signal);
|
||||
fetch.calls[0].resolve(jsonResponse(200, { success: true }));
|
||||
await pending;
|
||||
});
|
||||
|
||||
test("network failures propagate as ordinary errors", async () => {
|
||||
const fetch = fakeFetch();
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
@@ -272,3 +272,40 @@ test('the card subtitle is W × H · EXT · Seed N with unknown parts omitted (D
|
||||
assert.equal(mediaRatio({width:1536,height:1152}),'1536/1152');
|
||||
for (const image of [{}, {width:0,height:5}, {width:5,height:NaN}, {width:Infinity,height:5}]) assert.equal(mediaRatio(image),'4/3');
|
||||
});
|
||||
|
||||
test('updating one item replaces it immutably and later pages append after it', async () => {
|
||||
const { createImageStream, imageKey } = await source;
|
||||
const pages = [page([{...image('a'), favorite:false}, {...image('b'), favorite:true}], 3, 'next'), page([image('c')], 3)];
|
||||
const states = [];
|
||||
const stream = createImageStream({api:{get:async () => pages.shift()}, onChange:s => states.push(s), batchSize:2});
|
||||
await stream.reset({});
|
||||
const before = stream.state, notified = states.length;
|
||||
assert.equal(stream.updateItem('0:a', {favorite:true}), true);
|
||||
const after = stream.state;
|
||||
assert.equal(states.length, notified + 1); assert.equal(states.at(-1), after);
|
||||
assert.notEqual(after.items, before.items); assert.ok(Object.isFrozen(after.items)); assert.ok(Object.isFrozen(after.items[0]));
|
||||
assert.equal(after.items[0].favorite, true); assert.equal(before.items[0].favorite, false);
|
||||
assert.equal(after.items[1], before.items[1]);
|
||||
assert.equal(after.loaded, before.loaded); assert.equal(after.cursor, before.cursor); assert.equal(after.total, before.total);
|
||||
// Identity fields cannot be patched, and an unknown key changes nothing.
|
||||
assert.throws(() => stream.updateItem('0:a', {path:'z'}), TypeError);
|
||||
assert.throws(() => stream.updateItem('0:a', {root_index:1}), TypeError);
|
||||
assert.equal(stream.updateItem('0:missing', {favorite:true}), false);
|
||||
assert.equal(stream.state, after); assert.equal(states.length, notified + 1);
|
||||
await stream.loadMore();
|
||||
assert.deepEqual(stream.state.items.map(imageKey), ['0:a', '0:b', '0:c']);
|
||||
assert.equal(stream.state.items[0].favorite, true);
|
||||
stream.dispose();
|
||||
assert.equal(stream.updateItem('0:b', {favorite:false}), false);
|
||||
assert.equal(stream.state.items[1].favorite, true);
|
||||
});
|
||||
|
||||
test('withItem patches a saved browsing state without touching the other items', async () => {
|
||||
const { withItem } = await source;
|
||||
const items = Object.freeze([Object.freeze({...image('a'), favorite:false}), Object.freeze({...image('b'), favorite:false})]);
|
||||
const state = Object.freeze({items, total:2, cursor:null, hasMore:false, loading:false, error:null, loaded:2, positiveCount:null});
|
||||
const next = withItem(state, '0:b', {favorite:true});
|
||||
assert.ok(Object.isFrozen(next)); assert.equal(next.items[1].favorite, true); assert.equal(next.items[0], items[0]);
|
||||
assert.equal(state.items[1].favorite, false);
|
||||
assert.equal(withItem(state, '1:b', {favorite:true}), state);
|
||||
});
|
||||
|
||||
@@ -91,7 +91,8 @@
|
||||
post: (url, body, { signal } = {}) => request("POST", url, { body, signal }),
|
||||
put: (url, body, { signal } = {}) => request("PUT", url, { body, signal }),
|
||||
patch: (url, body, { signal } = {}) => request("PATCH", url, { body, signal }),
|
||||
del: (url, { signal } = {}) => request("DELETE", url, { signal }),
|
||||
// A DELETE body is optional; the favorites store names its file in one.
|
||||
del: (url, { signal, body } = {}) => request("DELETE", url, { body, signal }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,18 @@ function validatePage(page, cursor) {
|
||||
}
|
||||
}
|
||||
|
||||
/** `state` with the item named by `key` patched (both frozen); the same state when no item has that key. */
|
||||
export function withItem(state, key, patch) {
|
||||
if (!patch || typeof patch !== 'object' || 'path' in patch || 'root_index' in patch) {
|
||||
throw new TypeError('An image patch cannot change its identity');
|
||||
}
|
||||
const index = state.items.findIndex(item => imageKey(item) === key);
|
||||
if (index < 0) return state;
|
||||
const items = [...state.items];
|
||||
items[index] = Object.freeze({ ...items[index], ...patch });
|
||||
return Object.freeze({ ...state, items: Object.freeze(items) });
|
||||
}
|
||||
|
||||
/** Keep metadata for backward scrolling; the view bounds rendered media independently. */
|
||||
export function createImageStream({ api, onChange = () => {}, batchSize = 40, restore, fetchPage }) {
|
||||
if (fetchPage !== undefined && typeof fetchPage !== 'function') throw new TypeError('Invalid image page adapter');
|
||||
@@ -148,6 +160,14 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
|
||||
get state() { return state; },
|
||||
reset,
|
||||
loadMore,
|
||||
/** Patch one loaded item (a favorite toggle); false when it is not loaded or the stream is gone. */
|
||||
updateItem(key, patch) {
|
||||
const next = withItem(state, key, patch);
|
||||
if (disposed || next === state) return false;
|
||||
state = next;
|
||||
onChange(state);
|
||||
return true;
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createScope, createButton } from '../shared/toolkit/core.js';
|
||||
import { createImageStream, imageKey, 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 } from './inspection-state.mjs';
|
||||
@@ -39,6 +39,11 @@ const MEDIA_OPTIONS = Object.freeze([['all', 'All media'], ['image', 'Images'],
|
||||
const SOURCES_FAILED = 'Sources and folders could not be loaded. Rescan from Import to retry.';
|
||||
const CATEGORIES_FAILED = 'Categories could not be loaded. Rescan from Import to retry.';
|
||||
const RESCAN_FAILED = 'Output folders could not be rescanned. Try Rescan again.';
|
||||
// D7 = A: the concept's star (app.js actions.favorite) against the favorites store; its toasts without the demo's
|
||||
// "in this tab". The concept has no failure copy, so a failure follows the workspace "could not be …" pattern.
|
||||
const FAVORITE_URL = '/prompt_manager/images/favorite';
|
||||
const FAVORITE_DONE = Object.freeze({true:'Added to favorites.', false:'Removed from favorites.'});
|
||||
const FAVORITE_FAILED = Object.freeze({true:'Favorite could not be added. Try again.', false:'Favorite could not be removed. Try again.'});
|
||||
// The concept card's first `.media-info` reservation and list row (image-layout.mjs); real rows are measured.
|
||||
const INITIAL_METRICS = Object.freeze({columns:1, gap:13, footerHeight:54, listHeight:97});
|
||||
|
||||
@@ -65,6 +70,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
// Discovered sources and folders for "Refine this view" and the scope chip.
|
||||
let roots = [], folders = [];
|
||||
const rendered = new Map();
|
||||
// Keys with a favorite request in flight: a second press waits for the first answer.
|
||||
const favoritePending = new Set();
|
||||
const itemIndexes = new Map();
|
||||
const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...visit?.filters };
|
||||
filters.tags = [...filters.tags];
|
||||
@@ -220,8 +227,15 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
const open = createButton('', 'image-open');
|
||||
open.setAttribute('aria-label', `Open ${image.filename}`);
|
||||
open.append(mediaElement(image, true));
|
||||
// D7's star arrives with C5; D8 renders no selection checkbox.
|
||||
art.append(open, el('div', undefined, 'image-overlays'));
|
||||
// D7: the star, wherever the listing reports favorites (not on Library-embedded media); D8: no checkbox yet.
|
||||
const overlays = el('div', undefined, 'image-overlays');
|
||||
if (typeof image.favorite === 'boolean') {
|
||||
const star = createButton('', 'star-button');
|
||||
star.setAttribute('aria-label', `Favorite ${image.filename}`);
|
||||
paintStar(star, image.favorite);
|
||||
overlays.append(star);
|
||||
}
|
||||
art.append(open, overlays);
|
||||
const info = el('div', undefined, 'media-info');
|
||||
const text = el('div');
|
||||
const title = el('h3', cardTitle(image)); title.title = image.path;
|
||||
@@ -237,11 +251,45 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
markSelected(card, key === selection);
|
||||
return card;
|
||||
}
|
||||
function paintStar(button, favorite) {
|
||||
button.textContent = favorite ? '★' : '☆';
|
||||
button.setAttribute('aria-pressed', String(favorite));
|
||||
}
|
||||
function setFavorite(key, favorite) {
|
||||
// After leaving Images the saved visit, not the disposed stream, holds the card the user comes back to.
|
||||
if (scope.signal.aborted) {
|
||||
if (!embedded && lastVisit?.state) lastVisit = {...lastVisit, state: withItem(lastVisit.state, key, {favorite})};
|
||||
return;
|
||||
}
|
||||
stream.updateItem(key, {favorite});
|
||||
const button = rendered.get(key)?.querySelector('.star-button');
|
||||
if (button) paintStar(button, favorite);
|
||||
}
|
||||
/** Optimistic toggle: the star changes at once and returns to its previous state if the store refuses. */
|
||||
async function toggleFavorite(image) {
|
||||
const key = imageKey(image);
|
||||
if (favoritePending.has(key)) return;
|
||||
const favorite = !image.favorite;
|
||||
favoritePending.add(key);
|
||||
setFavorite(key, favorite);
|
||||
const body = {root_index: image.root_index, path: image.path};
|
||||
try {
|
||||
await (favorite ? api.put(FAVORITE_URL, body) : api.del(FAVORITE_URL, {body}));
|
||||
showWorkspaceToast(FAVORITE_DONE[favorite]);
|
||||
} catch {
|
||||
setFavorite(key, !favorite);
|
||||
showWorkspaceToast(FAVORITE_FAILED[favorite]);
|
||||
} finally {
|
||||
favoritePending.delete(key);
|
||||
}
|
||||
}
|
||||
scope.listen(grid, 'click', event => {
|
||||
const card = event.target.closest('article[data-image-key]');
|
||||
if (!card) return;
|
||||
const image = state.items.find(item => imageKey(item) === card.dataset.imageKey);
|
||||
if (event.target.closest('.image-open')) previewImage(image);
|
||||
if (!image) return;
|
||||
if (event.target.closest('.star-button')) toggleFavorite(image);
|
||||
else if (event.target.closest('.image-open')) previewImage(image);
|
||||
else if (event.target.closest('.image-card-actions button')) inspect(image);
|
||||
});
|
||||
|
||||
|
||||
@@ -67,7 +67,10 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
|
||||
/* #249 D18: the concept zooms a hovered card image (1.03 over 0.2s; its reduced-motion rule makes that instant). A touch
|
||||
device has no real hover, so a tap must not leave the zoom stuck. */
|
||||
@media (hover: none) { .image-open:hover img { transform: none; } }
|
||||
@media (hover: none) { .image-open:hover img { transform: none; /* D18 */ } }
|
||||
/* #249 D8: without the concept's selection checkbox the star is the overlay's only child; it keeps the concept's top-right
|
||||
place (the checkbox follow-up PR retires this rule). */
|
||||
.image-overlays > .star-button:only-child { margin-left: auto; /* D8 */ }
|
||||
|
||||
/* Images cards (#249 C3). #249 D3: bounded rendering mounts only the visible cards and places each one itself, in every
|
||||
layout (the concept places only masonry cards), so the grid reserves the collection's height and every card is
|
||||
|
||||
Reference in New Issue
Block a user