feat(images): favorite images from the card

This commit is contained in:
Vito Sansevero
2026-10-20 05:25:38 -07:00
parent 9a932cd87f
commit ccc526d545
14 changed files with 291 additions and 12 deletions
+5 -1
View File
@@ -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 `/`
+1
View File
@@ -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",
+134 -4
View File
@@ -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);
});
+6 -1
View File
@@ -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",
+13
View File
@@ -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);
+37
View File
@@ -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);
});
+2 -1
View File
@@ -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 }),
};
}
+20
View File
@@ -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;
+52 -4
View File
@@ -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);
});
+4 -1
View File
@@ -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