From 6024d022f2ebf198fd57770559073ce36df63bf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89douard=20WILLISSECK?= Date: Tue, 6 Oct 2026 10:46:21 +0200 Subject: [PATCH 01/69] Re:Builder: keep unique illustrations, fix collection and digital ownership searches (#131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Re:Builder: keep unique illustrations, fix collection and digital ownership searches - ac-card-art compares its source list by value: a unique whose card object is replaced once its printed effect arrives kept the same src, so `load` never fired again and the art stayed hidden. - The collection / ownership searches send list filters as `key[]`: PHP kept the last value of a repeated `rarity=…`, so the default search asked for Exalted cards of one type and one set only. - collection-search.php forwards those lists to the collection API in its array form too (cacCollectionQuery, tested in tests/CollectionQueryTest.php). - The stack's ownership mock answers GET /api/collection with a few filtered cards, for the e2e test. Co-Authored-By: Claude Opus 5.5 * Simplify: reset card art on its displayed src, share the owned query rationale - ac-card-art resets `loaded` when the displayed src changes rather than the source list. - ownedParams' doc carries the `key[]` rationale once; collection query loop without array_values. - e2e: the cards API answer waits for the shown illustration instead of a fixed delay; the digital collection test also runs on mobile (« Plus » menu); screenshots for the pull request. Co-Authored-By: Claude Opus 5.5 * e2e: check the physical collection search too, against the stack's mock The stack's collection API (COLLECTION_API_URL) is the ownership mock, which also answers GET /api/collection with the collection API's filter names (cardType, cardSet): the real API only takes altered.re tokens. The collection test fails with the previous client or the previous proxy. Co-Authored-By: Claude Opus 5.5 * Stack: start the decks API with the latest Mercure The decks API image is built on the latest FrankenPHP base, whose Mercure now refuses publisher_jwt / subscriber_jwt outside compatibility mode: the container exited at startup and the e2e job failed. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docker-compose.stack.yml | 3 ++ docker/stack/config.stack.php | 7 +-- docker/stack/ownership-mock/index.php | 28 +++++++++- .../api/collection-search.php | 40 ++------------ .../includes/collection-query.php | 33 ++++++++++++ .../src/app/core/owned-cards.service.spec.ts | 13 +++++ .../app/src/app/core/owned-cards.service.ts | 12 ++--- .../src/app/ui/metier/card-art/card-art.ts | 12 +++-- plugins/rebuilder/e2e/rebuilder.spec.ts | 54 +++++++++++++++++-- tests/CollectionQueryTest.php | 15 ++++++ 10 files changed, 163 insertions(+), 54 deletions(-) create mode 100644 plugins/core-altered-cards/includes/collection-query.php create mode 100644 tests/CollectionQueryTest.php diff --git a/docker-compose.stack.yml b/docker-compose.stack.yml index 17eba0cc..6632d365 100644 --- a/docker-compose.stack.yml +++ b/docker-compose.stack.yml @@ -110,6 +110,9 @@ services: MERCURE_SUBSCRIBER_JWT_KEY: "!ChangeThisMercureHubJWTSecretKey!" MERCURE_PUBLISHER_JWT_ALG: HS256 MERCURE_SUBSCRIBER_JWT_ALG: HS256 + # The image's Mercure now refuses publisher_jwt / subscriber_jwt outside compatibility mode (the build pulls + # the latest FrankenPHP base, so the pinned commit no longer pins it). + MERCURE_EXTRA_DIRECTIVES: "protocol_version_compatibility 8" ports: - "127.0.0.1:8001:80" depends_on: diff --git a/docker/stack/config.stack.php b/docker/stack/config.stack.php index bae4ff7e..4bf04228 100644 --- a/docker/stack/config.stack.php +++ b/docker/stack/config.stack.php @@ -28,16 +28,17 @@ define('GITHUB_APP_PRIVATE_KEY', ''); define('GITHUB_REPO', 'owner/community-feedback'); -// Cards, Uniques search, CDN and collection stay on production (read-only). Decks run locally; SPA plugins +// Cards, Uniques search and CDN stay on production (read-only). Decks run locally; SPA plugins // reach them through the site's relay (/api/v1/services/decks), never directly. define('CARDS_API_URL', 'https://cards.alteredcore.org'); define('UNIQUES_API_URL', 'https://search.altered.re'); define('DECKS_API_URL', 'http://decks-api:80'); define('CDN_URL', 'https://cdn.alteredcore.org'); -// Mock of the digital-ownership service (docker/stack/ownership-mock): alt arts in the site and Re:Builder. +// Mock of the digital-ownership service (docker/stack/ownership-mock): alt arts in the site and Re:Builder. It also +// answers the collection API's GET /api/collection (the real one only takes altered.re tokens). define('OWNERSHIP_API_URL', 'http://ownership:8080'); define('OWNERSHIP_WEB_URL', ''); -define('COLLECTION_API_URL', 'https://collection.alteredcore.org'); +define('COLLECTION_API_URL', 'http://ownership:8080'); define('COLLECTION_USE_API', false); define('BASE_URL', ''); diff --git a/docker/stack/ownership-mock/index.php b/docker/stack/ownership-mock/index.php index 48a3c511..ec8f3bc9 100644 --- a/docker/stack/ownership-mock/index.php +++ b/docker/stack/ownership-mock/index.php @@ -54,7 +54,7 @@ case 'POST /api/alt-arts/apply-to-deck': reply(['lines' => applyToDeck((array)$body, $user)]); case 'GET /api/collection': - reply([]); + reply(collection()); default: reply(['error' => "No mock for $method $path"], 404); } @@ -190,6 +190,32 @@ function search(string $user): array { return ['families' => $families, 'options' => $options, 'hasMore' => $page * $take < (int)($res['totalItems'] ?? 0)]; } +/** + * A few owned cards (the same for every user), filtered as the services do: `key[]` lists, `name` substring. Also the + * stack's collection API (COLLECTION_API_URL, same path): its filters are named `cardType` / `cardSet`. + */ +function collection(): array { + $owned = [ + ['reference' => 'ALT_CORE_B_AX_08_C', 'quantity' => 3, 'name' => 'Récupérateur Axiom', 'faction' => 'AX', 'rarity' => 'COMMON', 'type' => 'CHARACTER', 'set' => 'CORE'], + ['reference' => 'ALT_CORE_B_AX_08_R1', 'quantity' => 1, 'name' => 'Récupérateur Axiom', 'faction' => 'AX', 'rarity' => 'RARE', 'type' => 'CHARACTER', 'set' => 'CORE'], + ['reference' => 'ALT_CORE_B_AX_15_C', 'quantity' => 2, 'name' => 'Brouilleur Axiom', 'faction' => 'AX', 'rarity' => 'COMMON', 'type' => 'CHARACTER', 'set' => 'CORE'], + ['reference' => 'ALT_ALIZE_B_AX_32_R1', 'quantity' => 1, 'name' => 'La Machine dans la Glace', 'faction' => 'AX', 'rarity' => 'RARE', 'type' => 'CHARACTER', 'set' => 'ALIZE'], + ['reference' => 'ALT_CORE_B_BR_10_C', 'quantity' => 3, 'name' => 'Red', 'faction' => 'BR', 'rarity' => 'COMMON', 'type' => 'CHARACTER', 'set' => 'CORE'], + ]; + $name = mb_strtolower(trim((string)($_GET['name'] ?? ''))); + $filters = ['faction' => ['faction'], 'rarity' => ['rarity'], 'type' => ['type', 'cardType'], 'set' => ['set', 'cardSet']]; + $match = function ($c) use ($name, $filters) { + foreach ($filters as $field => $params) { + foreach ($params as $k) { + $wanted = (array)($_GET[$k] ?? []); + if ($wanted && !in_array($c[$field], $wanted, true)) return false; + } + } + return $name === '' || str_contains(mb_strtolower($c['name']), $name); + }; + return array_map(fn($c) => $c + ['cardReference' => $c['reference']], array_values(array_filter($owned, $match))); +} + /** Global mode: each copy takes its slot's print when owned, the standard print otherwise. */ function applyToDeck(array $lines, string $user): array { $global = (state($user)['mode'] ?? 'PerDeck') === 'Global'; diff --git a/plugins/core-altered-cards/api/collection-search.php b/plugins/core-altered-cards/api/collection-search.php index d67e11ed..60391ef3 100644 --- a/plugins/core-altered-cards/api/collection-search.php +++ b/plugins/core-altered-cards/api/collection-search.php @@ -3,6 +3,7 @@ // Fetches the user's collection from the collection API, applies filters, // handles server-side pagination, and returns a cards-API-compatible envelope. require_once dirname(__DIR__) . '/includes/functions.php'; +require_once dirname(__DIR__) . '/includes/collection-query.php'; header('Content-Type: application/json'); @@ -14,43 +15,8 @@ $userId = (int)($_SESSION['user_id'] ?? 0); -$scalar = [ - 'faction', 'rarity', 'cardType', 'variation', - 'isFoil', 'isBanned', 'isSuspended', - 'cardReference', 'name', 'subTypes', 'locale', -]; -$range = ['mainCost', 'recallCost', 'oceanPower', 'mountainPower', 'forestPower']; - -$parts = []; - -foreach ($scalar as $k) { - if (!isset($_GET[$k])) continue; - if (is_array($_GET[$k])) { - foreach ($_GET[$k] as $v) { - if ($v !== '') $parts[] = $k . '=' . rawurlencode($v); - } - } elseif ($_GET[$k] !== '') { - $parts[] = $k . '=' . rawurlencode($_GET[$k]); - } -} - -if (isset($_GET['cardSet'])) { - $sets = is_array($_GET['cardSet']) ? $_GET['cardSet'] : [$_GET['cardSet']]; - foreach ($sets as $v) { - if ($v !== '') $parts[] = 'cardSet=' . rawurlencode($v); - } -} - -foreach ($range as $k) { - if (!isset($_GET[$k]) || !is_array($_GET[$k])) continue; - foreach (['gte', 'lte', 'gt', 'lt'] as $op) { - if (isset($_GET[$k][$op]) && $_GET[$k][$op] !== '') { - $parts[] = $k . '[' . $op . ']=' . rawurlencode($_GET[$k][$op]); - } - } -} - -$path = '/api/collection' . ($parts ? '?' . implode('&', $parts) : ''); +$query = cacCollectionQuery($_GET); +$path = '/api/collection' . ($query !== '' ? '?' . $query : ''); $data = collApiRequest(COLLECTION_API_URL, 'GET', $path, $userId); if ($data === false) { diff --git a/plugins/core-altered-cards/includes/collection-query.php b/plugins/core-altered-cards/includes/collection-query.php new file mode 100644 index 00000000..b492f811 --- /dev/null +++ b/plugins/core-altered-cards/includes/collection-query.php @@ -0,0 +1,33 @@ + { expect(favoriteRarities({ ...f, rarities: ['UNIQUE'] }, 'nuc')).toEqual([]); }); }); + +describe('Collection and digital ownership query', () => { + it('sends every value of a list filter in the array form', () => { + for (const source of ['collection', 'owned'] as const) { + const p = ownedParams(source, defaultFilters(source), ['AX', 'BR'], 1, 36); + expect(p.getAll('rarity[]')).toEqual(['COMMON', 'RARE', 'EXALTED']); + expect(p.getAll('faction[]')).toEqual(['AX', 'BR']); + expect(p.getAll('cardType[]')?.length).toBe(4); + expect(p.getAll('cardSet[]')).toEqual(defaultFilters(source).sets); + expect(p.has('rarity')).toBe(false); + } + }); +}); diff --git a/plugins/rebuilder/app/src/app/core/owned-cards.service.ts b/plugins/rebuilder/app/src/app/core/owned-cards.service.ts index 57b095f0..4e633671 100644 --- a/plugins/rebuilder/app/src/app/core/owned-cards.service.ts +++ b/plugins/rebuilder/app/src/app/core/owned-cards.service.ts @@ -57,7 +57,7 @@ export function favoriteRarities(f: SearchFilters, format: DeckFormat | null): s return format && f.legalOnly && formatInfo(format).uniqueMax === 0 ? picked.filter((r) => r !== 'UNIQUE') : picked; } -/** Query of each endpoint (their names differ: `faction[]` / `faction`, `set[]` / `cardSet`…). */ +/** Query of each endpoint (their names differ: `set[]` / `cardSet[]`…). Lists as `key[]`: PHP keeps the last value of a repeated `key=…`. */ export function ownedParams(source: OwnedSource, f: SearchFilters, factions: string[], page: number, itemsPerPage: number, format: DeckFormat | null = null): HttpParams { let p = new HttpParams().set('page', page).set('itemsPerPage', itemsPerPage).set('locale', contentLocale()); const rarities = f.rarities.length ? f.rarities : RARITY_OPTIONS.map((r) => r.value); @@ -70,11 +70,11 @@ export function ownedParams(source: OwnedSource, f: SearchFilters, factions: str add('set[]', f.sets); return p; } - add('faction', factions); - add('rarity', rarities); - add('cardType', f.types); - add('cardSet', f.sets); - add('subTypes', f.subtypes); + add('faction[]', factions); + add('rarity[]', rarities); + add('cardType[]', f.types); + add('cardSet[]', f.sets); + add('subTypes[]', f.subtypes); if (f.q.trim()) p = p.set('name', f.q.trim()); const costs = parseCostExpression(f.mainCost) ?? []; if (costs.length) { diff --git a/plugins/rebuilder/app/src/app/ui/metier/card-art/card-art.ts b/plugins/rebuilder/app/src/app/ui/metier/card-art/card-art.ts index 9f109926..953a7650 100644 --- a/plugins/rebuilder/app/src/app/ui/metier/card-art/card-art.ts +++ b/plugins/rebuilder/app/src/app/ui/metier/card-art/card-art.ts @@ -35,11 +35,17 @@ export class AcCardArt implements OnDestroy { /** Replaces the fallback chain derived from `reference`. */ readonly sources = input(null); - private readonly chain = computed(() => this.sources() ?? cardImageSources(this.reference(), contentLocale())); - /** Reset with the chain: a new reference shows the placeholder until its own image loads. */ - protected readonly loaded = linkedSignal({ source: this.chain, computation: () => false }); + /** Compared by value: a parent that rebuilds the same list must not retry sources that already failed. */ + private readonly chain = computed(() => this.sources() ?? cardImageSources(this.reference(), contentLocale()), { + equal: (a, b) => a.length === b.length && a.every((src, i) => src === b[i]), + }); private readonly attempt = linkedSignal({ source: this.chain, computation: () => 0 }); protected readonly src = computed(() => this.chain()[this.attempt()] ?? null); + /** + * Reset with the displayed src only: a new image shows the placeholder until it loads, while an that keeps + * its src never fires `load` again (a unique whose card object is replaced once its effect arrives). + */ + protected readonly loaded = linkedSignal({ source: this.src, computation: () => false }); protected readonly tint = computed(() => factionColor(this.faction())); constructor() { diff --git a/plugins/rebuilder/e2e/rebuilder.spec.ts b/plugins/rebuilder/e2e/rebuilder.spec.ts index 588f4034..087ee338 100644 --- a/plugins/rebuilder/e2e/rebuilder.spec.ts +++ b/plugins/rebuilder/e2e/rebuilder.spec.ts @@ -151,6 +151,33 @@ test.describe('ReBuilder in the shell · signed in', () => { expect(leaks).toEqual([]); }); + for (const { tab, short, endpoint, shot } of [ + { tab: 'Collection physique', short: 'Collection', endpoint: 'collection-search', shot: '15-collection-search' }, + { tab: 'Propriété numérique', short: null, endpoint: 'ownership-search', shot: '15-owned-search' }, + ]) { + test(`lists « ${tab} » with every rarity, type and set of the default filters`, async ({ page, compact }, testInfo) => { + await login(page, 'alice', `${NEW_DECK}?lang=fr`); + await createDeck(page, `E2E ${endpoint} ${Date.now()}`); + const request = page.waitForRequest((r) => r.url().includes(`/papi/core-altered-cards/${endpoint}`)); + if (compact && !short) { + // Compact screens: the last sources are in the « Plus » menu of the tabs. + await page.getByRole('tab', { name: 'Plus' }).click(); + await page.getByRole('menuitemradio', { name: tab }).click(); + } else { + await page.getByRole('tab', { name: compact && short ? short : tab, exact: true }).click(); + } + // The Axiom cards of the stack's mock (docker/stack/ownership-mock): commons, rares, two sets. + const tiles = page.locator('app-search-results ac-card-tile'); + await expect(tiles).toHaveCount(4); + await expect(tiles.and(page.locator('[aria-label*="La Machine dans la Glace"]'))).toHaveCount(1); + // Lists in their array form: PHP keeps the last value of a repeated `rarity=…`. + const query = new URL((await request).url()).searchParams; + expect(query.getAll('rarity[]')).toEqual(['COMMON', 'RARE', 'EXALTED']); + expect(query.getAll('cardType[]').length).toBeGreaterThan(1); + await evidence(page, testInfo, shot); + }); + } + test('deletes a deck after a confirmation in a design-system dialog, not the browser’s', async ({ page, compact }, testInfo) => { const name = `E2E delete ${testInfo.project.name} ${Date.now()}`; const browserDialogs: string[] = []; @@ -670,9 +697,9 @@ test.describe('ReBuilder in the shell · altered.gg export', () => { }); test.describe('ReBuilder in the shell · deck page', () => { - /** Creates a deck of alice's account through the relay (9 cards: not legal, the API says why). */ - async function createServerDeck(page: Page, name: string): Promise { - return page.evaluate(async (deckName) => { + /** Creates a deck of alice's account through the relay (9 cards: not legal, the API says why), plus `extra` references. */ + async function createServerDeck(page: Page, name: string, extra: string[] = []): Promise { + return page.evaluate(async ([deckName, more]) => { const host = (window as unknown as { AlteredCore: { csrf: string; services: { decks: string } } }).AlteredCore; const res = await fetch(`${host.services.decks}/api/decks`, { method: 'POST', @@ -687,12 +714,13 @@ test.describe('ReBuilder in the shell · deck page', () => { { cardReference: 'ALT_CORE_B_AX_08_C', quantity: 3 }, { cardReference: 'ALT_CORE_B_AX_09_C', quantity: 3 }, { cardReference: 'ALT_CORE_B_AX_10_C', quantity: 3 }, + ...more.map((cardReference) => ({ cardReference, quantity: 1 })), ], }), }); if (res.status !== 201) throw new Error(`deck creation: HTTP ${res.status}`); return ((await res.json()) as { id: string }).id; - }, name); + }, [name, extra] as const); } /** Deck page tab (desktop tabs) or bottom navigation entry (mobile). */ @@ -801,6 +829,24 @@ test.describe('ReBuilder in the shell · deck page', () => { await expect(page.getByRole('list', { name: 'Mes decks' }).locator('ac-deck-card').filter({ hasText: `${name} bis` })).toBeVisible(); }); + test('keeps a unique\'s illustration once its printed effect arrives after it', async ({ page }, testInfo) => { + // No support ability and a short effect: the same frame, so the same illustration URL, before and after the effect. + const unique = 'ALT_ALIZE_B_AX_32_U_2'; + await login(page, 'alice', `${DECKS}?lang=fr`); + const id = await createServerDeck(page, `E2E unique art ${Date.now()}`, [unique]); + const tile = page.locator('ac-card-tile').filter({ has: page.locator('ac-unique-card') }); + // The cards API answers once the illustration is shown: the deck then replaces the unique's card object. + await page.route('**/api/cards/batch**', async (route) => { + await expect(tile.locator('ac-card-art')).toHaveClass(/\bloaded\b/); + await route.continue(); + }); + await page.goto(DECK(id)); + await expect(tile.locator('.main-text')).not.toBeEmpty(); + await expect(tile.locator('ac-card-art')).toHaveClass(/\bloaded\b/); + await expect(tile.locator('ac-card-art img')).toHaveCSS('opacity', '1'); + await evidence(page, testInfo, '26-unique-art'); + }); + test('names a public deck in the page title and link preview, also from a site-style link', async ({ page }) => { const res = await page.request.get('/api/v1/services/decks/api/decks/public', { params: { itemsPerPage: 1 }, headers: { Accept: 'application/json' } }); const deck = ((await res.json()) as { member: { id: string; name: string }[] }).member[0]; diff --git a/tests/CollectionQueryTest.php b/tests/CollectionQueryTest.php new file mode 100644 index 00000000..f895760d --- /dev/null +++ b/tests/CollectionQueryTest.php @@ -0,0 +1,15 @@ + ['AX'], 'rarity' => ['COMMON', 'RARE', 'EXALTED'], 'cardType' => ['CHARACTER', 'SPELL'], + 'cardSet' => ['CORE', 'ALIZE'], 'locale' => 'fr', 'page' => '2', 'itemsPerPage' => '36', + ]), + 'list filters keep every value' +); +assertSame('faction[]=AX&name=Red%20Axiom', cacCollectionQuery(['faction' => 'AX', 'name' => 'Red Axiom', 'rarity' => ['']]), 'a single value, empty values dropped'); +assertSame('mainCost[gte]=1&mainCost[lte]=3', cacCollectionQuery(['mainCost' => ['gte' => '1', 'lte' => '3', 'eq' => '2']]), 'cost range'); +assertSame('', cacCollectionQuery([]), 'no filter'); From 4863faeccb4bd765d38ed0b3b80f91347a4bcf1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89douard=20WILLISSECK?= Date: Tue, 6 Oct 2026 11:20:37 +0200 Subject: [PATCH 02/69] Re:Builder: number alt-art copies 1, 2, 3, one unnumbered marker for a token or a hero (#132) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Re:Builder: number alt-art copies 1, 2, 3, one unnumbered marker for a token or a hero The markers showed `slotIndex + 1`: a token's single copy showed « 2 » in production (the ownership API's slot indexes most likely start at 1). They are now numbered by position; a family with one copy (token, hero) has one marker with a check and no number, and the first copy moves first, as on the site's widget. The stack's ownership mock gives tokens one copy, as the service does. Co-Authored-By: Claude Opus 5.5 * Simplify the copy numbers of the alt-art markers Co-Authored-By: Claude Opus 5.5 * Simplify: number the copies once, fewer marker assertions - alt-art-slots numbers the copies once per render instead of once per illustration; the pointer cursor is set on the button markers only. - e2e: the token markers are checked with two locator counts instead of two assertions a marker. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docker/stack/ownership-mock/index.php | 2 +- .../shared/alt-art-slots/alt-art-slots.html | 8 +++-- .../shared/alt-art-slots/alt-art-slots.scss | 6 ++++ .../alt-art-slots/alt-art-slots.spec.ts | 31 ++++++++++++++++--- .../shared/alt-art-slots/alt-art-slots.ts | 21 ++++++++----- .../rebuilder/app/src/locale/messages.en.json | 1 + plugins/rebuilder/e2e/rebuilder.spec.ts | 27 ++++++++++++++++ 7 files changed, 81 insertions(+), 15 deletions(-) diff --git a/docker/stack/ownership-mock/index.php b/docker/stack/ownership-mock/index.php index ec8f3bc9..de77560a 100644 --- a/docker/stack/ownership-mock/index.php +++ b/docker/stack/ownership-mock/index.php @@ -122,7 +122,7 @@ function toFamily(array $g): ?array { 'cardType' => $type, 'name' => $g['name'] ?? '', 'slug' => $slug, - 'copies' => $type === 'HERO' ? 1 : 3, + 'copies' => $type === 'HERO' || str_starts_with($type, 'TOKEN') ? 1 : 3, 'prints' => $prints, ]; $f['key'] = $f['familyId'] . ':' . $faction . ':' . $rarity; diff --git a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.html b/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.html index 0287d804..7e87b8bb 100644 --- a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.html +++ b/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.html @@ -6,8 +6,12 @@ @if (t.markers.length) { - @for (m of t.markers; track m.slotIndex) { - + @if (single()) { + + } @else { + @for (m of t.markers; track m.slotIndex) { + + } } } diff --git a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.scss b/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.scss index 956c78aa..43e0d629 100644 --- a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.scss +++ b/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.scss @@ -56,8 +56,14 @@ li { color: var(--ac-color-on-strong); font-size: 10px; font-weight: 700; +} +button.marker { cursor: pointer; } +span.marker { + display: grid; + place-items: center; +} .marker.active { background: var(--ac-color-primary); } diff --git a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.spec.ts b/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.spec.ts index fe5b0bc4..231f5c21 100644 --- a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.spec.ts +++ b/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.spec.ts @@ -17,7 +17,7 @@ const CHOICE: AltArtChoice = { }; describe('AltArtSlots', () => { - function setup(result: 'ok' | 409) { + function setup(result: 'ok' | 409, choice: AltArtChoice = CHOICE) { const saved: string[][] = []; TestBed.configureTestingModule({ providers: [ @@ -33,18 +33,18 @@ describe('AltArtSlots', () => { ], }); const fixture = TestBed.createComponent(AltArtSlots); - fixture.componentRef.setInput('choice', CHOICE); + fixture.componentRef.setInput('choice', choice); fixture.detectChanges(); const el = fixture.nativeElement as HTMLElement; return { fixture, el, saved }; } - it('moves the active copy onto an owned illustration and saves the slots', () => { + it('moves the copies onto an owned illustration in order, from the first, and saves the slots', () => { const { fixture, el, saved } = setup('ok'); el.querySelectorAll('.tile')[1].click(); fixture.detectChanges(); - expect(saved).toEqual([['ALT_CORE_B_AX_04_C', 'ALT_CORE_B_AX_04_C', 'ALT_CORE_A_AX_04_C']]); - expect(el.querySelectorAll('li')[1].querySelectorAll('.marker')).toHaveLength(1); + expect(saved).toEqual([['ALT_CORE_A_AX_04_C', 'ALT_CORE_B_AX_04_C', 'ALT_CORE_B_AX_04_C']]); + expect([...el.querySelectorAll('li')[1].querySelectorAll('.marker')].map((m) => m.textContent?.trim())).toEqual(['1']); expect(el.querySelectorAll('.tile')[2].disabled).toBe(true); }); @@ -55,4 +55,25 @@ describe('AltArtSlots', () => { expect(el.querySelectorAll('li')[1].querySelectorAll('.marker')).toHaveLength(0); expect(el.querySelector('.error')?.textContent).toContain('ALT_CORE_A_AX_04_C (3/2)'); }); + + it('numbers the copies 1, 2, 3 whatever the API\'s slot indexes', () => { + const fromOne = { ...CHOICE, options: { ...CHOICE.options, slots: [1, 2, 3].map((slotIndex) => ({ slotIndex, reference: 'ALT_CORE_B_AX_04_C' })) } }; + const { el } = setup('ok', fromOne); + const markers = [...el.querySelectorAll('li:first-child .marker')]; + expect(markers.map((m) => m.textContent?.trim())).toEqual(['1', '2', '3']); + expect(markers.map((m) => m.getAttribute('aria-label'))).toEqual(['Exemplaire 1', 'Exemplaire 2', 'Exemplaire 3']); + }); + + it('shows one marker without a number for a family with one copy (token, hero)', () => { + const token = { ...CHOICE, options: { ...CHOICE.options, slots: [{ slotIndex: 1, reference: 'ALT_CORE_A_AX_04_C' }] } }; + const { fixture, el, saved } = setup('ok', token); + const marker = el.querySelector('li:nth-child(2) .marker'); + expect(marker?.textContent?.trim()).toBe(''); + expect(marker?.getAttribute('aria-label')).toBe('Illustration choisie'); + expect(el.querySelectorAll('.marker')).toHaveLength(1); + el.querySelectorAll('.tile')[0].click(); + fixture.detectChanges(); + expect(saved).toEqual([['ALT_CORE_B_AX_04_C']]); + expect(el.querySelector('li:nth-child(1) .marker')).not.toBeNull(); + }); }); diff --git a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.ts b/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.ts index 22fc2482..df012247 100644 --- a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.ts +++ b/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.ts @@ -3,14 +3,17 @@ import { HttpErrorResponse } from '@angular/common/http'; import { finalize } from 'rxjs'; import { cardImageUrl } from '../../../core/card-art'; import { OwnershipApiService, type AltArtChoice } from '../../../core/ownership-api.service'; +import { AcIcon } from '../../../ui/icon'; /** * The illustrations of a card family with the player's copies on them (one marker a copy), as the site's alt-art * widget in the card lightbox: an owned illustration takes the active marker, the active marker then moves on; the - * site saves the choice and a refusal puts the marker back, with the reason. + * site saves the choice and a refusal puts the marker back, with the reason. Markers are numbered by copy (1, 2, 3), + * not by the API's `slotIndex`; a family with one copy (hero, token) has a single marker without a number. */ @Component({ selector: 'app-alt-art-slots', + imports: [AcIcon], templateUrl: './alt-art-slots.html', styleUrl: './alt-art-slots.scss', }) @@ -18,20 +21,24 @@ export class AltArtSlots { private readonly ownership = inject(OwnershipApiService); readonly choice = input.required(); protected readonly slots = linkedSignal(() => [...this.choice().options.slots].sort((a, b) => a.slotIndex - b.slotIndex)); - protected readonly active = linkedSignal(() => this.slots().at(-1)?.slotIndex ?? 0); + /** The first copy moves first, then the next ones (the site's widget), so successive picks fill copies 1, 2, 3. */ + protected readonly active = linkedSignal(() => this.slots()[0]?.slotIndex ?? 0); + protected readonly single = computed(() => this.slots().length === 1); protected readonly error = signal(null); /** A save is running: the tiles wait for it, so that saves never cross (a late refusal would undo a later choice). */ protected readonly saving = signal(false); - protected readonly tiles = computed(() => - this.choice().options.options.map((o) => ({ + protected readonly tiles = computed(() => { + const copies = this.slots().map((s, i) => ({ ...s, copy: i + 1 })); + return this.choice().options.options.map((o) => ({ reference: o.reference, src: cardImageUrl(o.reference), owned: o.ownedQuantity === null || o.ownedQuantity > 0, - markers: this.slots().filter((s) => s.reference === o.reference), - })), - ); + markers: copies.filter((s) => s.reference === o.reference), + })); + }); protected readonly tileLabel = (n: number) => $localize`:@@altArt.tile:Illustration ${n}:n:`; protected readonly markerLabel = (n: number) => $localize`:@@altArt.marker:Exemplaire ${n}:n:`; + protected readonly chosenLabel = $localize`:@@altArt.chosen:Illustration choisie`; /** An owned illustration: the active marker (or the next one not already there) moves onto it. */ protected pick(reference: string, owned: boolean): void { diff --git a/plugins/rebuilder/app/src/locale/messages.en.json b/plugins/rebuilder/app/src/locale/messages.en.json index 7c22c697..79bb0edf 100644 --- a/plugins/rebuilder/app/src/locale/messages.en.json +++ b/plugins/rebuilder/app/src/locale/messages.en.json @@ -674,6 +674,7 @@ "ui.cardTile.favorite": "Add {$name} to favorites", "altArt.tile": "Illustration {$n}", "altArt.marker": "Copy {$n}", + "altArt.chosen": "Chosen illustration", "altArt.saveError": "Could not save your choice.", "core.violation.sets": "Set missing from BGA", "core.violation.frontierUniques": "Off the Frontier list", diff --git a/plugins/rebuilder/e2e/rebuilder.spec.ts b/plugins/rebuilder/e2e/rebuilder.spec.ts index 087ee338..9e449f03 100644 --- a/plugins/rebuilder/e2e/rebuilder.spec.ts +++ b/plugins/rebuilder/e2e/rebuilder.spec.ts @@ -178,6 +178,21 @@ test.describe('ReBuilder in the shell · signed in', () => { }); } + test('marks the chosen illustration of each token without a copy number (one copy)', async ({ page, compact }, testInfo) => { + await login(page, 'alice', `${NEW_DECK}?lang=fr`); + await createDeck(page, `E2E token arts ${Date.now()}`); + if (compact) await page.getByRole('navigation', { name: FR.deckNav }).getByRole('link', { name: /Deck/ }).click(); + await page.getByRole('button', { name: 'Choisir les arts des jetons' }).click(); + const dialog = page.getByRole('dialog', { name: 'Illustrations des jetons' }); + const families = dialog.locator('app-alt-art-slots'); + await expect(families.first()).toBeVisible(); + // One marker a token, all of them « Illustration choisie », none numbered. + await expect(dialog.getByRole('img', { name: 'Illustration choisie' })).toHaveCount(await families.count()); + await expect(dialog.locator('.marker')).toHaveCount(await families.count()); + await expect(dialog.locator('.marker').filter({ hasText: /\d/ })).toHaveCount(0); + await evidence(page, testInfo, '16-token-arts'); + }); + test('deletes a deck after a confirmation in a design-system dialog, not the browser’s', async ({ page, compact }, testInfo) => { const name = `E2E delete ${testInfo.project.name} ${Date.now()}`; const browserDialogs: string[] = []; @@ -847,6 +862,18 @@ test.describe('ReBuilder in the shell · deck page', () => { await evidence(page, testInfo, '26-unique-art'); }); + test('numbers the copies 1, 2, 3 on a card\'s illustrations in Global alt-art mode', async ({ page }, testInfo) => { + // Global mode for this page only: the mode is the account's, shared with the other tests. + await page.route('**/api/alt-arts/preference-mode', (route) => route.fulfill({ json: { mode: 'Global' } })); + await login(page, 'alice', `${DECKS}?lang=fr`); + const id = await createServerDeck(page, `E2E alt arts ${Date.now()}`, ['ALT_CORE_B_AX_04_C']); + await page.goto(DECK(id)); + await page.getByRole('button', { name: /^Agrandir Élémentaire de Kélon/ }).click(); + const slots = page.locator('app-alt-art-slots'); + await expect(slots.getByRole('button', { name: /^Exemplaire \d$/ })).toHaveText(['1', '2', '3']); + await evidence(page, testInfo, '27-alt-art-copies'); + }); + test('names a public deck in the page title and link preview, also from a site-style link', async ({ page }) => { const res = await page.request.get('/api/v1/services/decks/api/decks/public', { params: { itemsPerPage: 1 }, headers: { Accept: 'application/json' } }); const deck = ((await res.json()) as { member: { id: string; name: string }[] }).member[0]; From 313275265ab3c26aa6d375fc1bafe5c62f1436cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89douard=20WILLISSECK?= Date: Tue, 6 Oct 2026 12:21:17 +0200 Subject: [PATCH 03/69] Re:Builder: check several effect values in a row, without reopening a list (#133) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Re:Builder: check several effect values in a row, without reopening a list The effect editor's three criteria (Quand · déclencheur, Si · condition, Alors · effet) used a combobox that closed after each pick. They now use a checkbox list that stays open: - Window: the criteria as cards on the left (values one per line, « ou » on its own line), the checkbox list of the selected criterion on the right, at a fixed height so checking never moves the list. - Compact: each criterion shows its values and an « Ajouter un … » add button that opens the list as a step of the same sheet, over its whole height. - A list is narrowed again only when the two other criteria change (the server already leaves the narrowed criterion out), so checking values does not reshape the list being checked. New components: ac-check-list, ac-or-values, and the ac-ability-picker step. Co-Authored-By: Claude Opus 5.5 * Re:Builder: duplicate an effect from its summary A copy button between edit and delete on each effect summary puts a copy of the effect right after it, with its own id. Co-Authored-By: Claude Opus 5.5 * Re:Builder: share the list helpers of the effect editor - option-list.ts holds normalizeSearch and groupStarts, used by ac-combobox, ac-check-list and ac-select (which had its own copy of the search folding). - One _glyph.scss partial for the Altered Icons glyph of the three lists. - ac-check-list's search field uses the design system's ac-input-icon. - The effect editor builds its criteria from one text table, reads the picked values through one helper, and renders the intro and the load error from a single template each. - newEffectId() for the copy of a duplicated effect; ac-combobox drops its emptyPlaceholder input, unused since the effect editor stopped using it. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- plugins/rebuilder/app/design/COMPONENTS.md | 9 +- .../app/src/app/core/card-filters.ts | 6 +- .../ability-picker.overlay.html | 4 + .../ability-picker.overlay.scss | 4 + .../ability-picker.overlay.spec.ts | 49 +++ .../ability-picker/ability-picker.overlay.ts | 38 +++ .../effect-editor/effect-editor.overlay.html | 61 +++- .../effect-editor/effect-editor.overlay.scss | 76 ++++- .../effect-editor.overlay.spec.ts | 290 +++++++++--------- .../effect-editor/effect-editor.overlay.ts | 163 ++++++---- .../search/filters-panel/filters-panel.html | 2 +- .../search/filters-panel/filters-panel.ts | 9 +- .../app/src/app/ui/fields/_glyph.scss | 13 + .../app/ui/fields/check-list/check-list.html | 28 ++ .../app/ui/fields/check-list/check-list.scss | 55 ++++ .../app/ui/fields/check-list/check-list.ts | 53 ++++ .../src/app/ui/fields/combobox/combobox.html | 2 +- .../src/app/ui/fields/combobox/combobox.scss | 14 +- .../src/app/ui/fields/combobox/combobox.ts | 22 +- .../rebuilder/app/src/app/ui/fields/index.ts | 2 + .../app/src/app/ui/fields/option-list.ts | 15 + .../app/ui/fields/or-values/or-values.html | 13 + .../app/ui/fields/or-values/or-values.scss | 52 ++++ .../src/app/ui/fields/or-values/or-values.ts | 27 ++ .../app/src/app/ui/fields/select/select.ts | 16 +- .../metier/effect-summary/effect-summary.html | 1 + .../effect-summary/effect-summary.spec.ts | 18 ++ .../metier/effect-summary/effect-summary.ts | 2 + .../rebuilder/app/src/locale/messages.en.json | 25 +- plugins/rebuilder/e2e/rebuilder.spec.ts | 54 ++-- 30 files changed, 827 insertions(+), 296 deletions(-) create mode 100644 plugins/rebuilder/app/src/app/features/search/ability-picker/ability-picker.overlay.html create mode 100644 plugins/rebuilder/app/src/app/features/search/ability-picker/ability-picker.overlay.scss create mode 100644 plugins/rebuilder/app/src/app/features/search/ability-picker/ability-picker.overlay.spec.ts create mode 100644 plugins/rebuilder/app/src/app/features/search/ability-picker/ability-picker.overlay.ts create mode 100644 plugins/rebuilder/app/src/app/ui/fields/_glyph.scss create mode 100644 plugins/rebuilder/app/src/app/ui/fields/check-list/check-list.html create mode 100644 plugins/rebuilder/app/src/app/ui/fields/check-list/check-list.scss create mode 100644 plugins/rebuilder/app/src/app/ui/fields/check-list/check-list.ts create mode 100644 plugins/rebuilder/app/src/app/ui/fields/option-list.ts create mode 100644 plugins/rebuilder/app/src/app/ui/fields/or-values/or-values.html create mode 100644 plugins/rebuilder/app/src/app/ui/fields/or-values/or-values.scss create mode 100644 plugins/rebuilder/app/src/app/ui/fields/or-values/or-values.ts create mode 100644 plugins/rebuilder/app/src/app/ui/metier/effect-summary/effect-summary.spec.ts diff --git a/plugins/rebuilder/app/design/COMPONENTS.md b/plugins/rebuilder/app/design/COMPONENTS.md index 1dc177eb..69e9ce40 100644 --- a/plugins/rebuilder/app/design/COMPONENTS.md +++ b/plugins/rebuilder/app/design/COMPONENTS.md @@ -61,7 +61,9 @@ Classes et états des boutons : `design-system/docs/components/button.md`. | `ac-radio-card` (dans un conteneur `role=radiogroup`) | `name`, `title`, `description`, `tag?: {label, tone}`, `checked`, `layout: 'row' \| 'stacked' \| 'compact'` (`compact` : radio 18 px, titre `--ac-field-font-size`/700, description sur une ligne avec points de suspension, tag centré à droite) | `choose` | choix du format (création : `compact`, réglages : `row`) | | `ac-switch`, CSS DS (`field.css`) | `label`, `checked = model(false)` | — | Légaux uniquement (Communauté, desktop) | | `ac-checkbox`, CSS DS (`field.css`, `ac-check`) | `label`, `checked = model(false)` | — | Légaux uniquement (feuille de filtres mobile, appliquée par « Appliquer ») | -| `ac-combobox` | `options: {id, text, glyph?, thumb?, group?}[]` (`thumb` : vignette dans la liste ; `group` : intertitre au-dessus des options consécutives d’un même groupe), `values = model()`, `placeholder`, `emptyPlaceholder`, `searchPlaceholder = 'Rechercher…'` | `searchChange` (texte saisi) | éditeur d'effet (déclencheur, condition, effet), calculateurs de la main de départ (vignettes, groupes par type). Le contrôle « Ajouter… » est un bouton (`role=combobox`) qui ouvre la liste dans un overlay CDK (`cdkConnectedOverlay` : largeur du bouton, sous le bouton ou au-dessus faute de place, fermeture au clic extérieur et sur Échap). La liste est un `cdkListbox` (flèches, Début / Fin, Entrée, saisie rapide, ARIA). Le champ de recherche n'est **pas focalisé** à l'ouverture (pas de clavier tactile tant qu'on ne le touche pas) ; une lettre sur le bouton démarre la recherche, Entrée dans le champ prend le premier résultat, une flèche passe dans la liste. Après un choix ou Échap, la liste se ferme et le focus revient au bouton. `glyph` : caractère `Altered Icons` avant le texte, dans la liste et sur la puce (main, réserve, partout). « ou » entre chaque valeur et avant le bouton « Ajouter… » dès qu'au moins une valeur est choisie ; rien si la liste est vide | +| `ac-check-list` | `options: {id, text, glyph?, group?}[]`, `values = model()`, `label` (nom accessible de la liste), `searchPlaceholder = 'Rechercher…'` ; champ de recherche (`--ac-control-md`) puis une case `ac-check` par option, dans l'ordre de `options` : cocher ou décocher ne déplace aucune ligne ; une valeur cochée va à la fin de `values` (ordre de saisie) ; Entrée dans la recherche coche le premier résultat non coché et vide le champ ; la liste prend la hauteur restante et défile | — | éditeur d'effet (colonne de droite en fenêtre, étape `ac-ability-picker` en compact) | +| `ac-or-values` | `values = model()` (`{id, text, glyph?}`), `empty` (texte quand rien n'est choisi) ; une valeur par ligne, « ou » seul sur sa ligne entre deux valeurs, bouton « Retirer » (cible `--ac-hit-min`) sur chacune | — | éditeur d'effet (cartes des critères en fenêtre, critères en compact) | +| `ac-combobox` | `options: {id, text, glyph?, thumb?, group?}[]` (`thumb` : vignette dans la liste ; `group` : intertitre au-dessus des options consécutives d’un même groupe), `values = model()`, `placeholder`, `searchPlaceholder = 'Rechercher…'` | `searchChange` (texte saisi) | sous-types (filtres), calculateurs de la main de départ (vignettes, groupes par type). Le contrôle « Ajouter… » est un bouton (`role=combobox`) qui ouvre la liste dans un overlay CDK (`cdkConnectedOverlay` : largeur du bouton, sous le bouton ou au-dessus faute de place, fermeture au clic extérieur et sur Échap). La liste est un `cdkListbox` (flèches, Début / Fin, Entrée, saisie rapide, ARIA). Le champ de recherche n'est **pas focalisé** à l'ouverture (pas de clavier tactile tant qu'on ne le touche pas) ; une lettre sur le bouton démarre la recherche, Entrée dans le champ prend le premier résultat, une flèche passe dans la liste. Après un choix ou Échap, la liste se ferme et le focus revient au bouton. `glyph` : caractère `Altered Icons` avant le texte, dans la liste et sur la puce (main, réserve, partout). « ou » entre chaque valeur et avant le bouton « Ajouter… » dès qu'au moins une valeur est choisie ; rien si la liste est vide | | `ac-editable-title` | `value = model()`, `ariaLabel` | — | titre du deck (desktop) | Classes et états des champs et du contrôle segmenté : `design-system/docs/components/field.md` et `segmented.md`. @@ -123,8 +125,9 @@ Classes et états des champs et du contrôle segmenté : `design-system/docs/com | `app-new-deck` (contenu d'overlay) | héros intégré (`ac-faction-tabs` + `ac-hero-selector`), nom pré-rempli « Deck », visibilité, formats `compact` ; colonne des héros < 500 px (fenêtres de 768 à ~950 px) : factions en 3 × 2 et héros sur 3 colonnes ; fenêtre 1080 px × (fenêtre − 80 px) via `AcOverlayService` `height: 'fill'`, plein écran en compact | `NewDeckResult` | Nouveau deck | | `ac-deck-settings` (contenu d'overlay) | `deck` ; nom (`ac-input`, requis : « Enregistrer » désactivé s'il est vide), héros + « Changer », `ac-segmented` visibilité, description (`ac-textarea`), `ac-radio-card` formats | `save`, `cancel` | réglages desktop / mobile (seul endroit pour renommer en compact) | | `ac-save-status` | `state: 'idle' \| 'pending' \| 'saving' \| 'saved' \| 'error'`, `iconOnly` (texte réservé aux lecteurs d'écran, `title` au survol, cible 44 px) ; `role=status` ; rien en `idle`, point pulsé + « Enregistrement… », coche verte + « Enregistré », alerte rouge + « Non enregistré » (le motif et « Réessayer » sont affichés par l'écran) | — | éditeur : à côté du titre (desktop), barre d'app (compact) | -| `ac-effect-summary` | `effect: {triggers[], conditions[], effects[]}` (valeurs `{id, text, glyph?}` : le glyphe précède le texte) | `edit`, `remove` | filtres Uniques | -| `ac-effect-editor` (contenu d'overlay) | `effect` ; un `ac-combobox` par critère | `apply`, `clear` | modifier un effet | +| `ac-effect-summary` | `effect: {triggers[], conditions[], effects[]}` (valeurs `{id, text, glyph?}` : le glyphe précède le texte) | `edit`, `duplicate` (bouton copie entre modifier et supprimer), `remove` | filtres Uniques | +| `ac-effect-editor` (contenu d'overlay) | `effect` ; fenêtre (880 px, hauteur fixe) : à gauche les trois critères Quand / Si / Alors en cartes (`ac-or-values`, « Tous les … » quand vide), la carte active bordée de bleu ; à droite la `ac-check-list` du critère actif. Compact : chaque critère montre ses valeurs (`ac-or-values`) et un bouton qui ouvre l'étape `ac-ability-picker` | `apply`, `clear` | modifier un effet | +| `ac-ability-picker` (étape d'overlay) | `options`, `picked` (signal de l'éditeur : chaque case cochée s'affiche aussitôt sur le critère), `label`, `searchPlaceholder` ; `ac-check-list` sur toute la feuille (les valeurs avec « ou » s'affichent sur le critère au retour) ; « Tout décocher » dans l'en-tête, « Valider » revient au critère | — | éditeur d'effet en compact | | `ac-extension-tile` | `extension`, `selected = model()` | — | filtre extensions | | `ac-hero-tile` | `hero`, `selected`, `unavailableOnBga?` (tag « Indispo. BGA »), `size: 'sm' \| 'md'` (`sm` : nom 13 px, pour le carrousel) ; remplit sa cellule de grille ou un emplacement de largeur fixe, nom sur 2 lignes max. ; `aria-pressed` | `choose` | Nouveau deck, choisir un héros | | `ac-faction-tabs` | `active = model()`, `size: 'sm' \| 'md'` (`sm` : pastilles de 36 px, cible 44 px en touch), `layout: 'scroll' \| 'grid'` (`grid` : `columns` colonnes égales, 6 sur une ligne ou 3 sur deux lignes quand la place manque ; `scroll` : une ligne qui défile et garde l'onglet actif visible, marge de défilement via `--ac-faction-tabs-bleed`), `controls` (id du panneau) ; `role=tablist`, flèches / Début / Fin | — | Nouveau deck, choisir un héros | diff --git a/plugins/rebuilder/app/src/app/core/card-filters.ts b/plugins/rebuilder/app/src/app/core/card-filters.ts index a2f5bde8..62f113b5 100644 --- a/plugins/rebuilder/app/src/app/core/card-filters.ts +++ b/plugins/rebuilder/app/src/app/core/card-filters.ts @@ -369,6 +369,10 @@ export function activeFilterCount(filters: SearchFilters, source: CardSource): n return filterChips(filters, source).filter((c) => c.id !== 'environment' || filters.environment !== 'all').length; } +export function newEffectId(): string { + return `fx-${Math.random().toString(36).slice(2, 9)}`; +} + export function newEffectBlock(): EffectBlock { - return { id: `fx-${Math.random().toString(36).slice(2, 9)}`, triggers: [], conditions: [], effects: [] }; + return { id: newEffectId(), triggers: [], conditions: [], effects: [] }; } diff --git a/plugins/rebuilder/app/src/app/features/search/ability-picker/ability-picker.overlay.html b/plugins/rebuilder/app/src/app/features/search/ability-picker/ability-picker.overlay.html new file mode 100644 index 00000000..f2d891bc --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/search/ability-picker/ability-picker.overlay.html @@ -0,0 +1,4 @@ + + diff --git a/plugins/rebuilder/app/src/app/features/search/ability-picker/ability-picker.overlay.scss b/plugins/rebuilder/app/src/app/features/search/ability-picker/ability-picker.overlay.scss new file mode 100644 index 00000000..3dc52e66 --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/search/ability-picker/ability-picker.overlay.scss @@ -0,0 +1,4 @@ +.list { + flex: 1 1 auto; + padding: var(--ac-space-3) var(--ac-space-4) 0; +} diff --git a/plugins/rebuilder/app/src/app/features/search/ability-picker/ability-picker.overlay.spec.ts b/plugins/rebuilder/app/src/app/features/search/ability-picker/ability-picker.overlay.spec.ts new file mode 100644 index 00000000..ecd098b6 --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/search/ability-picker/ability-picker.overlay.spec.ts @@ -0,0 +1,49 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import type { AbilityRef } from '../../../core/card-filters'; +import { AcOverlayRef } from '../../../ui/overlay'; +import { AbilityPickerStep, type AbilityPickerData } from './ability-picker.overlay'; + +const OPTIONS: AbilityRef[] = [ + { id: 3, text: 'Ravitaillez' }, + { id: 4, text: 'Sabotez' }, + { id: 5, text: 'Piochez une carte.' }, +]; + +function setup(picked: AbilityRef[]) { + const data: AbilityPickerData = { + options: signal(OPTIONS), + picked: signal(picked), + label: 'effet', + searchPlaceholder: 'Rechercher un effet…', + }; + const ref = new AcOverlayRef(data); + TestBed.configureTestingModule({ imports: [AbilityPickerStep], providers: [{ provide: AcOverlayRef, useValue: ref }] }); + const fixture = TestBed.createComponent(AbilityPickerStep); + fixture.detectChanges(); + return { data, ref, el: fixture.nativeElement as HTMLElement, detect: () => fixture.detectChanges() }; +} + +describe('AbilityPickerStep', () => { + it('gives the sheet to the list, which stays open while values are checked', () => { + const { el, data, detect } = setup([]); + expect(el.querySelector('ac-or-values')).toBeNull(); + const boxes = () => [...el.querySelectorAll('ac-check-list input[type=checkbox]')]; + boxes()[1].click(); + detect(); + boxes()[0].click(); + detect(); + expect(boxes()).toHaveLength(3); + expect(data.picked().map((a) => a.id)).toEqual([4, 3]); + }); + + it('unchecks everything from the header, and « Valider » goes back', () => { + const { ref, data, el } = setup([OPTIONS[0], OPTIONS[1]]); + ref.headerAction()!.run(); + expect(data.picked()).toEqual([]); + let closed = false; + ref.dialogRef = { close: () => (closed = true) }; + el.querySelector('.ac-overlay-footer button')!.click(); + expect(closed).toBe(true); + }); +}); diff --git a/plugins/rebuilder/app/src/app/features/search/ability-picker/ability-picker.overlay.ts b/plugins/rebuilder/app/src/app/features/search/ability-picker/ability-picker.overlay.ts new file mode 100644 index 00000000..d1b4b69d --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/search/ability-picker/ability-picker.overlay.ts @@ -0,0 +1,38 @@ +import { Component, inject, type Signal, type WritableSignal } from '@angular/core'; +import type { AbilityRef } from '../../../core/card-filters'; +import { AcButton } from '../../../ui/buttons'; +import { AcCheckList } from '../../../ui/fields'; +import { AcOverlayRef } from '../../../ui/overlay'; + +export interface AbilityPickerData { + options: Signal; + /** The editor's own list: a check shows on its criterion at once, « Valider » only goes back. */ + picked: WritableSignal; + /** Accessible name of the list (« effet »). */ + label: string; + searchPlaceholder: string; +} + +/** + * ac-ability-picker — compact step of the effect editor for one criterion: the search and the checkbox + * list over the whole sheet, open while values are checked. The values show, joined by « ou », on the + * criterion once back. + */ +@Component({ + selector: 'ac-ability-picker', + imports: [AcCheckList, AcButton], + host: { class: 'ac-overlay-content' }, + templateUrl: './ability-picker.overlay.html', + styleUrl: './ability-picker.overlay.scss', +}) +export class AbilityPickerStep { + protected readonly ref = inject>(AcOverlayRef); + protected readonly data = this.ref.data; + + constructor() { + this.ref.headerAction.set({ + label: $localize`:@@search.effect.uncheckAll:Tout décocher`, + run: () => this.data.picked.set([]), + }); + } +} diff --git a/plugins/rebuilder/app/src/app/features/search/effect-editor/effect-editor.overlay.html b/plugins/rebuilder/app/src/app/features/search/effect-editor/effect-editor.overlay.html index 82b872dd..173871b7 100644 --- a/plugins/rebuilder/app/src/app/features/search/effect-editor/effect-editor.overlay.html +++ b/plugins/rebuilder/app/src/app/features/search/effect-editor/effect-editor.overlay.html @@ -1,22 +1,51 @@ -
-

Une carte correspond si elle a au moins une des valeurs de chaque critère rempli.

-
- Quand · déclencheur - -
-
- Si · condition - +@if (bp.compact()) { +
+ + @for (c of criteria; track c.kind) { +
+ {{ c.label }} · {{ c.sub }} + @if (c.picked().length) { + + } + +
+ } +
-
- Alors · effet - +} @else { +
+
+ + @for (c of criteria; track c.kind) { +
+ + +
+ } +
+ @for (c of criteria; track c.kind) { + @if (active() === c.kind) { +
+

{{ c.label }} · {{ c.sub }}

+ + +
+ } + }
- @if (loadError()) { - - } -
+} + +

Une carte correspond si elle a au moins une des valeurs de chaque critère rempli.

+
+ + @if (loadError()) { + + } + diff --git a/plugins/rebuilder/app/src/app/features/search/effect-editor/effect-editor.overlay.scss b/plugins/rebuilder/app/src/app/features/search/effect-editor/effect-editor.overlay.scss index 6c5e2332..0d7d54f2 100644 --- a/plugins/rebuilder/app/src/app/features/search/effect-editor/effect-editor.overlay.scss +++ b/plugins/rebuilder/app/src/app/features/search/effect-editor/effect-editor.overlay.scss @@ -1,8 +1,7 @@ -.ac-overlay-body { +.stack { display: flex; flex-direction: column; - gap: var(--ac-space-4); - min-height: 420px; + gap: var(--ac-space-5); } .intro { margin: 0; @@ -13,9 +12,10 @@ .crit { display: flex; flex-direction: column; - gap: var(--ac-space-1-5); + gap: var(--ac-space-2); } .label { + margin: 0; font-size: var(--ac-select-font-size); font-weight: 800; } @@ -23,6 +23,74 @@ font-weight: 600; color: var(--ac-color-text-muted); } +/* Window: fixed height, so neither column moves while values are checked. */ +.cols { + display: grid; + grid-template-columns: minmax(0, 320px) minmax(0, 1fr); + height: min(560px, calc(90vh - 150px)); + padding: 0; + overflow: hidden; +} +.side { + display: flex; + flex-direction: column; + gap: var(--ac-space-2-5); + padding: var(--ac-space-4); + background: var(--ac-color-bg-app); + border-right: 1px solid var(--ac-color-border); + overflow-y: auto; + overscroll-behavior: contain; +} +.side .intro { + margin-bottom: var(--ac-space-1); +} +.card { + flex-shrink: 0; + border: 1px solid var(--ac-color-border); + border-radius: var(--ac-radius-lg); + background: var(--ac-color-surface); +} +.card.on { + border-color: var(--ac-color-primary); + box-shadow: 0 0 0 1px var(--ac-color-primary); +} +.card-head { + display: flex; + align-items: center; + gap: var(--ac-space-2); + width: 100%; + min-height: var(--ac-control-md); + padding: 0 var(--ac-space-3); + border: none; + border-radius: var(--ac-radius-lg); + background: none; + color: var(--ac-color-text); + font: inherit; + text-align: left; + cursor: pointer; +} +.card-head .label { + flex: 1 1 auto; +} +.card-head ac-icon { + color: var(--ac-color-text-muted); +} +.card.on .card-head ac-icon { + color: var(--ac-color-primary); +} +.card-values { + padding: 0 var(--ac-space-3) var(--ac-space-3); +} +.pane { + display: flex; + flex-direction: column; + gap: var(--ac-space-3); + min-height: 0; + padding: var(--ac-space-4) var(--ac-space-5) 0; +} +.pane-list { + flex: 1 1 auto; +} .error { color: var(--ac-color-danger); font-size: var(--ac-font-size-small); diff --git a/plugins/rebuilder/app/src/app/features/search/effect-editor/effect-editor.overlay.spec.ts b/plugins/rebuilder/app/src/app/features/search/effect-editor/effect-editor.overlay.spec.ts index 28312f63..203b658a 100644 --- a/plugins/rebuilder/app/src/app/features/search/effect-editor/effect-editor.overlay.spec.ts +++ b/plugins/rebuilder/app/src/app/features/search/effect-editor/effect-editor.overlay.spec.ts @@ -2,7 +2,9 @@ import { TestBed } from '@angular/core/testing'; import { of, throwError, type Observable } from 'rxjs'; import type { AbilityRef, EffectBlock } from '../../../core/card-filters'; import { NO_CONDITION, UniquesApiService, toAbilityRefs, type AbilityKind, type UniquesEffect, type UniquesQuery } from '../../../core/uniques-api.service'; -import { AcOverlayRef } from '../../../ui/overlay'; +import { AcBreakpointService } from '../../../ui/layout.services'; +import { AcOverlayRef, type AcStepConfig } from '../../../ui/overlay'; +import type { AbilityPickerData } from '../ability-picker/ability-picker.overlay'; import { EffectEditorOverlay, type EffectEditorData } from './effect-editor.overlay'; const MAIN: AbilityRef = { id: 1, text: 'Joué depuis la Main' }; @@ -10,30 +12,26 @@ const EXPEDITION: AbilityRef = { id: 2, text: 'Quitte la zone d’Expédition' } const RAVITAILLEZ: AbilityRef = { id: 3, text: 'Ravitaillez' }; const SABOTEZ: AbilityRef = { id: 4, text: 'Sabotez' }; -function sequence(combo: Element): string[] { - return [...combo.children] - .filter((el) => el.classList.contains('picked') || el.classList.contains('or') || el.classList.contains('field')) - .map((el) => { - if (el.classList.contains('or')) return 'ou'; - if (el.classList.contains('field')) return 'field'; - return `chip:${el.querySelector('span')?.textContent}`; - }); -} - /** The API rows the editor reads, through the real mapper. */ const ROWS: Record = { triggers: [ { alteredId: 22, text: { fr: '{H}' } }, { alteredId: 1, text: { fr: '{R}' } }, { alteredId: 24, text: { fr: '{J}' } }, - { alteredId: 17, text: { fr: 'Au Crépuscule\u00a0' } }, + { alteredId: 17, text: { fr: 'Au Crépuscule ' } }, ], conditions: [ { alteredId: 191, text: { fr: '[]' } }, - { alteredId: 188, text: { fr: 'Si vous contrôlez un jeton\u00a0:' } }, + { alteredId: 188, text: { fr: 'Si vous contrôlez un jeton :' } }, + ], + effects: [ + { alteredId: 3, text: { fr: 'Ravitaillez' } }, + { alteredId: 4, text: { fr: 'Sabotez' } }, + { alteredId: 5, text: { fr: 'Piochez une carte.' } }, ], - effects: [{ alteredId: 3, text: { fr: '[Ravitaillez].' } }], }; +const api = (kind: AbilityKind) => of(toAbilityRefs(ROWS[kind], kind === 'conditions' ? NO_CONDITION : undefined)); +const EMPTY: EffectBlock = { id: 'e1', triggers: [], conditions: [], effects: [] }; type Narrow = (query: UniquesQuery, index: number, block: UniquesEffect, kind: AbilityKind) => Observable>; @@ -41,6 +39,7 @@ function setup( effect: EffectBlock, abilities: (kind: AbilityKind) => Observable = () => of([]), narrow?: { query: UniquesQuery; index: number; narrowAbilities: Narrow }, + width = 1440, ) { const ref = new AcOverlayRef({ effect, index: narrow?.index ?? 0, query: narrow?.query }); TestBed.configureTestingModule({ @@ -50,93 +49,85 @@ function setup( { provide: UniquesApiService, useValue: { abilities, narrowAbilities: narrow?.narrowAbilities } }, ], }); + TestBed.inject(AcBreakpointService).setWidth(width); const fixture = TestBed.createComponent(EffectEditorOverlay); fixture.detectChanges(); - return { fixture, ref, el: fixture.nativeElement as HTMLElement }; + const el = fixture.nativeElement as HTMLElement; + const detect = () => fixture.detectChanges(); + return { fixture, ref, el, detect }; } -describe('EffectEditorOverlay « ou »', () => { - function render(effect: EffectBlock): HTMLElement { - return setup(effect).el; - } - - it('uses the same « ou » before the add field on trigger, condition, and effect', () => { - const el = render({ - id: 'e1', - triggers: [], - conditions: [MAIN], - effects: [RAVITAILLEZ, SABOTEZ], - }); - const combos = [...el.querySelectorAll('ac-combobox')]; - expect(combos).toHaveLength(3); - expect(sequence(combos[0])).toEqual(['field']); - expect(sequence(combos[1])).toEqual([`chip:${MAIN.text}`, 'ou', 'field']); - expect(sequence(combos[2])).toEqual([`chip:${RAVITAILLEZ.text}`, 'ou', `chip:${SABOTEZ.text}`, 'ou', 'field']); - // The add control now carries its label as text (« Ajouter » contains « ou »): count separators. - expect(combos[0].querySelectorAll('.or')).toHaveLength(0); - }); +const text = (node: Element) => node.textContent!.replace(/[-]/g, '').trim(); - it('keeps « ou » on every criterion that already has two values', () => { - const el = render({ - id: 'e1', - triggers: [MAIN, EXPEDITION], - conditions: [MAIN, EXPEDITION], - effects: [RAVITAILLEZ, SABOTEZ], - }); - for (const combo of el.querySelectorAll('ac-combobox')) { - const parts = sequence(combo); - expect(parts.at(-2)).toBe('ou'); - expect(parts.at(-1)).toBe('field'); - expect(parts.filter((p) => p === 'ou')).toHaveLength(2); - } - }); -}); +/** What a criterion card (or the compact criterion) shows: values and « ou », in order. */ +function sequence(values: Element): string[] { + return [...values.children].map((c) => (c.classList.contains('or') ? 'ou' : c.classList.contains('empty') ? `empty:${text(c)}` : `value:${text(c)}`)); +} -describe('EffectEditorOverlay pickers', () => { - const api = (kind: AbilityKind) => of(toAbilityRefs(ROWS[kind], kind === 'conditions' ? NO_CONDITION : undefined)); - const empty: EffectBlock = { id: 'e1', triggers: [], conditions: [], effects: [] }; - - /** Opens a criterion; its list is a CDK overlay, rendered in the overlay container. */ - function open(el: HTMLElement, name: string, fixture: { detectChanges(): void }) { - const toggle = el.querySelector(`[role=combobox][aria-label="${name}"]`)!; - toggle.click(); - fixture.detectChanges(); - const options = [...document.querySelectorAll('.cdk-overlay-container .panel [role=option]')]; - return { combo: toggle.closest('ac-combobox') as HTMLElement, toggle, options }; - } - - it('opens each criterion on a button, not on a focused text field', () => { - const { el } = setup(empty, api); - for (const combo of el.querySelectorAll('ac-combobox')) { - const first = combo.querySelector('.field')!.firstElementChild!; - expect(first.tagName).toBe('BUTTON'); - expect(first.getAttribute('role')).toBe('combobox'); - expect(combo.querySelector('input')).toBeNull(); - } +const cards = (el: HTMLElement) => [...el.querySelectorAll('.card')]; +const rows = (el: HTMLElement) => [...el.querySelectorAll('ac-check-list .option')]; +const listed = (el: HTMLElement) => rows(el).map(text); +function select(el: HTMLElement, label: string, detect: () => void) { + [...el.querySelectorAll('.card-head')].find((b) => b.textContent!.includes(label))!.click(); + detect(); +} +function check(el: HTMLElement, value: string, detect: () => void) { + rows(el) + .find((r) => text(r) === value)! + .querySelector('input')! + .click(); + detect(); +} + +describe('EffectEditorOverlay window', () => { + it('shows each criterion with its values joined by « ou », or what it matches when empty', () => { + const { el } = setup({ id: 'e1', triggers: [], conditions: [MAIN], effects: [RAVITAILLEZ, SABOTEZ] }); + expect(cards(el).map((c) => text(c.querySelector('.card-head')!))).toEqual(['Quand · déclencheur', 'Si · condition', 'Alors · effet']); + expect(cards(el).map((c) => sequence(c.querySelector('ac-or-values')!))).toEqual([ + ['empty:Tous les déclencheurs'], + [`value:${MAIN.text}`], + [`value:${RAVITAILLEZ.text}`, 'ou', `value:${SABOTEZ.text}`], + ]); }); - it('shows the hand, reserve and anywhere glyphs on the triggers', () => { - const { el, fixture } = setup(empty, api); - const { options } = open(el, 'Ajouter un déclencheur…', fixture); - const glyphs = Object.fromEntries( - options.map((o) => [o.textContent!.replace(/^[\ue000-\uf8ff]/, ''), o.querySelector('.glyph')?.textContent ?? null]), - ); + it('lists the checkboxes of the selected criterion, triggers first, with their glyphs', () => { + const { el, detect } = setup(EMPTY, api); + expect(el.querySelector('.card.on')?.textContent).toContain('Quand'); + const glyphs = Object.fromEntries(rows(el).map((r) => [text(r), r.querySelector('.glyph')?.textContent ?? null])); expect(glyphs).toEqual({ + 'Joué depuis la Main': '', + 'Joué depuis la Réserve': '', + 'Joué de partout': '', 'Au Crépuscule': null, - 'Joué de partout': '\ue026', - 'Joué depuis la Main': '\ue023', - 'Joué depuis la Réserve': '\ue024', }); + select(el, 'Alors', detect); + expect(listed(el)).toEqual(['Piochez une carte.', 'Ravitaillez', 'Sabotez']); + }); + + it('checks several values in a row: the list stays, rows keep their place, the card fills with « ou »', () => { + const { el, ref, detect } = setup(EMPTY, api); + select(el, 'Alors', detect); + const before = listed(el); + check(el, 'Sabotez', detect); + check(el, 'Ravitaillez', detect); + check(el, 'Piochez une carte.', detect); + expect(listed(el)).toEqual(before); + expect(rows(el).map((r) => r.querySelector('input')!.checked)).toEqual([true, true, true]); + expect(sequence(cards(el)[2].querySelector('ac-or-values')!)).toEqual(['value:Sabotez', 'ou', 'value:Ravitaillez', 'ou', 'value:Piochez une carte.']); + check(el, 'Ravitaillez', detect); + + let applied: EffectBlock | undefined; + ref.dialogRef = { close: (v) => (applied = v as EffectBlock) }; + el.querySelector('.ac-overlay-footer button:last-child')!.click(); + expect(applied?.effects.map((a) => a.text)).toEqual(['Sabotez', 'Piochez une carte.']); }); it('offers « Sans condition » first and applies it as the empty condition', () => { - const { el, fixture, ref } = setup(empty, api); - const { combo, options } = open(el, 'Ajouter une condition…', fixture); - const first = options[0]; - expect(first.textContent).toBe('Sans condition'); - first.click(); - fixture.detectChanges(); - expect(sequence(combo)).toEqual(['chip:Sans condition', 'ou', 'field']); + const { el, ref, detect } = setup(EMPTY, api); + select(el, 'Si', detect); + expect(listed(el)[0]).toBe('Sans condition'); + check(el, 'Sans condition', detect); + expect(sequence(cards(el)[1].querySelector('ac-or-values')!)).toEqual(['value:Sans condition']); let applied: EffectBlock | undefined; ref.dialogRef = { close: (v) => (applied = v as EffectBlock) }; @@ -144,51 +135,51 @@ describe('EffectEditorOverlay pickers', () => { expect(applied?.conditions).toEqual([{ id: 191, text: 'Sans condition' }]); }); - it('shows « ou » after the last value on Quand, Si and Alors alike, « Sans condition » included', () => { - const { el } = setup( - { - id: 'e1', - triggers: [{ id: 17, text: 'Au Crépuscule' }], - conditions: [{ id: 191, text: 'Sans condition' }], - effects: [{ id: 3, text: 'Ravitaillez' }], - }, - api, - ); - const combos = [...el.querySelectorAll('ac-combobox')]; - expect(combos.map(sequence)).toEqual([ - ['chip:Au Crépuscule', 'ou', 'field'], - ['chip:Sans condition', 'ou', 'field'], - ['chip:Ravitaillez', 'ou', 'field'], - ]); + it('removes a value from its card, and Enter in the search checks the first match', () => { + const { el, detect } = setup({ ...EMPTY, triggers: [MAIN, EXPEDITION] }, api); + cards(el)[0].querySelector('ac-or-values button')!.click(); + detect(); + expect(sequence(cards(el)[0].querySelector('ac-or-values')!)).toEqual([`value:${EXPEDITION.text}`]); + + const search = el.querySelector('ac-check-list input[type=search]')!; + search.value = 'crep'; + search.dispatchEvent(new Event('input')); + detect(); + expect(listed(el)).toEqual(['Au Crépuscule']); + search.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + detect(); + expect(search.value).toBe(''); + expect(listed(el)).toHaveLength(4); + expect(sequence(cards(el)[0].querySelector('ac-or-values')!)).toEqual([`value:${EXPEDITION.text}`, 'ou', 'value:Au Crépuscule']); }); +}); - it('picking a value leaves every picker closed: the next criterion does not open', () => { - const { el, fixture } = setup(empty, api); - const { toggle, options } = open(el, 'Ajouter un déclencheur…', fixture); - options[0].click(); - fixture.detectChanges(); - expect([...el.querySelectorAll('[role=combobox]')].map((t) => t.getAttribute('aria-expanded'))).toEqual(['false', 'false', 'false']); - expect(document.querySelectorAll('.cdk-overlay-container .panel')).toHaveLength(0); - expect(document.activeElement).toBe(toggle); +describe('EffectEditorOverlay compact', () => { + it('shows each criterion with « ou » and opens its checkbox list as a step sharing the picked values', () => { + const { el, ref, detect } = setup({ ...EMPTY, effects: [RAVITAILLEZ, SABOTEZ] }, api, undefined, 390); + expect(el.querySelector('.cols')).toBeNull(); + const crits = [...el.querySelectorAll('.crit')]; + expect(crits.map((c) => c.querySelector('.add')!.textContent!.trim())).toEqual(['Ajouter un déclencheur', 'Ajouter une condition', 'Ajouter un effet']); + expect(sequence(crits[2].querySelector('ac-or-values')!)).toEqual(['value:Ravitaillez', 'ou', 'value:Sabotez']); + + let step: AcStepConfig | undefined; + ref.stepOpener = (_c, config) => { + step = config as AcStepConfig; + return new AcOverlayRef(config.data); + }; + crits[2].querySelector('.add')!.click(); + expect(step?.title).toBe('Alors'); + expect(step?.subtitle).toBe('effet'); + expect(step?.data?.options().map((o) => o.text)).toEqual(['Piochez une carte.', 'Ravitaillez', 'Sabotez']); + step!.data!.picked.update((v) => [...v, { id: 5, text: 'Piochez une carte.' }]); + detect(); + expect(sequence(el.querySelectorAll('.crit')[2].querySelector('ac-or-values')!)).toEqual(['value:Ravitaillez', 'ou', 'value:Sabotez', 'ou', 'value:Piochez une carte.']); }); }); describe('EffectEditorOverlay narrowing', () => { - const api = (kind: AbilityKind) => of(toAbilityRefs(ROWS[kind], kind === 'conditions' ? NO_CONDITION : undefined)); const query: UniquesQuery = { factions: ['AX'], sets: [], mainCosts: [], recallCosts: [], effects: [] }; - function listed(el: HTMLElement, name: string, fixture: { detectChanges(): void }): string[] { - const toggle = el.querySelector(`[role=combobox][aria-label="${name}"]`)!; - toggle.click(); - fixture.detectChanges(); - const out = [...document.querySelectorAll('.cdk-overlay-container .panel [role=option]')].map((o) => - o.textContent!.replace(/^[\ue000-\uf8ff]/, ''), - ); - toggle.click(); - fixture.detectChanges(); - return out; - } - it('lists only the values the API says still give a card, with the block as picked', () => { const calls: { index: number; block: UniquesEffect; kind: AbilityKind }[] = []; const allowed: Record = { triggers: [24, 17], conditions: [188], effects: [] }; @@ -196,44 +187,43 @@ describe('EffectEditorOverlay narrowing', () => { calls.push({ index, block, kind }); return of(new Set(allowed[kind])); }; - const { el, fixture } = setup({ id: 'e1', triggers: [], conditions: [], effects: [{ id: 3, text: 'Ravitaillez' }] }, api, { query, index: 1, narrowAbilities }); - expect(listed(el, 'Ajouter un déclencheur…', fixture)).toEqual(['Au Crépuscule', 'Joué de partout']); - expect(listed(el, 'Ajouter une condition…', fixture)).toEqual(['Si vous contrôlez un jeton']); - // A picked value stays as a chip even when the API no longer offers it. - expect(el.querySelectorAll('ac-combobox')[2].textContent).toContain('Ravitaillez'); + const { el, detect } = setup({ ...EMPTY, effects: [RAVITAILLEZ] }, api, { query, index: 1, narrowAbilities }); + expect(listed(el)).toEqual(['Au Crépuscule', 'Joué de partout']); + select(el, 'Si', detect); + expect(listed(el)).toEqual(['Si vous contrôlez un jeton']); + // A picked value stays on its card even when the API no longer offers it. + select(el, 'Alors', detect); + expect(listed(el)).toEqual([]); + expect(cards(el)[2].textContent).toContain('Ravitaillez'); expect(calls).toEqual( (['triggers', 'conditions', 'effects'] as AbilityKind[]).map((kind) => ({ index: 1, block: { triggers: [], conditions: [], effects: [3] }, kind })), ); }); - it('asks again when a pick changes, and keeps a whole list when the API fails for it', () => { - const blocks: UniquesEffect[] = []; + it('asks again for the other criteria when a value is checked, never for the list being checked', () => { + const asked: [AbilityKind, UniquesEffect][] = []; const narrowAbilities: Narrow = (_q, _i, block, kind) => { - if (kind === 'triggers') blocks.push(block); - return kind === 'conditions' ? throwError(() => new Error('down')) : of(new Set(kind === 'triggers' ? [24] : [])); + asked.push([kind, block]); + return kind === 'conditions' ? throwError(() => new Error('down')) : of(new Set(kind === 'triggers' ? [24, 22] : [3, 4, 5])); }; - const { el, fixture } = setup({ id: 'e1', triggers: [], conditions: [], effects: [] }, api, { query, index: 0, narrowAbilities }); - expect(listed(el, 'Ajouter une condition…', fixture)).toEqual(['Sans condition', 'Si vous contrôlez un jeton']); - const toggle = el.querySelector('[role=combobox][aria-label="Ajouter un déclencheur…"]')!; - toggle.click(); - fixture.detectChanges(); - document.querySelector('.cdk-overlay-container .panel [role=option]')!.click(); - fixture.detectChanges(); - expect(blocks).toEqual([ - { triggers: [], conditions: [], effects: [] }, - { triggers: [24], conditions: [], effects: [] }, - ]); + const { el, detect } = setup(EMPTY, api, { query, index: 0, narrowAbilities }); + const before = listed(el); + asked.length = 0; + check(el, 'Joué de partout', detect); + check(el, 'Joué depuis la Main', detect); + expect(listed(el)).toEqual(before); + expect(asked.map(([kind]) => kind)).toEqual(['conditions', 'effects', 'conditions', 'effects']); + expect(asked.at(-1)![1]).toEqual({ triggers: [24, 22], conditions: [], effects: [] }); + // The API failed for the conditions: their whole list stays. + select(el, 'Si', detect); + expect(listed(el)).toEqual(['Sans condition', 'Si vous contrôlez un jeton']); }); it('shows every value without the search it belongs to, or for an exact reference', () => { const narrowAbilities: Narrow = () => { throw new Error('not called'); }; - const { el, fixture } = setup({ id: 'e1', triggers: [], conditions: [], effects: [] }, api, { - query: { ...query, reference: 'ALT_CORE_B_AX_04_U_1' }, - index: 0, - narrowAbilities, - }); - expect(listed(el, 'Ajouter un déclencheur…', fixture)).toHaveLength(4); + const { el } = setup(EMPTY, api, { query: { ...query, reference: 'ALT_CORE_B_AX_04_U_1' }, index: 0, narrowAbilities }); + expect(listed(el)).toHaveLength(4); }); }); diff --git a/plugins/rebuilder/app/src/app/features/search/effect-editor/effect-editor.overlay.ts b/plugins/rebuilder/app/src/app/features/search/effect-editor/effect-editor.overlay.ts index 90b75feb..3dd8c364 100644 --- a/plugins/rebuilder/app/src/app/features/search/effect-editor/effect-editor.overlay.ts +++ b/plugins/rebuilder/app/src/app/features/search/effect-editor/effect-editor.overlay.ts @@ -1,12 +1,15 @@ -import { Component, computed, inject, signal } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import { Component, computed, inject, signal, type Signal, type WritableSignal } from '@angular/core'; import { toObservable, toSignal } from '@angular/core/rxjs-interop'; -import { catchError, concat, forkJoin, of, switchMap, type Observable } from 'rxjs'; +import { catchError, concat, of, switchMap } from 'rxjs'; import type { AbilityRef, EffectBlock } from '../../../core/card-filters'; import { UniquesApiService, type AbilityKind, type UniquesEffect, type UniquesQuery } from '../../../core/uniques-api.service'; import { AcButton } from '../../../ui/buttons'; -import { AcCombobox } from '../../../ui/fields'; +import { AcCheckList, AcOrValues } from '../../../ui/fields'; +import { AcIcon } from '../../../ui/icon'; import { AcBreakpointService } from '../../../ui/layout.services'; import { AcOverlayRef, AcOverlayService } from '../../../ui/overlay'; +import { AbilityPickerStep, type AbilityPickerData } from '../ability-picker/ability-picker.overlay'; export interface EffectEditorData { effect: EffectBlock; @@ -18,15 +21,59 @@ export interface EffectEditorData { query?: UniquesQuery; } -/** Ids each list is narrowed to; `null` keeps the whole list (not loaded yet, or the API failed). */ -type Narrowing = Record | null>; +const KINDS: AbilityKind[] = ['triggers', 'conditions', 'effects']; + +interface CriterionTexts { + /** « Quand », « Si », « Alors ». */ + label: string; + /** « déclencheur », « condition », « effet ». */ + sub: string; + /** Compact: button that opens the criterion's list. */ + add: string; + /** Nothing picked: the criterion does not filter. */ + any: string; + search: string; +} -const NO_NARROWING: Narrowing = { triggers: null, conditions: null, effects: null }; +const TEXTS: Record = { + triggers: { + label: $localize`:@@search.effect.whenLabel:Quand`, + sub: $localize`:@@search.effect.triggerSub:déclencheur`, + add: $localize`:@@search.effect.addTrigger:Ajouter un déclencheur`, + any: $localize`:@@search.effect.anyTrigger:Tous les déclencheurs`, + search: $localize`:@@search.effect.searchTrigger:Rechercher un déclencheur…`, + }, + conditions: { + label: $localize`:@@search.effect.ifLabel:Si`, + sub: $localize`:@@search.effect.conditionSub:condition`, + add: $localize`:@@search.effect.addCondition:Ajouter une condition`, + any: $localize`:@@search.effect.anyCondition:Toutes les conditions`, + search: $localize`:@@search.effect.searchCondition:Rechercher une condition…`, + }, + effects: { + label: $localize`:@@search.effect.thenLabel:Alors`, + sub: $localize`:@@search.effect.effectSub:effet`, + add: $localize`:@@search.effect.addEffect:Ajouter un effet`, + any: $localize`:@@search.effect.anyEffect:Tous les effets`, + search: $localize`:@@search.effect.searchEffect:Rechercher un effet…`, + }, +}; + +/** One criterion of the block: its texts, the values offered and the values picked. */ +interface Criterion extends CriterionTexts { + kind: AbilityKind; + options: Signal; + picked: WritableSignal; +} -/** ac-effect-editor — one combobox per criterion; values OR-ed, criteria AND-ed. */ +/** + * ac-effect-editor — values OR-ed inside a criterion, criteria AND-ed. Window: the three criteria on the + * left with their values, the checkbox list of the selected one on the right, so checking never moves + * the list. Compact: the criteria, each opening its checkbox list as a step (`ac-ability-picker`). + */ @Component({ selector: 'ac-effect-editor', - imports: [AcCombobox, AcButton], + imports: [AcCheckList, AcOrValues, AcButton, AcIcon, NgTemplateOutlet], host: { class: 'ac-overlay-content' }, templateUrl: './effect-editor.overlay.html', styleUrl: './effect-editor.overlay.scss', @@ -37,43 +84,37 @@ export class EffectEditorOverlay { protected readonly bp = inject(AcBreakpointService); protected readonly loadError = signal(false); - protected readonly triggers = this.load('triggers'); - protected readonly conditions = this.load('conditions'); - protected readonly effects = this.load('effects'); - - protected readonly pickedTriggers = signal(this.ref.data.effect.triggers); - protected readonly pickedConditions = signal(this.ref.data.effect.conditions); - protected readonly pickedEffects = signal(this.ref.data.effect.effects); - - private readonly narrowing = toSignal( - toObservable( - computed(() => ({ - triggers: this.pickedTriggers().map((a) => a.id), - conditions: this.pickedConditions().map((a) => a.id), - effects: this.pickedEffects().map((a) => a.id), - })), - ).pipe(switchMap((block) => this.narrow(block))), - { initialValue: NO_NARROWING }, - ); - - protected readonly triggerOptions = computed(() => narrowed(this.triggers(), this.narrowing().triggers)); - protected readonly conditionOptions = computed(() => narrowed(this.conditions(), this.narrowing().conditions)); - protected readonly effectOptions = computed(() => narrowed(this.effects(), this.narrowing().effects)); + private readonly picked: Record> = { + triggers: signal(this.ref.data.effect.triggers), + conditions: signal(this.ref.data.effect.conditions), + effects: signal(this.ref.data.effect.effects), + }; + + protected readonly criteria: Criterion[] = KINDS.map((kind) => ({ kind, ...TEXTS[kind], options: this.options(kind), picked: this.picked[kind] })); + + /** Window: the criterion whose list is shown. */ + protected readonly active = signal('triggers'); constructor() { this.ref.title.set(effectTitle(this.ref.data.index)); this.ref.headerAction.set({ label: $localize`:@@search.effect.clear:Effacer`, - run: () => { - this.pickedTriggers.set([]); - this.pickedConditions.set([]); - this.pickedEffects.set([]); - }, + run: () => KINDS.forEach((k) => this.picked[k].set([])), }); } - private load(kind: AbilityKind) { - return toSignal( + /** Compact: the criterion's checkbox list as a step of the same sheet. */ + protected openPicker(c: Criterion): void { + this.ref.openStep(AbilityPickerStep, { + title: c.label, + subtitle: c.sub, + data: { options: c.options, picked: c.picked, label: c.sub, searchPlaceholder: c.search }, + }); + } + + /** The values of `kind`, narrowed to those that still give a card. */ + private options(kind: AbilityKind): Signal { + const all = toSignal( this.api.abilities(kind).pipe( catchError(() => { this.loadError.set(true); @@ -82,32 +123,44 @@ export class EffectEditorOverlay { ), { initialValue: [] as AbilityRef[] }, ); + const ids = this.narrowing(kind); + return computed(() => { + const keep = ids(); + return keep ? all().filter((o) => keep.has(o.id)) : all(); + }); } /** - * The ids each list keeps with `block` as picked so far. The whole lists come back first, so a - * value that a change makes possible again is never missing while the answer is on its way. - * An exact reference looks one card up, whatever the other criteria: nothing to narrow. + * The ids `kind` keeps with the block as picked, `null` for the whole list. The server leaves the + * criterion being narrowed out, so only the two other criteria ask again: checking values in a list + * never reshapes that list. The whole list comes back first, so a value that a change makes possible + * again is never missing while the answer is on its way. An exact reference looks one card up, + * whatever the other criteria: nothing to narrow. */ - private narrow(block: UniquesEffect): Observable { + private narrowing(kind: AbilityKind): Signal | null> { const { query, index } = this.ref.data; - if (!query || query.reference) return of(NO_NARROWING); - const ids = (kind: AbilityKind) => this.api.narrowAbilities(query, index, block, kind).pipe(catchError(() => of(null))); - return concat(of(NO_NARROWING), forkJoin({ triggers: ids('triggers'), conditions: ids('conditions'), effects: ids('effects') })); + if (!query || query.reference) return signal(null); + const others = computed(() => KINDS.filter((k) => k !== kind).map((k) => this.picked[k]())); + const ids = (values: Record): UniquesEffect => ({ + triggers: values.triggers.map((a) => a.id), + conditions: values.conditions.map((a) => a.id), + effects: values.effects.map((a) => a.id), + }); + return toSignal( + toObservable(others).pipe( + switchMap(() => concat(of(null), this.api.narrowAbilities(query, index, ids(this.values()), kind).pipe(catchError(() => of(null))))), + ), + { initialValue: null }, + ); } - apply(): void { - this.ref.close({ - ...this.ref.data.effect, - triggers: this.pickedTriggers(), - conditions: this.pickedConditions(), - effects: this.pickedEffects(), - }); + private values(): Record { + return { triggers: this.picked.triggers(), conditions: this.picked.conditions(), effects: this.picked.effects() }; } -} -function narrowed(options: AbilityRef[], ids: Set | null): AbilityRef[] { - return ids ? options.filter((o) => ids.has(o.id)) : options; + apply(): void { + this.ref.close({ ...this.ref.data.effect, ...this.values() }); + } } /** « Effet n », 1-based. */ @@ -119,7 +172,7 @@ export function openEffectEditor(overlay: AcOverlayService, data: EffectEditorDa return overlay.open(EffectEditorOverlay, { title: effectTitle(data.index), data, - width: 520, + width: 880, sheetHeight: 'full', }); } diff --git a/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.html b/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.html index ffa99a5f..92af42b2 100644 --- a/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.html +++ b/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.html @@ -72,7 +72,7 @@
@for (e of value().effects; track e.id) { - + @if (!$last) { } diff --git a/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.ts b/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.ts index f136d54d..78a7a081 100644 --- a/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.ts +++ b/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.ts @@ -1,5 +1,5 @@ import { Component, computed, input, model, output, signal } from '@angular/core'; -import { COST_RELATIONS, TYPE_OPTIONS, rarityOptionsFor, newEffectBlock, parseCostExpression, promoSetsOf, setsFor, type CardSource, type EffectBlock, type SearchFilters } from '../../../core/card-filters'; +import { COST_RELATIONS, TYPE_OPTIONS, rarityOptionsFor, newEffectBlock, newEffectId, parseCostExpression, promoSetsOf, setsFor, type CardSource, type EffectBlock, type SearchFilters } from '../../../core/card-filters'; import { PROMO_SETS, SUBTYPES, termLabel } from '../../../core/card-vocabulary'; import { formatInfo } from '../../../core/formats'; import type { DeckFormat } from '../../../core/models'; @@ -110,6 +110,13 @@ export class FiltersPanel { this.editEffect.emit(effects.length - 1); } + /** A copy of effect `i` right after it, with its own id. */ + duplicateEffect(i: number): void { + const effects = [...this.value().effects]; + effects.splice(i + 1, 0, { ...effects[i], id: newEffectId() }); + this.set({ effects }); + } + removeEffect(i: number): void { this.set({ effects: this.value().effects.filter((_, idx) => idx !== i) }); } diff --git a/plugins/rebuilder/app/src/app/ui/fields/_glyph.scss b/plugins/rebuilder/app/src/app/ui/fields/_glyph.scss new file mode 100644 index 00000000..d3f275af --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/fields/_glyph.scss @@ -0,0 +1,13 @@ +/* `Altered Icons` glyph before an option or value text (main, réserve, partout). */ +.glyph { + display: inline-block; + width: 1.4em; + font-family: var(--ac-card-icons); + font-style: normal; + font-weight: 400; + font-size: 1.15em; + line-height: 1; + text-align: center; + margin-right: var(--ac-space-1-5); + vertical-align: -0.12em; +} diff --git a/plugins/rebuilder/app/src/app/ui/fields/check-list/check-list.html b/plugins/rebuilder/app/src/app/ui/fields/check-list/check-list.html new file mode 100644 index 00000000..c1e2996a --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/fields/check-list/check-list.html @@ -0,0 +1,28 @@ + +
+ @for (o of filtered(); track o.id) { + @if (groupStarts().has(o.id)) { + + } + + } @empty { +

{{ query() ? noResults : noValues }}

+ } +
diff --git a/plugins/rebuilder/app/src/app/ui/fields/check-list/check-list.scss b/plugins/rebuilder/app/src/app/ui/fields/check-list/check-list.scss new file mode 100644 index 00000000..e13e1401 --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/fields/check-list/check-list.scss @@ -0,0 +1,55 @@ +@use '../glyph'; + +:host { + display: flex; + flex-direction: column; + min-height: 0; +} +.search { + flex-shrink: 0; + margin-bottom: var(--ac-space-2); +} +.list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + display: flex; + flex-direction: column; +} +.option { + display: flex; + gap: var(--ac-space-3); + padding: var(--ac-space-2) var(--ac-space-2-5); + border-radius: var(--ac-radius-md); + font-size: var(--ac-field-font-size); + line-height: 1.35; + flex-shrink: 0; +} +@media (hover: hover) { + .option:hover { + background: var(--ac-color-primary-tint); + } +} +.option.on { + font-weight: 700; + color: var(--ac-color-primary-strong); +} +.option input { + flex-shrink: 0; +} +.group { + padding: var(--ac-space-3) var(--ac-space-2-5) var(--ac-space-1); + font-size: var(--ac-font-size-micro); + font-weight: 700; + letter-spacing: var(--ac-letter-spacing-overline); + text-transform: uppercase; + color: var(--ac-color-text-muted); + flex-shrink: 0; +} +.empty { + margin: 0; + padding: var(--ac-space-2-5); + font-size: var(--ac-font-size-small); + color: var(--ac-color-text-muted); +} diff --git a/plugins/rebuilder/app/src/app/ui/fields/check-list/check-list.ts b/plugins/rebuilder/app/src/app/ui/fields/check-list/check-list.ts new file mode 100644 index 00000000..f7324d37 --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/fields/check-list/check-list.ts @@ -0,0 +1,53 @@ +import { Component, computed, input, model, signal } from '@angular/core'; +import { AcIcon } from '../../icon'; +import type { ComboOption } from '../combobox/combobox'; +import { groupStarts, normalizeSearch } from '../option-list'; + +/** + * Multi-select list that stays on screen: a search field over one checkbox per option, in the order of + * `options`. Checking or unchecking leaves every row where it is; Enter in the search field checks the + * first unchecked match and empties the field, so several values go in from the keyboard in a row. + * A checked value goes to the end of `values`, the order the user picked them in. + */ +@Component({ + selector: 'ac-check-list', + imports: [AcIcon], + templateUrl: './check-list.html', + styleUrl: './check-list.scss', +}) +export class AcCheckList { + readonly options = input([]); + readonly values = model([]); + /** Accessible name of the list (« effet »). */ + readonly label = input.required(); + readonly searchPlaceholder = input($localize`:@@ui.combobox.search:Rechercher…`); + + protected readonly noResults = $localize`:@@ui.combobox.noResults:Aucun résultat`; + protected readonly noValues = $localize`:@@ui.combobox.noValues:Aucune valeur disponible`; + protected readonly query = signal(''); + protected readonly checked = computed(() => new Set(this.values().map((v) => v.id))); + + protected readonly filtered = computed(() => { + const q = normalizeSearch(this.query()); + return q ? this.options().filter((o) => normalizeSearch(o.text).includes(q)) : this.options(); + }); + + /** Where a group heading goes: the first listed option of each group. */ + protected readonly groupStarts = computed(() => groupStarts(this.filtered())); + + setChecked(o: ComboOption, on: boolean): void { + this.values.update((list) => { + const without = list.filter((v) => v.id !== o.id); + return on ? [...without, o] : without; + }); + } + + protected onSearchKey(e: KeyboardEvent): void { + if (e.key !== 'Enter') return; + e.preventDefault(); + const first = this.filtered().find((o) => !this.checked().has(o.id)); + if (!this.query() || !first) return; + this.setChecked(first, true); + this.query.set(''); + } +} diff --git a/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.html b/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.html index 1031e753..06938e5b 100644 --- a/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.html +++ b/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.html @@ -19,7 +19,7 @@ (keydown)="onToggleKey($event)" (focusout)="onFocusOut($event)" > - {{ label() }} + {{ placeholder() }}
diff --git a/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.scss b/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.scss index 90f05ba1..d4ece19d 100644 --- a/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.scss +++ b/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.scss @@ -1,3 +1,5 @@ +@use '../glyph'; + :host { display: flex; flex-direction: column; @@ -73,18 +75,6 @@ .chev.up { transform: translateY(-50%) rotate(180deg); } -.glyph { - display: inline-block; - width: 1.4em; - font-family: var(--ac-card-icons); - font-style: normal; - font-weight: 400; - font-size: 1.15em; - line-height: 1; - text-align: center; - margin-right: var(--ac-space-1-5); - vertical-align: -0.12em; -} /* In the CDK overlay container (plugin shadow root), sized to the button (matchWidth). */ .panel { width: 100%; diff --git a/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.ts b/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.ts index 43a1d8b9..aab3fea1 100644 --- a/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.ts +++ b/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.ts @@ -3,6 +3,7 @@ import { CdkConnectedOverlay, CdkOverlayOrigin, createRepositionScrollStrategy, import { _getEventTarget } from '@angular/cdk/platform'; import { Component, ElementRef, Injector, afterNextRender, computed, inject, input, model, output, signal, viewChild } from '@angular/core'; import { AcIcon } from '../../icon'; +import { groupStarts, normalizeSearch } from '../option-list'; import { nextId } from '../value-accessor'; export interface ComboOption { @@ -40,7 +41,6 @@ export class AcCombobox { readonly options = input([]); readonly values = model([]); readonly placeholder = input($localize`:@@ui.combobox.add:Ajouter…`); - readonly emptyPlaceholder = input(''); readonly searchPlaceholder = input($localize`:@@ui.combobox.search:Rechercher…`); readonly searchChange = output(); @@ -61,26 +61,16 @@ export class AcCombobox { protected readonly query = signal(''); protected readonly open = signal(false); - protected readonly label = computed(() => (this.values().length ? this.placeholder() : this.emptyPlaceholder() || this.placeholder())); - /** Where a group heading goes: the first listed option of each group. */ - protected readonly groupStarts = computed(() => { - const starts = new Set(); - let last: string | undefined; - for (const o of this.filtered()) { - if (o.group && o.group !== last) starts.add(o.id); - last = o.group; - } - return starts; - }); + protected readonly groupStarts = computed(() => groupStarts(this.filtered())); protected readonly filtered = computed(() => { - const q = normalize(this.query()); + const q = normalizeSearch(this.query()); const chosen = new Set(this.values().map((v) => v.id)); const out: ComboOption[] = []; for (const o of this.options()) { if (chosen.has(o.id)) continue; - if (q && !normalize(o.text).includes(q)) continue; + if (q && !normalizeSearch(o.text).includes(q)) continue; out.push(o); if (out.length >= 50) break; } @@ -179,7 +169,3 @@ export class AcCombobox { this.query.set(''); } } - -function normalize(s: string): string { - return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase(); -} diff --git a/plugins/rebuilder/app/src/app/ui/fields/index.ts b/plugins/rebuilder/app/src/app/ui/fields/index.ts index db6c5e8d..7faa9e8d 100644 --- a/plugins/rebuilder/app/src/app/ui/fields/index.ts +++ b/plugins/rebuilder/app/src/app/ui/fields/index.ts @@ -1,8 +1,10 @@ +export * from './check-list/check-list'; export * from './checkbox/checkbox'; export * from './combobox/combobox'; export * from './editable-title/editable-title'; export * from './file-input/file-input'; export * from './input/input'; +export * from './or-values/or-values'; export * from './radio-card/radio-card'; export * from './segmented/segmented'; export * from './select/select'; diff --git a/plugins/rebuilder/app/src/app/ui/fields/option-list.ts b/plugins/rebuilder/app/src/app/ui/fields/option-list.ts new file mode 100644 index 00000000..c8344db4 --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/fields/option-list.ts @@ -0,0 +1,15 @@ +/** Text compared by the list searches: lower case, accents removed (« épuisé » finds « Épuisé »). */ +export function normalizeSearch(s: string): string { + return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase(); +} + +/** Ids of the options that start a group (`group` differs from the previous option's): where a heading goes. */ +export function groupStarts(options: readonly { id: number; group?: string }[]): Set { + const starts = new Set(); + let last: string | undefined; + for (const o of options) { + if (o.group && o.group !== last) starts.add(o.id); + last = o.group; + } + return starts; +} diff --git a/plugins/rebuilder/app/src/app/ui/fields/or-values/or-values.html b/plugins/rebuilder/app/src/app/ui/fields/or-values/or-values.html new file mode 100644 index 00000000..cacc06e3 --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/fields/or-values/or-values.html @@ -0,0 +1,13 @@ +@for (v of values(); track v.id) { + + @if (v.glyph) {}{{ v.text }} + + + @if (!$last) { + + } +} @empty { + @if (empty()) { + {{ empty() }} + } +} diff --git a/plugins/rebuilder/app/src/app/ui/fields/or-values/or-values.scss b/plugins/rebuilder/app/src/app/ui/fields/or-values/or-values.scss new file mode 100644 index 00000000..8f3ef33e --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/fields/or-values/or-values.scss @@ -0,0 +1,52 @@ +@use '../glyph'; + +/* One value per line, each « ou » on its own line between them. */ +:host { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--ac-space-1); + min-width: 0; +} +.value { + display: inline-flex; + align-items: flex-start; + gap: var(--ac-space-1); + max-width: 100%; + padding: var(--ac-space-1) var(--ac-space-1) var(--ac-space-1) var(--ac-space-2-5); + border: 1px solid var(--ac-color-primary-border); + background: var(--ac-color-primary-soft); + color: var(--ac-color-primary-strong); + border-radius: var(--ac-radius-md); + font-size: var(--ac-select-font-size); + font-weight: 700; + line-height: 1.35; +} +.value button { + position: relative; + border: none; + background: none; + color: var(--ac-color-primary-strong); + display: flex; + padding: 2px; + border-radius: var(--ac-radius-sm); + cursor: pointer; + flex-shrink: 0; +} +/* Tap area of at least --ac-hit-min around the small cross. */ +.value button::after { + content: ''; + position: absolute; + inset: min(0px, calc((18px - var(--ac-hit-min)) / 2)); +} +.or { + font-size: var(--ac-font-size-micro); + font-weight: 800; + color: var(--ac-color-text-muted); + text-transform: uppercase; + padding-left: var(--ac-space-2-5); +} +.empty { + font-size: var(--ac-font-size-small); + color: var(--ac-color-text-muted); +} diff --git a/plugins/rebuilder/app/src/app/ui/fields/or-values/or-values.ts b/plugins/rebuilder/app/src/app/ui/fields/or-values/or-values.ts new file mode 100644 index 00000000..faa7bdd1 --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/fields/or-values/or-values.ts @@ -0,0 +1,27 @@ +import { Component, input, model } from '@angular/core'; +import { AcIcon } from '../../icon'; +import type { ComboOption } from '../combobox/combobox'; + +/** + * Values one per line, « ou » on its own line between two, each value with a button that removes it + * (effect editor). `empty` is shown when + * there is none (« Tous les effets »). + */ +@Component({ + selector: 'ac-or-values', + imports: [AcIcon], + templateUrl: './or-values.html', + styleUrl: './or-values.scss', +}) +export class AcOrValues { + readonly values = model([]); + readonly empty = input(''); + + protected removeLabel(v: ComboOption): string { + return $localize`:@@ui.combobox.remove:Retirer ${v.text}:value:`; + } + + protected remove(v: ComboOption): void { + this.values.update((list) => list.filter((x) => x.id !== v.id)); + } +} diff --git a/plugins/rebuilder/app/src/app/ui/fields/select/select.ts b/plugins/rebuilder/app/src/app/ui/fields/select/select.ts index 25765ddf..bb918024 100644 --- a/plugins/rebuilder/app/src/app/ui/fields/select/select.ts +++ b/plugins/rebuilder/app/src/app/ui/fields/select/select.ts @@ -5,6 +5,7 @@ import { Component, ElementRef, Injector, afterNextRender, computed, forwardRef, import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { AcIcon } from '../../icon'; import { nextId, ValueAccessor } from '../value-accessor'; +import { normalizeSearch } from '../option-list'; export interface AcOption { value: T; @@ -90,8 +91,8 @@ export class AcSelect extends ValueAccessor { /** Options without a group first, then the groups, filtered by the search (names, then group names). */ protected readonly view = computed(() => { - const q = fold(this.query().trim()); - const match = (o: AcOption) => !q || fold(o.label).includes(q) || (!!o.group && fold(o.group).startsWith(q)); + const q = normalizeSearch(this.query().trim()); + const match = (o: AcOption) => !q || normalizeSearch(o.label).includes(q) || (!!o.group && normalizeSearch(o.group).startsWith(q)); const loose: Row[] = []; const groups: Group[] = []; let index = 0; @@ -121,8 +122,8 @@ export class AcSelect extends ValueAccessor { return `${this.id}-g${i}`; } protected highlight(label: string): { before: string; match: string; after: string } { - const q = fold(this.query().trim()); - const i = q ? fold(label).indexOf(q) : -1; + const q = normalizeSearch(this.query().trim()); + const i = q ? normalizeSearch(label).indexOf(q) : -1; return i < 0 ? { before: label, match: '', after: '' } : { before: label.slice(0, i), match: label.slice(i, i + q.length), after: label.slice(i + q.length) }; } protected readonly noResults = computed(() => $localize`:@@ui.select.noResults:Aucun résultat pour « ${this.query().trim()}:query: »`); @@ -287,13 +288,13 @@ export class AcSelect extends ValueAccessor { private typeahead(ch: string): void { const now = Date.now(); - this.typed = (now - this.typedAt > 700 ? '' : this.typed) + fold(ch); + this.typed = (now - this.typedAt > 700 ? '' : this.typed) + normalizeSearch(ch); this.typedAt = now; const rows = this.view().rows; const start = this.typed.length === 1 ? this.active() + 1 : Math.max(this.active(), 0); for (let k = 0; k < rows.length; k++) { const i = (start + k) % rows.length; - if (!rows[i].option.disabled && fold(rows[i].option.label).startsWith(this.typed)) { + if (!rows[i].option.disabled && normalizeSearch(rows[i].option.label).startsWith(this.typed)) { this.active.set(i); afterNextRender(() => this.scrollToActive(), { injector: this.injector }); return; @@ -319,9 +320,6 @@ export class AcSelect extends ValueAccessor { } } -function fold(s: string): string { - return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase(); -} function printable(e: KeyboardEvent): boolean { return e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey; diff --git a/plugins/rebuilder/app/src/app/ui/metier/effect-summary/effect-summary.html b/plugins/rebuilder/app/src/app/ui/metier/effect-summary/effect-summary.html index 509b4fc1..c1c4414f 100644 --- a/plugins/rebuilder/app/src/app/ui/metier/effect-summary/effect-summary.html +++ b/plugins/rebuilder/app/src/app/ui/metier/effect-summary/effect-summary.html @@ -1,6 +1,7 @@
{{ title() }} +
@for (row of rows(); track row.label) { diff --git a/plugins/rebuilder/app/src/app/ui/metier/effect-summary/effect-summary.spec.ts b/plugins/rebuilder/app/src/app/ui/metier/effect-summary/effect-summary.spec.ts new file mode 100644 index 00000000..809a99e0 --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/metier/effect-summary/effect-summary.spec.ts @@ -0,0 +1,18 @@ +import { TestBed } from '@angular/core/testing'; +import { AcEffectSummary } from './effect-summary'; + +describe('AcEffectSummary', () => { + it('offers edit, duplicate and delete, in that order, each named after the effect', () => { + TestBed.configureTestingModule({ imports: [AcEffectSummary] }); + const fixture = TestBed.createComponent(AcEffectSummary); + fixture.componentRef.setInput('effect', { id: 'e1', triggers: [], conditions: [], effects: [{ id: 3, text: 'Ravitaillez' }] }); + fixture.componentRef.setInput('title', 'Effet 2'); + fixture.detectChanges(); + let duplicated = 0; + fixture.componentInstance.duplicate.subscribe(() => duplicated++); + const buttons = [...(fixture.nativeElement as HTMLElement).querySelectorAll('.head button')]; + expect(buttons.map((b) => b.getAttribute('aria-label'))).toEqual(['Modifier Effet 2', 'Dupliquer Effet 2', 'Supprimer Effet 2']); + buttons[1].click(); + expect(duplicated).toBe(1); + }); +}); diff --git a/plugins/rebuilder/app/src/app/ui/metier/effect-summary/effect-summary.ts b/plugins/rebuilder/app/src/app/ui/metier/effect-summary/effect-summary.ts index 67e2c4f6..5a8880ab 100644 --- a/plugins/rebuilder/app/src/app/ui/metier/effect-summary/effect-summary.ts +++ b/plugins/rebuilder/app/src/app/ui/metier/effect-summary/effect-summary.ts @@ -13,8 +13,10 @@ export class AcEffectSummary { readonly effect = input.required(); readonly title = input($localize`:@@ui.effectSummary.title:Effet 1`); readonly edit = output(); + readonly duplicate = output(); readonly remove = output(); protected readonly editLabel = computed(() => $localize`:@@ui.effectSummary.edit:Modifier ${this.title()}:title:`); + protected readonly duplicateLabel = computed(() => $localize`:@@ui.effectSummary.duplicate:Dupliquer ${this.title()}:title:`); protected readonly removeLabel = computed(() => $localize`:@@ui.effectSummary.remove:Supprimer ${this.title()}:title:`); protected readonly rows = computed(() => { const e = this.effect(); diff --git a/plugins/rebuilder/app/src/locale/messages.en.json b/plugins/rebuilder/app/src/locale/messages.en.json index 79bb0edf..cc80b9fd 100644 --- a/plugins/rebuilder/app/src/locale/messages.en.json +++ b/plugins/rebuilder/app/src/locale/messages.en.json @@ -253,13 +253,23 @@ "search.card.total": "{$count} cards", "search.card.totalOne": "1 card", "search.effect.intro": "A card matches if it has {$START_TAG_STRONG}at least one{$CLOSE_TAG_STRONG} of the values of each filled criterion.", - "search.effect.when": "When {$START_TAG_SPAN}· trigger{$CLOSE_TAG_SPAN}", - "search.effect.addTrigger": "Add a trigger…", - "search.effect.if": "If {$START_TAG_SPAN}· condition{$CLOSE_TAG_SPAN}", - "search.effect.addCondition": "Add a condition…", - "search.effect.anyCondition": "Any · add a condition…", - "search.effect.then": "Then {$START_TAG_SPAN}· effect{$CLOSE_TAG_SPAN}", - "search.effect.addEffect": "Add an effect…", + "search.effect.whenLabel": "When", + "search.effect.triggerSub": "trigger", + "search.effect.anyTrigger": "Any trigger", + "search.effect.searchTrigger": "Search a trigger…", + "search.effect.ifLabel": "If", + "search.effect.conditionSub": "condition", + "search.effect.searchCondition": "Search a condition…", + "search.effect.thenLabel": "Then", + "search.effect.effectSub": "effect", + "search.effect.anyEffect": "Any effect", + "search.effect.searchEffect": "Search an effect…", + "search.effect.uncheckAll": "Uncheck all", + "search.effect.done": "Done", + "search.effect.addTrigger": "Add a trigger", + "search.effect.addCondition": "Add a condition", + "search.effect.anyCondition": "Any condition", + "search.effect.addEffect": "Add an effect", "search.effect.loadError": "Could not load abilities from the cards API.", "search.effect.cancel": "Cancel", "search.effect.apply": "Apply", @@ -389,6 +399,7 @@ "ui.effectSummary.empty": "No criteria", "ui.effectSummary.title": "Effect 1", "ui.effectSummary.edit": "Edit {$title}", + "ui.effectSummary.duplicate": "Duplicate {$title}", "ui.effectSummary.remove": "Delete {$title}", "ui.effectSummary.when": "When", "ui.effectSummary.if": "If", diff --git a/plugins/rebuilder/e2e/rebuilder.spec.ts b/plugins/rebuilder/e2e/rebuilder.spec.ts index 9e449f03..32a1ee9b 100644 --- a/plugins/rebuilder/e2e/rebuilder.spec.ts +++ b/plugins/rebuilder/e2e/rebuilder.spec.ts @@ -517,13 +517,13 @@ test.describe('ReBuilder in the shell · Uniques search', () => { await page.getByRole('tab', { name: 'Uniques' }).click(); await expect(page.locator('ac-unique-card').first()).toBeVisible(); - /** Picks `text` (exact) in the criterion `index` of the open effect window. */ + /** Checks `text` (exact) in the criterion `index` of the open effect window: its list stays open. */ const pick = async (index: number, text: string) => { const dialog = page.getByRole('dialog'); - await dialog.getByRole('combobox').nth(index).click(); - await page.locator('.cdk-overlay-container .panel input').fill(text); - await page.getByRole('option', { name: text, exact: true }).click(); - await expect(dialog.locator('ac-combobox').nth(index).locator('.picked').filter({ hasText: text })).toBeVisible(); + await dialog.locator('.card-head').nth(index).click(); + await dialog.locator('ac-check-list input[type=search]').fill(text); + await dialog.getByRole('checkbox', { name: text, exact: true }).check(); + await expect(dialog.locator('.card').nth(index).locator('ac-or-values .value').filter({ hasText: text })).toBeVisible(); }; const editEffect = async (open: () => Promise, picks: [number, string][]) => { await open(); @@ -556,6 +556,13 @@ test.describe('ReBuilder in the shell · Uniques search', () => { // The count shown is the API's for that request. await expect(page.locator('.results .total, .results').getByText(new RegExp(`^${both.toLocaleString('fr-FR').replace(/\s/g, '\\s')} cartes?$`))).toBeVisible(); await evidence(page, testInfo, '11-uniques-effects'); + + // « Dupliquer » puts a copy of the effect right after it. + const summaries = page.locator('ac-effect-summary'); + const firstText = await summaries.nth(0).locator('p').allTextContents(); + await summaries.nth(0).getByRole('button', { name: 'Dupliquer Effet 1' }).click(); + await expect(summaries).toHaveCount(3); + expect(await summaries.nth(1).locator('p').allTextContents()).toEqual(firstText); }); test('narrows each effect list to the values that still give a card, for the effect being edited', async ({ page, compact }, testInfo) => { @@ -569,15 +576,23 @@ test.describe('ReBuilder in the shell · Uniques search', () => { const narrowing = (editing: string) => page.waitForResponse((r) => r.url().includes('/api/v2/effects/filtered') && new URL(r.url()).searchParams.get('editing') === editing); const idGds = async (res: Promise<{ json(): Promise }>) => ((await (await res).json()) as { idGds: number[] }).idGds; - /** Options of the criterion `index` of the open effect window, list closed again. */ + const dialog = () => page.getByRole('dialog'); + /** Shows the checkbox list of the criterion `index`: the right column, or a step in compact. */ + const openCriterion = async (index: number) => { + await dialog().locator(compact ? '.crit .add' : '.card-head').nth(index).click(); + await expect(dialog().locator('ac-check-list')).toBeVisible(); + }; + /** Compact: back from the step to the criteria. */ + const closeCriterion = async () => { + if (compact) await dialog().getByRole('button', { name: 'Valider' }).click(); + }; + /** Options of the criterion `index` of the open effect window. */ const listed = async (index: number) => { - const combo = page.getByRole('dialog').getByRole('combobox').nth(index); - await combo.click(); - const options = page.locator('.cdk-overlay-container .panel [role=option]'); + await openCriterion(index); + const options = dialog().locator('ac-check-list .option'); await options.first().waitFor(); const texts = await options.allTextContents(); - await combo.click(); - await expect(page.locator('.cdk-overlay-container .panel')).toHaveCount(0); + await closeCriterion(); return texts; }; const apply = async () => { @@ -594,9 +609,10 @@ test.describe('ReBuilder in the shell · Uniques search', () => { // The whole list shows until the answer comes, then only the ids it holds. await expect.poll(async () => (await listed(0)).length).toBeLessThanOrEqual(factionIds.length); const allTriggers = await listed(0); - await page.getByRole('dialog').getByRole('combobox').nth(2).click(); - await page.locator('.cdk-overlay-container .panel input').fill('Piochez deux cartes.'); - await page.getByRole('option', { name: 'Piochez deux cartes.', exact: true }).click(); + await openCriterion(2); + await dialog().locator('ac-check-list input[type=search]').fill('Piochez deux cartes.'); + await dialog().getByRole('checkbox', { name: 'Piochez deux cartes.', exact: true }).check(); + await closeCriterion(); await apply(); // Effect 2: slot 1, effect 1 sent as a constraint, the new block empty. @@ -610,12 +626,14 @@ test.describe('ReBuilder in the shell · Uniques search', () => { const narrowed = await listed(0); expect(narrowed.length).toBeGreaterThan(0); expect(narrowed.length).toBeLessThan(allTriggers.length); - await page.getByRole('dialog').getByRole('combobox').nth(0).click(); + await openCriterion(0); await evidence(page, testInfo, '14-uniques-effect-narrowed'); - await page.locator('.cdk-overlay-container .panel [role=option]').first().click(); + await dialog().locator('ac-check-list input[type=checkbox]').first().check(); + await closeCriterion(); await apply(); - // Effect 1 removed: the one left is slot 0 again, and its own trigger does not narrow its trigger list. + // Effect 1 removed: the one left is slot 0 again, and its own trigger does not narrow its trigger list + // (the checked trigger stays in the list, checked). await page.locator('ac-effect-summary').first().getByRole('button', { name: /^Supprimer/ }).click(); await expect(page.locator('ac-effect-summary')).toHaveCount(1); const again = narrowing('trigger:0'); @@ -624,7 +642,7 @@ test.describe('ReBuilder in the shell · Uniques search', () => { expect(reqAgain.searchParams.get('effect[0][t]')).toBeTruthy(); expect([...reqAgain.searchParams.keys()].filter((k) => k.startsWith('effect[1]'))).toEqual([]); await again; - await expect.poll(async () => (await listed(0)).length).toBe(allTriggers.length - 1); + await expect.poll(async () => (await listed(0)).length).toBe(allTriggers.length); }); }); From 74cb76f4e695c9f32aa7f0615780d86d392341a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89douard=20WILLISSECK?= Date: Tue, 6 Oct 2026 12:37:31 +0200 Subject: [PATCH 04/69] Re:Builder: larger Uniques on very wide windows, 7 per row at most (#134) Uniques are cards whose text is read. On a 2560 px window the results grid showed 8 of them per row, too small to read. The Uniques grid now caps at 7 columns: the cards only grow where 7 would be narrower than the usual 210 px, so 1920 px (5 per row) and 1440 px (3 per row) do not change, nor do the other tabs. Co-authored-by: Claude Opus 5.5 --- .../search/search-results/search-results.html | 2 +- .../search/search-results/search-results.scss | 7 +++++++ plugins/rebuilder/e2e/rebuilder.spec.ts | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/plugins/rebuilder/app/src/app/features/search/search-results/search-results.html b/plugins/rebuilder/app/src/app/features/search/search-results/search-results.html index 878586d9..85c8e973 100644 --- a/plugins/rebuilder/app/src/app/features/search/search-results/search-results.html +++ b/plugins/rebuilder/app/src/app/features/search/search-results/search-results.html @@ -14,7 +14,7 @@
} @else { @if (layout() === 'grid') { - + @for (c of grid.slice(); track c.reference) { { }); test.describe('ReBuilder in the shell · Uniques search', () => { + test('shows the Uniques at most 7 per row, larger, on a very wide window', async ({ page, compact }) => { + test.skip(compact, 'a desktop width'); + await page.setViewportSize({ width: 2560, height: 1440 }); + await page.goto(`${NEW_DECK}?lang=fr`); + await createDeck(page, `E2E uniques grid ${Date.now()}`); + const columns = () => page.locator('ac-virtual-grid.grid').evaluate((g) => getComputedStyle(g).gridTemplateColumns.split(' ').length); + await expect(page.locator('ac-card-tile').first()).toBeVisible(); + const all = await columns(); + await page.getByRole('tab', { name: 'Uniques' }).click(); + await expect(page.locator('ac-unique-card').first()).toBeVisible(); + expect(all).toBeGreaterThan(7); + await expect.poll(columns).toBe(7); + }); + test('ORs the values of a criterion and ANDs the effects, on the Uniques search API', async ({ page, compact }, testInfo) => { test.skip(compact, 'the effect filter is checked on the desktop panel'); await page.goto(`${NEW_DECK}?lang=fr`); From 3bee2b6567a0755fb956e329ec3575fbe008fa42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89douard=20WILLISSECK?= Date: Tue, 6 Oct 2026 16:38:36 +0200 Subject: [PATCH 05/69] Re:Builder: the whole deck at a glance from 768 px, read and edit (#135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Re:Builder: the whole deck at a glance from 768 px, read and edit Deck bar above the deck page and the editor: hero art with a faction border, the name on two lines at most, format and legality, counts per type, cost curve (hand and reserve), terrain totals, and every action at hand. The editor's search and starting hand get a one-row version with a mini curve and a « ⋯ » menu. « Aperçu » is now a board of piles, one column group per type, like the deck image: every group has the same row count, the smallest that fits 12 card columns. In the editor each pile has a stepper on hover or focus; a card brought to 0 stays, faded, until the view is left. My own deck opens in the editor on « Aperçu »; « Terminer » goes back to the deck list. Someone else's deck opens on its read-only page. The deck panel keeps its card list only (summary and stats are in the bar) and starts hidden on « Aperçu ». Phones are unchanged. Co-Authored-By: Claude Opus 5.5 * Re:Builder: deck board fills the window's width, copies of a pile further apart The number of card columns follows the board's measured width (150 px a column at least), so a wide window gets fewer rows; cards grow up to 260 px. Each copy behind the front one is 16 px lower, to count them at a glance. Co-Authored-By: Claude Opus 5.5 * Re:Builder: deck settings button stands out on any hero art Co-Authored-By: Claude Opus 5.5 * Re:Builder: deck board cards stay readable, 150 px wide at least The column count now comes from the narrowest card allowed, counting each group's padding: a deck with Uniques takes more rows instead of shrinking its cards. Co-Authored-By: Claude Opus 5.5 * Re:Builder: editor deck panel as tall as its cards, the window at most Co-Authored-By: Claude Opus 5.5 * Re:Builder: filters panel ends at the window's bottom under any header Its height left out a fixed 136 px, the old editor header; the deck bar is taller, so « Réinitialiser » and « Rechercher » were below the window. The panel now measures how far it sits below its sticky position. Co-Authored-By: Claude Opus 5.5 * Re:Builder: a click anywhere on an effect criterion opens it « Quand », « Si », « Alors »: only the title row was a button, the values below it (« Tous les déclencheurs »…) did nothing. The title's button now covers the whole card; the values' own buttons stay on top. Co-Authored-By: Claude Opus 5.5 * Re:Builder: more room around the deck bar's buttons Co-Authored-By: Claude Opus 5.5 * Re:Builder: deck settings show the hero's illustration, not its card Co-Authored-By: Claude Opus 5.5 * Re:Builder: disable the filters the Favoris tab cannot apply The site's favorites search filters its table by faction, set and rarity only: the name, cost, power, type and advanced fields did nothing there, with a chip as if they did. On Favoris they are now disabled and faded, under a note, and give no chip. Co-Authored-By: Claude Opus 5.5 * Re:Builder: a duplicated deck opens in the editor, on « Aperçu » From the deck page (someone else's deck or one's own) and from the editor's « Dupliquer en local », as « Dupliquer » in the editor already did. Co-Authored-By: Claude Opus 5.5 * Re:Builder: review fixes on the deck board and the filters panel - The board keeps at 0 only the cards removed with a pile's stepper (or the card zoom), not every reference it showed: a print swapped in the zoom or by Global alt-art mode no longer leaves a faded duplicate behind. - The filters panel measures its offset again each time it is shown from its tab, not only on scroll: reopened without scrolling, its buttons stayed below the window. Co-Authored-By: Claude Opus 5.5 * Re:Builder: simplify the deck bar, the board and the shared deck actions - The deck bar opens the hero zoom itself (no copy in each page) and renders its settings button once; overlines use the design system's .ac-text-overline. - deck-actions.ts holds the image source and the delete message, used by the deck page and the editor. - The board sorts its groups only when the cards change; a resize only recomputes the column count. Decklist order is one shared comparator. - Deck cards take a boolean for the editor link, not a route array built for every card on each change detection. - The filters panel is measured once a frame at most, and not on phones. - Split buttons share their menu positions with the editor's « ⋯ » menu. - Dead labels, translations, Sass variable and duplicate rules removed. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../app/src/app/core/card-filters.spec.ts | 9 + .../app/src/app/core/card-filters.ts | 8 +- .../rebuilder/app/src/app/core/deck-view.ts | 14 +- .../app/src/app/features/deck/deck-actions.ts | 41 +++ .../app/features/deck/deck-bar/deck-bar.html | 94 +++++++ .../app/features/deck/deck-bar/deck-bar.scss | 234 ++++++++++++++++++ .../app/features/deck/deck-bar/deck-bar.ts | 96 +++++++ .../deck/deck-board/board-layout.spec.ts | 46 ++++ .../features/deck/deck-board/board-layout.ts | 70 ++++++ .../features/deck/deck-board/deck-board.html | 27 ++ .../features/deck/deck-board/deck-board.scss | 46 ++++ .../features/deck/deck-board/deck-board.ts | 103 ++++++++ .../deck-image-export/deck-image-export.html | 1 + .../deck-image-export/deck-image-export.ts | 1 + .../features/deck/deck-page/deck.page.html | 64 ++--- .../features/deck/deck-page/deck.page.scss | 86 +------ .../app/features/deck/deck-page/deck.page.ts | 65 ++--- .../features/decks/decks-page/decks.page.html | 2 +- .../features/decks/decks-page/decks.page.ts | 2 + .../editor/deck-panel/deck-panel.html | 25 -- .../editor/deck-panel/deck-panel.scss | 13 - .../features/editor/deck-panel/deck-panel.ts | 30 +-- .../editor/editor-page/editor.page.html | 108 ++++++-- .../editor/editor-page/editor.page.scss | 81 +++--- .../editor/editor-page/editor.page.ts | 116 +++++++-- .../search/card-search/card-search.html | 23 +- .../search/card-search/card-search.scss | 19 +- .../search/card-search/card-search.ts | 23 +- .../effect-editor/effect-editor.overlay.scss | 28 +++ .../search/filters-panel/filters-panel.html | 114 +++++---- .../search/filters-panel/filters-panel.scss | 17 ++ .../search/filters-panel/filters-panel.ts | 2 + .../deck-settings/deck-settings.overlay.html | 2 +- .../deck-settings/deck-settings.overlay.ts | 8 +- .../ui/buttons/split-button/split-button.html | 6 +- .../ui/buttons/split-button/split-button.scss | 11 + .../ui/buttons/split-button/split-button.ts | 12 +- .../app/ui/metier/card-pile/card-pile.html | 30 +++ .../app/ui/metier/card-pile/card-pile.scss | 116 +++++++++ .../src/app/ui/metier/card-pile/card-pile.ts | 52 ++++ .../app/ui/metier/cost-curve/cost-curve.html | 28 +++ .../app/ui/metier/cost-curve/cost-curve.scss | 92 +++++++ .../app/ui/metier/cost-curve/cost-curve.ts | 45 ++++ .../src/app/ui/metier/deck-card/deck-card.ts | 4 +- .../rebuilder/app/src/app/ui/metier/index.ts | 2 + .../rebuilder/app/src/locale/messages.en.json | 29 ++- plugins/rebuilder/e2e/rebuilder.spec.ts | 57 +++-- 47 files changed, 1669 insertions(+), 433 deletions(-) create mode 100644 plugins/rebuilder/app/src/app/features/deck/deck-actions.ts create mode 100644 plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.html create mode 100644 plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.scss create mode 100644 plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.ts create mode 100644 plugins/rebuilder/app/src/app/features/deck/deck-board/board-layout.spec.ts create mode 100644 plugins/rebuilder/app/src/app/features/deck/deck-board/board-layout.ts create mode 100644 plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.html create mode 100644 plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.scss create mode 100644 plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.ts create mode 100644 plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.html create mode 100644 plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.scss create mode 100644 plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.ts create mode 100644 plugins/rebuilder/app/src/app/ui/metier/cost-curve/cost-curve.html create mode 100644 plugins/rebuilder/app/src/app/ui/metier/cost-curve/cost-curve.scss create mode 100644 plugins/rebuilder/app/src/app/ui/metier/cost-curve/cost-curve.ts diff --git a/plugins/rebuilder/app/src/app/core/card-filters.spec.ts b/plugins/rebuilder/app/src/app/core/card-filters.spec.ts index 9b36a6aa..0ed6d18b 100644 --- a/plugins/rebuilder/app/src/app/core/card-filters.spec.ts +++ b/plugins/rebuilder/app/src/app/core/card-filters.spec.ts @@ -180,3 +180,12 @@ describe('faction filter (card browser)', () => { expect(defaultFilters('uniques').factions).toEqual([]); }); }); + +describe('Favoris filters', () => { + it('gives chips only for the faction, set and rarity filters the favorites search applies', () => { + const f = { ...defaultFilters('favorites'), q: 'kelon', mainCost: '5', types: ['SPELL'], rarities: ['RARE'], sets: ['CORE'], otherFactions: ['BR'] }; + expect(filterChips(f, 'favorites').map((c) => c.id).sort()).toEqual(['otherFactions', 'rarities', 'sets']); + expect(activeFilterCount(f, 'favorites')).toBe(3); + }); +}); + diff --git a/plugins/rebuilder/app/src/app/core/card-filters.ts b/plugins/rebuilder/app/src/app/core/card-filters.ts index 62f113b5..688e5d93 100644 --- a/plugins/rebuilder/app/src/app/core/card-filters.ts +++ b/plugins/rebuilder/app/src/app/core/card-filters.ts @@ -278,6 +278,12 @@ export interface FilterChip { label: string; } +/** + * Filters the Favoris tab applies: the site's `favorites-search` filters its table by faction, rarity and set only. + * The others are disabled there and give no chip. + */ +export const FAVORITES_FILTERS: ReadonlySet = new Set(['factions', 'otherFactions', 'sets', 'rarities']); + export function filterChips(filters: SearchFilters, source: CardSource): FilterChip[] { const chips: FilterChip[] = []; if (filters.q.trim()) chips.push({ id: 'q', label: $localize`:@@search.filters.chip.query:« ${filters.q.trim()}:query: »` }); @@ -322,7 +328,7 @@ export function filterChips(filters: SearchFilters, source: CardSource): FilterC const n = filters.effects.filter((b) => b.triggers.length || b.conditions.length || b.effects.length).length; if (n) chips.push({ id: 'effects', label: n === 1 ? $localize`:@@search.filters.chip.oneEffect:1 effet` : $localize`:@@search.filters.chip.effects:${n}:count: effets` }); } - return chips; + return source === 'favorites' ? chips.filter((c) => FAVORITES_FILTERS.has(c.id)) : chips; } export function removeChip(filters: SearchFilters, id: string): SearchFilters { diff --git a/plugins/rebuilder/app/src/app/core/deck-view.ts b/plugins/rebuilder/app/src/app/core/deck-view.ts index d301c169..8ccaf10f 100644 --- a/plugins/rebuilder/app/src/app/core/deck-view.ts +++ b/plugins/rebuilder/app/src/app/core/deck-view.ts @@ -207,7 +207,7 @@ export interface DeckGroup { lines: HydratedLine[]; } -function groupIdOf(card: Card): DeckGroupId { +export function groupIdOf(card: Card): DeckGroupId { return groupIdOfType(typeOf(card)); } @@ -219,6 +219,12 @@ export function groupIdOfType(t: string): DeckGroupId { return 'other'; } +/** Decklist order: main cost, then name in the content language. */ +export function compareLines(a: HydratedLine, b: HydratedLine): number { + const locale = contentLocale(); + return (a.card.mainCost ?? 99) - (b.card.mainCost ?? 99) || localizedText(a.card.name, locale).localeCompare(localizedText(b.card.name, locale), locale); +} + /** Groups non-hero lines by type, sorted by main cost then name (decklist order). */ export function groupLines(lines: HydratedLine[]): DeckGroup[] { const buckets = new Map(); @@ -231,11 +237,7 @@ export function groupLines(lines: HydratedLine[]): DeckGroup[] { return order .filter((id) => buckets.has(id)) .map((id) => { - const rows = [...(buckets.get(id) ?? [])].sort( - (a, b) => - (a.card.mainCost ?? 99) - (b.card.mainCost ?? 99) || - localizedText(a.card.name, contentLocale()).localeCompare(localizedText(b.card.name, contentLocale()), contentLocale()), - ); + const rows = [...(buckets.get(id) ?? [])].sort(compareLines); return { id, label: GROUP_LABELS[id].label, diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-actions.ts b/plugins/rebuilder/app/src/app/features/deck/deck-actions.ts new file mode 100644 index 00000000..cadb053f --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/deck/deck-actions.ts @@ -0,0 +1,41 @@ +import { Observable, map, of, switchMap } from 'rxjs'; +import { deckImageSource, type DeckImageSource } from '../../core/deck-image'; +import type { DeckStore } from '../../core/deck-store'; +import { decklistText } from '../../core/deck-view'; +import { GuestDeckService } from '../../core/guest-deck.service'; +import type { AcOverlayService } from '../../ui/overlay'; +import { openConfirm } from '../shared/confirm/confirm.overlay'; + +/** « Copier la liste » (deck page, editor): the decklist in the clipboard; resolves with the toast's message. */ +export async function copyDecklist(deck: DeckStore): Promise { + try { + await navigator.clipboard.writeText(decklistText(deck.lines(), deck.hero())); + return $localize`:@@deck.page.listCopied:Liste copiée dans le presse-papiers.`; + } catch { + return $localize`:@@deck.page.copyFailed:Copie impossible dans ce navigateur.`; + } +} + +/** + * « Supprimer » (deck page, editor): asks first, then deletes. Emits `null` when the user gives up, `true` once deleted, + * the message to show when the deck could not be deleted. + */ +export function confirmDeleteDeck(overlay: AcOverlayService, deck: DeckStore): Observable { + return openConfirm(overlay, { + title: $localize`:@@deck.page.deleteTitle:Supprimer le deck ?`, + message: $localize`:@@deck.page.deleteConfirm:« ${deck.name()}:name: » sera supprimé. Cette action est irréversible.`, + confirmLabel: $localize`:@@deck.page.deleteAction:Supprimer`, + icon: 'trash-2', + danger: true, + }).pipe( + switchMap((confirmed) => (confirmed ? deck.delete() : of(null))), + map((ok) => (ok === null ? null : ok || (deck.actionError() ?? $localize`:@@deck.page.deleteFailed:Suppression impossible.`))), + ); +} + +/** The deck's image (« Copier en image »): a deck of the decks API by its id, a guest deck by its cards on this device. */ +export function deckImageSourceFor(id: string, guests: GuestDeckService): DeckImageSource | null { + if (!GuestDeckService.isGuestId(id)) return deckImageSource(id); + const guest = guests.get(id); + return guest ? deckImageSource(id, guest) : null; +} diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.html b/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.html new file mode 100644 index 00000000..70708d09 --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.html @@ -0,0 +1,94 @@ +
+
+ @if (deck.hero(); as h) { + @if (editable()) { + + } @else { + + } + } + @if (editable() && !reduced()) { + + } +
+
+ @if (!reduced()) { + Mes decks + } +
+ @if (renaming()) { + + } @else { +

{{ deck.name() }}

+ @if (editable() && !reduced()) { + + } + } +
+ @if (reduced()) { + + {deck.total(), plural, =1 {1 carte} other {{{ deck.total() }} cartes}} + @for (g of deck.groups(); track g.id) { + {{ g.count }} {{ g.label }} + } + + } @else { + + {{ deck.hero()?.name }}{{ formatLabel() }} + + @if (deck.isDraft() && !editable()) { + Brouillon + } + {{ deck.isPublic() ? labels.public : labels.private }} + + + @for (g of deck.groups(); track g.id) { + {{ g.count }} {{ g.label }} + } + + } +
+
+ + + + +
+ @if (!reduced()) { + Puissance totale + } + +
+@if (reduced()) { + +} + +
+ +
+ + + @if (legality().state === 'legal') { + + } @else if (legality().state === 'illegal') { + + } + diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.scss b/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.scss new file mode 100644 index 00000000..28991a80 --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.scss @@ -0,0 +1,234 @@ +:host { + container: deck-bar / inline-size; + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: var(--ac-space-5); + min-height: 108px; + padding-right: var(--ac-space-5); + background: var(--ac-color-surface); + border: 1px solid var(--ac-color-border); + border-radius: var(--ac-radius-card); + box-shadow: var(--ac-shadow-card); +} +.identity { + align-self: stretch; + flex: 1 1 auto; + min-width: 0; + display: flex; +} +.art { + position: relative; + flex: 0 0 150px; + overflow: hidden; + border-radius: var(--ac-radius-card) 0 0 var(--ac-radius-card); + background: var(--ac-color-track); +} +.zoom { + position: absolute; + inset: 0; + padding: 0; + border: 0; + background: none; + cursor: zoom-in; +} +.zoom:focus-visible, +.settings:focus-visible, +.legality:focus-visible, +.back:focus-visible { + outline: 2px solid var(--ac-color-primary); + outline-offset: -2px; +} +.settings { + /* A surface-coloured chip with a border and a shadow: it stands out on a dark or a light hero art alike. */ + position: absolute; + right: var(--ac-space-2); + bottom: var(--ac-space-2); + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + border: 1px solid var(--ac-color-border-control); + border-radius: var(--ac-radius-pill); + background: var(--ac-color-surface); + color: var(--ac-color-text); + box-shadow: var(--ac-shadow-e3); + cursor: pointer; +} +@media (hover: hover) { + .settings:hover { + background: var(--ac-color-bg-subtle); + color: var(--ac-color-primary); + } +} +.text { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; + padding: var(--ac-space-2) var(--ac-space-4); + border-left: 4px solid; +} +.back { + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: 2px; + font: var(--ac-font-caption); + color: var(--ac-color-link); + text-decoration: none; +} +@media (hover: hover) { + .back:hover { + color: var(--ac-color-link-hover); + } +} +.name-row { + display: flex; + align-items: center; + gap: var(--ac-space-1-5); + min-width: 0; +} +/* Two lines at most, the whole name in the tooltip; a long one a size smaller. */ +.name { + margin: 0; + min-width: 0; + font: var(--ac-font-title); + color: var(--ac-color-text); + overflow-wrap: anywhere; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.name.long { + font: var(--ac-font-heading); +} +.rename { + flex: 1 1 auto; + min-width: 0; + max-width: 40rem; + margin: 0; + padding: 0 var(--ac-space-1-5); + height: var(--ac-control-md); + font: var(--ac-font-title); + color: var(--ac-color-text); + background: var(--ac-color-surface); + border: 1px solid var(--ac-color-border-control); + border-radius: var(--ac-radius-control); +} +.rename.long { + font: var(--ac-font-heading); +} +.rename:focus-visible { + outline: 2px solid var(--ac-color-primary); + outline-offset: 0; +} +.rename-button { + flex-shrink: 0; + color: var(--ac-color-text-muted); +} +.meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--ac-space-1) var(--ac-space-2); + font: var(--ac-font-small); + color: var(--ac-color-text-muted); +} +.meta strong { + color: var(--ac-color-text); +} +.counts { + display: flex; + flex-wrap: wrap; + gap: 2px var(--ac-space-3); + font: var(--ac-font-small); + color: var(--ac-color-text-2); +} +.counts strong { + font: var(--ac-font-body-strong); + color: var(--ac-color-text); +} +.legality { + display: inline-flex; + padding: 0; + border: 0; + background: none; + cursor: pointer; + border-radius: var(--ac-radius-pill); +} +.sep { + align-self: stretch; + width: 1px; + flex-shrink: 0; + margin: var(--ac-space-3) 0; + background: var(--ac-color-divider); +} +.terrain { + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: var(--ac-space-1-5); +} +.actions { + flex-shrink: 0; + margin-left: auto; + /* Space between the buttons and the bar's top and bottom borders. */ + padding: var(--ac-space-3) 0; +} +.actions:empty { + display: none; +} + +/* Reduced bar (editor search, starting hand): one row. */ +:host(.reduced) { + gap: var(--ac-space-4); + min-height: 64px; + padding-right: var(--ac-space-4); +} +:host(.reduced) .actions { + padding: var(--ac-space-2) 0; +} +:host(.reduced) .art { + flex-basis: 96px; +} +:host(.reduced) .text { + padding: var(--ac-space-2) var(--ac-space-3); +} +:host(.reduced) .name { + font: var(--ac-font-heading); +} +:host(.reduced) .name.long { + font: var(--ac-font-body-strong); +} + +/* Narrower bars (window width, deck panel open): the curve goes first, then the terrain totals. */ +@container deck-bar (max-width: 1180px) { + :host(:not(.reduced)) ac-cost-curve, + :host(:not(.reduced)) .sep.curve { + display: none; + } +} +@container deck-bar (max-width: 960px) { + :host(:not(.reduced)) .terrain, + :host(:not(.reduced)) .sep { + display: none; + } +} +@container deck-bar (max-width: 1000px) { + :host(.reduced) ac-cost-curve, + :host(.reduced) .sep.curve { + display: none; + } +} +@container deck-bar (max-width: 840px) { + :host(.reduced) .terrain, + :host(.reduced) .sep { + display: none; + } +} diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.ts b/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.ts new file mode 100644 index 00000000..99a3d25e --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.ts @@ -0,0 +1,96 @@ +import { Component, ElementRef, Injector, afterNextRender, computed, inject, input, output, signal } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import { RouterLink } from '@angular/router'; +import { heroArtSources } from '../../../core/card-art'; +import type { DeckLegality } from '../../../core/deck-legality'; +import { DeckStore } from '../../../core/deck-store'; +import { formatInfo } from '../../../core/formats'; +import { AcIconButton } from '../../../ui/buttons'; +import { AcBadge, AcTerrainTotals } from '../../../ui/chips'; +import { AcIcon } from '../../../ui/icon'; +import { AcCardArt, AcCostCurve, factionColor } from '../../../ui/metier'; +import { AcOverlayService } from '../../../ui/overlay'; +import { openCardZoom } from '../../shared/card-zoom/card-zoom.overlay'; + +/** From this length the title takes one type size less, so that two lines keep the bar's height. */ +const LONG_NAME = 40; + +/** + * The deck bar from 768 px, above the deck page and the editor: hero art, faction border, name (two lines at most), + * format and badges, counts per type, cost curve, terrain totals, then the page's actions (`[barActions]`). + * `reduced` (editor search and starting hand): one row, smaller art, a mini curve with the `highlight` bucket outlined. + */ +@Component({ + selector: 'app-deck-bar', + imports: [NgTemplateOutlet, RouterLink, AcCardArt, AcCostCurve, AcTerrainTotals, AcBadge, AcIcon, AcIconButton], + host: { '[class.reduced]': 'reduced()' }, + templateUrl: './deck-bar.html', + styleUrl: './deck-bar.scss', +}) +export class DeckBar { + protected readonly deck = inject(DeckStore); + private readonly el = inject>(ElementRef).nativeElement; + private readonly injector = inject(Injector); + private readonly overlay = inject(AcOverlayService); + + readonly reduced = input(false); + readonly legality = input.required(); + /** Editor: the settings button on the art, the rename button next to the name, the number of problems. */ + readonly editable = input(false); + /** Bucket of the mini curve outlined (cost of the last card added). */ + readonly highlight = input(null); + readonly openSettings = output(); + readonly showLegality = output(); + + protected readonly renaming = signal(false); + protected readonly heroArt = computed(() => { + const ref = this.deck.hero()?.reference; + return ref ? heroArtSources(ref) : null; + }); + protected readonly border = computed(() => factionColor(this.deck.hero()?.faction)); + protected readonly long = computed(() => this.deck.name().length > LONG_NAME); + protected readonly formatLabel = computed(() => formatInfo(this.deck.format()).label); + protected readonly problems = computed(() => this.legality().rules.length + this.legality().errors.length); + protected readonly illegalLabel = computed(() => { + const n = this.problems(); + if (!this.editable() || !n) return $localize`:@@deck.page.illegal:Non légal`; + return n === 1 ? $localize`:@@deck.bar.illegalOne:Non légal · ${n}:count: problème` : $localize`:@@deck.bar.illegalMany:Non légal · ${n}:count: problèmes`; + }); + protected readonly labels = { + legalDetails: $localize`:@@deck.bar.legalDetails:Légal : voir les règles du format`, + illegalDetails: $localize`:@@deck.page.illegalDetails:Non légal : voir le détail`, + settings: $localize`:@@ui.deckSummary.settings:Réglages du deck : héros, format, visibilité`, + rename: $localize`:@@ui.editableTitle.rename:Renommer le deck`, + name: $localize`:@@ui.editableTitle.label:Nom du deck`, + public: $localize`:@@deck.page.public:Public`, + private: $localize`:@@deck.page.private:Privé`, + }; + protected readonly zoomLabel = computed(() => $localize`:@@ui.cardTile.zoom:Agrandir ${this.deck.hero()?.name ?? ''}:name:`); + + /** Read-only: the hero's card large. */ + protected zoomHero(): void { + const hero = this.deck.hero(); + if (hero) openCardZoom(this.overlay, { card: { reference: hero.reference, name: hero.name, faction: { code: hero.faction, name: hero.faction }, cardType: { reference: 'HERO' } } }); + } + + protected startRename(): void { + this.renaming.set(true); + afterNextRender( + () => { + const field = this.el.querySelector('input.rename'); + field?.focus(); + field?.select(); + }, + { injector: this.injector }, + ); + } + + /** Enter or leaving the field keeps the name (an empty one is ignored), Échap gives up. */ + protected endRename(value: string | null): void { + if (!this.renaming()) return; + this.renaming.set(false); + const name = value?.trim(); + if (name && name !== this.deck.name()) this.deck.rename(name); + afterNextRender(() => this.el.querySelector('.rename-button')?.focus(), { injector: this.injector }); + } +} diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-board/board-layout.spec.ts b/plugins/rebuilder/app/src/app/features/deck/deck-board/board-layout.spec.ts new file mode 100644 index 00000000..5939e321 --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/deck/deck-board/board-layout.spec.ts @@ -0,0 +1,46 @@ +import type { Card, HydratedLine } from '../../../core/models'; +import { boardRows, deckBoard, fitColumns } from './board-layout'; + +const line = (reference: string, type: string, mainCost: number, quantity = 1): HydratedLine => ({ + card: { reference, name: reference, mainCost, cardType: { reference: type } } as unknown as Card, + quantity, +}); + +describe('boardRows', () => { + it('takes the smallest row count that fits the columns', () => { + // 17 cards on one row: 17 columns > 12; on two rows: 2 + 4 + 1 + 2 = 9. + expect(boardRows([4, 8, 2, 3], 12)).toBe(2); + // A singleton deck of 45 cards. + expect(boardRows([15, 18, 6, 6], 12)).toBe(5); + expect(boardRows([3, 2], 12)).toBe(1); + }); + + it('stops at the largest group when the groups alone exceed the columns', () => { + expect(boardRows([1, 1, 1], 2)).toBe(1); + }); +}); + +describe('deckBoard', () => { + it('puts the Uniques apart, sorts by cost and gives each group its columns', () => { + const groups = deckBoard([ + line('ALT_CORE_B_YZ_08_C', 'CHARACTER', 3, 3), + line('ALT_CORE_B_YZ_04_C', 'CHARACTER', 1, 2), + line('ALT_CORE_B_YZ_08_U_123', 'CHARACTER', 2), + line('ALT_CORE_B_YZ_20_C', 'SPELL', 4, 0), + ]); + expect(groups.map((g) => g.id)).toEqual(['characters', 'spells', 'uniques']); + expect(groups[0].lines.map((l) => l.card.reference)).toEqual(['ALT_CORE_B_YZ_04_C', 'ALT_CORE_B_YZ_08_C']); + expect(groups[0].count).toBe(5); + expect(groups[1].count).toBe(0); + expect(groups.map((g) => g.columns)).toEqual([2, 1, 1]); + }); +}); + +describe('fitColumns', () => { + it('keeps the cards at their minimum width', () => { + // 1 940 px, 4 groups: (1940 - 4 × 28) / 162 = 11.3. + expect(fitColumns(1940, 4, 150, 20, 12)).toBe(11); + expect(fitColumns(1376, 4, 150, 20, 12)).toBe(7); + expect(fitColumns(100, 4, 150, 20, 12)).toBe(1); + }); +}); diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-board/board-layout.ts b/plugins/rebuilder/app/src/app/features/deck/deck-board/board-layout.ts new file mode 100644 index 00000000..3718f8c7 --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/deck/deck-board/board-layout.ts @@ -0,0 +1,70 @@ +import { isUniqueReference } from '../../../core/card-art'; +import { typeOf } from '../../../core/deck-rules'; +import { GROUP_LABELS, compareLines, groupIdOf, type DeckGroupId } from '../../../core/deck-view'; +import type { HydratedLine } from '../../../core/models'; + +export type BoardGroupId = DeckGroupId | 'uniques'; + +/** A column group of the deck board: one card type. */ +export interface BoardGroup { + id: BoardGroupId; + label: string; + count: number; + lines: HydratedLine[]; +} + +/** A group laid out on the board, `columns` card columns wide. */ +export interface BoardColumnGroup extends BoardGroup { + columns: number; +} + +const ORDER: BoardGroupId[] = ['characters', 'spells', 'permanents', 'uniques', 'other']; +const UNIQUES = $localize`:@@deck.board.uniques:Uniques`; + +/** + * The deck board's groups: one per type (Uniques apart from the other Characters), cards in decklist order. Lines at 0 + * copies stay (a card just removed in the editor). + */ +export function boardGroups(lines: readonly HydratedLine[]): BoardGroup[] { + const buckets = new Map(); + for (const line of lines) { + if (typeOf(line.card) === 'HERO' || line.quantity < 0) continue; + const id: BoardGroupId = isUniqueReference(line.card.reference) ? 'uniques' : groupIdOf(line.card); + let bucket = buckets.get(id); + if (!bucket) buckets.set(id, (bucket = [])); + bucket.push(line); + } + return ORDER.flatMap((id) => { + const rows = buckets.get(id); + if (!rows) return []; + rows.sort(compareLines); + return [{ id, label: id === 'uniques' ? UNIQUES : GROUP_LABELS[id].label, count: rows.reduce((n, l) => n + l.quantity, 0), lines: rows }]; + }); +} + +/** Every group gets the same number of rows, the smallest that fits the board in `maxColumns` card columns. */ +export function layoutBoard(groups: readonly BoardGroup[], maxColumns: number): BoardColumnGroup[] { + const rows = boardRows(groups.map((g) => g.lines.length), maxColumns); + return groups.map((g) => ({ ...g, columns: Math.max(1, Math.ceil(g.lines.length / rows)) })); +} + +/** The deck board: its groups laid out in `maxColumns` card columns at most. */ +export function deckBoard(lines: readonly HydratedLine[], maxColumns = 12): BoardColumnGroup[] { + return layoutBoard(boardGroups(lines), maxColumns); +} + +/** The smallest row count with which groups of these sizes fit in `maxColumns` columns (each group at least one). */ +export function boardRows(sizes: readonly number[], maxColumns: number): number { + const most = Math.max(1, ...sizes); + let rows = 1; + while (rows < most && sizes.reduce((n, size) => n + Math.max(1, Math.ceil(size / rows)), 0) > maxColumns) rows++; + return rows; +} + +/** + * Card columns that fit `width` with cards of `minCard` px at least: each group has its padding on both sides less one + * gap (the board's CSS), each column a card and a gap. + */ +export function fitColumns(width: number, groups: number, minCard: number, padding: number, gap: number): number { + return Math.max(1, Math.floor((width - groups * (2 * padding - gap)) / (minCard + gap))); +} diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.html b/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.html new file mode 100644 index 00000000..9805877c --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.html @@ -0,0 +1,27 @@ +@for (g of groups(); track g.id) { +
+

{{ g.label }} · {{ g.count }}

+
+ @for (l of g.lines; track l.card.reference) { + + } +
+
+} @empty { +
+ Deck vide + @if (editable()) { +

Ajoutez des cartes depuis la recherche.

+ } @else { +

Ce deck ne contient encore aucune carte.

+ } +
+} diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.scss b/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.scss new file mode 100644 index 00000000..4f9ee86e --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.scss @@ -0,0 +1,46 @@ +:host { + display: flex; + background: var(--ac-color-surface); + border: 1px solid var(--ac-color-border); + border-radius: var(--ac-radius-card); + padding: var(--ac-space-5) 0; +} +.group { + /* Card columns of the same width in every group, 260px at most (a small deck does not get giant cards). */ + --card-max: 260px; + /* Grows with its columns from its padding less one gap: every card column gets the same width. */ + flex: var(--columns) 1 calc(2 * var(--ac-space-5) - var(--ac-space-3)); + min-width: 0; + max-width: calc(var(--columns) * var(--card-max) + (var(--columns) - 1) * var(--ac-space-3) + 2 * var(--ac-space-5)); + display: flex; + flex-direction: column; + gap: var(--ac-space-3); + padding: 0 var(--ac-space-5); +} +.group + .group { + border-left: 1px solid var(--ac-color-divider); +} +.title { + margin: 0; + font: var(--ac-font-overline); + font-size: var(--ac-font-size-small); + letter-spacing: var(--ac-letter-spacing-overline); + text-transform: uppercase; + color: var(--ac-color-text); +} +.cards { + display: grid; + grid-template-columns: repeat(var(--columns), minmax(0, 1fr)); + column-gap: var(--ac-space-3); + row-gap: var(--ac-space-4); + align-items: end; +} +.empty { + flex: 1; + text-align: center; + padding: var(--ac-space-10) var(--ac-space-4); + color: var(--ac-color-text-2); +} +.empty p { + margin: var(--ac-space-1) 0 0; +} diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.ts b/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.ts new file mode 100644 index 00000000..cd8ffc28 --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.ts @@ -0,0 +1,103 @@ +import { Component, DestroyRef, ElementRef, afterNextRender, computed, effect, inject, input, signal, untracked } from '@angular/core'; +import { DeckStore } from '../../../core/deck-store'; +import type { Card, HydratedLine } from '../../../core/models'; +import { AcCardPile } from '../../../ui/metier'; +import { AcOverlayService } from '../../../ui/overlay'; +import { EditorAltArts } from '../../editor/editor-alt-arts'; +import { lineIssues } from '../../editor/editor-legality'; +import { openCardZoom } from '../../shared/card-zoom/card-zoom.overlay'; +import { boardGroups, fitColumns, layoutBoard } from './board-layout'; + +/** Narrowest card the board shows, to keep it readable: a wider board gets more columns, so fewer rows. */ +const MIN_CARD = 150; +/** The board's CSS: a group's side padding (--ac-space-5) and the gap between cards (--ac-space-3). */ +const PADDING = 20; +const GAP = 12; +/** Columns before the board is measured. */ +const DEFAULT_COLUMNS = 8; + +/** + * « Aperçu » from 768 px: the whole deck at a glance, as its image. One column group per type, each card a pile of its + * copies. Editable (editor): a stepper on each pile; a card brought to 0 keeps its place, faded, until the view is left. + */ +@Component({ + selector: 'app-deck-board', + imports: [AcCardPile], + templateUrl: './deck-board.html', + styleUrl: './deck-board.scss', +}) +export class DeckBoard { + protected readonly deck = inject(DeckStore); + private readonly overlay = inject(AcOverlayService); + /** Editor only: prints of the deck's cards (per-deck alt-art mode). */ + private readonly altArts = inject(EditorAltArts, { optional: true }); + readonly readonly = input(false); + /** The board's width, measured: the number of card columns follows it. */ + private readonly width = signal(0); + + protected readonly editable = computed(() => !this.readonly() && this.deck.editable()); + /** Cards brought to 0 with a pile's stepper since this view opened, by reference: they keep their place, faded. */ + private readonly removed = signal(new Map()); + /** The groups, sorted: recomputed when the cards change, not when the board is resized. */ + private readonly board = computed(() => { + const lines: HydratedLine[] = this.deck.lines().filter((l) => l.quantity > 0); + const present = new Set(lines.map((l) => l.card.reference)); + if (this.editable()) { + for (const [ref, card] of this.removed()) if (!present.has(ref)) lines.push({ card, quantity: 0 }); + } + return boardGroups(lines); + }); + /** Card columns the width allows: a number, so a resize that keeps it changes nothing downstream. */ + private readonly columns = computed(() => { + const width = this.width(); + return width ? fitColumns(width, this.board().length, MIN_CARD, PADDING, GAP) : DEFAULT_COLUMNS; + }); + protected readonly groups = computed(() => layoutBoard(this.board(), this.columns())); + protected readonly issues = computed(() => lineIssues(this.deck)); + protected readonly noIssues: readonly string[] = []; + + constructor() { + const host = inject>(ElementRef).nativeElement; + const destroyRef = inject(DestroyRef); + afterNextRender(() => { + if (typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(([entry]) => this.width.set(Math.round(entry.contentRect.width))); + observer.observe(host); + destroyRef.onDestroy(() => observer.disconnect()); + }); + // Another deck loaded: its own cards only. + effect(() => { + this.deck.deckId(); + untracked(() => this.removed.set(new Map())); + }); + } + + /** + * A pile's stepper. Only a card removed here stays on the board at 0: a reference that leaves the deck otherwise (an + * illustration swapped in the zoom, the preferred prints of Global mode) leaves no faded pile behind. + */ + protected setQuantity(card: Card, quantity: number): void { + this.deck.setQuantity(card, quantity); + this.removed.update((map) => { + const next = new Map(map); + if (quantity === 0) next.set(card.reference, card); + else next.delete(card.reference); + return next; + }); + } + + protected quantityOf(card: Card): number { + return this.deck.quantities().get(card.reference) ?? 0; + } + + /** The card large, with its copies and prints when the deck can be edited (the site's card lightbox). */ + protected zoom(card: Card): void { + const editable = this.editable(); + const choice = this.altArts?.choiceFor(card.reference) ?? null; + openCardZoom(this.overlay, { + card, + quantity: editable ? { value: this.quantityOf(card), max: this.deck.maxFor(card), change: (n) => this.setQuantity(card, n) } : undefined, + illustrations: editable && choice ? { choice, pick: (ref) => this.deck.swapReference(card, ref) } : undefined, + }); + } +} diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-image-export/deck-image-export.html b/plugins/rebuilder/app/src/app/features/deck/deck-image-export/deck-image-export.html index 04fabbe8..abc95498 100644 --- a/plugins/rebuilder/app/src/app/features/deck/deck-image-export/deck-image-export.html +++ b/plugins/rebuilder/app/src/app/features/deck/deck-image-export/deck-image-export.html @@ -1,5 +1,6 @@ (); /** The deck's name: the saved file's. */ readonly name = input(''); + readonly size = input<'md' | 'sm'>('md'); readonly notice = output(); protected readonly busy = signal(false); diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.html b/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.html index 31d2503e..12ae96fd 100644 --- a/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.html +++ b/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.html @@ -53,7 +53,22 @@
@if (!bp.compact()) { - Mes decks + +
+ @if (deck.owned()) { + + } + + @if (imageSource(); as source) { + + } + + + @if (deck.owned()) { + + } +
+
} @else {
@@ -120,53 +135,16 @@

} - + @if (bp.compact()) { + + } @else { + + } } @default never; }

- @if (!bp.compact()) { - - }
@if (bp.compact()) { diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.scss b/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.scss index 6287a827..f0c0d44d 100644 --- a/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.scss +++ b/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.scss @@ -125,8 +125,7 @@ color: var(--ac-color-text-muted); } -.m-meta h1, -.titles h1 { +.m-meta h1 { display: flex; align-items: center; gap: var(--ac-space-2); @@ -213,16 +212,6 @@ gap: var(--ac-space-5); } - .back { - display: inline-flex; - align-items: center; - gap: var(--ac-space-1); - font-size: var(--ac-font-size-body); - font-weight: 700; - color: var(--ac-color-text-2); - margin-bottom: calc(-1 * var(--ac-space-2)); - } - .grouping { display: flex; justify-content: flex-end; @@ -240,72 +229,15 @@ bottom: var(--ac-space-6); } - .aside { - width: 300px; - flex-shrink: 0; - background: var(--ac-color-surface); - border: 1px solid var(--ac-color-border); - border-radius: var(--ac-radius-card); - overflow: hidden; - position: sticky; - top: calc(var(--ac-app-bar-height) + var(--ac-space-6)); - } - - .banner { - position: relative; - height: 170px; - background: var(--ac-color-text-2); - } - - button.banner { - display: block; - width: 100%; - padding: 0; - border: 0; - cursor: pointer; - } - - button.banner:focus-visible { - outline: 2px solid var(--ac-color-primary); - outline-offset: -2px; - } - - .aside-body { - padding: 18px; - display: flex; - flex-direction: column; - gap: var(--ac-space-4); - } - - .titles { - display: flex; - flex-direction: column; - gap: var(--ac-space-1); - font-size: var(--ac-font-size-body); - color: var(--ac-color-text-muted); - } - - .titles h1 { - margin: 0; - font-size: 24px; - font-weight: 800; - letter-spacing: var(--ac-letter-spacing-display); - color: var(--ac-color-text); - } - - .image-export { - margin-top: calc(-1 * var(--ac-space-2)); - } - - .actions { + /* Deck bar actions: two columns of small buttons. */ + .bar-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--ac-space-2); - margin-top: calc(-1 * var(--ac-space-2)); + gap: var(--ac-space-1-5); } - .actions button { - padding: 0 var(--ac-space-2); + .bar-actions > button { + padding: 0 var(--ac-space-2-5); } } @@ -315,8 +247,4 @@ } } -@media (min-width: 1200px) { - .aside { - width: 360px; - } -} + diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.ts b/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.ts index 72693296..b5beefd7 100644 --- a/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.ts +++ b/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.ts @@ -7,11 +7,9 @@ import { AuthSession } from '../../../core/auth-session'; import { PageTitle } from '../../../core/page-title'; import { factionSrc } from '../../../core/assets'; import { DeckStore } from '../../../core/deck-store'; -import { deckImageSource } from '../../../core/deck-image'; -import { decklistText, groupByCost } from '../../../core/deck-view'; +import { groupByCost } from '../../../core/deck-view'; import { formatInfo } from '../../../core/formats'; import { GuestDeckService } from '../../../core/guest-deck.service'; -import { uiLocale } from '../../../core/i18n'; import { AcButton, AcIconButton } from '../../../ui/buttons'; import { AcBadge, AcRaritySummary } from '../../../ui/chips'; import { AcCollapsible } from '../../../ui/containers'; @@ -26,11 +24,12 @@ import { DeckPreview } from '../../editor/deck-preview/deck-preview'; import { DecklistTable } from '../decklist-table/decklist-table'; import { DeckActionsSheet, type DeckActionsData, type DeckActionsResult } from '../deck-actions-sheet/deck-actions-sheet'; import { deckImageBusyMessage } from '../deck-image-actions'; +import { confirmDeleteDeck, copyDecklist, deckImageSourceFor } from '../deck-actions'; import { DeckImageExport } from '../deck-image-export/deck-image-export'; +import { DeckBar } from '../deck-bar/deck-bar'; +import { DeckBoard } from '../deck-board/deck-board'; import { openDuplicateDeck } from '../duplicate-deck/duplicate-deck.overlay'; -import { openConfirm } from '../../shared/confirm/confirm.overlay'; import { openLegalityDetails } from '../../shared/legality-details/legality-details.overlay'; -import { openCardZoom } from '../../shared/card-zoom/card-zoom.overlay'; import { openShareDeck } from '../share-deck/share-deck.overlay'; import { HandCalculators } from '../hand-calculators/hand-calculators'; import { HandStats } from '../hand-stats/hand-stats'; @@ -42,7 +41,10 @@ type DeckTab = 'cartes' | 'decklist' | 'description' | 'main'; /** Path of each tab under `/decks/:id` (`cartes` is the deck page itself). */ const TAB_PATHS: Record = { cartes: null, decklist: 'deck', description: 'description', main: 'main' }; -/** Consultation: Cartes / Decklist / Description / Main de départ tabs, read-only, summary aside with actions. */ +/** + * Consultation: Aperçu / Decklist / Description / Main de départ, read-only. From 768 px the deck bar (art, stats, + * actions) sits above the tabs and « Aperçu » is the deck board; on phones, the summary and the bottom navigation. + */ @Component({ selector: 'app-deck-page', imports: [ @@ -65,6 +67,8 @@ const TAB_PATHS: Record = { cartes: null, decklist: 'dec DeckListView, DecklistTable, DeckImageExport, + DeckBar, + DeckBoard, TestHand, HandStats, HandCalculators, @@ -92,7 +96,7 @@ export class DeckPage { private toastTimer?: ReturnType; protected readonly tabs = [ - { id: 'cartes', label: $localize`:@@deck.page.tabCards:Cartes` }, + { id: 'cartes', label: $localize`:@@deck.page.tabPreview:Aperçu` }, { id: 'decklist', label: 'Decklist' }, { id: 'description', label: $localize`:@@deck.page.tabDescription:Description` }, { id: 'main', label: $localize`:@@deck.page.tabHand:Main de départ` }, @@ -114,10 +118,6 @@ export class DeckPage { protected readonly legality = this.deck.legality; protected readonly legal = computed(() => this.legality().state === 'legal'); protected readonly groups = computed(() => (this.grouping() === 'type' ? this.deck.groups() : groupByCost(this.deck.lines()))); - protected readonly created = computed(() => { - const d = this.deck.createdAt(); - return d ? new Date(d).toLocaleDateString(uiLocale()) : ''; - }); protected readonly navItems = computed(() => [ { route: `/decks/${this.id()}`, icon: 'eye', label: $localize`:@@deck.page.navPreview:Aperçu` }, { route: `/decks/${this.id()}/deck`, icon: 'layers', label: 'Deck', badge: this.deck.total(), badgeTone: this.legal() ? 'success' : 'dark' }, @@ -142,23 +142,11 @@ export class DeckPage { }); } - /** The deck's image (« Copier en image »): a deck of the decks API by its id, a guest deck by its cards. */ - protected readonly imageSource = computed(() => { - const id = this.id(); - if (!GuestDeckService.isGuestId(id)) return deckImageSource(id); - const guest = this.guestDecks.get(id); - return guest ? deckImageSource(id, guest) : null; - }); + /** The deck's image (« Copier en image »). */ + protected readonly imageSource = computed(() => deckImageSourceFor(this.id(), this.guestDecks)); protected readonly factionLogo = computed(() => factionSrc(this.deck.hero()?.faction)); protected readonly factionLabel = computed(() => factionName(this.deck.hero()?.faction)); - protected readonly heroZoomLabel = computed(() => $localize`:@@ui.cardTile.zoom:Agrandir ${this.deck.hero()?.name ?? ''}:name:`); - - /** The hero banner: the hero card large, as on the site's deck page. */ - protected zoomHero(): void { - const hero = this.deck.hero(); - if (hero) openCardZoom(this.overlay, { card: { reference: hero.reference, name: hero.name, faction: { code: hero.faction, name: hero.faction }, cardType: { reference: 'HERO' } } }); - } protected setTab(id: string): void { const path = TAB_PATHS[id as DeckTab] ?? null; @@ -176,13 +164,7 @@ export class DeckPage { } protected async copyList(): Promise { - const text = decklistText(this.deck.lines(), this.deck.hero()); - try { - await navigator.clipboard.writeText(text); - this.flash($localize`:@@deck.page.listCopied:Liste copiée dans le presse-papiers.`); - } catch { - this.flash($localize`:@@deck.page.copyFailed:Copie impossible dans ce navigateur.`); - } + this.flash(await copyDecklist(this.deck)); } /** @@ -203,24 +185,15 @@ export class DeckPage { protected duplicate(): void { openDuplicateDeck(this.overlay).afterClosed.subscribe((id) => { if (!id) return; - this.flash($localize`:@@deck.page.duplicated:Deck dupliqué.`); - void this.router.navigate(['/decks', id]); + // The copy is the user's: it opens in the editor, on « Aperçu ». + void this.router.navigate(['/decks', id, 'edit', 'apercu']); }); } protected remove(): void { - openConfirm(this.overlay, { - title: $localize`:@@deck.page.deleteTitle:Supprimer le deck ?`, - message: $localize`:@@deck.page.deleteConfirm:« ${this.deck.name()}:name: » sera supprimé. Cette action est irréversible.`, - confirmLabel: $localize`:@@deck.page.deleteAction:Supprimer`, - icon: 'trash-2', - danger: true, - }).subscribe((confirmed) => { - if (!confirmed) return; - this.deck.delete().subscribe((ok) => { - if (ok) void this.router.navigateByUrl('/decks'); - else this.flash(this.deck.actionError() ?? $localize`:@@deck.page.deleteFailed:Suppression impossible.`); - }); + confirmDeleteDeck(this.overlay, this.deck).subscribe((result) => { + if (result === true) void this.router.navigateByUrl('/decks'); + else if (result) this.flash(result); }); } diff --git a/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.html b/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.html index d17dd1a1..51cb00bb 100644 --- a/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.html +++ b/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.html @@ -105,7 +105,7 @@

Decks

@for (d of list(); track d.id) { - + } @if (tab() === 'mine' && !bp.compact()) { -
- } -
@for (g of deck.groups(); track g.id) {
diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.scss b/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.scss index 90fabad2..89e2bac8 100644 --- a/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.scss +++ b/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.scss @@ -7,15 +7,6 @@ overflow: hidden; min-height: 0; } -.summary { - padding: var(--ac-space-4); - border-bottom: 1px solid var(--ac-color-border); - flex-shrink: 0; -} -.stats { - border-bottom: 1px solid var(--ac-color-border); - flex-shrink: 0; -} .rows { flex: 1 1 auto; min-height: 0; @@ -37,7 +28,3 @@ font-size: var(--ac-font-size-small); color: var(--ac-color-text-muted); } -/* Part of the Stats block, above its bottom border: space on both sides of the button. */ -.tokens { - padding: var(--ac-space-1) var(--ac-space-4) var(--ac-space-4); -} diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.ts b/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.ts index f4b5b6d7..9504d8b7 100644 --- a/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.ts +++ b/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.ts @@ -1,38 +1,20 @@ -import { Component, computed, inject, output, signal } from '@angular/core'; +import { Component, computed, inject } from '@angular/core'; import { DeckStore } from '../../../core/deck-store'; -import { formatInfo } from '../../../core/formats'; -import { AcCollapsible } from '../../../ui/containers'; -import { AcDeckRow, AcDeckStats, AcDeckSummary } from '../../../ui/metier'; -import { AcOverlayService } from '../../../ui/overlay'; +import { AcDeckRow } from '../../../ui/metier'; import { EditorAltArts } from '../editor-alt-arts'; -import { openTokenArts } from '../token-arts/token-arts.overlay'; -import { AcButton } from '../../../ui/buttons'; -import { editorLegality, lineIssues, openEditorLegality } from '../editor-legality'; +import { lineIssues } from '../editor-legality'; -/** Desktop right-hand panel: embedded summary, Stats, grouped rows with steppers. */ +/** Desktop right-hand panel of the editor: the deck's cards grouped by type, with steppers (the deck bar holds the rest). */ @Component({ selector: 'app-deck-panel', - imports: [AcButton, AcDeckSummary, AcCollapsible, AcDeckStats, AcDeckRow], + imports: [AcDeckRow], templateUrl: './deck-panel.html', styleUrl: './deck-panel.scss', }) export class DeckPanel { - /** Editor only (not the deck page): illustrations used more times than owned. */ + /** Illustrations used more times than owned. */ protected readonly altArts = inject(EditorAltArts, { optional: true }); protected readonly deck = inject(DeckStore); - private readonly overlay = inject(AcOverlayService); - readonly openSettings = output(); - protected readonly statsOpen = signal(true); - protected readonly formatLabel = computed(() => formatInfo(this.deck.format()).label); - protected readonly legality = computed(() => editorLegality(this.deck)); protected readonly issues = computed(() => lineIssues(this.deck)); protected readonly noIssues: readonly string[] = []; - - protected showLegality(): void { - openEditorLegality(this.overlay, this.deck); - } - - protected chooseTokenArts(): void { - openTokenArts(this.overlay); - } } diff --git a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html index dc9295a2..abe63b81 100644 --- a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html +++ b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html @@ -30,36 +30,70 @@
} @else {
-
- @if (!bp.compact()) { -
- -
- - -
-
-
-
-
- + @if (effectiveView() === 'apercu') { + @if (imageSource(); as source) { + + } + + @if (deck.editable()) { - + + } + } @else { + @if (imageSource()) { + } -
+ + } +
+ + +
+ @if (imageSource()) { + + +
+ } + + + @if (deck.editable()) { +
+ + }
+
+
+ + @if (effectiveView() === 'apercu' && deck.editable()) { + Survolez une pile pour changer le nombre d’exemplaires. + } + + @if (deck.editable()) { + + } + @if (altArts.canChooseTokens()) { + + }
- } +
+ } +
@if (deck.saveError(); as saveError) {
@if (!bp.compact()) { - @if (deckDrawer.open()) { -
- - + @let drawer = deckDrawer(); + @if (drawer.open()) { +
+ +
} @else { - @@ -131,7 +183,9 @@ @if (bp.compact()) { } - @if (toast(); as t) { + @if (notice(); as n) { + {{ n }} + } @else if (toast(); as t) { {{ t.name }} ×{{ t.quantity }} diff --git a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.scss b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.scss index bc9e15a9..b6e1fb30 100644 --- a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.scss +++ b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.scss @@ -83,73 +83,70 @@ ac-app-bar.static { /* ---------- medium and up ---------- */ @media (min-width: 768px) { + /* The deck bar and the modes row span the page; the main column and the deck panel share the row below. */ .editor { + flex-wrap: wrap; + row-gap: var(--ac-space-4); padding: var(--ac-space-6); align-items: flex-start; } - .main { + .top { + flex: 0 0 100%; + min-width: 0; + display: flex; + flex-direction: column; gap: var(--ac-space-4); } - .head-top { - display: flex; - align-items: center; - justify-content: space-between; + .main { + flex: 1 1 0; gap: var(--ac-space-4); - margin-bottom: calc(-1 * var(--ac-space-3)); } - .head-top .actions { + /* Deck bar actions: two columns of small buttons in « Aperçu », one row in the reduced bar. */ + .bar-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--ac-space-1-5); + } + + .bar-actions.row { display: flex; align-items: center; - gap: var(--ac-space-2); - flex-shrink: 0; } - .head { + .bar-actions > button[acButton] { + padding: 0 var(--ac-space-2-5); + } + + /* Modes, the board's hint, the save state, the tokens' illustrations. */ + .modes { display: flex; - align-items: flex-end; - justify-content: space-between; - gap: var(--ac-space-6); + align-items: center; + flex-wrap: wrap; + gap: var(--ac-space-3) var(--ac-space-6); } - /* « Recherche » sits next to « Voir le deck ». The label must stay whole: - the segmented control ellipsizes spans once a button is allowed to shrink. */ - .head ac-segmented { + /* The mode labels stay whole: the segmented control ellipsizes spans once a button may shrink. */ + .modes ac-segmented { flex-shrink: 0; } - .head ac-segmented ::ng-deep button { + .modes ac-segmented ::ng-deep button { min-width: max-content; overflow: visible; } - .head ac-segmented ::ng-deep button span { + .modes ac-segmented ::ng-deep button span { overflow: visible; text-overflow: clip; } - .title-block { - display: flex; - flex-direction: column; - gap: var(--ac-space-1); - min-width: 0; - } - - .title-row { - display: flex; - align-items: center; - gap: var(--ac-space-3); - min-width: 0; - } - - .title-row ac-editable-title { - min-width: 0; - } - - .title-row ac-save-status { - flex-shrink: 0; + .hint { + flex: 1 1 12rem; + font: var(--ac-font-small); + color: var(--ac-color-text-muted); } /* The deck panel and its « Masquer » handle, on the panel's left edge. */ @@ -158,11 +155,15 @@ ac-app-bar.static { flex-shrink: 0; position: sticky; top: calc(var(--ac-sticky-top) + var(--ac-space-6)); - height: calc(100dvh - var(--ac-sticky-top) - 2 * var(--ac-space-6)); + /* As tall as its cards, the window's height at most (then its list scrolls). */ + display: flex; + flex-direction: column; + max-height: calc(100dvh - var(--ac-sticky-top) - 2 * var(--ac-space-6)); } .panel app-deck-panel { - height: 100%; + flex: 0 1 auto; + min-height: 0; } /* Deck hidden: the tab takes its place, against the right edge of the page. */ diff --git a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.ts b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.ts index 1a40ffe9..4fae5475 100644 --- a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.ts +++ b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.ts @@ -1,5 +1,6 @@ import { Component, DestroyRef, ElementRef, Injector, afterNextRender, computed, effect, inject, signal, untracked } from '@angular/core'; import { LocationStrategy } from '@angular/common'; +import { CdkMenu, CdkMenuItem, CdkMenuTrigger } from '@angular/cdk/menu'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { map } from 'rxjs'; @@ -7,8 +8,8 @@ import { AuthSession } from '../../../core/auth-session'; import { DeckStore } from '../../../core/deck-store'; import { GuestDeckService } from '../../../core/guest-deck.service'; import { formatInfo } from '../../../core/formats'; -import { AcButton, AcIconButton } from '../../../ui/buttons'; -import { AcEditableTitle, AcSegmented } from '../../../ui/fields'; +import { AcButton, AcIconButton, MENU_POSITIONS } from '../../../ui/buttons'; +import { AcSegmented } from '../../../ui/fields'; import { AcIcon } from '../../../ui/icon'; import { AcDrawerHandle, AcDrawerState, AcDrawerTab, AcToast } from '../../../ui/containers'; import { storedFlag } from '../../../core/stored-flag'; @@ -16,7 +17,7 @@ import { contentLocale } from '../../../core/locale'; import { localizedText, type Card } from '../../../core/models'; import { AcSaveStatus } from '../../../ui/metier'; import { AcBreakpointService } from '../../../ui/layout.services'; -import { AcAppBar, AcBackButton, AcBottomNav, AcBreadcrumb, type AcBottomNavItem } from '../../../ui/nav'; +import { AcAppBar, AcBackButton, AcBottomNav, type AcBottomNavItem } from '../../../ui/nav'; import { AcOverlayService } from '../../../ui/overlay'; import { openDeckSettings } from '../../shared/deck-settings/deck-settings.overlay'; import { EditorAltArts } from '../editor-alt-arts'; @@ -28,7 +29,14 @@ import { DeckPreview } from '../deck-preview/deck-preview'; import { TestHand } from '../../deck/test-hand/test-hand'; import { HandStats } from '../../deck/hand-stats/hand-stats'; import { HandCalculators } from '../../deck/hand-calculators/hand-calculators'; -import { editorLegality } from '../editor-legality'; +import { editorLegality, openEditorLegality } from '../editor-legality'; +import { DeckBar } from '../../deck/deck-bar/deck-bar'; +import { DeckBoard } from '../../deck/deck-board/deck-board'; +import { DeckImageExport } from '../../deck/deck-image-export/deck-image-export'; +import { deckImageBusyMessage, runDeckImageAction, type DeckImageAction } from '../../deck/deck-image-actions'; +import { confirmDeleteDeck, copyDecklist, deckImageSourceFor } from '../../deck/deck-actions'; +import { openDuplicateDeck } from '../../deck/duplicate-deck/duplicate-deck.overlay'; +import { openTokenArts } from '../token-arts/token-arts.overlay'; import { openShareDeck } from '../../deck/share-deck/share-deck.overlay'; import { deckShareUrl } from '../../deck/deck-page/share-url'; import { openSignInToShare } from '../sign-in-to-share/sign-in-to-share.overlay'; @@ -49,8 +57,12 @@ export type EditorAction = 'share' | 'done'; AcAppBar, AcBackButton, AcBottomNav, - AcBreadcrumb, - AcEditableTitle, + CdkMenu, + CdkMenuItem, + CdkMenuTrigger, + DeckBar, + DeckBoard, + DeckImageExport, AcSegmented, AcButton, AcIconButton, @@ -78,9 +90,11 @@ export class EditorPage { private readonly overlay = inject(AcOverlayService); private readonly locationStrategy = inject(LocationStrategy); private readonly auth = inject(AuthSession); + private readonly guestDecks = inject(GuestDeckService); private readonly destroyRef = inject(DestroyRef); protected readonly bp = inject(AcBreakpointService); protected readonly deck = inject(DeckStore); + protected readonly altArts = inject(EditorAltArts); private readonly injector = inject(Injector); private readonly el = inject>(ElementRef).nativeElement; @@ -92,39 +106,51 @@ export class EditorPage { myDeck: $localize`:@@title.myDeck:Mon deck`, public: $localize`:@@editor.public:Public`, private: $localize`:@@editor.private:Privé`, - myDecks: $localize`:@@editor.myDecks:Mes decks`, - edit: $localize`:@@editor.edit:Modifier`, share: $localize`:@@editor.share:Partager`, saving: $localize`:@@editor.savingFirst:Enregistrement…`, saved: $localize`:@@editor.share.saved:Deck enregistré : le lien affiche la dernière version.`, savedToAccount: $localize`:@@editor.share.savedToAccount:Deck enregistré sur votre compte : vous pouvez le partager.`, + doneHint: $localize`:@@editor.doneHint:Retour à Mes decks`, + more: $localize`:@@editor.moreActions:Plus d’actions`, + imageBusy: deckImageBusyMessage(), }; - /** « Mes decks / / Modifier »: the deck's name leads to its page too. */ - protected readonly breadcrumb = computed(() => [ - { label: this.labels.myDecks, route: '/decks' }, - { label: this.deck.name() || 'Deck', route: `/decks/${this.id()}` }, - { label: this.labels.edit }, - ]); + /** The « ⋯ » menu: placed as the split buttons' menus. */ + protected readonly menuPositions = MENU_POSITIONS; protected readonly modeOptions = [ { value: 'search' as const, label: $localize`:@@editor.search:Recherche`, icon: 'search' as const }, - { value: 'apercu' as const, label: $localize`:@@editor.viewDeck:Voir le deck`, icon: 'eye' as const }, + { value: 'apercu' as const, label: $localize`:@@editor.preview:Aperçu`, icon: 'eye' as const }, { value: 'main' as const, label: $localize`:@@editor.hand:Main de départ`, icon: 'hand' as const }, ]; protected readonly formatLabel = computed(() => formatInfo(this.deck.format()).label); /** From 768 px: the deck panel can be hidden (a tab on the page edge brings it back), remembered in this browser. */ protected readonly deckOpen = storedFlag('rebuilder.editor.deckOpen', true); - protected readonly legal = computed(() => editorLegality(this.deck).state === 'legal'); + protected readonly legality = computed(() => editorLegality(this.deck)); + protected readonly legal = computed(() => this.legality().state === 'legal'); + /** Mini curve of the reduced bar: the cost of the last card added. */ + protected readonly highlight = computed(() => { + const change = this.deck.lastChange(); + return change && change.delta > 0 ? Math.min(6, Math.max(0, (change.card.mainCost ?? 1) - 1)) : null; + }); + /** The deck's image (« Copier en image »), once the deck is loaded. */ + protected readonly imageSource = computed(() => { + const id = this.deck.deckId(); + return id && !this.deck.loading() ? deckImageSourceFor(id, this.guestDecks) : null; + }); + protected readonly imageBusy = signal(false); protected readonly deckTabLabel = computed(() => this.legal() ? $localize`:@@editor.showDeckLegal:Afficher le deck : ${this.deck.total()}:count: cartes, valide` : $localize`:@@editor.showDeckIssues:Afficher le deck : ${this.deck.total()}:count: cartes, à corriger`, ); /** Hides or shows the deck panel, with its motion; the focus moves to the control that undoes it. */ - protected readonly deckDrawer = new AcDrawerState(this.deckOpen, 'end', (open) => + private readonly drawerFocus = (open: boolean) => afterNextRender(() => this.el.querySelector(open ? '.panel > .ac-drawer-handle' : '.deck-tab')?.focus(), { injector: this.injector, - }), - ); + }); + private readonly searchDrawer = new AcDrawerState(this.deckOpen, 'end', this.drawerFocus); + /** « Aperçu » shows the whole deck already: the panel starts hidden there, behind its tab. */ + private readonly apercuDrawer = new AcDrawerState(signal(false), 'end', this.drawerFocus); + protected readonly deckDrawer = computed(() => (this.effectiveView() === 'apercu' ? this.apercuDrawer : this.searchDrawer)); protected readonly subtitle = computed(() => `${this.formatLabel()} · ${this.deck.isPublic() ? this.labels.public : this.labels.private}`); protected readonly effectiveView = computed(() => (this.view() === 'deck' && !this.bp.compact() ? 'search' : this.view())); protected readonly base = computed(() => `/decks/${this.id()}/edit`); @@ -150,6 +176,9 @@ export class EditorPage { protected readonly toast = signal<{ card: Card; name: string; quantity: number; delta: number } | null>(null); protected readonly undoLabel = $localize`:@@editor.toast.undo:Annuler`; private toastTimer?: ReturnType; + /** A message of the deck bar's actions (copied, image unavailable…), over the card toast. */ + protected readonly notice = signal(null); + private noticeTimer?: ReturnType; /** The change « Annuler » makes is not announced again. */ private undone: unknown = null; @@ -208,7 +237,8 @@ export class EditorPage { this.stopped.set(action); return; } - if (action === 'done') void this.router.navigate(['/decks', this.id()]); + // From 768 px « Terminer » goes back to the deck list; on phones, to the deck's page. + if (action === 'done') void this.router.navigate(this.bp.compact() ? ['/decks', this.id()] : ['/decks']); else this.openShare(this.labels.saved); }); } @@ -259,6 +289,50 @@ export class EditorPage { void this.router.navigateByUrl(mode === 'apercu' || mode === 'main' ? `${this.base()}/${mode}` : this.base()); } + protected flash(message: string): void { + this.notice.set(message); + clearTimeout(this.noticeTimer); + this.noticeTimer = setTimeout(() => this.notice.set(null), 3200); + } + + protected showLegality(): void { + openEditorLegality(this.overlay, this.deck); + } + + protected chooseTokenArts(): void { + openTokenArts(this.overlay); + } + + /** The deck's image: copied, saved or opened (reduced bar: the button and its « ⋯ » menu). */ + protected async image(action: DeckImageAction): Promise { + const source = this.imageSource(); + if (!source || this.imageBusy()) return; + this.imageBusy.set(true); + const message = await runDeckImageAction(action, source, this.deck.name()); + this.imageBusy.set(false); + if (message) this.flash(message); + } + + protected async copyList(): Promise { + this.flash(await copyDecklist(this.deck)); + } + + /** The copy is the user's: it opens in the editor. */ + protected duplicate(): void { + openDuplicateDeck(this.overlay).afterClosed.subscribe((id) => { + if (!id) return; + this.flash($localize`:@@deck.page.duplicated:Deck dupliqué.`); + void this.router.navigate(['/decks', id, 'edit', 'apercu']); + }); + } + + protected remove(): void { + confirmDeleteDeck(this.overlay, this.deck).subscribe((result) => { + if (result === true) void this.router.navigateByUrl('/decks'); + else if (result) this.flash(result); + }); + } + protected openSettings(): void { openDeckSettings(this.overlay, { name: this.deck.name(), @@ -294,6 +368,6 @@ export class EditorPage { protected duplicateToGuest(): void { const id = this.deck.duplicateToGuest(this.deck.name()); - void this.router.navigate(['/decks', id, 'edit']); + void this.router.navigate(['/decks', id, 'edit', 'apercu']); } } diff --git a/plugins/rebuilder/app/src/app/features/search/card-search/card-search.html b/plugins/rebuilder/app/src/app/features/search/card-search/card-search.html index 42014da3..9893d3fe 100644 --- a/plugins/rebuilder/app/src/app/features/search/card-search/card-search.html +++ b/plugins/rebuilder/app/src/app/features/search/card-search/card-search.html @@ -1,16 +1,19 @@ @if (bp.compact()) {
- + +
+ +
@if (advancedOpen()) { -
- - -
+
+
+ + +
+
@if (!factionFilter()) {
@@ -122,21 +134,23 @@
} - @if (value().altArts && promoOptions().length) { -
- Éditions promo -
- @for (p of promoOptions(); track p.code) { - - } +
+ @if (value().altArts && promoOptions().length) { +
+ Éditions promo +
+ @for (p of promoOptions(); track p.code) { + + } +
+ } +
+ Sous-type +
- } -
- Sous-type - -
- + +
}
diff --git a/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.scss b/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.scss index 23350e1e..d6fe8c5c 100644 --- a/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.scss +++ b/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.scss @@ -111,3 +111,20 @@ .three ::ng-deep .label { font-weight: 600; } + +/* Fields the source cannot filter by (Favoris): in the flow as if there were no fieldset, disabled and faded. */ +.unsupported { + display: contents; +} +.unsupported:disabled > *, +.unsupported:disabled .adv-field { + opacity: 0.45; +} +.limited { + margin: 0; + padding: var(--ac-space-2) var(--ac-space-3); + border-radius: var(--ac-radius-control); + background: var(--ac-color-bg-subtle); + font: var(--ac-font-small); + color: var(--ac-color-text-2); +} diff --git a/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.ts b/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.ts index 78a7a081..80faa65f 100644 --- a/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.ts +++ b/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.ts @@ -42,6 +42,8 @@ export class FiltersPanel { all: $localize`:@@search.filters.all:Toutes`, }; protected readonly sets = computed(() => setsFor(this.source())); + /** Favoris: the site filters them by faction, set and rarity only; the other fields are disabled. */ + protected readonly limited = computed(() => this.source() === 'favorites'); protected readonly allSets = computed(() => this.sets().map((s) => s.reference)); protected readonly setsOpen = signal(false); protected readonly advancedOpen = signal(false); diff --git a/plugins/rebuilder/app/src/app/features/shared/deck-settings/deck-settings.overlay.html b/plugins/rebuilder/app/src/app/features/shared/deck-settings/deck-settings.overlay.html index 6d5d4d2a..38635b68 100644 --- a/plugins/rebuilder/app/src/app/features/shared/deck-settings/deck-settings.overlay.html +++ b/plugins/rebuilder/app/src/app/features/shared/deck-settings/deck-settings.overlay.html @@ -10,7 +10,7 @@
@if (hero(); as h) { - + } diff --git a/plugins/rebuilder/app/src/app/features/shared/deck-settings/deck-settings.overlay.ts b/plugins/rebuilder/app/src/app/features/shared/deck-settings/deck-settings.overlay.ts index c7224690..baf58f6b 100644 --- a/plugins/rebuilder/app/src/app/features/shared/deck-settings/deck-settings.overlay.ts +++ b/plugins/rebuilder/app/src/app/features/shared/deck-settings/deck-settings.overlay.ts @@ -1,4 +1,5 @@ -import { Component, inject, signal } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; +import { heroArtSources } from '../../../core/card-art'; import { BGA_LABEL, bgaTag, heroOnBga, visibleFormats, type FormatInfo } from '../../../core/formats'; import type { DeckFormat, DeckHero } from '../../../core/models'; import { AcButton } from '../../../ui/buttons'; @@ -44,6 +45,11 @@ export class DeckSettingsOverlay { protected readonly name = signal(this.ref.data.name); protected readonly description = signal(this.ref.data.description); protected readonly hero = signal(this.ref.data.hero); + /** The hero's illustration alone, without the card frame (the deck bar's art). */ + protected readonly heroArt = computed(() => { + const ref = this.hero()?.reference; + return ref ? heroArtSources(ref) : null; + }); protected readonly format = signal(this.ref.data.format); protected readonly isPublic = signal(this.ref.data.isPublic); diff --git a/plugins/rebuilder/app/src/app/ui/buttons/split-button/split-button.html b/plugins/rebuilder/app/src/app/ui/buttons/split-button/split-button.html index 4f11a757..b8101b3d 100644 --- a/plugins/rebuilder/app/src/app/ui/buttons/split-button/split-button.html +++ b/plugins/rebuilder/app/src/app/ui/buttons/split-button/split-button.html @@ -1,9 +1,9 @@
@@ -17,7 +17,7 @@ [attr.aria-label]="moreLabel()" (cdkMenuOpened)="opened(split)" > - +
diff --git a/plugins/rebuilder/app/src/app/ui/buttons/split-button/split-button.scss b/plugins/rebuilder/app/src/app/ui/buttons/split-button/split-button.scss index 15fdcc01..5499d02b 100644 --- a/plugins/rebuilder/app/src/app/ui/buttons/split-button/split-button.scss +++ b/plugins/rebuilder/app/src/app/ui/buttons/split-button/split-button.scss @@ -61,6 +61,17 @@ cursor: progress; } +:host(.sm) .split { + height: var(--ac-control-sm); +} +:host(.sm) .main { + gap: var(--ac-space-1-5); + font-size: var(--ac-font-size-small); +} +:host(.sm) .more { + width: var(--ac-control-sm); +} + .sep { width: 1px; background: var(--ac-color-border-control); diff --git a/plugins/rebuilder/app/src/app/ui/buttons/split-button/split-button.ts b/plugins/rebuilder/app/src/app/ui/buttons/split-button/split-button.ts index 16634048..33d60444 100644 --- a/plugins/rebuilder/app/src/app/ui/buttons/split-button/split-button.ts +++ b/plugins/rebuilder/app/src/app/ui/buttons/split-button/split-button.ts @@ -1,4 +1,4 @@ -import { Component, input, output, signal } from '@angular/core'; +import { Component, computed, input, output, signal } from '@angular/core'; import { CdkMenu, CdkMenuItem, CdkMenuTrigger } from '@angular/cdk/menu'; import type { ConnectedPosition } from '@angular/cdk/overlay'; import { AcIcon, type AcIconName } from '../../icon'; @@ -14,8 +14,8 @@ export interface AcSplitButtonItem { badge?: string; } -/** Below the button, right edges aligned; above when there is no room below. */ -const POSITIONS: ConnectedPosition[] = [ +/** A button's menu: below it, right edges aligned; above when there is no room below. */ +export const MENU_POSITIONS: ConnectedPosition[] = [ { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top', offsetY: 8 }, { originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'bottom', offsetY: -8 }, ]; @@ -28,6 +28,7 @@ const POSITIONS: ConnectedPosition[] = [ @Component({ selector: 'ac-split-button', imports: [AcIcon, CdkMenuTrigger, CdkMenu, CdkMenuItem], + host: { '[class.sm]': "size() === 'sm'" }, templateUrl: './split-button.html', styleUrl: './split-button.scss', }) @@ -35,6 +36,9 @@ export class AcSplitButton { readonly icon = input(undefined); /** Working: a spinner instead of the icon, both parts disabled (the label is the caller's, e.g. « Génération… »). */ readonly busy = input(false); + /** `sm`: the height and type of the small buttons (deck bar actions). */ + readonly size = input<'md' | 'sm'>('md'); + protected readonly iconSize = computed(() => (this.size() === 'sm' ? 16 : 18)); readonly items = input.required(); /** The arrow's aria-label. */ readonly moreLabel = input.required(); @@ -45,7 +49,7 @@ export class AcSplitButton { /** The whole button's width: the menu's minimum. */ protected readonly width = signal(0); - protected readonly positions = POSITIONS; + protected readonly positions = MENU_POSITIONS; protected opened(split: HTMLElement): void { this.width.set(split.offsetWidth); diff --git a/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.html b/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.html new file mode 100644 index 00000000..41dfdd63 --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.html @@ -0,0 +1,30 @@ + + @if (unique()) { + + } @else { + + } + + +@if (issues().length && quantity() > 0) { + +} +@if (quantity() > 1) { + +} +@if (!readonly()) { + +} diff --git a/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.scss b/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.scss new file mode 100644 index 00000000..0dd02ea4 --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.scss @@ -0,0 +1,116 @@ +:host { + /* Offset of each copy behind the front one: enough to count them over the cards' dark top edge. */ + --pile-offset: 16px; + position: relative; + display: block; + min-width: 0; +} +.stack { + position: relative; + display: block; + width: 100%; + padding: calc(var(--copies, 0) * var(--pile-offset)) 0 0; + border: 0; + background: none; + border-radius: var(--ac-radius-lg); + cursor: zoom-in; +} +.stack:focus-visible { + outline: 2px solid var(--ac-color-primary); + outline-offset: 2px; +} +.face { + display: block; + aspect-ratio: 5 / 7; + border-radius: var(--ac-radius-lg); + overflow: hidden; + box-shadow: var(--ac-shadow-card); + background: var(--ac-color-text-2); +} +.layer { + position: absolute; + left: 0; + right: 0; + top: calc(var(--i) * var(--pile-offset)); +} +.front { + position: relative; +} +:host(.zero) .stack { + opacity: 0.4; +} +:host(.issue) .front { + box-shadow: 0 0 0 3px var(--ac-color-warning); +} +.marker { + position: absolute; + left: var(--ac-space-1-5); + top: var(--ac-space-1-5); + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--ac-radius-pill); + background: var(--ac-color-warning); + color: var(--ac-color-on-strong); + box-shadow: var(--ac-shadow-e3); + pointer-events: auto; +} +.qty { + position: absolute; + right: var(--ac-space-1-5); + bottom: var(--ac-space-1-5); + min-width: 28px; + height: 24px; + padding: 0 var(--ac-space-1-5); + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--ac-radius-pill); + background: var(--ac-color-overlay-strong); + color: var(--ac-color-on-strong); + font: var(--ac-font-caption); + pointer-events: none; +} +/* The stepper: over the card while it is hovered or holds the focus (always shown on touch screens). */ +.stepper { + position: absolute; + left: 50%; + bottom: var(--ac-space-2); + transform: translateX(-50%); + opacity: 0; + pointer-events: none; + transition: opacity 0.12s; +} +:host(:focus-within) .stepper { + opacity: 1; + pointer-events: auto; +} +:host(.editable:focus-within) .qty { + opacity: 0; +} +@media (hover: hover) { + :host(:hover) .stepper { + opacity: 1; + pointer-events: auto; + } + :host(.editable:hover) .qty { + opacity: 0; + } +} +@media (hover: none) { + .stepper { + opacity: 1; + pointer-events: auto; + } + :host(.editable) .qty { + opacity: 0; + } +} +@media (prefers-reduced-motion: reduce) { + .stepper { + transition: none; + } +} diff --git a/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.ts b/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.ts new file mode 100644 index 00000000..42098a3b --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.ts @@ -0,0 +1,52 @@ +import { NgTemplateOutlet } from '@angular/common'; +import { Component, computed, input, output } from '@angular/core'; +import { isUniqueReference } from '../../../core/card-art'; +import { contentLocale } from '../../../core/locale'; +import type { Card } from '../../../core/models'; +import { localizedText } from '../../../core/models'; +import { AcStepper } from '../../buttons'; +import { AcIcon } from '../../icon'; +import { AcCardArt } from '../card-art/card-art'; +import { AcUniqueCard } from '../unique-card/unique-card'; + +/** + * A card of the deck board (« Aperçu »): its copies stacked, each one a few pixels lower, « ×n » from 2 copies. Editable: + * the « − n + » stepper over the card on hover or focus; at 0 the pile stays, faded, until the view is left. A line + * with legality issues gets an orange ring and marker. + */ +@Component({ + selector: 'ac-card-pile', + imports: [NgTemplateOutlet, AcCardArt, AcUniqueCard, AcStepper, AcIcon], + host: { + role: 'listitem', + '[class.zero]': 'quantity() === 0', + '[class.issue]': 'issues().length > 0 && quantity() > 0', + '[class.editable]': '!readonly()', + }, + templateUrl: './card-pile.html', + styleUrl: './card-pile.scss', +}) +export class AcCardPile { + readonly card = input.required(); + readonly quantity = input(1); + readonly max = input(3); + readonly readonly = input(true); + /** Why the line is not legal (the deck panel's tooltips). */ + readonly issues = input([]); + readonly quantityChange = output(); + /** The card was clicked: the page opens it large. */ + readonly zoom = output(); + + protected readonly name = computed(() => localizedText(this.card().name, contentLocale()) || this.card().reference); + protected readonly unique = computed(() => isUniqueReference(this.card().reference)); + /** Copies behind the front one (a pile at 0 shows one card). */ + protected readonly behind = computed(() => Array.from({ length: Math.max(0, Math.min(this.quantity(), 3) - 1) }, (_, i) => i)); + protected readonly zoomLabel = computed(() => { + const n = this.quantity(); + return n === 1 + ? $localize`:@@ui.cardPile.zoomOne:Agrandir ${this.name()}:name:, ${n}:count: exemplaire` + : $localize`:@@ui.cardPile.zoom:Agrandir ${this.name()}:name:, ${n}:count: exemplaires`; + }); + protected readonly copiesLabel = computed(() => $localize`:@@ui.cardTile.copiesInDeck:Exemplaires de ${this.name()}:name: dans le deck`); + protected readonly issueLabel = computed(() => this.issues().join(' · ')); +} diff --git a/plugins/rebuilder/app/src/app/ui/metier/cost-curve/cost-curve.html b/plugins/rebuilder/app/src/app/ui/metier/cost-curve/cost-curve.html new file mode 100644 index 00000000..a5183e2a --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/metier/cost-curve/cost-curve.html @@ -0,0 +1,28 @@ +@if (size() === 'md') { + +} + diff --git a/plugins/rebuilder/app/src/app/ui/metier/cost-curve/cost-curve.scss b/plugins/rebuilder/app/src/app/ui/metier/cost-curve/cost-curve.scss new file mode 100644 index 00000000..fefd35bb --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/metier/cost-curve/cost-curve.scss @@ -0,0 +1,92 @@ +:host { + display: flex; + align-items: flex-end; + gap: var(--ac-space-3); + flex-shrink: 0; +} +.legend { + display: flex; + flex-direction: column; + gap: var(--ac-space-1-5); + padding-bottom: var(--ac-space-4); +} +.key { + display: inline-flex; + align-items: center; + gap: var(--ac-space-1); + font: var(--ac-font-caption); + color: var(--ac-color-text-2); +} +.dot { + width: 8px; + height: 8px; + border-radius: 2px; +} +.main { + background: var(--ac-color-chart-main); +} +.reserve { + background: var(--ac-color-chart-reserve); +} +.buckets { + display: flex; + align-items: flex-end; + gap: var(--ac-space-2); +} +.bucket { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--ac-space-1); +} +.bars { + display: flex; + align-items: flex-end; + gap: 1px; + height: 62px; +} +.col { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} +.bar { + width: 9px; + border-radius: 2px 2px 0 0; +} +.n { + font: var(--ac-font-micro); + line-height: 1; + color: var(--ac-color-text-muted); +} +.n.zero { + color: var(--ac-color-text-disabled); +} +.axis { + font: var(--ac-font-caption); + color: var(--ac-color-text-2); +} + +/* Reduced bar: one row high, the bucket of the last card added outlined. */ +:host(.sm) .buckets { + gap: 2px; +} +:host(.sm) .bucket { + gap: 2px; + padding: 2px 3px; + border-radius: var(--ac-radius-xs); +} +:host(.sm) .bucket.on { + background: var(--ac-color-primary-soft); + box-shadow: 0 0 0 1px var(--ac-color-primary-border); +} +:host(.sm) .bars { + height: 32px; +} +:host(.sm) .bar { + width: 6px; +} +:host(.sm) .axis { + font: var(--ac-font-micro); +} diff --git a/plugins/rebuilder/app/src/app/ui/metier/cost-curve/cost-curve.ts b/plugins/rebuilder/app/src/app/ui/metier/cost-curve/cost-curve.ts new file mode 100644 index 00000000..e174d0a5 --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/metier/cost-curve/cost-curve.ts @@ -0,0 +1,45 @@ +import { Component, computed, input } from '@angular/core'; + +const AXIS = ['1', '2', '3', '4', '5', '6', '7+']; + +/** + * Main and reserve costs side by side, 1…7+, for the deck bar. `md`: legend and counts above the bars (deck bar); + * `sm`: bars and axis only, one row high (reduced bar), the `highlight` bucket outlined (cost of the last card added). + */ +@Component({ + selector: 'ac-cost-curve', + host: { role: 'img', '[attr.aria-label]': 'summary()', '[class.sm]': "size() === 'sm'" }, + templateUrl: './cost-curve.html', + styleUrl: './cost-curve.scss', +}) +export class AcCostCurve { + readonly main = input.required(); + readonly reserve = input.required(); + readonly size = input<'md' | 'sm'>('md'); + /** Bucket index (0…6) outlined, `null` for none. */ + readonly highlight = input(null); + + private readonly peak = computed(() => Math.max(1, ...this.main(), ...this.reserve())); + private readonly scale = computed(() => (this.size() === 'sm' ? 26 : 48)); + protected readonly buckets = computed(() => + AXIS.map((label, i) => { + const m = this.main()[i] ?? 0; + const r = this.reserve()[i] ?? 0; + return { + label, + m, + r, + mh: this.height(m), + rh: this.height(r), + title: $localize`:@@ui.costCurve.bucket:Coût ${label}:cost: : ${m}:main: en main, ${r}:reserve: en réserve`, + }; + }), + ); + protected readonly summary = computed( + () => `${$localize`:@@ui.costCurve.label:Courbe de coût : main et réserve`}. ${this.buckets().map((b) => b.title).join(' ; ')}`, + ); + + private height(v: number): number { + return v ? Math.max(3, Math.round((v / this.peak()) * this.scale())) : 2; + } +} diff --git a/plugins/rebuilder/app/src/app/ui/metier/deck-card/deck-card.ts b/plugins/rebuilder/app/src/app/ui/metier/deck-card/deck-card.ts index 56584bef..85602599 100644 --- a/plugins/rebuilder/app/src/app/ui/metier/deck-card/deck-card.ts +++ b/plugins/rebuilder/app/src/app/ui/metier/deck-card/deck-card.ts @@ -28,6 +28,8 @@ export class AcDeckCard { readonly deck = input.required(); readonly layout = input<'grid' | 'row'>('grid'); readonly variant = input<'mine' | 'community' | 'contest'>('mine'); + /** The card leads to the deck's editor, on « Aperçu », instead of its page (the user's own deck, from 768 px). */ + readonly openInEditor = input(false); readonly likeToggle = output(); /** « Non légal » badge clicked: the page opens the deck's rules window. */ readonly legalityClick = output(); @@ -35,7 +37,7 @@ export class AcDeckCard { protected readonly illegal = computed(() => this.deck().legality.state === 'illegal'); protected readonly showDraft = computed(() => this.variant() === 'mine' && this.deck().draft); protected readonly illegalLabel = computed(() => $localize`:@@ui.deckCard.illegalRules:Non légal : voir le détail de ${this.deck().name}:name:`); - protected readonly link = computed(() => ['/decks', this.deck().id]); + protected readonly link = computed(() => (this.openInEditor() ? ['/decks', this.deck().id, 'edit', 'apercu'] : ['/decks', this.deck().id])); protected readonly factionLogo = computed(() => factionSrc(this.deck().hero?.faction)); protected readonly factionLabel = computed(() => factionName(this.deck().hero?.faction)); protected readonly updatedLabel = computed(() => relativeTime(this.deck().updatedAt)); diff --git a/plugins/rebuilder/app/src/app/ui/metier/index.ts b/plugins/rebuilder/app/src/app/ui/metier/index.ts index bae3f456..0ad308ad 100644 --- a/plugins/rebuilder/app/src/app/ui/metier/index.ts +++ b/plugins/rebuilder/app/src/app/ui/metier/index.ts @@ -17,3 +17,5 @@ export * from './probability-bars/probability-bars'; export * from './save-status/save-status'; export * from './scroll'; export * from './unique-card/unique-card'; +export * from './card-pile/card-pile'; +export * from './cost-curve/cost-curve'; diff --git a/plugins/rebuilder/app/src/locale/messages.en.json b/plugins/rebuilder/app/src/locale/messages.en.json index cc80b9fd..39468659 100644 --- a/plugins/rebuilder/app/src/locale/messages.en.json +++ b/plugins/rebuilder/app/src/locale/messages.en.json @@ -96,13 +96,10 @@ "deck.page.cardCount": "{$START_TAG_STRONG}{$INTERPOLATION}{$CLOSE_TAG_STRONG} cards", "deck.page.groupBy": "Group by", "deck.page.noCards": "This deck has no cards yet.", - "deck.page.heroArt": "Hero art", - "deck.page.createdOn": "created on {$INTERPOLATION}", "deck.page.copyList": "Copy decklist", "deck.page.duplicate": "Duplicate", "deck.page.delete": "Delete", "deck.page.bottomNav": "Deck views", - "deck.page.tabCards": "Cards", "deck.page.byType": "By type", "deck.page.byCost": "By cost", "deck.page.public": "Public", @@ -197,8 +194,6 @@ "editor.bottomNav": "Deck editor", "editor.public": "Public", "editor.private": "Private", - "editor.myDecks": "My decks", - "editor.edit": "Edit", "editor.share": "Share", "editor.done": "Done", "editor.hideDeck": "Hide the deck", @@ -217,7 +212,6 @@ "editor.signInToShare.signIn": "Sign in", "core.deckStore.moveFailed": "Could not save the deck to your account (HTTP {$status}).", "editor.search": "Search", - "editor.viewDeck": "View deck", "editor.preview": "Preview", "editor.deck": "Deck", "search.store.timeout": "The search took too long. Narrow the filters (set, cost, effect) and try again.", @@ -752,6 +746,27 @@ "deck.image.open": "Open in a new tab", "deck.actions.imageCopy": "{$START_TAG_AC_ICON}{$CLOSE_TAG_AC_ICON}Copy as image", "deck.actions.imageSave": "{$START_TAG_AC_ICON}{$CLOSE_TAG_AC_ICON}Save the image", - "deck.actions.imageOpen": "{$START_TAG_AC_ICON}{$CLOSE_TAG_AC_ICON}Open the image in a new tab" + "deck.actions.imageOpen": "{$START_TAG_AC_ICON}{$CLOSE_TAG_AC_ICON}Open the image in a new tab", + "deck.bar.total": "{VAR_PLURAL, plural, =1 {1 card} other {{INTERPOLATION} cards}}", + "deck.bar.power": "Total power", + "deck.bar.illegalOne": "Not legal · {$count} problem", + "deck.bar.illegalMany": "Not legal · {$count} problems", + "deck.bar.legalDetails": "Legal: see the format rules", + "deck.board.uniques": "Uniques", + "deck.bar.edit": "Edit", + "deck.page.tabPreview": "Preview", + "editor.imageOpen": "Open the image in a new tab", + "editor.boardHint": "Hover a pile to change its number of copies.", + "editor.tokenArts.short": "Token illustrations", + "editor.doneHint": "Back to My decks", + "editor.moreActions": "More actions", + "ui.cardPile.zoomOne": "Enlarge {$name}, {$count} copy", + "ui.cardPile.zoom": "Enlarge {$name}, {$count} copies", + "ui.costCurve.title": "Cost curve", + "ui.costCurve.main": "Hand", + "ui.costCurve.reserve": "Reserve", + "ui.costCurve.bucket": "Cost {$cost}: {$main} in hand, {$reserve} in reserve", + "ui.costCurve.label": "Cost curve: hand and reserve", + "search.filters.favoritesLimited": "In favorites, only the faction, set and rarity filter the cards." } } diff --git a/plugins/rebuilder/e2e/rebuilder.spec.ts b/plugins/rebuilder/e2e/rebuilder.spec.ts index c462a7e7..12fa9b75 100644 --- a/plugins/rebuilder/e2e/rebuilder.spec.ts +++ b/plugins/rebuilder/e2e/rebuilder.spec.ts @@ -11,18 +11,20 @@ import { evidence, expect, login, setBeta, test, type Page } from '../../../test /** Labels in both site languages (the interface follows AlteredCore.lang). */ const LABELS = { fr: { - newDeck: 'Nouveau deck', deckName: 'Nom du deck', create: 'Créer le deck', search: 'Recherche', viewDeck: 'Voir le deck', cancel: 'Annuler', + newDeck: 'Nouveau deck', deckName: 'Nom du deck', create: 'Créer le deck', search: 'Recherche', viewDeck: 'Aperçu', cancel: 'Annuler', deckNav: 'Éditeur de deck', decksNav: 'Decks', myDecks: 'Mes decks', community: 'Communauté', communityDecks: 'Decks de la communauté', sortBy: 'Trier par', guest: 'Mode invité', rarity: 'Rareté', advanced: 'Recherche avancée', clearAll: 'Tout effacer', }, en: { - newDeck: 'New deck', deckName: 'Deck name', create: 'Create deck', search: 'Search', viewDeck: 'View deck', cancel: 'Cancel', + newDeck: 'New deck', deckName: 'Deck name', create: 'Create deck', search: 'Search', viewDeck: 'Preview', cancel: 'Cancel', deckNav: 'Deck editor', decksNav: 'Decks', myDecks: 'My decks', community: 'Community', communityDecks: 'Community decks', sortBy: 'Sort by', guest: 'Guest mode', rarity: 'Rarity', advanced: 'Advanced search', clearAll: 'Clear all', }, }; type Lang = keyof typeof LABELS; const FR = LABELS.fr; +/** The deck page's « Modifier » (deck bar, from 768 px) or « Modifier le deck » (app bar, phones). */ +const EDIT_DECK = /^Modifier( le deck)?$/; /** Opens the new-deck window, picks the first hero, names the deck and creates it. */ async function createDeck(page: Page, name: string, lang: Lang = 'fr'): Promise { @@ -38,7 +40,7 @@ async function createDeck(page: Page, name: string, lang: Lang = 'fr'): Promise< await expect(dialog).toBeHidden(); } -/** « Recherche / Voir le deck » switch (desktop) or bottom navigation (mobile), in `lang`. */ +/** « Recherche / Aperçu » switch (desktop) or bottom navigation (mobile), in `lang`. */ async function expectEditorLabels(page: Page, compact: boolean, lang: Lang): Promise { const l = LABELS[lang]; if (compact) await expect(page.getByRole('navigation', { name: l.deckNav }).getByRole('link', { name: l.search })).toBeVisible(); @@ -69,7 +71,7 @@ async function expectDeckCount(page: Page, compact: boolean, count: number): Pro if (compact) { await expect(page.getByRole('navigation', { name: FR.deckNav }).getByRole('link', { name: /Deck/ })).toContainText(String(count)); } else { - await expect(page.locator('app-deck-panel ac-deck-summary')).toContainText(String(count)); + await expect(page.locator('app-deck-bar .meta strong')).toHaveText(new RegExp(`^${count} cartes?$`)); } } @@ -121,22 +123,29 @@ test.describe('ReBuilder in the shell · signed in', () => { await expect(page).toHaveURL(at(EDITOR(deck.id))); await expectDeckCount(page, compact, 2); if (!compact) { - await expect(page.locator('ac-editable-title input')).toHaveValue(name); + await expect(page.locator('app-deck-bar h1')).toHaveText(name); for (const card of cards) await expect(page.locator('app-deck-panel')).toContainText(card.replace(/ ×.*$/, '')); } - // Re:Builder's list shows it (account decks through the relay); its card opens the deck page. + // Re:Builder's list shows it (account decks through the relay); its card opens the deck page (phones) or, from + // 768 px, the editor on « Aperçu »: the deck board. await page.goto(DECKS); const item = page.getByRole('list', { name: 'Mes decks' }).locator('ac-deck-card').filter({ hasText: name }); await expect(item).toBeVisible(); await evidence(page, testInfo, '03-listed'); await item.getByRole('link').first().click(); + if (!compact) { + await expect(page).toHaveURL(at(`${EDITOR(deck.id)}&view=apercu`)); + await expect(page.locator('app-deck-board ac-card-pile')).toHaveCount(2); + await evidence(page, testInfo, '04-editor-board'); + await page.goto(DECK(deck.id)); + } await expect(page).toHaveURL(at(DECK(deck.id))); await expect(page.locator('app-deck-page')).toContainText(name); await evidence(page, testInfo, '04-deck-page'); // Alice's own deck: she can edit it. const deckPage = page.locator('app-deck-page'); - await expect(deckPage.getByRole('button', { name: 'Modifier le deck' })).toBeVisible(); + await expect(deckPage.getByRole('button', { name: EDIT_DECK })).toBeVisible(); // Same decks API: with the beta off, the site's own list sees the deck too, and links to the same editor URL. await setBeta(page, false); @@ -182,7 +191,7 @@ test.describe('ReBuilder in the shell · signed in', () => { await login(page, 'alice', `${NEW_DECK}?lang=fr`); await createDeck(page, `E2E token arts ${Date.now()}`); if (compact) await page.getByRole('navigation', { name: FR.deckNav }).getByRole('link', { name: /Deck/ }).click(); - await page.getByRole('button', { name: 'Choisir les arts des jetons' }).click(); + await page.getByRole('button', { name: compact ? 'Choisir les arts des jetons' : 'Arts des jetons' }).click(); const dialog = page.getByRole('dialog', { name: 'Illustrations des jetons' }); const families = dialog.locator('app-alt-art-slots'); await expect(families.first()).toBeVisible(); @@ -208,7 +217,7 @@ test.describe('ReBuilder in the shell · signed in', () => { const deckPage = page.locator('app-deck-page'); await expect(deckPage).toContainText(name); - // Desktop: « Supprimer » in the side panel; mobile: in the « Plus d’actions » sheet. + // Desktop: « Supprimer » in the deck bar; mobile: in the « Plus d’actions » sheet. const askDelete = async () => { if (compact) { await deckPage.getByRole('button', { name: /^Plus d’actions/ }).click(); @@ -331,7 +340,7 @@ test.describe('ReBuilder in the shell · signed in', () => { const deckPage = page.locator('app-deck-page'); await expect(deckPage.locator('ac-card-art').first()).toBeVisible(); await expect(deckPage.getByRole('button', { name: /Plus d’actions|Dupliquer/ }).first()).toBeVisible(); - await expect(deckPage.getByRole('button', { name: 'Modifier le deck' })).toHaveCount(0); + await expect(deckPage.getByRole('button', { name: EDIT_DECK })).toHaveCount(0); await expect(deckPage.getByRole('button', { name: 'Supprimer' })).toHaveCount(0); await evidence(page, testInfo, '05b-community-deck'); }); @@ -534,7 +543,11 @@ test.describe('ReBuilder in the shell · Uniques search', () => { /** Checks `text` (exact) in the criterion `index` of the open effect window: its list stays open. */ const pick = async (index: number, text: string) => { const dialog = page.getByRole('dialog'); - await dialog.locator('.card-head').nth(index).click(); + // Anywhere on the criterion's card opens it, its values too (« Tous les déclencheurs »…), not only its title. + const card = dialog.locator('.card').nth(index); + const box = (await card.boundingBox())!; + await card.click({ position: { x: 24, y: box.height - 12 } }); + await expect(card.locator('.card-head')).toHaveAttribute('aria-pressed', 'true'); await dialog.locator('ac-check-list input[type=search]').fill(text); await dialog.getByRole('checkbox', { name: text, exact: true }).check(); await expect(dialog.locator('.card').nth(index).locator('ac-or-values .value').filter({ hasText: text })).toBeVisible(); @@ -869,9 +882,9 @@ test.describe('ReBuilder in the shell · deck page', () => { expect(res.status()).toBe(201); const copy = (await res.json()) as { id: string; isPublic: boolean }; expect(copy.isPublic).toBe(false); - await expect(page).toHaveURL(at(DECK(copy.id))); - await expect(deckPage).toContainText(`${name} bis`); - await expect(deckPage.getByRole('button', { name: 'Modifier le deck' }).first()).toBeVisible(); + // The copy is the user's: it opens in the editor, on « Aperçu ». + await expect(page).toHaveURL(at(`${EDITOR(copy.id)}&view=apercu`)); + await expect(page.locator('app-editor-page')).toContainText(`${name} bis`); await page.goto(DECKS); await expect(page.getByRole('list', { name: 'Mes decks' }).locator('ac-deck-card').filter({ hasText: `${name} bis` })).toBeVisible(); }); @@ -881,7 +894,8 @@ test.describe('ReBuilder in the shell · deck page', () => { const unique = 'ALT_ALIZE_B_AX_32_U_2'; await login(page, 'alice', `${DECKS}?lang=fr`); const id = await createServerDeck(page, `E2E unique art ${Date.now()}`, [unique]); - const tile = page.locator('ac-card-tile').filter({ has: page.locator('ac-unique-card') }); + // A pile of the deck board from 768 px, a tile on phones. + const tile = page.locator('ac-card-tile, ac-card-pile').filter({ has: page.locator('ac-unique-card') }); // The cards API answers once the illustration is shown: the deck then replaces the unique's card object. await page.route('**/api/cards/batch**', async (route) => { await expect(tile.locator('ac-card-art')).toHaveClass(/\bloaded\b/); @@ -962,7 +976,7 @@ test.describe('ReBuilder in the shell · « Partager » and « Terminer » in th const shareButton = (page: Page) => page.locator('app-editor-page').getByRole('button', { name: 'Partager', exact: true }); const doneButton = (page: Page) => page.locator('app-editor-page').getByRole('button', { name: 'Terminer', exact: true }); - test('saves the deck first, then shares it, then goes back to the deck page', async ({ page, compact }, testInfo) => { + test('saves the deck first, then shares it, then leaves the editor', async ({ page, compact }, testInfo) => { const name = `E2E share ${testInfo.project.name} ${Date.now()}`; await login(page, 'bob', `${DECKS}?lang=fr`); await page.getByRole('button', { name: FR.newDeck }).first().click(); @@ -987,10 +1001,15 @@ test.describe('ReBuilder in the shell · « Partager » and « Terminer » in th await page.keyboard.press('Escape'); await expect(dialog).toBeHidden(); + // « Terminer »: the deck list from 768 px, the deck's page on phones. await doneButton(page).click(); - await expect(page).toHaveURL(at(DECK(deck.id))); - await expect(page.locator('app-deck-page')).toContainText(name); - if (!compact) await expect(page.locator('app-deck-page').getByRole('button', { name: 'Modifier le deck' })).toBeVisible(); + if (compact) { + await expect(page).toHaveURL(at(DECK(deck.id))); + await expect(page.locator('app-deck-page')).toContainText(name); + } else { + await expect(page).toHaveURL(/\/pages\/decks(\?|$)/); + await expect(page.getByRole('list', { name: 'Mes decks' }).locator('ac-deck-card').filter({ hasText: name })).toBeVisible(); + } }); test('a guest signs in to share: the deck moves to the account and the share window opens', async ({ page }, testInfo) => { From 86141f875ffa932faed0d5296e2f2cb1de4f5874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89douard=20WILLISSECK?= Date: Tue, 6 Oct 2026 17:12:30 +0200 Subject: [PATCH 06/69] Re:Builder: check several cards or subtypes in a row, without reopening a list (#136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hand calculators (« Choisir… », A, B) and the subtype filter closed their list after each pick. ac-combobox gets a keepOpen mode, on for both: - Each option has a check box; a press checks or unchecks it and the list stays open. Checked values stay in the list, in bold. - Enter in the search field checks the first unchecked match and empties the field, as in the effect editor. - The chips go under the button, so checking never moves the button or the list under the pointer. - The listbox only gets the checked options it lists: the CDK listbox rejects a selected value it has no option for (one the search hides). Co-authored-by: Claude Opus 5.5 --- plugins/rebuilder/app/design/COMPONENTS.md | 2 +- .../hand-calculators/hand-calculators.html | 6 +- .../search/filters-panel/filters-panel.html | 2 +- .../src/app/ui/fields/combobox/combobox.html | 21 ++++-- .../src/app/ui/fields/combobox/combobox.scss | 23 +++++++ .../app/ui/fields/combobox/combobox.spec.ts | 65 +++++++++++++++++++ .../src/app/ui/fields/combobox/combobox.ts | 47 ++++++++++++-- plugins/rebuilder/e2e/rebuilder.spec.ts | 28 ++++++++ 8 files changed, 180 insertions(+), 14 deletions(-) diff --git a/plugins/rebuilder/app/design/COMPONENTS.md b/plugins/rebuilder/app/design/COMPONENTS.md index 69e9ce40..cb5dccb5 100644 --- a/plugins/rebuilder/app/design/COMPONENTS.md +++ b/plugins/rebuilder/app/design/COMPONENTS.md @@ -63,7 +63,7 @@ Classes et états des boutons : `design-system/docs/components/button.md`. | `ac-checkbox`, CSS DS (`field.css`, `ac-check`) | `label`, `checked = model(false)` | — | Légaux uniquement (feuille de filtres mobile, appliquée par « Appliquer ») | | `ac-check-list` | `options: {id, text, glyph?, group?}[]`, `values = model()`, `label` (nom accessible de la liste), `searchPlaceholder = 'Rechercher…'` ; champ de recherche (`--ac-control-md`) puis une case `ac-check` par option, dans l'ordre de `options` : cocher ou décocher ne déplace aucune ligne ; une valeur cochée va à la fin de `values` (ordre de saisie) ; Entrée dans la recherche coche le premier résultat non coché et vide le champ ; la liste prend la hauteur restante et défile | — | éditeur d'effet (colonne de droite en fenêtre, étape `ac-ability-picker` en compact) | | `ac-or-values` | `values = model()` (`{id, text, glyph?}`), `empty` (texte quand rien n'est choisi) ; une valeur par ligne, « ou » seul sur sa ligne entre deux valeurs, bouton « Retirer » (cible `--ac-hit-min`) sur chacune | — | éditeur d'effet (cartes des critères en fenêtre, critères en compact) | -| `ac-combobox` | `options: {id, text, glyph?, thumb?, group?}[]` (`thumb` : vignette dans la liste ; `group` : intertitre au-dessus des options consécutives d’un même groupe), `values = model()`, `placeholder`, `searchPlaceholder = 'Rechercher…'` | `searchChange` (texte saisi) | sous-types (filtres), calculateurs de la main de départ (vignettes, groupes par type). Le contrôle « Ajouter… » est un bouton (`role=combobox`) qui ouvre la liste dans un overlay CDK (`cdkConnectedOverlay` : largeur du bouton, sous le bouton ou au-dessus faute de place, fermeture au clic extérieur et sur Échap). La liste est un `cdkListbox` (flèches, Début / Fin, Entrée, saisie rapide, ARIA). Le champ de recherche n'est **pas focalisé** à l'ouverture (pas de clavier tactile tant qu'on ne le touche pas) ; une lettre sur le bouton démarre la recherche, Entrée dans le champ prend le premier résultat, une flèche passe dans la liste. Après un choix ou Échap, la liste se ferme et le focus revient au bouton. `glyph` : caractère `Altered Icons` avant le texte, dans la liste et sur la puce (main, réserve, partout). « ou » entre chaque valeur et avant le bouton « Ajouter… » dès qu'au moins une valeur est choisie ; rien si la liste est vide | +| `ac-combobox` | `options: {id, text, glyph?, thumb?, group?}[]` (`thumb` : vignette dans la liste ; `group` : intertitre au-dessus des options consécutives d’un même groupe), `values = model()`, `placeholder`, `searchPlaceholder = 'Rechercher…'`, `keepOpen = false` | `searchChange` (texte saisi) | sous-types (filtres), calculateurs de la main de départ (vignettes, groupes par type). Le contrôle « Ajouter… » est un bouton (`role=combobox`) qui ouvre la liste dans un overlay CDK (`cdkConnectedOverlay` : largeur du bouton, sous le bouton ou au-dessus faute de place, fermeture au clic extérieur et sur Échap). La liste est un `cdkListbox` (flèches, Début / Fin, Entrée, saisie rapide, ARIA). Le champ de recherche n'est **pas focalisé** à l'ouverture (pas de clavier tactile tant qu'on ne le touche pas) ; une lettre sur le bouton démarre la recherche, Entrée dans le champ prend le premier résultat, une flèche passe dans la liste. Après un choix ou Échap, la liste se ferme et le focus revient au bouton. `glyph` : caractère `Altered Icons` avant le texte, dans la liste et sur la puce (main, réserve, partout). « ou » entre chaque valeur et avant le bouton « Ajouter… » dès qu'au moins une valeur est choisie ; rien si la liste est vide. `keepOpen` (calculateurs, sous-types) : une case à cocher devant chaque option, un clic coche ou décoche sans fermer la liste, les valeurs cochées restent dans la liste (gras, couleur primaire) ; Entrée dans la recherche coche le premier résultat non coché et vide le champ ; les puces passent **sous** le bouton (« ou » entre elles seulement), pour que cocher ne déplace ni le bouton ni la liste | | `ac-editable-title` | `value = model()`, `ariaLabel` | — | titre du deck (desktop) | Classes et états des champs et du contrôle segmenté : `design-system/docs/components/field.md` et `segmented.md`. diff --git a/plugins/rebuilder/app/src/app/features/deck/hand-calculators/hand-calculators.html b/plugins/rebuilder/app/src/app/features/deck/hand-calculators/hand-calculators.html index ab3fbd4c..52ca1a8d 100644 --- a/plugins/rebuilder/app/src/app/features/deck/hand-calculators/hand-calculators.html +++ b/plugins/rebuilder/app/src/app/features/deck/hand-calculators/hand-calculators.html @@ -11,7 +11,7 @@

Calculateurs

Chances d’avoir

cartes parmi

- +

en piochant {{ drawn() }} cartes

@if (cardRatio(); as r) {

{{ r }}

@@ -23,11 +23,11 @@

Chances d’avoir au moins une de

parmi

- +
- +

en piochant {{ drawn() }} cartes

@if (comboRatio(); as r) { diff --git a/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.html b/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.html index 9edf2912..a59f25f9 100644 --- a/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.html +++ b/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.html @@ -147,7 +147,7 @@ }
Sous-type - +
diff --git a/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.html b/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.html index 06938e5b..175d3ddd 100644 --- a/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.html +++ b/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.html @@ -1,9 +1,14 @@ -@for (v of values(); track v.id) { +
@if (v.glyph) {}{{ v.text }}
- +
+@if (!keepOpen()) { + @for (v of values(); track v.id) { + + + } }
+@if (keepOpen()) { + @for (v of values(); track v.id) { + @if (!$first) { + + } + + } +}
@if (filtered().length) { -
+
@for (o of filtered(); track o.id) { @if (groupStarts().has(o.id)) { } -
@if (o.thumb) {}@if (o.glyph) {}{{ o.text }}
+
@if (keepOpen()) {}@if (o.thumb) {}@if (o.glyph) {}{{ o.text }}
}
} @else { diff --git a/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.scss b/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.scss index d4ece19d..4a308bd9 100644 --- a/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.scss +++ b/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.scss @@ -168,3 +168,26 @@ outline: 2px solid var(--ac-color-primary); outline-offset: -2px; } +/* `keepOpen`: a check box before each option, filled when the value is checked. */ +.box { + width: 18px; + height: 18px; + margin-right: var(--ac-space-2-5); + border: 1.5px solid var(--ac-color-border-control); + border-radius: var(--ac-radius-sm); + background: var(--ac-color-surface); + color: transparent; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.option[aria-selected='true'] { + font-weight: 700; + color: var(--ac-color-primary-strong); +} +.option[aria-selected='true'] .box { + border-color: var(--ac-color-primary); + background: var(--ac-color-primary); + color: var(--ac-color-on-primary); +} diff --git a/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.spec.ts b/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.spec.ts index f50079fb..107b8990 100644 --- a/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.spec.ts +++ b/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.spec.ts @@ -203,6 +203,71 @@ describe('AcCombobox picker', () => { }); }); +describe('AcCombobox keepOpen', () => { + const panel = () => document.querySelector('.cdk-overlay-container .panel'); + const options = () => [...(panel()?.querySelectorAll('[role=option]') ?? [])]; + + function setup(values: ComboOption[] = []) { + const fixture = TestBed.createComponent(AcCombobox); + fixture.componentRef.setInput('options', [MAIN, EXPEDITION]); + fixture.componentRef.setInput('values', values); + fixture.componentRef.setInput('keepOpen', true); + fixture.detectChanges(); + const el = fixture.nativeElement as HTMLElement; + document.body.appendChild(el); + el.querySelector('.field [role=combobox]')!.click(); + fixture.detectChanges(); + const done = () => { + fixture.destroy(); + el.remove(); + }; + return { fixture, el, done }; + } + + it('checks and unchecks several values without closing the list, chips under the button', () => { + const { fixture, el, done } = setup(); + options()[1].click(); + fixture.detectChanges(); + options()[0].click(); + fixture.detectChanges(); + expect(panel()).not.toBeNull(); + expect(fixture.componentInstance.values()).toEqual([EXPEDITION, MAIN]); + expect(options().map((o) => o.getAttribute('aria-selected'))).toEqual(['true', 'true']); + expect(sequence(el)).toEqual(['field', `chip:${EXPEDITION.text}`, 'ou', `chip:${MAIN.text}`]); + + options()[1].click(); + fixture.detectChanges(); + expect(fixture.componentInstance.values()).toEqual([MAIN]); + expect(options().map((o) => o.getAttribute('aria-selected'))).toEqual(['true', 'false']); + done(); + }); + + it('Enter in the search checks the first unchecked match, empties the field and stays open', () => { + const { fixture, done } = setup([MAIN]); + const search = panel()!.querySelector('input')!; + search.value = 'e'; // both match, MAIN is already checked + search.dispatchEvent(new Event('input')); + fixture.detectChanges(); + search.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + fixture.detectChanges(); + expect(fixture.componentInstance.values()).toEqual([MAIN, EXPEDITION]); + expect(search.value).toBe(''); + expect(panel()).not.toBeNull(); + done(); + }); + + it('a checked value the search hides stays checked', () => { + const { fixture, done } = setup([MAIN]); + const search = panel()!.querySelector('input')!; + search.value = 'expé'; + search.dispatchEvent(new Event('input')); + fixture.detectChanges(); + expect(options().map((o) => o.textContent?.trim())).toEqual([EXPEDITION.text]); + expect(fixture.componentInstance.values()).toEqual([MAIN]); + done(); + }); +}); + /** The component and its CDK overlay container in a shadow root, as in the plugin (`ShadowOverlayContainer`). */ const shadowHost = document.createElement('div'); const shadowRoot = shadowHost.attachShadow({ mode: 'open' }); diff --git a/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.ts b/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.ts index aab3fea1..cceab656 100644 --- a/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.ts +++ b/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.ts @@ -1,7 +1,8 @@ import { CdkListbox, CdkOption, type ListboxValueChangeEvent } from '@angular/cdk/listbox'; import { CdkConnectedOverlay, CdkOverlayOrigin, createRepositionScrollStrategy, type ConnectedPosition } from '@angular/cdk/overlay'; import { _getEventTarget } from '@angular/cdk/platform'; -import { Component, ElementRef, Injector, afterNextRender, computed, inject, input, model, output, signal, viewChild } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import { Component, ElementRef, booleanAttribute, Injector, afterNextRender, computed, inject, input, model, output, signal, viewChild } from '@angular/core'; import { AcIcon } from '../../icon'; import { groupStarts, normalizeSearch } from '../option-list'; import { nextId } from '../value-accessor'; @@ -29,10 +30,14 @@ const POSITIONS: ConnectedPosition[] = [ * field and a CDK listbox (arrows, Home / End, Enter, typeahead, ARIA). Opening does not focus the * search field, so touch keyboards stay closed until the user taps it; typing on the button moves * the key into it. + * + * `keepOpen`: several values in a row (calculators, subtypes). Each option has a check box, a press checks or + * unchecks it and the list stays open; Enter in the search field checks the first unchecked match and + * empties the field. The chips go under the button, so checking never moves the button or the list. */ @Component({ selector: 'ac-combobox', - imports: [AcIcon, CdkOverlayOrigin, CdkConnectedOverlay, CdkListbox, CdkOption], + imports: [AcIcon, NgTemplateOutlet, CdkOverlayOrigin, CdkConnectedOverlay, CdkListbox, CdkOption], templateUrl: './combobox.html', styleUrl: './combobox.scss', }) @@ -42,6 +47,8 @@ export class AcCombobox { readonly values = model([]); readonly placeholder = input($localize`:@@ui.combobox.add:Ajouter…`); readonly searchPlaceholder = input($localize`:@@ui.combobox.search:Rechercher…`); + /** Check several values without the list closing; checked values stay in the list. */ + readonly keepOpen = input(false, { transform: booleanAttribute }); readonly searchChange = output(); private readonly toggle = viewChild.required>('toggle'); @@ -61,12 +68,22 @@ export class AcCombobox { protected readonly query = signal(''); protected readonly open = signal(false); + /** + * Selection of the listbox: with `keepOpen`, the checked options among those listed (the listbox + * rejects a value it has no option for, such as one the search hides); none otherwise. + */ + protected readonly listValue = computed(() => { + if (!this.keepOpen()) return this.noSelection; + const checked = new Set(this.values().map((v) => v.id)); + return this.filtered().filter((o) => checked.has(o.id)); + }); + /** Where a group heading goes: the first listed option of each group. */ protected readonly groupStarts = computed(() => groupStarts(this.filtered())); protected readonly filtered = computed(() => { const q = normalizeSearch(this.query()); - const chosen = new Set(this.values().map((v) => v.id)); + const chosen = this.keepOpen() ? new Set() : new Set(this.values().map((v) => v.id)); const out: ComboOption[] = []; for (const o of this.options()) { if (chosen.has(o.id)) continue; @@ -83,11 +100,23 @@ export class AcCombobox { this.close(); } + /** `keepOpen`: a checked value goes to the end of `values`, the order the user picked them in. */ + setChecked(o: ComboOption, on: boolean): void { + this.values.update((list) => { + const without = list.filter((v) => v.id !== o.id); + return on ? [...without, o] : without; + }); + } + remove(o: ComboOption): void { this.values.update((v) => v.filter((x) => x.id !== o.id)); } protected onSelect(e: ListboxValueChangeEvent): void { + if (this.keepOpen()) { + if (e.option) this.setChecked(e.option.value, e.option.isSelected()); + return; + } const o = e.value[0]; if (o) this.pick(o); } @@ -111,15 +140,23 @@ export class AcCombobox { } } - /** In the search field: arrows go to the list, Enter picks the first match. */ + /** In the search field: arrows go to the list, Enter picks (checks, with `keepOpen`) the first match. */ protected onSearchKey(e: KeyboardEvent): void { if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); this.focusList(); } else if (e.key === 'Enter') { e.preventDefault(); + if (!this.query()) return; + if (this.keepOpen()) { + const checked = new Set(this.values().map((v) => v.id)); + const first = this.filtered().find((o) => !checked.has(o.id)); + if (first) this.setChecked(first, true); + this.query.set(''); + return; + } const first = this.filtered()[0]; - if (first && this.query()) this.pick(first); + if (first) this.pick(first); } } diff --git a/plugins/rebuilder/e2e/rebuilder.spec.ts b/plugins/rebuilder/e2e/rebuilder.spec.ts index 12fa9b75..71cf685b 100644 --- a/plugins/rebuilder/e2e/rebuilder.spec.ts +++ b/plugins/rebuilder/e2e/rebuilder.spec.ts @@ -673,6 +673,30 @@ test.describe('ReBuilder in the shell · Uniques search', () => { }); }); +test.describe('ReBuilder in the shell · card filters', () => { + test('checks several subtypes in a row: the list stays open', async ({ page, compact }, testInfo) => { + await page.goto(`${NEW_DECK}?lang=fr`); + await createDeck(page, `E2E sous-types ${Date.now()}`); + await expect(page.locator('ac-card-tile').first()).toBeVisible(); + if (compact) await page.getByRole('button', { name: /^Filtres/ }).first().click(); + const filters = compact ? page.getByRole('dialog', { name: 'Filtres' }) : page.getByRole('complementary', { name: 'Filtres' }); + await filters.getByRole('button', { name: 'Recherche avancée' }).click(); + const subtypes = filters.locator('ac-combobox'); + await subtypes.getByRole('combobox', { name: 'Ajouter…' }).click(); + const options = page.getByRole('listbox', { name: 'Ajouter…' }).getByRole('option'); + await options.nth(0).click(); + await options.nth(1).click(); + await expect(page.getByRole('listbox', { name: 'Ajouter…' }).getByRole('option', { selected: true })).toHaveCount(2); + await expect(subtypes.locator('.picked')).toHaveCount(2); + await evidence(page, testInfo, '12-subtypes'); + // The button closes the list; the two subtypes make one filter. + await subtypes.getByRole('combobox', { name: 'Ajouter…' }).click(); + await expect(page.getByRole('listbox', { name: 'Ajouter…' })).toBeHidden(); + if (compact) await filters.getByRole('button', { name: 'Rechercher' }).click(); + await expect(page.getByText('2 sous-types').first()).toBeVisible(); + }); +}); + test.describe('ReBuilder in the shell · Starter Deck Contest', () => { test('lists the contest winners, then every entry, with the site filters', async ({ page }, testInfo) => { await page.goto(`${DECKS}?tab=contest&lang=fr`); @@ -832,9 +856,13 @@ test.describe('ReBuilder in the shell · deck page', () => { await expect(deckPage.locator('app-hand-stats')).toContainText('Démarrage optimal'); const calc = deckPage.locator('app-hand-calculators'); await calc.getByRole('combobox', { name: 'Choisir…', exact: true }).click(); + // The list stays open: several cards are checked in a row. await page.getByRole('option').first().click(); + await page.getByRole('option').nth(1).click(); + await expect(page.getByRole('option', { selected: true })).toHaveCount(2); await expect(calc.locator('ac-probability-bars').first()).toContainText('%'); await evidence(page, testInfo, '23-deck-test-hand'); + await page.keyboard.press('Escape'); // Game mode (the site's playground, desktop only): 3 cards to mana, then a card played from its menu. if (!compact) { await deckPage.getByRole('button', { name: 'Mode jeu' }).click(); From 7d501f165a6cd66ca6e52f78a18776063feb7347 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89douard=20WILLISSECK?= Date: Tue, 6 Oct 2026 17:50:27 +0200 Subject: [PATCH 07/69] =?UTF-8?q?Re:Builder:=20rarity=20caps=20in=20the=20?= =?UTF-8?q?deck=20bar,=20=C2=AB=20Mes=20decks=20=C2=BB=20instead=20of=20?= =?UTF-8?q?=C2=AB=20Terminer=20=C2=BB,=20physical=20collection=20fixed=20(?= =?UTF-8?q?#137)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Re:Builder: « Mes decks » instead of « Terminer », hero settings in the search bar, collection search with several card types - The editor's « Terminer » goes: « ‹ Mes decks » of the deck bar leaves the editor (shown in the reduced bar too), the deck is saved on the way out as before. - The reduced deck bar (Recherche, Main de départ) keeps the settings button on the hero art: the hero can be changed without going back to « Aperçu ». - The collection API fails (HTTP 500) on several cardType[]: the proxy sends the card type only when there is one and filters several itself. Re:Builder sends four types by default, so its « Collection physique » tab always failed on preprod. Co-Authored-By: Claude Opus 5.5 * Re:Builder: Rares, Exalteds and Uniques against the format's caps in the deck bar Back from the deck panel's summary removed in #135, with the caps the site's builder shows: one badge per capped rarity (12/15), grey below the cap, green at it, red above. Under « Puissance totale » in the full bar, a column of its own in the reduced one; a rarity without a cap (Singleton, Sandbox) is left out. The reduced bar drops its mini curve from 1320 px, the terrain totals from 1060 px and the rarities from 900 px; the full bar keeps the rarities when the terrain totals go. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../api/collection-search.php | 6 +++- .../includes/collection-query.php | 23 +++++++++++---- .../app/src/app/core/deck-rules.spec.ts | 24 ++++++++++++++- .../rebuilder/app/src/app/core/deck-rules.ts | 18 ++++++++++++ .../app/features/deck/deck-bar/deck-bar.html | 16 ++++++---- .../app/features/deck/deck-bar/deck-bar.scss | 29 +++++++++++++++++-- .../app/features/deck/deck-bar/deck-bar.ts | 7 +++-- .../editor/editor-page/editor.page.html | 12 -------- .../editor/editor-page/editor.page.scss | 6 +--- .../editor/editor-page/editor.page.ts | 11 +++---- .../rebuilder/app/src/app/ui/metier/index.ts | 1 + .../metier/rarity-limits/rarity-limits.html | 3 ++ .../metier/rarity-limits/rarity-limits.scss | 11 +++++++ .../ui/metier/rarity-limits/rarity-limits.ts | 29 +++++++++++++++++++ .../rebuilder/app/src/locale/messages.en.json | 6 ++-- plugins/rebuilder/e2e/rebuilder.spec.ts | 16 +++++----- tests/CollectionQueryTest.php | 7 +++-- 17 files changed, 171 insertions(+), 54 deletions(-) create mode 100644 plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.html create mode 100644 plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.scss create mode 100644 plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.ts diff --git a/plugins/core-altered-cards/api/collection-search.php b/plugins/core-altered-cards/api/collection-search.php index 60391ef3..20cff90a 100644 --- a/plugins/core-altered-cards/api/collection-search.php +++ b/plugins/core-altered-cards/api/collection-search.php @@ -25,7 +25,11 @@ exit; } -$items = is_array($data) ? $data : []; +$items = is_array($data) ? $data : []; +$types = cacCollectionLocalTypes($_GET); +if ($types !== null) { + $items = array_values(array_filter($items, fn($it) => is_array($it) && in_array($it['cardType'] ?? '', $types, true))); +} $total = count($items); $perPage = max(1, (int)($_GET['itemsPerPage'] ?? 30)); $page = max(1, (int)($_GET['page'] ?? 1)); diff --git a/plugins/core-altered-cards/includes/collection-query.php b/plugins/core-altered-cards/includes/collection-query.php index b492f811..9f3ac0f3 100644 --- a/plugins/core-altered-cards/includes/collection-query.php +++ b/plugins/core-altered-cards/includes/collection-query.php @@ -3,19 +3,19 @@ /** * List filters go out as `key[]=v` (the API's array form): a repeated `key=v` keeps the last value only, so a - * search on three rarities or several sets came back with one. + * search on three rarities or several sets came back with one. The API fails (HTTP 500) on several card types: + * those go out only when there is one, the proxy filters the others itself (cacCollectionLocalTypes). */ function cacCollectionQuery(array $get): string { - $arrays = ['faction', 'rarity', 'cardType', 'variation', 'cardSet']; + $arrays = ['faction', 'rarity', 'variation', 'cardSet']; + if (count(cacCollectionValues($get, 'cardType')) === 1) $arrays[] = 'cardType'; $scalar = ['isFoil', 'isBanned', 'isSuspended', 'cardReference', 'name', 'subTypes', 'locale']; $range = ['mainCost', 'recallCost', 'oceanPower', 'mountainPower', 'forestPower']; $parts = []; foreach ($arrays as $k) { - foreach ((array)($get[$k] ?? []) as $v) { - if (is_string($v) && $v !== '') $parts[] = $k . '[]=' . rawurlencode($v); - } + foreach (cacCollectionValues($get, $k) as $v) $parts[] = $k . '[]=' . rawurlencode($v); } foreach ($scalar as $k) { $v = $get[$k] ?? null; @@ -31,3 +31,16 @@ function cacCollectionQuery(array $get): string } return implode('&', $parts); } + +/** The card types the proxy keeps itself (several of them, see cacCollectionQuery); `null` when the API filters. */ +function cacCollectionLocalTypes(array $get): ?array +{ + $types = cacCollectionValues($get, 'cardType'); + return count($types) > 1 ? $types : null; +} + +/** The non-empty string values of a list filter. */ +function cacCollectionValues(array $get, string $key): array +{ + return array_values(array_filter((array)($get[$key] ?? []), fn($v) => is_string($v) && $v !== '')); +} diff --git a/plugins/rebuilder/app/src/app/core/deck-rules.spec.ts b/plugins/rebuilder/app/src/app/core/deck-rules.spec.ts index 9bb22f32..177e1195 100644 --- a/plugins/rebuilder/app/src/app/core/deck-rules.spec.ts +++ b/plugins/rebuilder/app/src/app/core/deck-rules.spec.ts @@ -1,4 +1,4 @@ -import { DECK_SIZE, addBlockedReason, allowedInFormat, computeDeckStatus, heroKey, maxCopiesFor, rarityCountsFromRefs, uniqueLimit } from './deck-rules'; +import { DECK_SIZE, addBlockedReason, allowedInFormat, computeDeckStatus, heroKey, maxCopiesFor, rarityCountsFromRefs, rarityLimits, uniqueLimit } from './deck-rules'; import { formatInfo } from './formats'; import type { Card, HydratedLine } from './models'; import { rarityFromReference } from './models'; @@ -213,3 +213,25 @@ describe('allowedInFormat (Favoris « légales »)', () => { expect(allowedInFormat(card('ALT_COREKS_B_AX_04_U_3'), 'frontier')).toBe(true); }); }); + +describe('rarityLimits', () => { + const line = (reference: string, quantity: number): HydratedLine => ({ quantity, card: { reference, name: reference, cardType: { reference: 'SPELL' } } }); + const lines = [line('ALT_CORE_B_AX_20_R1', 3), line('ALT_CORE_B_AX_21_R1', 2), line('ALT_CORE_B_AX_22_C', 3), line('ALT_EOLE_B_AX_109_U_374', 1)]; + + it('counts Rares, Exalteds and Uniques against the format caps', () => { + expect(rarityLimits(computeDeckStatus(lines, 'standard'), 'standard')).toEqual([ + { key: 'R', count: 5, limit: 15 }, + { key: 'E', count: 0, limit: 3 }, + { key: 'U', count: 1, limit: 3 }, + ]); + }); + + it('leaves out a rarity without a cap', () => { + expect(rarityLimits(computeDeckStatus(lines, 'singleton'), 'singleton').map((r) => r.key)).toEqual(['U']); + expect(rarityLimits(computeDeckStatus(lines, 'sandbox'), 'sandbox')).toEqual([]); + }); + + it('No Unique: 0 Uniques allowed', () => { + expect(rarityLimits(computeDeckStatus(lines, 'nuc'), 'nuc').find((r) => r.key === 'U')).toEqual({ key: 'U', count: 1, limit: 0 }); + }); +}); diff --git a/plugins/rebuilder/app/src/app/core/deck-rules.ts b/plugins/rebuilder/app/src/app/core/deck-rules.ts index 58b9ee62..704a5528 100644 --- a/plugins/rebuilder/app/src/app/core/deck-rules.ts +++ b/plugins/rebuilder/app/src/app/core/deck-rules.ts @@ -294,3 +294,21 @@ export function rarityCountsFromRefs( } return out; } + +/** A rarity the format caps: its copies in the deck and the cap. */ +export interface RarityLimit { + key: 'R' | 'E' | 'U'; + count: number; + limit: number; +} + +/** Rares, Exalteds and Uniques against the format's caps (the hero's for Uniques); a rarity without a cap is left out. */ +export function rarityLimits(status: DeckStatus, format: DeckFormat): RarityLimit[] { + const limits = formatInfo(format); + const all: { key: RarityLimit['key']; count: number; limit: number | null }[] = [ + { key: 'R', count: status.rare, limit: limits.rareMax }, + { key: 'E', count: status.exalted, limit: limits.exaltedMax }, + { key: 'U', count: status.unique, limit: status.uniqueMax }, + ]; + return all.filter((r): r is RarityLimit => r.limit !== null); +} diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.html b/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.html index 70708d09..1885b9a7 100644 --- a/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.html +++ b/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.html @@ -9,16 +9,14 @@ } } - @if (editable() && !reduced()) { + @if (editable()) { }
- @if (!reduced()) { - Mes decks - } + Mes decks
@if (renaming()) { {{ deck.name() }} +@if (reduced() && rarityLimits().length) { + + +}
@if (!reduced()) { - Puissance totale + Puissance totale } + @if (!reduced() && rarityLimits().length) { + Raretés + + }
@if (reduced()) { diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.scss b/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.scss index 28991a80..9d5c3d1e 100644 --- a/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.scss +++ b/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.scss @@ -197,6 +197,12 @@ :host(.reduced) .art { flex-basis: 96px; } +:host(.reduced) .settings { + right: var(--ac-space-1-5); + bottom: var(--ac-space-1-5); + width: 28px; + height: 28px; +} :host(.reduced) .text { padding: var(--ac-space-2) var(--ac-space-3); } @@ -214,20 +220,39 @@ display: none; } } +/* Then the terrain totals; the rarities under them stay. */ @container deck-bar (max-width: 960px) { + :host(:not(.reduced)) .terrain .power, + :host(:not(.reduced)) ac-terrain-totals { + display: none; + } + :host(:not(.reduced)) .terrain:not(:has(ac-rarity-limits)), + :host(:not(.reduced)) .sep:not(:has(+ .terrain ac-rarity-limits)) { + display: none; + } +} +/* Then the rarities. */ +@container deck-bar (max-width: 860px) { :host(:not(.reduced)) .terrain, :host(:not(.reduced)) .sep { display: none; } } -@container deck-bar (max-width: 1000px) { +/* Reduced bar: the curve, then the terrain totals, then the rarities. */ +@container deck-bar (max-width: 1320px) { :host(.reduced) ac-cost-curve, :host(.reduced) .sep.curve { display: none; } } -@container deck-bar (max-width: 840px) { +@container deck-bar (max-width: 1060px) { :host(.reduced) .terrain, + :host(.reduced) .sep:has(+ .terrain) { + display: none; + } +} +@container deck-bar (max-width: 900px) { + :host(.reduced) ac-rarity-limits, :host(.reduced) .sep { display: none; } diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.ts b/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.ts index 99a3d25e..d7eabfaa 100644 --- a/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.ts +++ b/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.ts @@ -3,12 +3,13 @@ import { NgTemplateOutlet } from '@angular/common'; import { RouterLink } from '@angular/router'; import { heroArtSources } from '../../../core/card-art'; import type { DeckLegality } from '../../../core/deck-legality'; +import { rarityLimits } from '../../../core/deck-rules'; import { DeckStore } from '../../../core/deck-store'; import { formatInfo } from '../../../core/formats'; import { AcIconButton } from '../../../ui/buttons'; import { AcBadge, AcTerrainTotals } from '../../../ui/chips'; import { AcIcon } from '../../../ui/icon'; -import { AcCardArt, AcCostCurve, factionColor } from '../../../ui/metier'; +import { AcCardArt, AcCostCurve, AcRarityLimits, factionColor } from '../../../ui/metier'; import { AcOverlayService } from '../../../ui/overlay'; import { openCardZoom } from '../../shared/card-zoom/card-zoom.overlay'; @@ -22,7 +23,7 @@ const LONG_NAME = 40; */ @Component({ selector: 'app-deck-bar', - imports: [NgTemplateOutlet, RouterLink, AcCardArt, AcCostCurve, AcTerrainTotals, AcBadge, AcIcon, AcIconButton], + imports: [NgTemplateOutlet, RouterLink, AcCardArt, AcCostCurve, AcRarityLimits, AcTerrainTotals, AcBadge, AcIcon, AcIconButton], host: { '[class.reduced]': 'reduced()' }, templateUrl: './deck-bar.html', styleUrl: './deck-bar.scss', @@ -49,6 +50,8 @@ export class DeckBar { }); protected readonly border = computed(() => factionColor(this.deck.hero()?.faction)); protected readonly long = computed(() => this.deck.name().length > LONG_NAME); + /** Rares, Exalteds and Uniques against the format's caps. */ + protected readonly rarityLimits = computed(() => rarityLimits(this.deck.status(), this.deck.format())); protected readonly formatLabel = computed(() => formatInfo(this.deck.format()).label); protected readonly problems = computed(() => this.legality().rules.length + this.legality().errors.length); protected readonly illegalLabel = computed(() => { diff --git a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html index abe63b81..7ade5125 100644 --- a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html +++ b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html @@ -17,9 +17,6 @@ (click)="act('share')" > } - @if (!deck.loadError()) { - - } } @@ -41,13 +38,6 @@ (showLegality)="showLegality()" >
- diff --git a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.scss b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.scss index b6e1fb30..5272e485 100644 --- a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.scss +++ b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.scss @@ -66,7 +66,7 @@ ac-app-bar.static { white-space: pre-line; } -/* « Partager » / « Terminer » while the deck is being saved first. */ +/* « Partager » while the deck is being saved first. */ .waiting ::ng-deep .ac-icon { animation: ac-icon-spin 1s linear infinite; } @@ -77,10 +77,6 @@ ac-app-bar.static { } } -.done { - padding-inline: var(--ac-space-2-5); -} - /* ---------- medium and up ---------- */ @media (min-width: 768px) { /* The deck bar and the modes row span the page; the main column and the deck panel share the row below. */ diff --git a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.ts b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.ts index 4fae5475..0e52d8d7 100644 --- a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.ts +++ b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.ts @@ -43,8 +43,8 @@ import { openSignInToShare } from '../sign-in-to-share/sign-in-to-share.overlay' import { rememberShareAfterSignIn, takeShareAfterSignIn } from '../share-after-sign-in'; export type EditorView = 'search' | 'apercu' | 'deck' | 'main'; -/** « Partager » and « Terminer »: both save the deck first, so the link and the deck page show the latest changes. */ -export type EditorAction = 'share' | 'done'; +/** « Partager » saves the deck first, so the link shows the latest changes. */ +export type EditorAction = 'share'; @Component({ selector: 'app-editor-page', @@ -110,7 +110,6 @@ export class EditorPage { saving: $localize`:@@editor.savingFirst:Enregistrement…`, saved: $localize`:@@editor.share.saved:Deck enregistré : le lien affiche la dernière version.`, savedToAccount: $localize`:@@editor.share.savedToAccount:Deck enregistré sur votre compte : vous pouvez le partager.`, - doneHint: $localize`:@@editor.doneHint:Retour à Mes decks`, more: $localize`:@@editor.moreActions:Plus d’actions`, imageBusy: deckImageBusyMessage(), }; @@ -220,7 +219,7 @@ export class EditorPage { }); } - /** « Partager » / « Terminer »: saves the deck, then opens the share window or the deck page. */ + /** « Partager »: saves the deck, then opens the share window. */ protected act(action: EditorAction): void { // Before the deck is loaded, the share window would show the store's defaults (a public deck as private). if (this.waiting() || this.deck.loading()) return; @@ -237,9 +236,7 @@ export class EditorPage { this.stopped.set(action); return; } - // From 768 px « Terminer » goes back to the deck list; on phones, to the deck's page. - if (action === 'done') void this.router.navigate(this.bp.compact() ? ['/decks', this.id()] : ['/decks']); - else this.openShare(this.labels.saved); + this.openShare(this.labels.saved); }); } diff --git a/plugins/rebuilder/app/src/app/ui/metier/index.ts b/plugins/rebuilder/app/src/app/ui/metier/index.ts index 0ad308ad..4f7b7d96 100644 --- a/plugins/rebuilder/app/src/app/ui/metier/index.ts +++ b/plugins/rebuilder/app/src/app/ui/metier/index.ts @@ -19,3 +19,4 @@ export * from './scroll'; export * from './unique-card/unique-card'; export * from './card-pile/card-pile'; export * from './cost-curve/cost-curve'; +export * from './rarity-limits/rarity-limits'; diff --git a/plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.html b/plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.html new file mode 100644 index 00000000..d238174c --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.html @@ -0,0 +1,3 @@ +@for (r of items(); track r.key) { + {{ r.count }}/{{ r.limit }} +} diff --git a/plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.scss b/plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.scss new file mode 100644 index 00000000..0e284e29 --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.scss @@ -0,0 +1,11 @@ +:host { + display: inline-flex; + align-items: center; + gap: var(--ac-space-1-5); + flex-shrink: 0; +} +img { + height: 13px; + width: auto; + display: block; +} diff --git a/plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.ts b/plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.ts new file mode 100644 index 00000000..cfd21e4c --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.ts @@ -0,0 +1,29 @@ +import { Component, computed, input } from '@angular/core'; +import type { RarityLimit } from '../../../core/deck-rules'; +import { AcBadge, RARITY_ICONS } from '../../chips'; + +/** + * Rares, Exalteds and Uniques against the format's caps, one badge each: grey below the cap, green at it, red above. + */ +@Component({ + selector: 'ac-rarity-limits', + imports: [AcBadge], + host: { role: 'group', '[attr.aria-label]': 'groupLabel' }, + templateUrl: './rarity-limits.html', + styleUrl: './rarity-limits.scss', +}) +export class AcRarityLimits { + readonly limits = input.required(); + protected readonly groupLabel = $localize`:@@ui.rarityLimits.label:Raretés limitées par le format`; + protected readonly items = computed(() => + this.limits().map((r) => { + const icon = RARITY_ICONS.find((i) => i.key === r.key)!; + return { + ...r, + icon: icon.src, + tone: r.count > r.limit ? ('red' as const) : r.count === r.limit ? ('green' as const) : ('neutral' as const), + label: $localize`:@@ui.rarityLimits.item:${icon.label}:rarity: : ${r.count}:count: sur ${r.limit}:limit: maximum`, + }; + }), + ); +} diff --git a/plugins/rebuilder/app/src/locale/messages.en.json b/plugins/rebuilder/app/src/locale/messages.en.json index 39468659..87874212 100644 --- a/plugins/rebuilder/app/src/locale/messages.en.json +++ b/plugins/rebuilder/app/src/locale/messages.en.json @@ -195,7 +195,6 @@ "editor.public": "Public", "editor.private": "Private", "editor.share": "Share", - "editor.done": "Done", "editor.hideDeck": "Hide the deck", "editor.deckTab": "Deck · {$INTERPOLATION} cards", "editor.showDeckLegal": "Show the deck: {$count} cards, valid", @@ -204,7 +203,6 @@ "editor.share.saved": "Deck saved: the link shows the latest version.", "editor.share.savedToAccount": "Deck saved to your account: you can share it.", "editor.stopped.share": "The share window will open once the deck is saved.", - "editor.stopped.done": "The deck page will open once the deck is saved.", "editor.signInToShare.title": "Sign in to share", "editor.signInToShare.body": "To share this deck and save it on the server, sign in.", "editor.signInToShare.note": "Once you are signed in, the deck is saved to your account and the share link opens.", @@ -758,8 +756,10 @@ "editor.imageOpen": "Open the image in a new tab", "editor.boardHint": "Hover a pile to change its number of copies.", "editor.tokenArts.short": "Token illustrations", - "editor.doneHint": "Back to My decks", "editor.moreActions": "More actions", + "deck.bar.rarities": "Rarities", + "ui.rarityLimits.label": "Rarities capped by the format", + "ui.rarityLimits.item": "{$rarity}: {$count} of {$limit} at most", "ui.cardPile.zoomOne": "Enlarge {$name}, {$count} copy", "ui.cardPile.zoom": "Enlarge {$name}, {$count} copies", "ui.costCurve.title": "Cost curve", diff --git a/plugins/rebuilder/e2e/rebuilder.spec.ts b/plugins/rebuilder/e2e/rebuilder.spec.ts index 71cf685b..c57710ab 100644 --- a/plugins/rebuilder/e2e/rebuilder.spec.ts +++ b/plugins/rebuilder/e2e/rebuilder.spec.ts @@ -999,12 +999,11 @@ test.describe('ReBuilder in the shell · guest', () => { }); }); -test.describe('ReBuilder in the shell · « Partager » and « Terminer » in the editor', () => { +test.describe('ReBuilder in the shell · « Partager » in the editor', () => { /** « Partager »: a button on desktop, an icon in the app bar on mobile. */ const shareButton = (page: Page) => page.locator('app-editor-page').getByRole('button', { name: 'Partager', exact: true }); - const doneButton = (page: Page) => page.locator('app-editor-page').getByRole('button', { name: 'Terminer', exact: true }); - test('saves the deck first, then shares it, then leaves the editor', async ({ page, compact }, testInfo) => { + test('saves the deck first, then shares it', async ({ page, compact }, testInfo) => { const name = `E2E share ${testInfo.project.name} ${Date.now()}`; await login(page, 'bob', `${DECKS}?lang=fr`); await page.getByRole('button', { name: FR.newDeck }).first().click(); @@ -1016,6 +1015,8 @@ test.describe('ReBuilder in the shell · « Partager » and « Terminer » in th // « Partager » right after a change: no wait for the autosave delay, the change is sent first. const saved = page.waitForResponse((r) => r.request().method() === 'PATCH' && r.url().includes(`/api/decks/${deck.id}`) && r.ok()); await addTwoCards(page); + // The deck bar counts the rarities the format caps (from 768 px). + if (!compact) await expect(page.locator('app-deck-bar').getByRole('group', { name: 'Raretés limitées par le format' })).toContainText('/15'); await shareButton(page).click(); const patch = await saved; expect(((patch.request().postDataJSON() as { deckCards?: unknown[] }).deckCards ?? []).length).toBeGreaterThanOrEqual(2); @@ -1029,12 +1030,9 @@ test.describe('ReBuilder in the shell · « Partager » and « Terminer » in th await page.keyboard.press('Escape'); await expect(dialog).toBeHidden(); - // « Terminer »: the deck list from 768 px, the deck's page on phones. - await doneButton(page).click(); - if (compact) { - await expect(page).toHaveURL(at(DECK(deck.id))); - await expect(page.locator('app-deck-page')).toContainText(name); - } else { + // « Mes decks » of the deck bar leaves the editor (from 768 px; the app bar's back button on phones). + if (!compact) { + await page.locator('app-deck-bar').getByRole('link', { name: 'Mes decks' }).click(); await expect(page).toHaveURL(/\/pages\/decks(\?|$)/); await expect(page.getByRole('list', { name: 'Mes decks' }).locator('ac-deck-card').filter({ hasText: name })).toBeVisible(); } diff --git a/tests/CollectionQueryTest.php b/tests/CollectionQueryTest.php index f895760d..c5c7beee 100644 --- a/tests/CollectionQueryTest.php +++ b/tests/CollectionQueryTest.php @@ -1,9 +1,9 @@ ['AX'], 'rarity' => ['COMMON', 'RARE', 'EXALTED'], 'cardType' => ['CHARACTER', 'SPELL'], 'cardSet' => ['CORE', 'ALIZE'], 'locale' => 'fr', 'page' => '2', 'itemsPerPage' => '36', @@ -13,3 +13,6 @@ assertSame('faction[]=AX&name=Red%20Axiom', cacCollectionQuery(['faction' => 'AX', 'name' => 'Red Axiom', 'rarity' => ['']]), 'a single value, empty values dropped'); assertSame('mainCost[gte]=1&mainCost[lte]=3', cacCollectionQuery(['mainCost' => ['gte' => '1', 'lte' => '3', 'eq' => '2']]), 'cost range'); assertSame('', cacCollectionQuery([]), 'no filter'); +assertSame('cardType[]=SPELL', cacCollectionQuery(['cardType' => ['SPELL', '']]), 'one card type goes to the API'); +assertSame(null, cacCollectionLocalTypes(['cardType' => ['SPELL']]), 'one card type: the API filters'); +assertSame(['CHARACTER', 'SPELL'], cacCollectionLocalTypes(['cardType' => ['CHARACTER', '', 'SPELL']]), 'several card types: the proxy filters'); From 8cc12ef3fd045283bbec63424c7d3303d74aa931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89douard=20WILLISSECK?= Date: Tue, 6 Oct 2026 18:09:40 +0200 Subject: [PATCH 08/69] Stack: the collection mock answers like the collection API (cardType, cardSet, HTTP 500 on several types) (#138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #137 filters several card types in the collection proxy, on the items' cardType. The mock answered with type / set instead, so the proxy dropped every card and the « Collection physique » e2e found 0. The mock now names the fields as the collection API does, and fails on several cardType[] like it: the e2e checks the proxy's own filtering. Co-authored-by: Claude Opus 5.5 --- docker/stack/ownership-mock/index.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docker/stack/ownership-mock/index.php b/docker/stack/ownership-mock/index.php index de77560a..d85d061d 100644 --- a/docker/stack/ownership-mock/index.php +++ b/docker/stack/ownership-mock/index.php @@ -169,6 +169,8 @@ function search(string $user): array { $types = array_values(array_filter((array)($_GET['type'] ?? []), 'is_string')) ?: ['TOKEN', 'TOKEN_LANDMARK_PERMANENT', 'TOKEN_MANA']; $factions = array_values(array_filter((array)($_GET['faction'] ?? []), 'is_string')); $rarities = array_values(array_filter((array)($_GET['rarity'] ?? []), 'is_string')); + // The collection API (cardType[], cardSet[]) fails on several card types, like the real one: its proxy filters them. + if (count((array)($_GET['cardType'] ?? [])) > 1) reply(['error' => 'several cardType'], 500); $name = mb_strtolower(trim((string)($_GET['name'] ?? ''))); $locale = (string)($_GET['locale'] ?? 'fr'); $take = max(1, min(100, (int)($_GET['take'] ?? 25))); @@ -202,6 +204,8 @@ function collection(): array { ['reference' => 'ALT_ALIZE_B_AX_32_R1', 'quantity' => 1, 'name' => 'La Machine dans la Glace', 'faction' => 'AX', 'rarity' => 'RARE', 'type' => 'CHARACTER', 'set' => 'ALIZE'], ['reference' => 'ALT_CORE_B_BR_10_C', 'quantity' => 3, 'name' => 'Red', 'faction' => 'BR', 'rarity' => 'COMMON', 'type' => 'CHARACTER', 'set' => 'CORE'], ]; + // The collection API (cardType[], cardSet[]) fails on several card types, like the real one: its proxy filters them. + if (count((array)($_GET['cardType'] ?? [])) > 1) reply(['error' => 'several cardType'], 500); $name = mb_strtolower(trim((string)($_GET['name'] ?? ''))); $filters = ['faction' => ['faction'], 'rarity' => ['rarity'], 'type' => ['type', 'cardType'], 'set' => ['set', 'cardSet']]; $match = function ($c) use ($name, $filters) { @@ -213,7 +217,8 @@ function collection(): array { } return $name === '' || str_contains(mb_strtolower($c['name']), $name); }; - return array_map(fn($c) => $c + ['cardReference' => $c['reference']], array_values(array_filter($owned, $match))); + // Both APIs name the card type `cardType`; the collection API names the set `cardSet`, the ownership API `set`. + return array_map(fn($c) => $c + ['cardReference' => $c['reference'], 'cardType' => $c['type'], 'cardSet' => $c['set']], array_values(array_filter($owned, $match))); } /** Global mode: each copy takes its slot's print when owned, the standard print otherwise. */ From f472adf5e23c07c1707b6f8ae1ef3ecf812d5dca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89douard=20WILLISSECK?= Date: Wed, 7 Oct 2026 08:42:28 +0200 Subject: [PATCH 09/69] =?UTF-8?q?Re:Builder:=20phones=20open=20=C2=AB=20Me?= =?UTF-8?q?s=20decks=20=C2=BB=20in=20the=20editor,=20=C2=AB=20=E2=8B=AF=20?= =?UTF-8?q?=C2=BB=20menu=20in=20its=20app=20bar=20(#139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Re:Builder: on phones too, « Mes decks » opens a deck in the editor; « ⋯ » in its app bar Like from 768 px, a deck of « Mes decks » opens in the editor on « Aperçu ». The deck page's actions (image, copy the list, duplicate, delete) were reached through « Terminer », gone in #137: the editor's app bar gets the deck bar's « ⋯ » menu. Co-Authored-By: Claude Opus 5.5 * Re:Builder: one element per app bar block, so « Partager » and « ⋯ » are both projected Content projection only takes a control-flow block with a single root: with both buttons in one @if, neither reached the app bar's actions. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../features/decks/decks-page/decks.page.ts | 4 +-- .../editor/editor-page/editor.page.html | 36 +++++++++++-------- plugins/rebuilder/e2e/rebuilder.spec.ts | 19 ++++++---- 3 files changed, 35 insertions(+), 24 deletions(-) diff --git a/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.ts b/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.ts index 052f72df..7d9cf84a 100644 --- a/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.ts +++ b/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.ts @@ -97,8 +97,8 @@ export class DecksPage { !this.auth.isLoggedIn() && !this.auth.sessionRestoring() && !this.guests.decks().length ? 'community' : 'mine', ); protected readonly tab = computed(() => this.queryTab() ?? this.defaultTab()); - /** « Mes decks » from 768 px: a deck opens in the editor, on « Aperçu »; elsewhere on its page. */ - protected readonly openInEditor = computed(() => this.tab() === 'mine' && !this.bp.compact()); + /** « Mes decks »: a deck opens in the editor, on « Aperçu »; elsewhere on its page. */ + protected readonly openInEditor = computed(() => this.tab() === 'mine'); /** Bumped when the list is shown again, so its filters go back to the URL. */ private readonly urlSync = signal(0); protected readonly filters = signal(EMPTY_DECK_FILTERS); diff --git a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html index 7ade5125..0a183d01 100644 --- a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html +++ b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html @@ -17,6 +17,10 @@ (click)="act('share')" > } + + @if (!deck.loadError()) { + + } } @@ -68,21 +72,6 @@ }
- -
- @if (imageSource()) { - - -
- } - - - @if (deck.editable()) { -
- - } -
-
} } + + + +
+ @if (imageSource()) { + + +
+ } + + + @if (deck.editable()) { +
+ + } +
+
diff --git a/plugins/rebuilder/e2e/rebuilder.spec.ts b/plugins/rebuilder/e2e/rebuilder.spec.ts index c57710ab..67ec94b2 100644 --- a/plugins/rebuilder/e2e/rebuilder.spec.ts +++ b/plugins/rebuilder/e2e/rebuilder.spec.ts @@ -127,19 +127,24 @@ test.describe('ReBuilder in the shell · signed in', () => { for (const card of cards) await expect(page.locator('app-deck-panel')).toContainText(card.replace(/ ×.*$/, '')); } - // Re:Builder's list shows it (account decks through the relay); its card opens the deck page (phones) or, from - // 768 px, the editor on « Aperçu »: the deck board. + // Re:Builder's list shows it (account decks through the relay); its card opens the editor on « Aperçu »: the deck + // board from 768 px, the deck preview on phones. await page.goto(DECKS); const item = page.getByRole('list', { name: 'Mes decks' }).locator('ac-deck-card').filter({ hasText: name }); await expect(item).toBeVisible(); await evidence(page, testInfo, '03-listed'); await item.getByRole('link').first().click(); - if (!compact) { - await expect(page).toHaveURL(at(`${EDITOR(deck.id)}&view=apercu`)); - await expect(page.locator('app-deck-board ac-card-pile')).toHaveCount(2); - await evidence(page, testInfo, '04-editor-board'); - await page.goto(DECK(deck.id)); + await expect(page).toHaveURL(at(`${EDITOR(deck.id)}&view=apercu`)); + // Phones: the deck page's actions (image, copy, duplicate, delete) are in the app bar's « ⋯ ». + if (compact) { + await expect(page.locator('app-deck-preview')).toBeVisible(); + await page.getByRole('button', { name: 'Plus d’actions' }).click(); + await expect(page.getByRole('menuitem', { name: 'Supprimer' })).toBeVisible(); + await page.keyboard.press('Escape'); } + else await expect(page.locator('app-deck-board ac-card-pile')).toHaveCount(2); + await evidence(page, testInfo, '04-editor-board'); + await page.goto(DECK(deck.id)); await expect(page).toHaveURL(at(DECK(deck.id))); await expect(page.locator('app-deck-page')).toContainText(name); await evidence(page, testInfo, '04-deck-page'); From 57750240ccd1708033c40fd90d3d85bacc6f13f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89douard=20WILLISSECK?= Date: Wed, 7 Oct 2026 09:21:49 +0200 Subject: [PATCH 10/69] CI: e2e reads the stack images from preprod's cache, mobile replays only @mobile tests (#140) The e2e job wrote its Docker layer cache from each pull request: a cache scoped to that PR, unreadable by the others, so every new PR rebuilt both images (~3-4 min), and the repository cache sat above its 10 GB limit. stack-images.yml now writes it on preprod (push, weekly, manual); pull requests only read it. On the CI, the mobile project runs only the tests tagged @mobile (bottom navigation, sheets, app bar, touch sizes): 56 tests instead of 88. Locally both projects still run everything, for pr-screenshots.mjs. Co-authored-by: Claude Opus 5.5 --- .github/workflows/plugins-ci.yml | 8 ++-- .github/workflows/stack-images.yml | 42 +++++++++++++++++++ .../e2e/deckbuilder-share.spec.ts | 6 +-- plugins/rebuilder/e2e/rebuilder.spec.ts | 16 +++---- tests/e2e/playwright.config.ts | 4 ++ 5 files changed, 61 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/stack-images.yml diff --git a/.github/workflows/plugins-ci.yml b/.github/workflows/plugins-ci.yml index b7980cf8..73491373 100644 --- a/.github/workflows/plugins-ci.yml +++ b/.github/workflows/plugins-ci.yml @@ -151,20 +151,20 @@ jobs: nohup bash -c 'bash "$RUNNER_TEMP/frontends.sh"; echo $? > "$RUNNER_TEMP/frontends.exit"' > "$RUNNER_TEMP/frontends.log" 2>&1 & - uses: docker/setup-buildx-action@v3 - name: Build the stack images - # From the GitHub Actions cache (one scope per image): rebuilt only when the Dockerfile or - # the pinned decks API commit changes. + # From preprod's GitHub Actions cache (one scope per image, written by stack-images.yml): rebuilt only when + # the pull request changes the Dockerfile or the pinned decks API commit. Read only: a cache written here + # would be scoped to this pull request, unreadable by the others, and would fill the repository's 10 GB. uses: docker/bake-action@v6 with: source: . files: | docker-compose.yml docker-compose.stack.yml + targets: web,decks-api load: true set: | web.cache-from=type=gha,scope=stack-web - web.cache-to=type=gha,scope=stack-web,mode=max decks-api.cache-from=type=gha,scope=stack-decks-api - decks-api.cache-to=type=gha,scope=stack-decks-api,mode=max - name: Start the stack run: $COMPOSE up -d --no-build --wait --wait-timeout 600 - name: Wait for the front-end builds diff --git a/.github/workflows/stack-images.yml b/.github/workflows/stack-images.yml new file mode 100644 index 00000000..ecf6ddea --- /dev/null +++ b/.github/workflows/stack-images.yml @@ -0,0 +1,42 @@ +name: Stack images + +# Builds the e2e stack images (web, decks-api) on preprod and writes their layers to the GitHub Actions cache. +# Pull requests only read that cache (plugins-ci.yml, job e2e): a cache written by a pull request is scoped to its +# own ref and no other pull request can read it, while every pull request against preprod reads preprod's. +# • push on preprod: a merged Dockerfile or decks API commit is cached for the next pull requests; +# • weekly: GitHub drops caches unused for 7 days, the schedule keeps this one when nothing is merged. +on: + push: + branches: [preprod] + schedule: + - cron: '0 4 * * 1' + workflow_dispatch: {} + +concurrency: + group: stack-images + cancel-in-progress: true + +permissions: + contents: read + +jobs: + cache: + name: Build and cache the stack images + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - name: Build the stack images + # Same targets and scopes as plugins-ci.yml (job e2e). + uses: docker/bake-action@v6 + with: + source: . + files: | + docker-compose.yml + docker-compose.stack.yml + targets: web,decks-api + set: | + web.cache-from=type=gha,scope=stack-web + web.cache-to=type=gha,scope=stack-web,mode=max + decks-api.cache-from=type=gha,scope=stack-decks-api + decks-api.cache-to=type=gha,scope=stack-decks-api,mode=max diff --git a/plugins/core-altered-cards/e2e/deckbuilder-share.spec.ts b/plugins/core-altered-cards/e2e/deckbuilder-share.spec.ts index 8d52e316..f73ebd06 100644 --- a/plugins/core-altered-cards/e2e/deckbuilder-share.spec.ts +++ b/plugins/core-altered-cards/e2e/deckbuilder-share.spec.ts @@ -53,7 +53,7 @@ const saveRequest = (page: Page) => test.describe('Site deck builder · « Partager » and « Terminer »', () => { for (const theme of ['light', 'dark'] as const) { - test(`saves the deck first, shares it, then opens the deck page (${theme})`, async ({ page, compact }, testInfo) => { + test(`saves the deck first, shares it, then opens the deck page (${theme})`, { tag: theme === 'light' ? '@mobile' : [] }, async ({ page, compact }, testInfo) => { const name = `E2E legacy ${testInfo.project.name} ${theme} ${Date.now()}`; await login(page, 'bob', '/pages/decks?lang=fr'); const id = await createServerDeck(page, name); @@ -92,7 +92,7 @@ test.describe('Site deck builder · « Partager » and « Terminer »', () => { }); } - test('stops on a failed save and carries the action on with « Réessayer »', async ({ page, compact }, testInfo) => { + test('stops on a failed save and carries the action on with « Réessayer »', { tag: '@mobile' }, async ({ page, compact }, testInfo) => { const name = `E2E legacy error ${testInfo.project.name} ${Date.now()}`; await login(page, 'bob', '/pages/decks?lang=fr'); const id = await createServerDeck(page, name); @@ -113,7 +113,7 @@ test.describe('Site deck builder · « Partager » and « Terminer »', () => { await expect(error).toBeHidden(); }); - test('a guest signs in to share: the browser deck moves to the account, then the share window opens', async ({ page, compact }, testInfo) => { + test('a guest signs in to share: the browser deck moves to the account, then the share window opens', { tag: '@mobile' }, async ({ page, compact }, testInfo) => { const name = `E2E legacy guest ${testInfo.project.name} ${Date.now()}`; await setBeta(page, false); await page.addInitScript(([deckName, hero]) => { diff --git a/plugins/rebuilder/e2e/rebuilder.spec.ts b/plugins/rebuilder/e2e/rebuilder.spec.ts index 67ec94b2..7f8b73c5 100644 --- a/plugins/rebuilder/e2e/rebuilder.spec.ts +++ b/plugins/rebuilder/e2e/rebuilder.spec.ts @@ -86,7 +86,7 @@ const at = (path: string, more = false) => new RegExp(`${escape(path)}${more ? ' test.beforeEach(async ({ page }) => setBeta(page, true)); test.describe('ReBuilder in the shell · signed in', () => { - test('creates a deck from the decks list, edits it, finds it in the list and opens its page', async ({ page, compact }, testInfo) => { + test('creates a deck from the decks list, edits it, finds it in the list and opens its page', { tag: '@mobile' }, async ({ page, compact }, testInfo) => { const name = `E2E ${testInfo.project.name} ${Date.now()}`; // The browser talks to the site only (relay) for decks, and never sends a bearer token. const leaks: string[] = []; @@ -207,7 +207,7 @@ test.describe('ReBuilder in the shell · signed in', () => { await evidence(page, testInfo, '16-token-arts'); }); - test('deletes a deck after a confirmation in a design-system dialog, not the browser’s', async ({ page, compact }, testInfo) => { + test('deletes a deck after a confirmation in a design-system dialog, not the browser’s', { tag: '@mobile' }, async ({ page, compact }, testInfo) => { const name = `E2E delete ${testInfo.project.name} ${Date.now()}`; const browserDialogs: string[] = []; page.on('dialog', (d) => { @@ -250,7 +250,7 @@ test.describe('ReBuilder in the shell · signed in', () => { expect(browserDialogs).toEqual([]); }); - test('touch: every control of the windows is at least --ac-hit-min (44 px) on each side', async ({ page, compact }, testInfo) => { + test('touch: every control of the windows is at least --ac-hit-min (44 px) on each side', { tag: '@mobile' }, async ({ page, compact }, testInfo) => { test.skip(!compact, 'touch density: mobile project'); await login(page, 'alice', `${NEW_DECK}?lang=fr`); const name = `E2E touch ${testInfo.project.name} ${Date.now()}`; @@ -488,7 +488,7 @@ test.describe('ReBuilder in the shell · languages', () => { // Decks list, community tab, editor search and deck summary, in each site language; the switch // goes through the site's ?lang= (a change reloads the page, translations load before the app). for (const lang of ['en', 'fr'] as const) { - test(`shows its interface in the site language (${lang})`, async ({ page, compact }, testInfo) => { + test(`shows its interface in the site language (${lang})`, { tag: '@mobile' }, async ({ page, compact }, testInfo) => { const l = LABELS[lang]; const other = LABELS[lang === 'en' ? 'fr' : 'en']; await page.goto(`${DECKS}?tab=mine&lang=${lang}`); @@ -597,7 +597,7 @@ test.describe('ReBuilder in the shell · Uniques search', () => { expect(await summaries.nth(1).locator('p').allTextContents()).toEqual(firstText); }); - test('narrows each effect list to the values that still give a card, for the effect being edited', async ({ page, compact }, testInfo) => { + test('narrows each effect list to the values that still give a card, for the effect being edited', { tag: '@mobile' }, async ({ page, compact }, testInfo) => { await page.goto(`${NEW_DECK}?lang=fr`); await createDeck(page, `E2E uniques narrowing ${Date.now()}`); await expect(page.locator('ac-card-tile').first()).toBeVisible(); @@ -679,7 +679,7 @@ test.describe('ReBuilder in the shell · Uniques search', () => { }); test.describe('ReBuilder in the shell · card filters', () => { - test('checks several subtypes in a row: the list stays open', async ({ page, compact }, testInfo) => { + test('checks several subtypes in a row: the list stays open', { tag: '@mobile' }, async ({ page, compact }, testInfo) => { await page.goto(`${NEW_DECK}?lang=fr`); await createDeck(page, `E2E sous-types ${Date.now()}`); await expect(page.locator('ac-card-tile').first()).toBeVisible(); @@ -818,7 +818,7 @@ test.describe('ReBuilder in the shell · deck page', () => { else await page.getByRole('tab', { name: tab }).click(); } - test('shows the API legality, description and a test hand; shares and duplicates on the account', async ({ page, compact, baseURL }, testInfo) => { + test('shows the API legality, description and a test hand; shares and duplicates on the account', { tag: '@mobile' }, async ({ page, compact, baseURL }, testInfo) => { // navigator.share is recorded: sharing must never use the system share sheet. await page.addInitScript(() => { Object.defineProperty(navigator, 'share', { @@ -1008,7 +1008,7 @@ test.describe('ReBuilder in the shell · « Partager » in the editor', () => { /** « Partager »: a button on desktop, an icon in the app bar on mobile. */ const shareButton = (page: Page) => page.locator('app-editor-page').getByRole('button', { name: 'Partager', exact: true }); - test('saves the deck first, then shares it', async ({ page, compact }, testInfo) => { + test('saves the deck first, then shares it', { tag: '@mobile' }, async ({ page, compact }, testInfo) => { const name = `E2E share ${testInfo.project.name} ${Date.now()}`; await login(page, 'bob', `${DECKS}?lang=fr`); await page.getByRole('button', { name: FR.newDeck }).first().click(); diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index f12413cd..938a5933 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -37,6 +37,10 @@ export default defineConfig({ { name: 'desktop', use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 900 } } }, { name: 'mobile', + // On the CI, mobile replays only the tests tagged @mobile: those that drive the compact interface (bottom + // navigation, sheets, app bar, touch sizes). The others check logic that does not depend on the width. + // Locally every test runs on both: pr-screenshots.mjs pairs each desktop evidence with its mobile one. + grep: process.env['CI'] ? /@mobile/ : undefined, use: { ...devices['Pixel 7'], browserName: 'chromium', viewport: { width: 390, height: 844 }, deviceScaleFactor: 2 }, }, ], From ac343812ae9ce5fd0bcc096f5dfdc89e2180bafd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89douard=20WILLISSECK?= Date: Wed, 7 Oct 2026 13:01:28 +0200 Subject: [PATCH 11/69] Re:Builder: loading skeletons, never an empty page or an empty deck on a slow network (#141) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SPA pages: a server-rendered placeholder (manifest `placeholder`) shown while the scripts load, kept by the host runtime until the plugin's first screen, replaced by an error notice if a module fails to load. Re:Builder's draws the screen of the URL (decks list, deck page, editor views) in its layout. - Design system: `ac-skeleton` (text, title, control, control-sm, circle, card, panel), `[acSkeleton]` directive. - DeckStore.opening: deck bar, panel, board, preview, list view, hand and deck page show skeletons in their own layout instead of « 0 cartes », « Deck vide », « Non légal ». owned() is unknown while the deck opens, so the actions stay in skeleton rather than « Modifier » appearing then disappearing. - Card search waits for configure() (the deck's faction and format) before its first request. - Decks list: skeletons at a deck card's size, « Chargement… » for the count. Co-authored-by: Claude Opus 5.5 --- design-system/css/components/feedback.css | 19 ++ design-system/docs/components/feedback.md | 39 +++- includes/spa.php | 34 +++- js/altered-core-host.js | 32 +++ pages/design-system.php | 10 + plugins/README.html | 1 + plugins/plugin.schema.json | 4 + plugins/rebuilder/app/design/COMPONENTS.md | 1 + .../app/src/app/core/deck-store.spec.ts | 15 ++ .../rebuilder/app/src/app/core/deck-store.ts | 11 +- .../app/features/deck/deck-bar/deck-bar.html | 24 ++- .../app/features/deck/deck-bar/deck-bar.scss | 33 ++++ .../app/features/deck/deck-bar/deck-bar.ts | 5 +- .../features/deck/deck-board/deck-board.html | 11 ++ .../features/deck/deck-board/deck-board.ts | 3 +- .../features/deck/deck-page/deck.page.html | 72 +++++-- .../features/deck/deck-page/deck.page.scss | 20 ++ .../app/features/deck/deck-page/deck.page.ts | 8 +- .../features/decks/decks-page/decks.page.html | 30 +-- .../features/decks/decks-page/decks.page.scss | 28 +-- .../features/decks/decks-page/decks.page.ts | 10 +- .../editor/deck-list-view/deck-list-view.html | 13 ++ .../editor/deck-list-view/deck-list-view.scss | 21 ++ .../editor/deck-list-view/deck-list-view.ts | 4 +- .../editor/deck-panel/deck-panel.html | 9 + .../editor/deck-panel/deck-panel.scss | 12 ++ .../features/editor/deck-panel/deck-panel.ts | 3 +- .../editor/deck-preview/deck-preview.html | 15 ++ .../editor/deck-preview/deck-preview.scss | 11 ++ .../editor/deck-preview/deck-preview.ts | 3 +- .../editor/editor-page/editor.page.html | 42 +++- .../editor/editor-page/editor.page.ts | 8 +- .../app/features/search/card-search.store.ts | 11 +- .../search/card-search/card-search.ts | 2 +- .../search/search-results/search-results.html | 2 +- .../search/search-results/search-results.scss | 11 -- .../search/search-results/search-results.ts | 4 +- .../shared/hand-skeleton/hand-skeleton.html | 23 +++ .../shared/hand-skeleton/hand-skeleton.scss | 30 +++ .../shared/hand-skeleton/hand-skeleton.ts | 18 ++ .../app/src/app/ui/containers/index.ts | 1 + .../app/ui/containers/skeleton/skeleton.ts | 23 +++ .../app/src/app/ui/nav/app-bar/app-bar.scss | 6 + .../rebuilder/app/src/locale/messages.en.json | 2 + plugins/rebuilder/e2e/rebuilder.spec.ts | 37 ++++ plugins/rebuilder/placeholder.css | 101 ++++++++++ plugins/rebuilder/placeholder.php | 183 ++++++++++++++++++ plugins/rebuilder/plugin.json | 1 + 48 files changed, 925 insertions(+), 81 deletions(-) create mode 100644 plugins/rebuilder/app/src/app/features/shared/hand-skeleton/hand-skeleton.html create mode 100644 plugins/rebuilder/app/src/app/features/shared/hand-skeleton/hand-skeleton.scss create mode 100644 plugins/rebuilder/app/src/app/features/shared/hand-skeleton/hand-skeleton.ts create mode 100644 plugins/rebuilder/app/src/app/ui/containers/skeleton/skeleton.ts create mode 100644 plugins/rebuilder/placeholder.css create mode 100644 plugins/rebuilder/placeholder.php diff --git a/design-system/css/components/feedback.css b/design-system/css/components/feedback.css index eed01874..9950802c 100644 --- a/design-system/css/components/feedback.css +++ b/design-system/css/components/feedback.css @@ -82,6 +82,25 @@ animation: ac-icon-spin 0.8s linear infinite; } +/* ---- Skeleton: the shape of content that is loading ---- */ +.ac-skeleton { + display: block; + width: var(--ac-skeleton-width, 100%); + min-height: 1px; + background: var(--ac-color-track); + border-radius: var(--ac-radius-md); + animation: ac-skeleton-pulse 1.4s ease-in-out infinite; +} +.ac-skeleton--text { height: 0.8em; margin: 0.1em 0; border-radius: var(--ac-radius-pill); } +.ac-skeleton--title { height: 1.1em; border-radius: var(--ac-radius-pill); } +.ac-skeleton--control { height: var(--ac-control-md); border-radius: var(--ac-radius-control); } +.ac-skeleton--control-sm { height: var(--ac-control-sm); border-radius: var(--ac-radius-control); } +.ac-skeleton--circle { width: var(--ac-skeleton-width, var(--ac-control-md)); aspect-ratio: 1; border-radius: 50%; } +.ac-skeleton--card { aspect-ratio: 5 / 7; border-radius: var(--ac-radius-lg); } +.ac-skeleton--panel { border-radius: var(--ac-radius-card); } +@keyframes ac-skeleton-pulse { 50% { opacity: 0.55; } } +@media (prefers-reduced-motion: reduce) { .ac-skeleton { animation: none; } } + /* ---- Blocking loader: scrim + panel with spinner, label and optional progress ---- */ .ac-loader { display: flex; align-items: center; justify-content: center; z-index: var(--ac-z-toast); } .ac-loader__panel { diff --git a/design-system/docs/components/feedback.md b/design-system/docs/components/feedback.md index 1afa7aa6..15a3c5ba 100644 --- a/design-system/docs/components/feedback.md +++ b/design-system/docs/components/feedback.md @@ -1,7 +1,7 @@ # Feedback and data -`css/components/feedback.css`: notice, toast, scrim, dialog, spinner, blocking loader, progress -bar, table. +`css/components/feedback.css`: notice, toast, scrim, dialog, spinner, skeleton, blocking loader, +progress bar, table. | Component | Use | Not for | |---|---|---| @@ -10,6 +10,7 @@ bar, table. | Dialog `ac-dialog` | A task or a confirmation that needs an answer before going on. | Long content or navigation (use a page). | | Scrim `ac-scrim` | The dimmed layer behind a dialog, drawer or loader. | — | | Spinner `ac-spinner` | A short wait inside a component (button, panel). | Waits the user should not interrupt (use the loader). | +| Skeleton `ac-skeleton` | Content that is loading, in the place and shape it will take: the page stays readable and nothing jumps when it arrives. | An empty state or a « 0 »: never show them before the data is there. | | Loader `ac-loader` | A blocking wait with a message (import, generation), optionally with progress. | Background loading of part of a page. | | Progress `ac-progress` | Known progress of a task (0–100 %). | Unknown duration (use the spinner). | | Table `ac-table` | Tabular data with columns to compare. | Layout; lists of items with one or two values (use a [list](card.md)). | @@ -137,6 +138,40 @@ openConfirm(this.overlay, { ``` +## Skeleton — `ac-skeleton` + +A track-coloured shape that pulses (still with `prefers-reduced-motion`). It takes the size the +layout gives it: width 100 % by default (or `--ac-skeleton-width`), height from the variant, the +layout or the content it replaces. Mark the loading region `aria-busy="true"` and give it a text +for screen readers; the shapes themselves are `aria-hidden`. + +```php +
+ + + + +
+ + +
+
+``` + +| Variant | Shape | +|---|---| +| (none) | A block, 8 px radius; the layout sets its height. | +| `ac-skeleton--text` | A line of body text (0.8 em, pill). | +| `ac-skeleton--title` | A heading line (1.1 em, pill). | +| `ac-skeleton--control` | A field or a button (`--ac-control-md`). | +| `ac-skeleton--control-sm` | A small button (`--ac-control-sm`). | +| `ac-skeleton--circle` | An avatar or an icon button (`--ac-control-md` square, round). | +| `ac-skeleton--card` | A card illustration (5 : 7, 12 px radius). | +| `ac-skeleton--panel` | A card or panel (14 px radius); the layout sets its height. | + +SPA pages: the plugin's manifest `placeholder` renders a skeleton of the page from the server, +shown until the plugin draws its first screen (see `plugins/README.html`). + ## Blocking loader — `ac-loader` A scrim with a centred panel: spinner, label and an optional progress bar. Put `ac-loader` on diff --git a/includes/spa.php b/includes/spa.php index ea0bf40b..a40707da 100644 --- a/includes/spa.php +++ b/includes/spa.php @@ -84,6 +84,7 @@ function spaResolvePage(array $plugin, array $page): array { 'title_fr' => $page['title_fr'] ?? '', 'fullwidth' => !array_key_exists('fullwidth', $page) || !empty($page['fullwidth']), 'meta_file' => spaMetaFile($plugin, $page), + 'placeholder_file' => spaPluginFile($plugin, $page['placeholder'] ?? null), // Base href of the client routes (pluginFindBetaPage(): /pages/, the routes being the slugs it takes over) 'base_path' => BASE_URL . '/pages/' . $slug . '/', 'spa' => $spa, @@ -95,7 +96,12 @@ function spaResolvePage(array $plugin, array $page): array { * before the header, so a client route can set its title and link preview ($pageTitle, $pageDescription, $pageImage). */ function spaMetaFile(array $plugin, array $page): ?string { - $rel = isset($page['meta']) && is_string($page['meta']) ? spaSafeRelPath($page['meta']) : null; + return spaPluginFile($plugin, $page['meta'] ?? null); +} + +/** Absolute path of a file named by the manifest, relative to the plugin; null when absent or outside the plugin. */ +function spaPluginFile(array $plugin, $path): ?string { + $rel = is_string($path) ? spaSafeRelPath($path) : null; if ($rel === null) return null; $abs = $plugin['_dir'] . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $rel); return is_file($abs) ? $abs : null; @@ -178,6 +184,28 @@ function spaHostConfig(array $page): array { ]; } +/** + * Manifest `placeholder`: what the page shows while its scripts load (a skeleton of the page), rendered by the server + * inside the mount point with $slug (the requested page) and $subPath (client route) in scope, the query in $_GET. The + * runtime keeps it until the plugin draws its first screen (js/altered-core-host.js). Without one, a generic skeleton. + */ +function spaRenderPlaceholder(array $page): void { + $label = getUiLang() === 'fr' ? 'Chargement…' : 'Loading…'; + echo '
' . h($label) . ''; + if (!empty($page['placeholder_file'])) { + (function (string $slug, string $subPath) use ($page) { + include $page['placeholder_file']; + })($page['slug'], (string)($page['sub_path'] ?? '')); + } else { + echo ''; + } + echo '
'; +} + /** Body of an SPA page: mount point, host contract, runtime and plugin modules. */ function spaRenderPage(array $page): void { $spa = $page['spa']; @@ -195,12 +223,12 @@ function spaRenderPage(array $page): void { $noscript = $lang === 'fr' ? 'Cette page nécessite JavaScript.' : 'This page requires JavaScript.'; ?>
-
+
- + .ac-spa-placeholder').forEach(function (placeholder) { + var alert = document.createElement('div'); + alert.className = 'ac-notice ac-notice--warning'; + alert.setAttribute('role', 'alert'); + alert.textContent = host.lang === 'fr' ? 'Cette page n’est pas disponible pour le moment.' : 'This page is not available right now.'; + placeholder.replaceWith(alert); + }); + }, true); + // Sticky offset for plugin layouts: the site header is sticky at the top of the page. var header = document.querySelector('.site-header'); function syncHeaderHeight() { diff --git a/pages/design-system.php b/pages/design-system.php index 73e5a38e..b0cd7a11 100644 --- a/pages/design-system.php +++ b/pages/design-system.php @@ -499,6 +499,16 @@
Loading…
+
+ Loading… +
+ + + + +
+ +
diff --git a/plugins/README.html b/plugins/README.html index 5616d642..3697dfaf 100644 --- a/plugins/README.html +++ b/plugins/README.html @@ -517,6 +517,7 @@

SPA plugins (manifest v2)

pages[].typeoptionalphp (default) or spa. An SPA page has no file. pages[].entryspaBuild manifest written by the plugin's build (relative to the plugin): {"version":1, "base":"browser/", "js":["main-HASH.js"], "css":["embed.css?v=…"], "documentCss":["document.css?v=…"]}. js: ES modules, in order. css: loaded inside the shadow root. documentCss: loaded in <head> (@font-face is ignored in a shadow root). pages[].mountoptionalshadow (default): the plugin renders in a Shadow DOM, so Bootstrap and css/style.css do not reach it and its styles do not reach the site. light: rendered in the page. + pages[].placeholderoptionalSPA: PHP file rendered by the server inside the mount point, with $slug (the requested page) and $subPath in scope: what the page shows while its scripts load, usually a skeleton of the screen built with ac-skeleton (design system, feedback). The runtime keeps it in view until the plugin draws its first screen, so a slow connection never shows an empty page. Without it, a generic skeleton (rebuilder: a skeleton of the decks list, deck page or editor, desktop and phone). pages[].metaoptionalSPA: PHP file included before the site header, with $subPath (the client route under /pages/{slug}/) in scope. It may set $pageTitle, $pageDescription and $pageImage, so a client route has its own title and link preview (rebuilder: the deck's name on decks/{id}). pages[].fullwidthoptionalFull-width layout. Default true for SPA pages; false keeps the page in the site's content width (rebuilder). menuoptionalSuggested navigation entries, inserted on activation when no item has the same URL. Admins edit or hide them afterwards (Admin → Navigation). An entry targets page (URL /pages/{page}), plus path for a client route of an SPA page (/pages/{page}/{path}), or an explicit url. An entry with children is a dropdown (no URL of its own); its missing children are added under it. An entry with parent_url goes inside the existing top-level menu that has this URL (e.g. /pages/decks), last unless sort_order is set; at the top level when the site has no such menu. diff --git a/plugins/plugin.schema.json b/plugins/plugin.schema.json index 0e6e8a5a..1d109ab3 100644 --- a/plugins/plugin.schema.json +++ b/plugins/plugin.schema.json @@ -89,6 +89,10 @@ "type": "boolean", "description": "Full-width layout (default true for spa pages, false for php pages)." }, + "placeholder": { + "type": "string", + "description": "spa: PHP file, relative to the plugin, rendered by the server inside the mount point with $slug (the requested page) and $subPath (client route) in scope: what the page shows while its scripts load, usually a skeleton of the screen (ac-skeleton). The runtime keeps it until the plugin draws its first screen. Without it, a generic skeleton." + }, "meta": { "type": "string", "description": "spa: PHP file, relative to the plugin, included before the site header with $slug (the requested page) and $subPath (client route under /pages/{slug}/) in scope; it may set $pageTitle, $pageDescription and $pageImage (title and link previews of a client route)." diff --git a/plugins/rebuilder/app/design/COMPONENTS.md b/plugins/rebuilder/app/design/COMPONENTS.md index cb5dccb5..83126c3d 100644 --- a/plugins/rebuilder/app/design/COMPONENTS.md +++ b/plugins/rebuilder/app/design/COMPONENTS.md @@ -101,6 +101,7 @@ Classes et états des champs et du contrôle segmenté : `design-system/docs/com | Composant | Entrées | Écrans | |---|---|---| | `ac-card`, CSS DS (`card.css`) | `padding: 'md' \| 'sm' \| 'none'` | panneaux, sections de deck | +| `[acSkeleton]`, CSS DS (`feedback.css` : `ac-skeleton`) | `acSkeleton`: `''` (bloc) \| `text` \| `title` \| `control` \| `control-sm` \| `circle` \| `card` \| `panel`, `width?` (`--ac-skeleton-width`) ; `aria-hidden` (la zone qui charge porte `aria-busy` et un texte `role=status`) | barre du deck, vues de l’éditeur et de la page deck pendant l’ouverture du deck (`app-hand-skeleton` pour la main de départ), résultats de recherche, actions tant que le propriétaire est inconnu | | `ac-progress-bar`, CSS DS (`feedback.css` : `ac-progress`) | `value` (0–100), `ariaLabel` ; `role=progressbar` | import de l'export altered.gg | | `ac-toast` | contenu projeté, `actionLabel?` ; `role=status`, en bas au centre (au-dessus de la barre de navigation en compact), fond inversé | `action` | ajout / retrait d’une carte dans l’éditeur (« Annuler ») | | `ac-collapsible` | `title`, `open = model(false)`, contenu projeté | Stats (ouvertes par défaut : `[(open)]` à `true`) | diff --git a/plugins/rebuilder/app/src/app/core/deck-store.spec.ts b/plugins/rebuilder/app/src/app/core/deck-store.spec.ts index c58af9c4..c9ad088a 100644 --- a/plugins/rebuilder/app/src/app/core/deck-store.spec.ts +++ b/plugins/rebuilder/app/src/app/core/deck-store.spec.ts @@ -186,6 +186,21 @@ describe('DeckStore (guest mode)', () => { expect(store.total()).toBe(2); }); + it('is opening from the request to the answer, and not after an error', async () => { + store.load('slow'); + TestBed.tick(); + expect(store.opening()).toBe(true); + http.expectOne((r) => r.url.endsWith('/api/decks/slow')).flush({ id: 'slow', name: 'Lent', format: 'standard', cards: [] }); + await settle(); + expect(store.opening()).toBe(false); + store.load('gone'); + TestBed.tick(); + expect(store.opening()).toBe(true); + http.expectOne((r) => r.url.endsWith('/api/decks/gone')).flush({}, { status: 404, statusText: 'Not Found' }); + await settle(); + expect(store.opening()).toBe(false); + }); + it('shows a login hint for private decks', async () => { store.load('private-id'); TestBed.tick(); diff --git a/plugins/rebuilder/app/src/app/core/deck-store.ts b/plugins/rebuilder/app/src/app/core/deck-store.ts index b47af1a6..bceb81e4 100644 --- a/plugins/rebuilder/app/src/app/core/deck-store.ts +++ b/plugins/rebuilder/app/src/app/core/deck-store.ts @@ -72,6 +72,11 @@ export class DeckStore { }); readonly loading = computed(() => this.serverDeck.isLoading()); + /** + * The deck is being opened (fetched, or not chosen yet): its views show skeletons, never the empty deck the store + * holds meanwhile (« 0 cartes », « Deck vide », « Non légal »). + */ + readonly opening = computed(() => !this.loadError() && (this.loading() || !this.deckId())); /** A save request to the decks API is running. */ readonly saving = signal(false); /** Why the deck could not be opened; a failed save goes to `saveError`, a failed delete or « Rendre public » to `actionError`. */ @@ -132,8 +137,12 @@ export class DeckStore { }); /** Decks created on the account from this tab: theirs before the account list is refetched. */ private readonly createdIds = signal>(new Set()); - /** The open deck belongs to the user: `true` for guest decks, `null` while the account list loads. */ + /** + * The open deck belongs to the user: `true` for guest decks, `null` while the deck opens (the store holds an empty guest + * deck meanwhile) or the account list loads. + */ readonly owned = computed(() => { + if (this.opening()) return null; if (this.isGuest()) return true; const id = this.deckId(); if (!id || !this.auth.isLoggedIn()) return false; diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.html b/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.html index 1885b9a7..63c2d9d5 100644 --- a/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.html +++ b/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.html @@ -1,5 +1,5 @@
-
+
@if (deck.hero(); as h) { @if (editable()) { @@ -17,6 +17,17 @@
Mes decks + @if (deck.opening()) { + + @if (reduced()) { + + } @else { + + + + + } + } @else {
@if (renaming()) { {{ deck.name() }} } + }
+@if (deck.opening()) { + + @if (reduced()) { + + + } + + +} @else { @if (reduced() && rarityLimits().length) { @@ -82,6 +103,7 @@

{{ deck.name() }} } +}
diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.scss b/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.scss index 9d5c3d1e..f9be29d9 100644 --- a/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.scss +++ b/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.scss @@ -257,3 +257,36 @@ display: none; } } + +/* While the deck opens: blocks of the size of the curve, the rarity limits and the terrain totals, so that nothing moves + when the deck arrives. */ +:host(.opening) { + min-height: 100px; +} +:host(.opening:not(.reduced)) { + /* The page sets the height of its full bar (actions differ): editor « Aperçu », deck page. */ + min-height: var(--deck-bar-opening-height, 206px); +} +:host(.opening) .text { + gap: var(--ac-space-1-5); +} +.curve-skeleton { + flex: 0 0 145px; + height: 51px; +} +.limits-skeleton { + flex: 0 1 230px; + height: 30px; +} +.terrain-skeleton { + flex: 0 0 162px; + height: 30px; +} +:host(:not(.reduced)) .curve-skeleton { + flex-basis: 306px; + height: 82px; +} +:host(:not(.reduced)) .terrain-skeleton { + flex-basis: 216px; + height: 103px; +} diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.ts b/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.ts index d7eabfaa..7ed11538 100644 --- a/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.ts +++ b/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.ts @@ -8,6 +8,7 @@ import { DeckStore } from '../../../core/deck-store'; import { formatInfo } from '../../../core/formats'; import { AcIconButton } from '../../../ui/buttons'; import { AcBadge, AcTerrainTotals } from '../../../ui/chips'; +import { AcSkeleton } from '../../../ui/containers'; import { AcIcon } from '../../../ui/icon'; import { AcCardArt, AcCostCurve, AcRarityLimits, factionColor } from '../../../ui/metier'; import { AcOverlayService } from '../../../ui/overlay'; @@ -23,8 +24,8 @@ const LONG_NAME = 40; */ @Component({ selector: 'app-deck-bar', - imports: [NgTemplateOutlet, RouterLink, AcCardArt, AcCostCurve, AcRarityLimits, AcTerrainTotals, AcBadge, AcIcon, AcIconButton], - host: { '[class.reduced]': 'reduced()' }, + imports: [NgTemplateOutlet, RouterLink, AcCardArt, AcCostCurve, AcRarityLimits, AcTerrainTotals, AcBadge, AcIcon, AcIconButton, AcSkeleton], + host: { '[class.reduced]': 'reduced()', '[class.opening]': 'deck.opening()', '[attr.aria-busy]': 'deck.opening() || null' }, templateUrl: './deck-bar.html', styleUrl: './deck-bar.scss', }) diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.html b/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.html index 9805877c..d3f07a0b 100644 --- a/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.html +++ b/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.html @@ -1,3 +1,13 @@ +@if (deck.opening()) { +
+ +
+ @for (c of [0, 1, 2, 3]; track c) { + + } +
+
+} @else { @for (g of groups(); track g.id) {

{{ g.label }} · {{ g.count }}

@@ -25,3 +35,4 @@

{{ g.label }} · {{ g.count }}

}
} +} diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.ts b/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.ts index cd8ffc28..1892e5db 100644 --- a/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.ts +++ b/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.ts @@ -2,6 +2,7 @@ import { Component, DestroyRef, ElementRef, afterNextRender, computed, effect, i import { DeckStore } from '../../../core/deck-store'; import type { Card, HydratedLine } from '../../../core/models'; import { AcCardPile } from '../../../ui/metier'; +import { AcSkeleton } from '../../../ui/containers'; import { AcOverlayService } from '../../../ui/overlay'; import { EditorAltArts } from '../../editor/editor-alt-arts'; import { lineIssues } from '../../editor/editor-legality'; @@ -22,7 +23,7 @@ const DEFAULT_COLUMNS = 8; */ @Component({ selector: 'app-deck-board', - imports: [AcCardPile], + imports: [AcCardPile, AcSkeleton], templateUrl: './deck-board.html', styleUrl: './deck-board.scss', }) diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.html b/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.html index 12ae96fd..07e1855b 100644 --- a/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.html +++ b/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.html @@ -1,14 +1,18 @@ @if (bp.compact()) { - + + @for (i of deck.owned() === null && !deck.error() ? [0, 1, 2] : []; track i) { + + } @if (!deck.error() && deck.owned()) { } - @if (!deck.error()) { + @if (!deck.error() && deck.owned() !== null) { } - @if (!deck.error()) { + @if (!deck.error() && deck.owned() !== null) { } @@ -47,14 +51,23 @@ Retour à mes decks

-} @else if (deck.loading()) { -

Chargement du deck…

} @else { -
+ +
+ @if (deck.opening()) { + {{ loadingLabel }} + }
@if (!bp.compact()) {
+ + @if (deck.owned() === null) { + @for (i of [0, 1, 2, 3, 4, 5]; track i) { + + } + } @else { @if (deck.owned()) { } @@ -67,11 +80,27 @@ @if (deck.owned()) { } + }
} @else {
+ @if (deck.opening()) { +
+ +
+ + +
+
+
+ + + +
+
+ } @else {
@if (deck.hero(); as h) { @@ -94,6 +123,7 @@

+ }
} @@ -102,6 +132,8 @@

@case ('decklist') { @if (bp.compact()) { + } @else if (deck.opening()) { + } @else {
@@ -117,7 +149,11 @@

} @case ('description') {
- @if (deck.description().trim(); as text) { + @if (deck.opening()) { + + + + } @else if (deck.description().trim(); as text) {

{{ text }}

} @else {

Aucune description pour ce deck.

@@ -125,17 +161,23 @@

} @case ('main') { - - - + @if (deck.opening()) { + + } @else { + + + + } } @case ('cartes') { @if (bp.compact()) { - - - - } - @if (bp.compact()) { + @if (deck.opening()) { + + } @else { + + + + } } @else { diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.scss b/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.scss index f0c0d44d..4b1ae84c 100644 --- a/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.scss +++ b/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.scss @@ -248,3 +248,23 @@ } + +/* While the deck opens: blocks of the size of what they stand for. */ +.skeleton-meta { + flex: 1 1 auto; + gap: var(--ac-space-1-5); +} +.badge-skeleton { + height: 26px; + border-radius: var(--ac-radius-pill); +} +.stats-skeleton { + height: 190px; +} +.tables-skeleton { + height: 320px; +} + +app-deck-bar { + --deck-bar-opening-height: 184px; +} diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.ts b/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.ts index b5beefd7..a1828103 100644 --- a/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.ts +++ b/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.ts @@ -12,7 +12,7 @@ import { formatInfo } from '../../../core/formats'; import { GuestDeckService } from '../../../core/guest-deck.service'; import { AcButton, AcIconButton } from '../../../ui/buttons'; import { AcBadge, AcRaritySummary } from '../../../ui/chips'; -import { AcCollapsible } from '../../../ui/containers'; +import { AcCollapsible, AcSkeleton } from '../../../ui/containers'; import { AcSegmented } from '../../../ui/fields'; import { AcIcon } from '../../../ui/icon'; import { AcBreakpointService } from '../../../ui/layout.services'; @@ -35,6 +35,7 @@ import { HandCalculators } from '../hand-calculators/hand-calculators'; import { HandStats } from '../hand-stats/hand-stats'; import { TestHand } from '../test-hand/test-hand'; import { deckShareUrl } from './share-url'; +import { HandSkeleton } from '../../shared/hand-skeleton/hand-skeleton'; type DeckTab = 'cartes' | 'decklist' | 'description' | 'main'; @@ -72,6 +73,8 @@ const TAB_PATHS: Record = { cartes: null, decklist: 'dec TestHand, HandStats, HandCalculators, + HandSkeleton, + AcSkeleton, ], templateUrl: './deck.page.html', styleUrl: './deck.page.scss', @@ -118,9 +121,10 @@ export class DeckPage { protected readonly legality = this.deck.legality; protected readonly legal = computed(() => this.legality().state === 'legal'); protected readonly groups = computed(() => (this.grouping() === 'type' ? this.deck.groups() : groupByCost(this.deck.lines()))); + protected readonly loadingLabel = $localize`:@@deck.page.loading:Chargement du deck…`; protected readonly navItems = computed(() => [ { route: `/decks/${this.id()}`, icon: 'eye', label: $localize`:@@deck.page.navPreview:Aperçu` }, - { route: `/decks/${this.id()}/deck`, icon: 'layers', label: 'Deck', badge: this.deck.total(), badgeTone: this.legal() ? 'success' : 'dark' }, + { route: `/decks/${this.id()}/deck`, icon: 'layers', label: 'Deck', badge: this.deck.opening() ? undefined : this.deck.total(), badgeTone: this.legal() ? 'success' : 'dark' }, { route: `/decks/${this.id()}/description`, icon: 'text', label: $localize`:@@deck.page.navDescription:Infos` }, { route: `/decks/${this.id()}/main`, icon: 'hand', label: $localize`:@@deck.page.navHand:Main` }, ]); diff --git a/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.html b/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.html index 51cb00bb..4c249373 100644 --- a/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.html +++ b/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.html @@ -43,7 +43,11 @@

Decks

- {{ countLabel() }} + @if (listLoading() && !list().length) { + Chargement… + } @else { + {{ countLabel() }} + } @if (tab() !== 'contest') { } @@ -72,6 +76,12 @@

Decks

@for (f of factions; track f.code) { } + + @if (tab() === 'mine' && mineLoading()) { +

+ Chargement de vos decks… Cela peut prendre un moment si vous en avez beaucoup. +

+ }

} @@ -88,11 +98,6 @@

Decks

}
} - @if (tab() === 'mine' && mineLoading()) { -

- Chargement de vos decks… Cela peut prendre un moment si vous en avez beaucoup. -

- } @if (tab() === 'mine' && (serverError() || auth.sessionNotice())) {

{{ serverError() || auth.sessionNotice() }}

} @@ -103,21 +108,22 @@

Decks

} -
+
@for (d of list(); track d.id) { } + + @if (listLoading()) { + @for (i of skeletons(); track i) { + + } + } @if (tab() === 'mine' && !bp.compact()) { } - @if ((tab() === 'mine' && mineLoading()) || (tab() === 'community' && communityLoading()) || (tab() === 'contest' && contestLoading())) { - @for (i of [1, 2, 3, 4]; track i) { - - } - }
@if (tab() === 'mine' && !mineLoading() && !list().length) { diff --git a/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.scss b/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.scss index 8c33d892..ecd46e36 100644 --- a/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.scss +++ b/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.scss @@ -185,23 +185,15 @@ justify-content: center; } +/* A deck card's height: grid tile from 768 px, row on phones. */ .skeleton { - height: 188px; - border-radius: var(--ac-radius-card); - background: var(--ac-color-track); - animation: pulse 1.4s ease-in-out infinite; + height: 227px; } .spin { animation: spin 0.9s linear infinite; } -@keyframes pulse { - 50% { - opacity: 0.55; - } -} - @keyframes spin { to { transform: rotate(360deg); @@ -209,7 +201,6 @@ } @media (prefers-reduced-motion: reduce) { - .skeleton, .spin { animation: none; } @@ -295,3 +286,18 @@ margin-right: var(--ac-space-1); } } + +.grid.rows .skeleton { + height: 149px; +} + +.counting { + display: inline-flex; + align-items: center; + gap: var(--ac-space-1-5); +} + +/* The loading notice in the factions row: on its right, without taking a row of its own. */ +.factions .notice { + margin-left: auto; +} diff --git a/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.ts b/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.ts index 7d9cf84a..c8513de2 100644 --- a/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.ts +++ b/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.ts @@ -12,6 +12,7 @@ import { uiLocale } from '../../../core/i18n'; import { contentLocale } from '../../../core/locale'; import { DeckStore } from '../../../core/deck-store'; import { AcButton, AcIconButton } from '../../../ui/buttons'; +import { AcSkeleton } from '../../../ui/containers'; import { AcChip, AcCount } from '../../../ui/chips'; import { AcInput, AcSegmented, AcSelect, AcSwitch } from '../../../ui/fields'; import { AcIcon } from '../../../ui/icon'; @@ -74,7 +75,7 @@ type LikeState = Pick; @Component({ selector: 'app-decks-page', - imports: [RouterLink, AcAppBar, AcBottomNav, AcTabs, AcButton, AcIconButton, AcInput, AcSelect, AcSegmented, AcSwitch, AcChip, AcDeckCard, AcIcon, AcCount, AcInfiniteSentinel], + imports: [RouterLink, AcAppBar, AcBottomNav, AcTabs, AcButton, AcIconButton, AcInput, AcSelect, AcSegmented, AcSwitch, AcChip, AcDeckCard, AcIcon, AcCount, AcInfiniteSentinel, AcSkeleton], templateUrl: './decks.page.html', styleUrl: './decks.page.scss', }) @@ -113,6 +114,13 @@ export class DecksPage { }); /** Account decks on their way (first load, or another session): skeletons instead of the empty state. A reload keeps the list. */ protected readonly mineLoading = computed(() => this.serverRes.status() === 'loading'); + /** The open tab's list is loading: skeletons in its place, « Chargement… » for its count. */ + protected readonly listLoading = computed(() => { + const tab = this.tab(); + return (tab === 'mine' && this.mineLoading()) || (tab === 'community' && this.communityLoading()) || (tab === 'contest' && this.contestLoading()); + }); + /** Two rows of deck cards from 768 px, a screen of rows on phones. */ + protected readonly skeletons = computed(() => Array.from({ length: this.bp.compact() ? 5 : 10 }, (_, i) => i)); private readonly serverDecks = computed(() => (this.serverRes.hasValue() ? this.serverRes.value() : [])); protected readonly serverError = computed(() => { const err = this.serverRes.error(); diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.html b/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.html index 439f470e..48c7dbd4 100644 --- a/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.html +++ b/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.html @@ -1,4 +1,16 @@ +@if (deck.opening()) { + @if (!readonly()) { + + } + + +} @else { @if (!readonly()) { Aucune carte pour l’instant.

} +} diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.scss b/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.scss index f2784c2a..359b219d 100644 --- a/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.scss +++ b/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.scss @@ -10,3 +10,24 @@ text-align: center; color: var(--ac-color-text-muted); } +/* While the deck opens: the summary, the stats and one section of rows, at their sizes. */ +.summary-skeleton { + height: 159px; +} +.stats-skeleton { + height: 190px; +} +.section-skeleton { + display: flex; + flex-direction: column; +} +.section-skeleton > .ac-skeleton { + margin: var(--ac-space-2) var(--ac-space-1) var(--ac-space-3); +} +.row-skeleton { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--ac-space-3); + height: 51px; +} diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.ts b/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.ts index 54617e6b..7ffdc1fb 100644 --- a/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.ts +++ b/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.ts @@ -1,7 +1,7 @@ import { Component, computed, inject, input, output, signal } from '@angular/core'; import { DeckStore } from '../../../core/deck-store'; import { formatInfo } from '../../../core/formats'; -import { AcCollapsible } from '../../../ui/containers'; +import { AcCollapsible, AcSkeleton } from '../../../ui/containers'; import { AcDeckRow, AcDeckSection, AcDeckStats, AcDeckSummary } from '../../../ui/metier'; import { AcOverlayService } from '../../../ui/overlay'; import { EditorAltArts } from '../editor-alt-arts'; @@ -12,7 +12,7 @@ import { editorLegality, lineIssues, openEditorLegality } from '../editor-legali /** Mobile « Mon deck »: summary card, Stats card, flat grouped lists (steppers or read-only). */ @Component({ selector: 'app-deck-list-view', - imports: [AcDeckSummary, AcCollapsible, AcDeckStats, AcDeckRow, AcDeckSection, AcButton], + imports: [AcDeckSummary, AcCollapsible, AcDeckStats, AcDeckRow, AcDeckSection, AcButton, AcSkeleton], templateUrl: './deck-list-view.html', styleUrl: './deck-list-view.scss', }) diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.html b/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.html index dab3dea3..9e7c9f51 100644 --- a/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.html +++ b/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.html @@ -1,4 +1,12 @@
+ @if (deck.opening()) { +
+
+ @for (r of [0, 1, 2, 3, 4]; track r) { +
+ } +
+ } @else { @for (g of deck.groups(); track g.id) {
{{ g.label }}{{ g.count }}
@@ -18,4 +26,5 @@ } @empty {

Ajoutez des cartes depuis la recherche avec le bouton « + ».

} + }
diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.scss b/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.scss index 89e2bac8..894a32c0 100644 --- a/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.scss +++ b/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.scss @@ -28,3 +28,15 @@ font-size: var(--ac-font-size-small); color: var(--ac-color-text-muted); } +.group-head .ac-skeleton { + margin: 0.2em 0; +} +/* While the deck opens: a card row's height (ac-deck-row, panel appearance). */ +.row-skeleton { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--ac-space-3); + height: 36px; + padding: 0 var(--ac-space-1); +} diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.ts b/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.ts index 9504d8b7..756d829d 100644 --- a/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.ts +++ b/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.ts @@ -3,11 +3,12 @@ import { DeckStore } from '../../../core/deck-store'; import { AcDeckRow } from '../../../ui/metier'; import { EditorAltArts } from '../editor-alt-arts'; import { lineIssues } from '../editor-legality'; +import { AcSkeleton } from '../../../ui/containers'; /** Desktop right-hand panel of the editor: the deck's cards grouped by type, with steppers (the deck bar holds the rest). */ @Component({ selector: 'app-deck-panel', - imports: [AcDeckRow], + imports: [AcDeckRow, AcSkeleton], templateUrl: './deck-panel.html', styleUrl: './deck-panel.scss', }) diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.html b/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.html index 070673b1..361cad6c 100644 --- a/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.html +++ b/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.html @@ -1,3 +1,17 @@ +@if (deck.opening()) { + + +} @else {

{deck.total(), plural, =1 {1 carte} other {{{ deck.total() }} cartes}} · {deck.distinct(), plural, =1 {1 différente} other {{{ deck.distinct() }} différentes}}

@if (deck.groups().length) { @@ -35,3 +49,4 @@

Ajoutez des cartes depuis la recherche.

} +} diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.scss b/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.scss index 78b01422..a6bfaf4b 100644 --- a/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.scss +++ b/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.scss @@ -57,3 +57,14 @@ padding: var(--ac-space-10) var(--ac-space-4); color: var(--ac-color-text-2); } +/* While the deck opens: the bar and one section (ac-deck-section, an ac-card) of four cards, in place. */ +.section-skeleton { + display: flex; + flex-direction: column; + gap: var(--ac-space-3); +} +.head-skeleton { + display: flex; + align-items: center; + min-height: var(--ac-hit-min); +} diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.ts b/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.ts index 24cc0b7b..e453222f 100644 --- a/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.ts +++ b/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.ts @@ -1,6 +1,7 @@ import { Component, computed, inject, input, signal } from '@angular/core'; import { DeckStore } from '../../../core/deck-store'; import { AcButton } from '../../../ui/buttons'; +import { AcSkeleton } from '../../../ui/containers'; import { AcCardTile, AcDeckSection } from '../../../ui/metier'; import { AcOverlayService } from '../../../ui/overlay'; import { FavoritesService } from '../../../core/favorites.service'; @@ -11,7 +12,7 @@ import { openCardZoom } from '../../shared/card-zoom/card-zoom.overlay'; /** « Aperçu » / « Cartes »: count line, « Tout replier », one section per type with tiles. */ @Component({ selector: 'app-deck-preview', - imports: [AcDeckSection, AcCardTile, AcButton], + imports: [AcDeckSection, AcCardTile, AcButton, AcSkeleton], templateUrl: './deck-preview.html', styleUrl: './deck-preview.scss', }) diff --git a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html index 0a183d01..5839b7f4 100644 --- a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html +++ b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html @@ -1,10 +1,14 @@ @if (bp.compact()) { - + @if (deck.editable()) { } - @if (!deck.loadError()) { + + @for (i of deck.owned() === null && !deck.loadError() ? [0, 1] : []; track i) { + + } + @if (deck.owned() !== null && !deck.loadError()) { } - @if (!deck.loadError()) { + @if (deck.owned() !== null && !deck.loadError()) { } @@ -30,7 +34,10 @@ Retour à mes decks
} @else { -
+ @if (deck.opening()) { + {{ labels.loading }} + } +
@if (!bp.compact()) {
+ + @if (deck.owned() === null) { + @if (effectiveView() === 'apercu') { + @for (i of [0, 1, 2, 3, 4, 5]; track i) { + + } + } @else { + + + + } + } @else { } + }
@@ -118,7 +138,7 @@ @let view = effectiveView(); @switch (view) { @case ('search') { - + } @case ('apercu') { @if (bp.compact()) { @@ -131,11 +151,15 @@ } @case ('main') { + @if (deck.opening()) { + + } @else {
+ } } @default never; } @@ -150,8 +174,12 @@
} @else { } } diff --git a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.ts b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.ts index 0e52d8d7..f591aaf4 100644 --- a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.ts +++ b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.ts @@ -11,7 +11,7 @@ import { formatInfo } from '../../../core/formats'; import { AcButton, AcIconButton, MENU_POSITIONS } from '../../../ui/buttons'; import { AcSegmented } from '../../../ui/fields'; import { AcIcon } from '../../../ui/icon'; -import { AcDrawerHandle, AcDrawerState, AcDrawerTab, AcToast } from '../../../ui/containers'; +import { AcDrawerHandle, AcDrawerState, AcDrawerTab, AcSkeleton, AcToast } from '../../../ui/containers'; import { storedFlag } from '../../../core/stored-flag'; import { contentLocale } from '../../../core/locale'; import { localizedText, type Card } from '../../../core/models'; @@ -26,6 +26,7 @@ import { CardSearch } from '../../search/card-search/card-search'; import { DeckListView } from '../deck-list-view/deck-list-view'; import { DeckPanel } from '../deck-panel/deck-panel'; import { DeckPreview } from '../deck-preview/deck-preview'; +import { HandSkeleton } from '../../shared/hand-skeleton/hand-skeleton'; import { TestHand } from '../../deck/test-hand/test-hand'; import { HandStats } from '../../deck/hand-stats/hand-stats'; import { HandCalculators } from '../../deck/hand-calculators/hand-calculators'; @@ -75,6 +76,8 @@ export type EditorAction = 'share'; HandStats, HandCalculators, DeckListView, + HandSkeleton, + AcSkeleton, ], host: { '(window:beforeunload)': 'beforeUnload($event)', @@ -112,6 +115,7 @@ export class EditorPage { savedToAccount: $localize`:@@editor.share.savedToAccount:Deck enregistré sur votre compte : vous pouvez le partager.`, more: $localize`:@@editor.moreActions:Plus d’actions`, imageBusy: deckImageBusyMessage(), + loading: $localize`:@@deck.page.loading:Chargement du deck…`, }; /** The « ⋯ » menu: placed as the split buttons' menus. */ protected readonly menuPositions = MENU_POSITIONS; @@ -160,7 +164,7 @@ export class EditorPage { route: `${this.base()}/deck`, icon: 'layers', label: $localize`:@@editor.deck:Deck`, - badge: this.deck.total(), + badge: this.deck.opening() ? undefined : this.deck.total(), badgeTone: editorLegality(this.deck).state === 'legal' ? 'success' : 'dark', }, { route: `${this.base()}/main`, icon: 'hand', label: $localize`:@@editor.handShort:Main` }, diff --git a/plugins/rebuilder/app/src/app/features/search/card-search.store.ts b/plugins/rebuilder/app/src/app/features/search/card-search.store.ts index 87bf57d6..b095be00 100644 --- a/plugins/rebuilder/app/src/app/features/search/card-search.store.ts +++ b/plugins/rebuilder/app/src/app/features/search/card-search.store.ts @@ -91,6 +91,11 @@ export class CardSearchStore { readonly format = signal(null); readonly filters = signal(defaultFilters('all')); private readonly run = signal(0); + /** + * `configure()` has run: no request before, since the query depends on the context (the editor's deck: its hero's + * faction and its format, known once the deck is open). Meanwhile the search reads as loading. + */ + readonly configured = signal(false); private readonly wantMore = signal(false); private readonly query = computed(() => ({ @@ -106,6 +111,7 @@ export class CardSearchStore { private readonly pageRes = rxResource({ params: () => { const query = this.query(); + if (!this.configured()) return undefined; return LOGIN_ONLY.includes(query.source) && !this.auth.isLoggedIn() ? undefined : { query, request: this.requestedPage() }; }, stream: ({ params: { query, request } }) => { @@ -135,7 +141,7 @@ export class CardSearchStore { readonly cards = computed(() => this.state().cards); readonly total = computed(() => this.state().total); readonly page = computed(() => this.state().page); - readonly loading = computed(() => this.pageRes.isLoading()); + readonly loading = computed(() => !this.configured() || this.pageRes.isLoading()); readonly error = computed(() => { const err = this.pageRes.error(); if (!err) return null; @@ -161,7 +167,7 @@ export class CardSearchStore { effect((onCleanup) => { this.slow.set(false); - if (!this.loading()) return; + if (!this.pageRes.isLoading()) return; const timer = setTimeout(() => this.slow.set(true), SLOW_MS); onCleanup(() => clearTimeout(timer)); }); @@ -173,6 +179,7 @@ export class CardSearchStore { this.faction.set(faction); this.format.set(format); if (changedSource) this.filters.set(defaultFilters(source)); + this.configured.set(true); this.restart(); } diff --git a/plugins/rebuilder/app/src/app/features/search/card-search/card-search.ts b/plugins/rebuilder/app/src/app/features/search/card-search/card-search.ts index 64ffaf2a..199b8fb4 100644 --- a/plugins/rebuilder/app/src/app/features/search/card-search/card-search.ts +++ b/plugins/rebuilder/app/src/app/features/search/card-search/card-search.ts @@ -130,7 +130,7 @@ export class CardSearch { const format = this.format(); if (!this.ready()) return; untracked(() => { - if (source !== this.search.source() || faction !== this.search.faction() || format !== this.search.format() || (this.search.page() === 0 && !this.search.loading())) { + if (!this.search.configured() || source !== this.search.source() || faction !== this.search.faction() || format !== this.search.format() || (this.search.page() === 0 && !this.search.loading())) { this.search.configure(source, faction, format); this.draft.set(this.search.filters()); } diff --git a/plugins/rebuilder/app/src/app/features/search/search-results/search-results.html b/plugins/rebuilder/app/src/app/features/search/search-results/search-results.html index 85c8e973..5e4e29e9 100644 --- a/plugins/rebuilder/app/src/app/features/search/search-results/search-results.html +++ b/plugins/rebuilder/app/src/app/features/search/search-results/search-results.html @@ -35,7 +35,7 @@ } @if (search.loading() && grid.atEnd()) { @for (s of skeletons(); track $index) { - + } } diff --git a/plugins/rebuilder/app/src/app/features/search/search-results/search-results.scss b/plugins/rebuilder/app/src/app/features/search/search-results/search-results.scss index 3b4a0c75..17d88b40 100644 --- a/plugins/rebuilder/app/src/app/features/search/search-results/search-results.scss +++ b/plugins/rebuilder/app/src/app/features/search/search-results/search-results.scss @@ -32,17 +32,6 @@ grid-template-columns: repeat(auto-fill, minmax(max(210px, calc((100% - 6 * var(--ac-space-2)) / 7)), 1fr)); } } -.skeleton { - aspect-ratio: 5 / 7; - border-radius: var(--ac-radius-lg); - background: var(--ac-color-track); - animation: pulse 1.2s ease-in-out infinite alternate; -} -@keyframes pulse { - to { - opacity: 0.55; - } -} .list { display: flex; flex-direction: column; diff --git a/plugins/rebuilder/app/src/app/features/search/search-results/search-results.ts b/plugins/rebuilder/app/src/app/features/search/search-results/search-results.ts index 731315d7..b94f4dc4 100644 --- a/plugins/rebuilder/app/src/app/features/search/search-results/search-results.ts +++ b/plugins/rebuilder/app/src/app/features/search/search-results/search-results.ts @@ -5,7 +5,7 @@ import { DeckStore } from '../../../core/deck-store'; import { AuthSession } from '../../../core/auth-session'; import type { Card } from '../../../core/models'; import { AcButton } from '../../../ui/buttons'; -import { AcVirtualGrid } from '../../../ui/containers'; +import { AcSkeleton, AcVirtualGrid } from '../../../ui/containers'; import { AcInfiniteSentinel } from '../../../ui/infinite'; import { AcCardTile, AcDeckRow } from '../../../ui/metier'; import { AcOverlayService } from '../../../ui/overlay'; @@ -15,7 +15,7 @@ import { CardSearchStore } from '../card-search.store'; @Component({ selector: 'app-search-results', - imports: [AcCardTile, AcDeckRow, AcInfiniteSentinel, AcButton, RouterLink, AcVirtualGrid], + imports: [AcCardTile, AcDeckRow, AcInfiniteSentinel, AcButton, RouterLink, AcVirtualGrid, AcSkeleton], templateUrl: './search-results.html', styleUrl: './search-results.scss', }) diff --git a/plugins/rebuilder/app/src/app/features/shared/hand-skeleton/hand-skeleton.html b/plugins/rebuilder/app/src/app/features/shared/hand-skeleton/hand-skeleton.html new file mode 100644 index 00000000..bb5d10c4 --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/shared/hand-skeleton/hand-skeleton.html @@ -0,0 +1,23 @@ + + diff --git a/plugins/rebuilder/app/src/app/features/shared/hand-skeleton/hand-skeleton.scss b/plugins/rebuilder/app/src/app/features/shared/hand-skeleton/hand-skeleton.scss new file mode 100644 index 00000000..e1f858eb --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/shared/hand-skeleton/hand-skeleton.scss @@ -0,0 +1,30 @@ +:host { + display: flex; + flex-direction: column; + gap: var(--ac-space-6); +} +/* The test hand's layout (features/deck/test-hand): toolbar of buttons, summary line, grid of cards. */ +.test-hand, +.stats { + display: flex; + flex-direction: column; + gap: var(--ac-space-3); +} +.toolbar { + display: flex; + flex-wrap: wrap; + gap: var(--ac-space-2); +} +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(clamp(100px, 28vw, 150px), 1fr)); + gap: var(--ac-space-3); +} +.panels { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: var(--ac-space-3); +} +.panel { + height: 200px; +} diff --git a/plugins/rebuilder/app/src/app/features/shared/hand-skeleton/hand-skeleton.ts b/plugins/rebuilder/app/src/app/features/shared/hand-skeleton/hand-skeleton.ts new file mode 100644 index 00000000..9045b902 --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/shared/hand-skeleton/hand-skeleton.ts @@ -0,0 +1,18 @@ +import { Component, inject } from '@angular/core'; +import { AcSkeleton } from '../../../ui/containers'; +import { AcBreakpointService } from '../../../ui/layout.services'; + +/** + * « Main de départ » while the deck opens (DeckStore.opening): the test hand's toolbar, a hand of cards and the stats + * panels, in their layout, rather than an empty deck. + */ +@Component({ + selector: 'app-hand-skeleton', + imports: [AcSkeleton], + host: { 'aria-busy': 'true' }, + templateUrl: './hand-skeleton.html', + styleUrl: './hand-skeleton.scss', +}) +export class HandSkeleton { + protected readonly bp = inject(AcBreakpointService); +} diff --git a/plugins/rebuilder/app/src/app/ui/containers/index.ts b/plugins/rebuilder/app/src/app/ui/containers/index.ts index dc69c835..02888cfb 100644 --- a/plugins/rebuilder/app/src/app/ui/containers/index.ts +++ b/plugins/rebuilder/app/src/app/ui/containers/index.ts @@ -5,3 +5,4 @@ export * from './virtual-grid/virtual-grid'; export * from './progress-bar/progress-bar'; export * from './toast/toast'; export * from './drawer/drawer'; +export * from './skeleton/skeleton'; diff --git a/plugins/rebuilder/app/src/app/ui/containers/skeleton/skeleton.ts b/plugins/rebuilder/app/src/app/ui/containers/skeleton/skeleton.ts new file mode 100644 index 00000000..764eea6d --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/containers/skeleton/skeleton.ts @@ -0,0 +1,23 @@ +import { Directive, computed, input } from '@angular/core'; + +export type AcSkeletonShape = '' | 'text' | 'title' | 'control' | 'control-sm' | 'circle' | 'card' | 'panel'; + +/** + * Shape of content that is loading (`ac-skeleton`, design system feedback), hidden from screen readers: the loading + * region carries `aria-busy` and the page a status text. ``; `width` sets + * `--ac-skeleton-width` (100 % by default). No shape: a block the layout sizes. + */ +@Directive({ + selector: '[acSkeleton]', + host: { + class: 'ac-skeleton', + '[class]': 'variant()', + '[style.--ac-skeleton-width]': 'width()', + 'aria-hidden': 'true', + }, +}) +export class AcSkeleton { + readonly acSkeleton = input(''); + readonly width = input(null); + protected readonly variant = computed(() => (this.acSkeleton() ? `ac-skeleton--${this.acSkeleton()}` : '')); +} diff --git a/plugins/rebuilder/app/src/app/ui/nav/app-bar/app-bar.scss b/plugins/rebuilder/app/src/app/ui/nav/app-bar/app-bar.scss index d472368d..56b0f6d3 100644 --- a/plugins/rebuilder/app/src/app/ui/nav/app-bar/app-bar.scss +++ b/plugins/rebuilder/app/src/app/ui/nav/app-bar/app-bar.scss @@ -18,6 +18,12 @@ header { align-items: center; padding-right: 4px; } +/* An action in skeleton (``, owner not known yet): the size of a ghost icon button's + round, in its slot. Projected content keeps its page's scope, hence ::ng-deep. */ +.actions ::ng-deep > .ac-skeleton--circle { + --ac-skeleton-width: 36px; + margin: 0 var(--ac-space-1); +} .titles { flex-grow: 1; min-width: 0; diff --git a/plugins/rebuilder/app/src/locale/messages.en.json b/plugins/rebuilder/app/src/locale/messages.en.json index 87874212..25220542 100644 --- a/plugins/rebuilder/app/src/locale/messages.en.json +++ b/plugins/rebuilder/app/src/locale/messages.en.json @@ -197,6 +197,8 @@ "editor.share": "Share", "editor.hideDeck": "Hide the deck", "editor.deckTab": "Deck · {$INTERPOLATION} cards", + "editor.deckTabLoading": "Deck · …", + "decks.page.countLoading": "Loading…", "editor.showDeckLegal": "Show the deck: {$count} cards, valid", "editor.showDeckIssues": "Show the deck: {$count} cards, to fix", "editor.savingFirst": "Saving…", diff --git a/plugins/rebuilder/e2e/rebuilder.spec.ts b/plugins/rebuilder/e2e/rebuilder.spec.ts index 7f8b73c5..7772211f 100644 --- a/plugins/rebuilder/e2e/rebuilder.spec.ts +++ b/plugins/rebuilder/e2e/rebuilder.spec.ts @@ -165,6 +165,43 @@ test.describe('ReBuilder in the shell · signed in', () => { expect(leaks).toEqual([]); }); + test('shows the page skeleton while the scripts load, then the deck skeleton while the deck loads, never an empty deck', { tag: '@mobile' }, async ({ page }) => { + await login(page, 'alice', `${NEW_DECK}?lang=fr`); + await createDeck(page, `E2E skeleton ${Date.now()}`); + await expect(page).toHaveURL(/[?&]id=/); + const id = new URL(page.url()).searchParams.get('id')!; + + // The app's scripts and the deck are held: the server's skeleton stands for the page meanwhile. + let releaseScripts!: () => void; + const scripts = new Promise((resolve) => (releaseScripts = resolve)); + await page.route(/\/plugins\/rebuilder\/dist\/browser\/main-[^/]+\.js/, async (route) => { + await scripts; + await route.continue(); + }); + let releaseDeck!: () => void; + const deckAnswer = new Promise((resolve) => (releaseDeck = resolve)); + await page.route(new RegExp(`/api/v1/services/decks/api/decks/${id}(\\?|$)`), async (route) => { + if (route.request().method() === 'GET') await deckAnswer; + await route.continue(); + }); + await page.goto(`${EDITOR(id)}&view=apercu&lang=fr`, { waitUntil: 'domcontentloaded' }); + const placeholder = page.locator('.ac-spa-placeholder'); + await expect(placeholder.locator('.ac-skeleton').filter({ visible: true }).first()).toBeVisible(); + await expect(placeholder.getByRole('status')).toHaveText('Chargement…'); + + // The app draws the editor: the deck skeleton replaces the server's, the store's empty deck never shows. + releaseScripts(); + await expect(placeholder).toHaveCount(0); + const deckView = page.locator('app-deck-board, app-deck-preview'); + await expect(deckView.locator('.ac-skeleton').first()).toBeVisible(); + await expect(page.getByText('Deck vide')).toHaveCount(0); + await expect(page.getByText(/^0 cartes?/)).toHaveCount(0); + + releaseDeck(); + await expect(deckView.locator('.ac-skeleton')).toHaveCount(0); + await expect(page.getByText('Deck vide')).toBeVisible(); + }); + for (const { tab, short, endpoint, shot } of [ { tab: 'Collection physique', short: 'Collection', endpoint: 'collection-search', shot: '15-collection-search' }, { tab: 'Propriété numérique', short: null, endpoint: 'ownership-search', shot: '15-owned-search' }, diff --git a/plugins/rebuilder/placeholder.css b/plugins/rebuilder/placeholder.css new file mode 100644 index 00000000..f75afdce --- /dev/null +++ b/plugins/rebuilder/placeholder.css @@ -0,0 +1,101 @@ +/* Skeleton of the Re:Builder screens while the scripts load (placeholder.php). Sizes follow the app's layout + (deck-bar.scss, decks.page.scss, deck-list-view.scss, hand-skeleton.scss…): keep them in step. */ +.rbph { --rbph-panel-width: 300px; } +@media (min-width: 1200px) { .rbph { --rbph-panel-width: var(--ac-deck-panel-width); } } +.rbph-desktop, .rbph-compact { display: flex; flex-direction: column; } +.rbph-row { display: flex; align-items: center; gap: var(--ac-space-2); } +.rbph-row > .ac-skeleton, .rbph-wrap > .ac-skeleton { flex: 0 0 auto; } +.rbph-wrap { flex-wrap: wrap; } +.rbph-grow { flex: 1 1 auto !important; } +.rbph-col12 { display: flex; flex-direction: column; gap: var(--ac-space-3); } +.rbph-h44 { height: 44px; border-radius: var(--ac-radius-lg); } +.rbph-h159 { height: 159px; } +.rbph-h190 { height: 190px; } +.rbph-h200 { height: 200px; } +.rbph-h320 { height: 320px; } +.rbph-mt12 { margin-top: var(--ac-space-3); } +.rbph-icon { --ac-skeleton-width: 36px; } +.rbph-icon24 { width: 24px; height: 24px; } +.rbph-panel { background: var(--ac-color-surface); border: 1px solid var(--ac-color-border); border-radius: var(--ac-radius-card); } +.rbph-tabs { display: flex; align-items: center; gap: var(--ac-space-6); height: 44px; padding: 0 var(--ac-space-3); overflow: hidden; border-bottom: 1px solid var(--ac-color-divider); } +.rbph-tabs > .ac-skeleton { flex: 0 0 auto; } +/* Deck bar (features/deck/deck-bar) and its loading state. */ +.rbph-bar { display: flex; align-items: center; gap: var(--ac-space-5); min-height: 206px; padding-right: var(--ac-space-5); background: var(--ac-color-surface); border: 1px solid var(--ac-color-border); border-radius: var(--ac-radius-card); box-shadow: var(--ac-shadow-card); } +.rbph-bar--reduced { min-height: 100px; } +.rbph-bar__art { align-self: stretch; flex: 0 0 150px; border-radius: var(--ac-radius-card) 0 0 var(--ac-radius-card); } +.rbph-bar--reduced .rbph-bar__art { flex-basis: 96px; } +.rbph-bar__text { flex: 0 1 260px; display: flex; flex-direction: column; justify-content: center; gap: var(--ac-space-1-5); align-self: stretch; padding: var(--ac-space-2) var(--ac-space-4); border-left: 4px solid var(--ac-color-track); } +.rbph-bar__sep { align-self: stretch; width: 1px; margin: var(--ac-space-4) 0; background: var(--ac-color-divider); } +.rbph-bar__curve { flex: 0 0 306px; height: 82px; } +.rbph-bar__terrain { flex: 0 0 216px; height: 103px; } +.rbph-bar--reduced .rbph-bar__curve { flex-basis: 145px; height: 51px; } +.rbph-bar__limits { flex: 0 1 230px; height: 30px; } +.rbph-bar--reduced .rbph-bar__terrain { flex-basis: 162px; height: 30px; } +.rbph-bar__actions { margin-left: auto; display: grid; grid-template-columns: repeat(2, 120px); gap: var(--ac-space-1-5); } +.rbph-bar--reduced .rbph-bar__actions { display: flex; } +.rbph-bar--reduced .rbph-bar__actions > .ac-skeleton { width: 95px; } +/* Editor (features/editor/editor-page) from 768 px. */ +.rbph-editor { display: flex; flex-direction: row; flex-wrap: wrap; align-items: flex-start; gap: var(--ac-space-4) var(--ac-space-6); padding: var(--ac-space-6); } +.rbph-editor__top { flex: 0 0 100%; display: flex; flex-direction: column; gap: var(--ac-space-4); } +.rbph-modes { display: flex; align-items: center; justify-content: space-between; height: 48px; } +.rbph-modes__segmented { width: 377px; height: 48px; border-radius: var(--ac-radius-lg); } +.rbph-editor__main { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: var(--ac-space-4); } +.rbph-deckpanel { width: var(--rbph-panel-width); flex-shrink: 0; padding: var(--ac-space-2) var(--ac-space-3) var(--ac-space-4); } +.rbph-row34 { display: flex; align-items: center; height: 34px; padding: 0 var(--ac-space-1); } +.rbph-row36 { display: flex; align-items: center; justify-content: space-between; gap: var(--ac-space-3); height: 36px; padding: 0 var(--ac-space-1); } +.rbph-search { display: flex; gap: var(--ac-space-6); align-items: flex-start; } +.rbph-filters { width: 320px; flex-shrink: 0; height: calc(100vh - 384px); min-height: 360px; padding: var(--ac-space-4); display: flex; flex-direction: column; gap: var(--ac-space-4); } +.rbph-results { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: var(--ac-space-4); } +.rbph-toolbar { height: 40px; } +.rbph-chip { height: 32px; border-radius: var(--ac-radius-pill); } +.rbph-results__cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: var(--ac-space-2); } +.rbph-board { flex: 1 1 0; min-width: 0; padding: var(--ac-space-5) 0; } +.rbph-board__group { display: flex; flex-direction: column; gap: var(--ac-space-3); padding: 0 var(--ac-space-5); max-width: calc(4 * 260px + 3 * var(--ac-space-3) + 2 * var(--ac-space-5)); } +.rbph-board__cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--ac-space-4) var(--ac-space-3); } +.rbph-decktab { width: 32px; height: 175px; flex-shrink: 0; margin-right: calc(-1 * var(--ac-space-6)); border-radius: var(--ac-radius-card) 0 0 var(--ac-radius-card); } +.rbph-hand { display: flex; flex-direction: column; gap: var(--ac-space-6); } +.rbph-hand__cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px, 28vw, 150px), 1fr)); gap: var(--ac-space-3); } +.rbph-hand__panels { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: var(--ac-space-3); } +/* Deck page from 768 px. */ +.rbph-deckpage { display: flex; flex-direction: column; gap: var(--ac-space-5); padding: var(--ac-space-6); } +.rbph-deckpage .rbph-board { flex: none; } +.rbph-deckpage .rbph-bar { min-height: 184px; } +/* Decks list (features/decks/decks-page) from 768 px. */ +.rbph-decks { display: flex; flex-direction: column; padding: 28px 32px var(--ac-space-8); max-width: 1600px; margin: 0 auto; } +.rbph-decks__head { display: flex; align-items: center; gap: var(--ac-space-2); height: 40px; margin-bottom: 20px; } +.rbph-decks__filters { display: flex; align-items: center; gap: var(--ac-space-2); height: 40px; margin: 16px 0 12px; } +.rbph-decks__factions { display: flex; align-items: center; gap: var(--ac-space-2); height: 32px; margin-bottom: 20px; } +.rbph-decks__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(232px, 1fr)); gap: var(--ac-space-4); } +.rbph-decks__grid > .ac-skeleton { height: 227px; } +/* New deck: the window over the page. */ +.rbph-dialog { max-width: 960px; width: calc(100% - 2 * var(--ac-page-padding)); margin: 10vh auto 0; display: grid; grid-template-columns: 3fr 2fr; gap: var(--ac-space-6); padding: var(--ac-space-6); border-radius: var(--ac-radius-dialog); background: var(--ac-color-surface); box-shadow: var(--ac-shadow-dialog); } +.rbph-dialog .rbph-col12 > div:last-child { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--ac-space-3); } +/* Phones. */ +.rbph-appbar { display: flex; align-items: center; gap: var(--ac-space-1); height: var(--ac-app-bar-height); padding: 0 var(--ac-space-3); background: var(--ac-color-surface); border-bottom: 1px solid var(--ac-color-border); } +.rbph-appbar__titles { flex: 1 1 auto; display: flex; flex-direction: column; gap: var(--ac-space-1); padding-left: var(--ac-space-1); } +.rbph-mpage { display: flex; flex-direction: column; padding: var(--ac-space-3) var(--ac-page-padding) calc(var(--ac-bottom-nav-height) + var(--ac-space-4)); } +.rbph-bleed { margin-inline: calc(-1 * var(--ac-page-padding)); } +.rbph-mchips { display: flex; gap: var(--ac-space-2); height: 36px; padding: 0 var(--ac-page-padding); overflow: hidden; } +.rbph-mchips > .ac-skeleton { flex: 0 0 auto; height: 36px; border-radius: var(--ac-radius-pill); } +.rbph-mcards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--ac-space-3); } +.rbph-msection { display: flex; flex-direction: column; gap: var(--ac-space-3); padding: var(--ac-space-3); } +.rbph-msection .rbph-mcards { gap: var(--ac-space-2-5); } +.rbph-msection .rbph-dense { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--ac-space-2); } +.rbph-mhead { display: flex; align-items: center; height: 44px; } +.rbph-mlist { display: flex; flex-direction: column; gap: var(--ac-space-3); } +.rbph-mlist > .ac-skeleton { height: 149px; } +.rbph-row51 { display: flex; align-items: center; justify-content: space-between; height: 51px; } +.rbph-nav { position: fixed; z-index: 1; left: 0; right: 0; bottom: 0; display: flex; justify-content: space-around; align-items: center; height: calc(var(--ac-bottom-nav-height) + env(safe-area-inset-bottom)); padding-bottom: env(safe-area-inset-bottom); background: var(--ac-color-surface); border-top: 1px solid var(--ac-color-border); } +.rbph-nav__item { display: flex; flex-direction: column; align-items: center; gap: var(--ac-space-1); width: 56px; } +.rbph-nav__item .ac-skeleton--circle { --ac-skeleton-width: 24px; } +@media (max-width: 767.98px) { .rbph-desktop { display: none; } } +@media (min-width: 768px) { .rbph-compact { display: none; } } +/* Rows and stacks of the phone screens (sizes of the app's search toolbar, preview bar, deck summary). */ +.rbph-row44 { height: 44px; margin: var(--ac-space-3) 0; } +.rbph-decks .rbph-row44, .rbph-mpage--decks .rbph-row44 { margin: var(--ac-space-4) 0; } +.rbph-row36 { height: 36px; } +.rbph-mb16 { margin-bottom: var(--ac-space-4); } +.rbph-gap3 { gap: var(--ac-space-3); } +.rbph-gap4 { gap: var(--ac-space-4); } +.rbph-summary { height: 132px; } +.rbph-summary__meta { flex: 1; gap: var(--ac-space-1-5); } diff --git a/plugins/rebuilder/placeholder.php b/plugins/rebuilder/placeholder.php new file mode 100644 index 00000000..cb079bbf --- /dev/null +++ b/plugins/rebuilder/placeholder.php @@ -0,0 +1,183 @@ + 'decklist', 'description' => 'description', 'main' => 'main'][$tab] ?? 'cartes'; + +/** `$n` skeletons with `$class` (variants and layout classes) and an optional `--ac-skeleton-width`. */ +$sk = function (string $class = '', int $n = 1, string $width = ''): string { + $style = $width !== '' ? ' style="--ac-skeleton-width: ' . $width . '"' : ''; + return str_repeat('', $n); +}; +/** Grid of card illustrations (5:7). */ +$cards = function (string $class, int $n) use ($sk): string { + return '
' . $sk('ac-skeleton--card', $n) . '
'; +}; +/** Deck bar (from 768 px): hero art, name, curve, rarity limits, terrain totals, actions; `reduced` in the editor. */ +$deckBar = function (bool $reduced, int $actions) use ($sk): string { + return '
' + . '' + . '
' . $sk('ac-skeleton--text', 1, '70px') . $sk('ac-skeleton--title', 1, '80%') + . ($reduced ? $sk('ac-skeleton--text', 1, '60%') : $sk('ac-skeleton--title', 1, '50%') . $sk('ac-skeleton--text', 1, '70%') . $sk('ac-skeleton--text', 1, '50%') . $sk('ac-skeleton--text', 1, '40%')) + . '
' + . '' + . ($reduced ? '' : '') + . '' + . '
' . $sk('ac-skeleton--control-sm', $actions) . '
' + . '
'; +}; +/** Phones: app bar (back, title and subtitle, icon buttons). */ +$appBar = function (int $icons, bool $back = true) use ($sk): string { + return '
' . ($back ? $sk('ac-skeleton--circle rbph-icon') : '') + . '
' . $sk('ac-skeleton--title', 1, '65%') . ($back ? $sk('ac-skeleton--text', 1, '45%') : '') . '
' + . $sk('ac-skeleton--circle rbph-icon', $icons) . '
'; +}; +/** Phones: bottom navigation with `$n` entries. */ +$bottomNav = function (int $n) use ($sk): string { + return '
' . str_repeat('' . $sk('ac-skeleton--circle') . $sk('ac-skeleton--text') . '', $n) . '
'; +}; +/** A row of tabs on a divider. */ +$tabs = function (array $widths, string $class = '') use ($sk): string { + $out = '
'; + foreach ($widths as $w) $out .= $sk('ac-skeleton--text', 1, $w); + return $out . '
'; +}; +/** Deck panel (editor, from 768 px): a type and its rows. */ +$deckPanel = function () use ($sk): string { + $rows = str_repeat('
' . $sk('ac-skeleton--text', 1, '60%') . $sk('ac-skeleton--text', 1, '56px') . '
', 5); + return '
' . $sk('ac-skeleton--text', 1, '35%') . '
' . $rows . '
'; +}; +/** « Main de départ »: toolbar, summary, hand, stats panels (features/shared/hand-skeleton). */ +$hand = function (bool $compact) use ($sk): string { + return '
' . $sk('ac-skeleton--control', 1, '150px') . $sk('ac-skeleton--control', 1, '170px') . ($compact ? '' : $sk('ac-skeleton--control', 1, '120px')) . '
' + . $sk('ac-skeleton--text', 1, 'min(320px, 80%)') . '
' . $sk('ac-skeleton--card', 6) . '
' + . '
' . $sk('ac-skeleton--text', 1, '200px') . '
' . $sk('ac-skeleton--panel rbph-h200', 3) . '
'; +}; +/** Phones: a type and its card rows (« Deck » / « Decklist »). */ +$listSection = function () use ($sk): string { + return '
' . $sk('ac-skeleton--text', 1, '35%') . '
' + . str_repeat('
' . $sk('ac-skeleton--text', 1, '55%') . $sk('ac-skeleton--text', 1, '80px') . '
', 4) . '
'; +}; +/** Board (« Aperçu » from 768 px): one group of four piles. */ +$board = function () use ($sk): string { + return '
' . $sk('ac-skeleton--text', 1, '140px') . '
' . $sk('ac-skeleton--card', 4) . '
'; +}; +?> + + + +
+ + + + + + + +
+ +
+
+ +
+ + + +
+ +
+
+ + + + +
+ +
+ +
+
+ +
+
+ + + + +
+ + + + + +
+
+
+
+
+ +
+ + +
+
+ +
+ +
+ +
+
+ + +
+
+ +
+ + + +
+ + + +
+ +
+ +
diff --git a/plugins/rebuilder/plugin.json b/plugins/rebuilder/plugin.json index 1a88dc6b..f4e8c8d9 100644 --- a/plugins/rebuilder/plugin.json +++ b/plugins/rebuilder/plugin.json @@ -16,6 +16,7 @@ "mount": "shadow", "fullwidth": false, "meta": "meta.php", + "placeholder": "placeholder.php", "beta_slugs": ["decks", "deck", "deckbuilder"], "title_en": "Re:Builder", "title_fr": "Re:Builder" From 2b7b0f40958e03c5dfe50e9f3140f5e8f6ec38f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89douard=20WILLISSECK?= Date: Wed, 7 Oct 2026 13:29:05 +0200 Subject: [PATCH 12/69] =?UTF-8?q?Re:Builder:=20the=20hero=20portrait=20ope?= =?UTF-8?q?ns=20=C2=AB=20Choisir=20un=20h=C3=A9ros=20=C2=BB=20(#142)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Re:Builder: the hero portrait opens « Choisir un héros » (desktop deck bar and mobile summary) Co-Authored-By: Claude Opus 5.5 * Re:Builder: one portrait button in the deck bar, one hero picker config Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- plugins/rebuilder/app/design/COMPONENTS.md | 2 +- .../app/features/deck/deck-bar/deck-bar.html | 21 +++++++---- .../app/features/deck/deck-bar/deck-bar.scss | 7 ++-- .../app/features/deck/deck-bar/deck-bar.ts | 4 ++- .../editor/deck-list-view/deck-list-view.html | 1 + .../editor/deck-list-view/deck-list-view.ts | 1 + .../editor/editor-page/editor.page.html | 3 +- .../editor/editor-page/editor.page.ts | 8 +++++ .../shared/hero-picker/hero-picker.overlay.ts | 19 ++++++---- .../ui/metier/deck-summary/deck-summary.html | 18 +++++++--- .../ui/metier/deck-summary/deck-summary.scss | 9 +++++ .../metier/deck-summary/deck-summary.spec.ts | 35 +++++++++++++++++++ .../ui/metier/deck-summary/deck-summary.ts | 5 ++- .../rebuilder/app/src/locale/messages.en.json | 1 + plugins/rebuilder/e2e/rebuilder.spec.ts | 30 ++++++++++++++++ 15 files changed, 140 insertions(+), 24 deletions(-) create mode 100644 plugins/rebuilder/app/src/app/ui/metier/deck-summary/deck-summary.spec.ts diff --git a/plugins/rebuilder/app/design/COMPONENTS.md b/plugins/rebuilder/app/design/COMPONENTS.md index 83126c3d..b9345626 100644 --- a/plugins/rebuilder/app/design/COMPONENTS.md +++ b/plugins/rebuilder/app/design/COMPONENTS.md @@ -122,7 +122,7 @@ Classes et états des champs et du contrôle segmenté : `design-system/docs/com | `ac-cost-chart` | `values: number[7]` (coûts 1…7+), `tone: 'main' \| 'reserve'` | — | Stats | | `ac-donut-chart` | `segments: {key, label, value, display, tone: 'character' \| 'spell' \| 'permanent'}[]`, `ariaLabel` ; anneau `conic-gradient` (parts proportionnelles à `value`, couleurs `--ac-cards-type-*`, piste `--ac-color-track` si tout vaut 0) et légende (pastille, libellé, `display` dans la couleur du segment) | — | Main de départ (composition moyenne) | | `ac-probability-bars` | `rows: {key, label, p: number \| null, warn?}[]`, `labelWidth: 'narrow' \| 'wide'`, `ariaLabel?` ; barre remplie à `p` (`--ac-color-chart-main`, `--ac-color-chart-reserve` si `warn`, filet minimal pour une valeur non nulle), pourcentage arrondi (« < 1 % », « > 99 % ») avec deux décimales au survol ; `p: null` = barre vide sans valeur | — | Main de départ (détails des stats, calculateurs) | -| `ac-deck-summary` | `deck`, `appearance: 'card' \| 'embedded'` (`embedded` dans le panneau desktop : sans le nom, déjà dans le titre) ; vignette du héros : son illustration sans cadre (`illustrations/{SET}/{REF}_FRAMELESS_T1.webp`, repli `cards/assets/` puis la carte) ; total, validité et raretés centrés ; le badge de validité est un bouton (`aria-haspopup="dialog"`) | `openSettings` → `AcOverlayService` « Réglages du deck » ; `showLegality` → « Légalité du deck » (règles du format ✓ / ✗) | éditeur desktop et mobile | +| `ac-deck-summary` | `deck`, `appearance: 'card' \| 'embedded'` (`embedded` dans le panneau desktop : sans le nom, déjà dans le titre) ; vignette du héros : son illustration sans cadre (`illustrations/{SET}/{REF}_FRAMELESS_T1.webp`, repli `cards/assets/` puis la carte), un bouton « Changer de héros » quand `editable` ; total, validité et raretés centrés ; le badge de validité est un bouton (`aria-haspopup="dialog"`) | `openSettings` → `AcOverlayService` « Réglages du deck » ; `changeHero` (vignette) → « Choisir un héros » seul ; `showLegality` → « Légalité du deck » (règles du format ✓ / ✗) | éditeur desktop et mobile | | `app-new-deck` (contenu d'overlay) | héros intégré (`ac-faction-tabs` + `ac-hero-selector`), nom pré-rempli « Deck », visibilité, formats `compact` ; colonne des héros < 500 px (fenêtres de 768 à ~950 px) : factions en 3 × 2 et héros sur 3 colonnes ; fenêtre 1080 px × (fenêtre − 80 px) via `AcOverlayService` `height: 'fill'`, plein écran en compact | `NewDeckResult` | Nouveau deck | | `ac-deck-settings` (contenu d'overlay) | `deck` ; nom (`ac-input`, requis : « Enregistrer » désactivé s'il est vide), héros + « Changer », `ac-segmented` visibilité, description (`ac-textarea`), `ac-radio-card` formats | `save`, `cancel` | réglages desktop / mobile (seul endroit pour renommer en compact) | | `ac-save-status` | `state: 'idle' \| 'pending' \| 'saving' \| 'saved' \| 'error'`, `iconOnly` (texte réservé aux lecteurs d'écran, `title` au survol, cible 44 px) ; `role=status` ; rien en `idle`, point pulsé + « Enregistrement… », coche verte + « Enregistré », alerte rouge + « Non enregistré » (le motif et « Réessayer » sont affichés par l'écran) | — | éditeur : à côté du titre (desktop), barre d'app (compact) | diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.html b/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.html index 63c2d9d5..35019b92 100644 --- a/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.html +++ b/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.html @@ -1,13 +1,20 @@
- @if (deck.hero(); as h) { - @if (editable()) { - - } @else { - - } + } + } @if (editable()) { + } @else { + + @if (hero(); as h) { + + } + + }
@if (appearance() === 'embedded') { Héros diff --git a/plugins/rebuilder/app/src/app/ui/metier/deck-summary/deck-summary.scss b/plugins/rebuilder/app/src/app/ui/metier/deck-summary/deck-summary.scss index 1080e182..e29b5240 100644 --- a/plugins/rebuilder/app/src/app/ui/metier/deck-summary/deck-summary.scss +++ b/plugins/rebuilder/app/src/app/ui/metier/deck-summary/deck-summary.scss @@ -23,6 +23,15 @@ flex-shrink: 0; background: var(--ac-color-track); } +button.thumb { + padding: 0; + border: 0; + cursor: pointer; +} +button.thumb:focus-visible { + outline: 2px solid var(--ac-color-primary); + outline-offset: 2px; +} .meta { flex-grow: 1; min-width: 0; diff --git a/plugins/rebuilder/app/src/app/ui/metier/deck-summary/deck-summary.spec.ts b/plugins/rebuilder/app/src/app/ui/metier/deck-summary/deck-summary.spec.ts new file mode 100644 index 00000000..3e3d78a5 --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/metier/deck-summary/deck-summary.spec.ts @@ -0,0 +1,35 @@ +import { TestBed } from '@angular/core/testing'; +import { AcDeckSummary } from './deck-summary'; + +const hero = { reference: 'ALT_CORE_B_AX_01_C', name: 'Sierra & Oddball', faction: 'AX' }; + +describe('AcDeckSummary', () => { + function render(editable: boolean) { + const fixture = TestBed.createComponent(AcDeckSummary); + fixture.componentRef.setInput('hero', hero); + fixture.componentRef.setInput('rarity', { C: 0, R: 0, U: 0, E: 0 }); + fixture.componentRef.setInput('editable', editable); + fixture.detectChanges(); + return fixture; + } + + it('opens « Choisir un héros » from the hero thumbnail when the deck is editable', () => { + const fixture = render(true); + let changes = 0; + let settings = 0; + fixture.componentInstance.changeHero.subscribe(() => changes++); + fixture.componentInstance.openSettings.subscribe(() => settings++); + const thumb = (fixture.nativeElement as HTMLElement).querySelector('button.thumb'); + expect(thumb?.getAttribute('aria-label')).toBe('Changer de héros'); + expect(thumb?.querySelector('ac-card-art')).not.toBeNull(); + thumb!.click(); + expect(changes).toBe(1); + expect(settings).toBe(0); + }); + + it('keeps a plain thumbnail on a read-only deck', () => { + const el = render(false).nativeElement as HTMLElement; + expect(el.querySelector('button.thumb')).toBeNull(); + expect(el.querySelector('.thumb ac-card-art')).not.toBeNull(); + }); +}); diff --git a/plugins/rebuilder/app/src/app/ui/metier/deck-summary/deck-summary.ts b/plugins/rebuilder/app/src/app/ui/metier/deck-summary/deck-summary.ts index 9608825d..e32d82cd 100644 --- a/plugins/rebuilder/app/src/app/ui/metier/deck-summary/deck-summary.ts +++ b/plugins/rebuilder/app/src/app/ui/metier/deck-summary/deck-summary.ts @@ -7,7 +7,7 @@ import { AcBadge, AcRaritySummary } from '../../chips'; import { AcIcon } from '../../icon'; import { AcCardArt } from '../card-art/card-art'; -/** Hero thumbnail + deck meta + counters; the settings button opens « Réglages du deck ». */ +/** Hero thumbnail + deck meta + counters; the thumbnail opens « Choisir un héros », the settings button « Réglages du deck ». */ @Component({ selector: 'ac-deck-summary', imports: [AcCardArt, AcIcon, AcIconButton, AcRaritySummary, AcBadge], @@ -27,6 +27,8 @@ export class AcDeckSummary { readonly appearance = input<'card' | 'embedded'>('card'); readonly editable = input(true); readonly openSettings = output(); + /** Click on the hero thumbnail (editable only). */ + readonly changeHero = output(); /** Click on the validity badge: the format's rules, passed or failed. */ readonly showLegality = output(); @@ -35,6 +37,7 @@ export class AcDeckSummary { const ref = this.hero()?.reference; return ref ? heroArtSources(ref) : null; }); + protected readonly changeHeroLabel = $localize`:@@ui.deckSummary.changeHero:Changer de héros`; protected readonly noHero = $localize`:@@ui.deckSummary.noHero:Aucun héros`; protected readonly publicLabel = $localize`:@@ui.deckSummary.public:Public`; protected readonly privateLabel = $localize`:@@ui.deckSummary.private:Privé`; diff --git a/plugins/rebuilder/app/src/locale/messages.en.json b/plugins/rebuilder/app/src/locale/messages.en.json index 25220542..e510f597 100644 --- a/plugins/rebuilder/app/src/locale/messages.en.json +++ b/plugins/rebuilder/app/src/locale/messages.en.json @@ -377,6 +377,7 @@ "ui.deckSummary.hero": "Hero", "ui.deckSummary.heroName": "Hero: {$INTERPOLATION}", "ui.deckSummary.settings": "Deck settings: hero, format, visibility", + "ui.deckSummary.changeHero": "Change hero", "ui.deckSummary.cards": "{VAR_PLURAL, plural, =1 {card} other {cards}}", "ui.deckSummary.valid": "Valid", "ui.deckSummary.noHero": "No hero", diff --git a/plugins/rebuilder/e2e/rebuilder.spec.ts b/plugins/rebuilder/e2e/rebuilder.spec.ts index 7772211f..3abe2159 100644 --- a/plugins/rebuilder/e2e/rebuilder.spec.ts +++ b/plugins/rebuilder/e2e/rebuilder.spec.ts @@ -822,6 +822,36 @@ test.describe('ReBuilder in the shell · altered.gg export', () => { }); }); +test.describe('ReBuilder in the shell · hero portrait', () => { + test('the hero portrait opens « Choisir un héros » alone; the chosen hero replaces the deck\'s', { tag: '@mobile' }, async ({ page, compact }, testInfo) => { + await login(page, 'alice', `${DECKS}?lang=fr`); + await page.getByRole('button', { name: FR.newDeck }).first().click(); + await createDeck(page, `E2E hero ${testInfo.project.name} ${Date.now()}`); + // The portrait: in the deck bar from 768 px, in the « Deck » view's summary on phones. + if (compact) await page.getByRole('navigation', { name: FR.deckNav }).getByRole('link', { name: /Deck/ }).click(); + const portrait = page.getByRole('button', { name: 'Changer de héros' }); + await expect(portrait.locator('img').first()).toBeVisible(); + await portrait.click(); + + const picker = page.getByRole('dialog', { name: 'Choisir un héros' }); + await expect(picker).toBeVisible(); + // The other settings stay behind the settings button: the picker comes alone, without « Réglages du deck ». + await expect(page.getByRole('dialog', { name: 'Réglages du deck' })).toHaveCount(0); + const other = picker.locator('ac-hero-tile button[aria-pressed="false"]').first(); + const name = ((await other.locator('.name').textContent()) ?? '').trim(); + await other.click(); + await evidence(page, testInfo, '01-hero-picker-from-portrait'); + await picker.getByRole('button', { name: 'Choisir ce héros' }).click(); + await expect(picker).toBeHidden(); + if (compact) await expect(page.locator('ac-deck-summary')).toContainText(name); + else { + // The reduced bar of « Recherche » has no hero name: « Aperçu » shows it. + await page.locator('ac-segmented').getByText(FR.viewDeck, { exact: true }).click(); + await expect(page.locator('app-deck-bar .meta')).toContainText(name); + } + }); +}); + test.describe('ReBuilder in the shell · deck page', () => { /** Creates a deck of alice's account through the relay (9 cards: not legal, the API says why), plus `extra` references. */ async function createServerDeck(page: Page, name: string, extra: string[] = []): Promise { From a24d883cf0daf2d740b1e5633f8d7848389ad6a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89douard=20WILLISSECK?= Date: Wed, 7 Oct 2026 15:37:56 +0200 Subject: [PATCH 13/69] Re:Builder: phones keep the hero and the rarity caps in the editor's app bar (#143) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Re:Builder: phones keep the hero and the rarity caps in the editor's app bar In « Recherche » and « Aperçu » on phones, the app bar of an editable deck shows the hero (it opens « Choisir un héros ») and, under the deck name, the Rares / Exaltées / Uniques against the format's caps, what the deck bar shows from 768 px. The bar stays on screen while scrolling; the condensed search head sticks below it. Its title opens « Résumé du deck »: hero, legality, a bar per capped rarity, counts per type. « Partager » moves to the « ⋯ » menu there. Also: ac-progress--success / --danger and ac-badge--dense in the design system, a [titles] slot on ac-app-bar, cached eager art (the hero after a tab change) shown without placeholder or fade, and shared helpers for the rarity labels (rarityLimitItems), the « Non légal » label and the hero art. Co-Authored-By: Claude Opus 5.5 * e2e: read the created deck's id from the editor URL, not from the POST's body On the CI, Chrome had dropped the body of the deck creation response (the editor's images fill its buffer) by the time the test read it: « response.json: No data found for resource ». The status is still checked on the response. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- design-system/css/components/chips.css | 2 + design-system/css/components/feedback.css | 3 + design-system/docs/components/chips.md | 3 +- design-system/docs/components/feedback.md | 8 ++ pages/design-system.php | 3 + plugins/rebuilder/app/design/COMPONENTS.md | 7 +- .../app/src/app/core/deck-legality.ts | 8 ++ .../rebuilder/app/src/app/core/deck-store.ts | 6 + .../app/features/deck/deck-bar/deck-bar.html | 2 +- .../app/features/deck/deck-bar/deck-bar.ts | 13 +- .../deck-overview/deck-overview.overlay.html | 58 ++++++++ .../deck-overview/deck-overview.overlay.scss | 136 ++++++++++++++++++ .../deck-overview/deck-overview.overlay.ts | 73 ++++++++++ .../editor/editor-page/editor.page.html | 39 ++++- .../editor/editor-page/editor.page.scss | 86 +++++++++++ .../editor/editor-page/editor.page.ts | 39 ++++- .../search/card-search/card-search.scss | 7 +- .../app/src/app/ui/chips/badge/badge.ts | 3 + .../containers/progress-bar/progress-bar.ts | 4 + plugins/rebuilder/app/src/app/ui/icon/icon.ts | 1 + .../src/app/ui/metier/card-art/card-art.html | 1 + .../src/app/ui/metier/card-art/card-art.scss | 3 + .../src/app/ui/metier/card-art/card-art.ts | 24 +++- .../metier/rarity-limits/rarity-limits.html | 2 +- .../metier/rarity-limits/rarity-limits.scss | 6 + .../ui/metier/rarity-limits/rarity-limits.ts | 39 +++-- .../app/src/app/ui/nav/app-bar/app-bar.html | 16 ++- .../app/src/app/ui/nav/app-bar/app-bar.ts | 2 +- .../rebuilder/app/src/locale/messages.en.json | 10 +- plugins/rebuilder/e2e/rebuilder.spec.ts | 101 +++++++++++-- 30 files changed, 646 insertions(+), 59 deletions(-) create mode 100644 plugins/rebuilder/app/src/app/features/editor/deck-overview/deck-overview.overlay.html create mode 100644 plugins/rebuilder/app/src/app/features/editor/deck-overview/deck-overview.overlay.scss create mode 100644 plugins/rebuilder/app/src/app/features/editor/deck-overview/deck-overview.overlay.ts diff --git a/design-system/css/components/chips.css b/design-system/css/components/chips.css index af1ffb94..f5d1ff45 100644 --- a/design-system/css/components/chips.css +++ b/design-system/css/components/chips.css @@ -67,6 +67,8 @@ text-decoration: none; } .ac-badge--lg { height: 28px; } +/* Dense: tighter padding and gap, for a row of badges where room is short (a phone's app bar). */ +.ac-badge--dense { padding: 0 var(--ac-space-1-5); gap: 3px; } .ac-badge--blue { background: var(--ac-color-primary-soft); color: var(--ac-color-primary-strong); } .ac-badge--green { background: var(--ac-color-success-soft); color: var(--ac-color-success); } .ac-badge--violet { background: var(--ac-color-accent-soft); color: var(--ac-color-accent); } diff --git a/design-system/css/components/feedback.css b/design-system/css/components/feedback.css index 9950802c..3ed97671 100644 --- a/design-system/css/components/feedback.css +++ b/design-system/css/components/feedback.css @@ -119,6 +119,9 @@ /* ---- Progress bar ---- */ .ac-progress { height: 6px; border-radius: var(--ac-radius-pill); background: var(--ac-color-track); overflow: hidden; } .ac-progress__bar { height: 100%; width: 0%; border-radius: var(--ac-radius-pill); background: var(--ac-color-primary); transition: width 0.3s ease; } +/* A bar measured against a cap: green when it is reached, red when it is exceeded. */ +.ac-progress--success .ac-progress__bar { background: var(--ac-color-success); } +.ac-progress--danger .ac-progress__bar { background: var(--ac-color-danger); } /* ---- Table ---- */ .ac-table-wrap { diff --git a/design-system/docs/components/chips.md b/design-system/docs/components/chips.md index 9f7d68fd..0edc572e 100644 --- a/design-system/docs/components/chips.md +++ b/design-system/docs/components/chips.md @@ -51,7 +51,8 @@ Active filter with a remove button (the whole chip removes the filter): ## Badge — `ac-badge` Pill, 24 px tall (`ac-badge--lg`: 28 px), 12 px bold text, soft background with the matching -text colour. +text colour. `ac-badge--dense` tightens the padding and the gap, for a row of badges where room is short +(the rarities in a phone's app bar). ```php diff --git a/design-system/docs/components/feedback.md b/design-system/docs/components/feedback.md index 15a3c5ba..4163068d 100644 --- a/design-system/docs/components/feedback.md +++ b/design-system/docs/components/feedback.md @@ -218,6 +218,14 @@ over 0.3 s.
``` +A bar measured against a cap (cards of a rarity against the format's maximum) takes a tone: +`ac-progress--success` when the cap is reached, `ac-progress--danger` when it is exceeded. + +| Class | Use | +|---|---| +| `ac-progress--success` | Green bar: the cap is reached. | +| `ac-progress--danger` | Red bar: the cap is exceeded. | + ## Table — `ac-table` Wrap the table in `ac-table-wrap` (border, 14 px radius, surface, horizontal scroll on narrow diff --git a/pages/design-system.php b/pages/design-system.php index b0cd7a11..315a72aa 100644 --- a/pages/design-system.php +++ b/pages/design-system.php @@ -315,6 +315,7 @@ Invalid Neutral Large + Dense NEW UNIQUE BANNED @@ -498,6 +499,8 @@
Loading…
+
+
Loading… diff --git a/plugins/rebuilder/app/design/COMPONENTS.md b/plugins/rebuilder/app/design/COMPONENTS.md index b9345626..a55f5a33 100644 --- a/plugins/rebuilder/app/design/COMPONENTS.md +++ b/plugins/rebuilder/app/design/COMPONENTS.md @@ -75,7 +75,7 @@ Classes et états des champs et du contrôle segmenté : `design-system/docs/com | `ac-chip`, CSS DS (`chips.css` : ` } diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.ts b/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.ts index 9be83c54..44efc87b 100644 --- a/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.ts +++ b/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.ts @@ -1,8 +1,7 @@ import { Component, ElementRef, Injector, afterNextRender, computed, inject, input, output, signal } from '@angular/core'; import { NgTemplateOutlet } from '@angular/common'; import { RouterLink } from '@angular/router'; -import { heroArtSources } from '../../../core/card-art'; -import type { DeckLegality } from '../../../core/deck-legality'; +import { illegalLabel, type DeckLegality } from '../../../core/deck-legality'; import { rarityLimits } from '../../../core/deck-rules'; import { DeckStore } from '../../../core/deck-store'; import { formatInfo } from '../../../core/formats'; @@ -46,21 +45,13 @@ export class DeckBar { readonly showLegality = output(); protected readonly renaming = signal(false); - protected readonly heroArt = computed(() => { - const ref = this.deck.hero()?.reference; - return ref ? heroArtSources(ref) : null; - }); protected readonly border = computed(() => factionColor(this.deck.hero()?.faction)); protected readonly long = computed(() => this.deck.name().length > LONG_NAME); /** Rares, Exalteds and Uniques against the format's caps. */ protected readonly rarityLimits = computed(() => rarityLimits(this.deck.status(), this.deck.format())); protected readonly formatLabel = computed(() => formatInfo(this.deck.format()).label); protected readonly problems = computed(() => this.legality().rules.length + this.legality().errors.length); - protected readonly illegalLabel = computed(() => { - const n = this.problems(); - if (!this.editable() || !n) return $localize`:@@deck.page.illegal:Non légal`; - return n === 1 ? $localize`:@@deck.bar.illegalOne:Non légal · ${n}:count: problème` : $localize`:@@deck.bar.illegalMany:Non légal · ${n}:count: problèmes`; - }); + protected readonly illegalLabel = computed(() => illegalLabel(this.editable() ? this.problems() : 0)); protected readonly labels = { legalDetails: $localize`:@@deck.bar.legalDetails:Légal : voir les règles du format`, illegalDetails: $localize`:@@deck.page.illegalDetails:Non légal : voir le détail`, diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-overview/deck-overview.overlay.html b/plugins/rebuilder/app/src/app/features/editor/deck-overview/deck-overview.overlay.html new file mode 100644 index 00000000..369216d9 --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/editor/deck-overview/deck-overview.overlay.html @@ -0,0 +1,58 @@ +
+
+ + @if (deck.hero(); as h) { + + } + +
+ {{ deck.hero()?.name || noHero }} + {{ meta() }} + +
+
+ +
+ + @if (problems().length) { +
    + @for (p of problems(); track $index) { +
  • {{ p }}
  • + } +
+ } +
+ + @if (limits().length) { +
+

{{ rarityTitle() }}

+
    + @for (r of limits(); track r.key) { +
  • + + {{ r.name }} + + {{ r.count }}/{{ r.limit }} +
  • + } +
+
+ } + +
+

Composition

+

+ {{ deck.total() }}&ngsp;{deck.total(), plural, =1 {carte} other {cartes}} + @for (g of deck.groups(); track g.id) { + {{ g.count }} {{ g.label }} + } +

+
+
+ diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-overview/deck-overview.overlay.scss b/plugins/rebuilder/app/src/app/features/editor/deck-overview/deck-overview.overlay.scss new file mode 100644 index 00000000..07ed3232 --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/editor/deck-overview/deck-overview.overlay.scss @@ -0,0 +1,136 @@ +.ac-overlay-body { + display: flex; + flex-direction: column; + gap: var(--ac-space-5); + padding-bottom: var(--ac-space-5); +} +section { + display: flex; + flex-direction: column; + gap: var(--ac-space-2); +} +h3 { + margin: 0; + font-size: var(--ac-font-size-micro); + font-weight: 700; + letter-spacing: var(--ac-letter-spacing-overline); + text-transform: uppercase; + color: var(--ac-color-text-muted); +} + +/* Hero: the card's art, then its name, « faction · format · visibility » and « Changer de héros ». */ +.hero { + flex-direction: row; + align-items: center; + gap: var(--ac-space-4); +} +.art { + position: relative; + width: 76px; + aspect-ratio: 63 / 88; + flex-shrink: 0; + border-radius: var(--ac-radius-card); + overflow: hidden; + background: var(--ac-color-track); + box-shadow: var(--ac-shadow-card); +} +.who { + min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--ac-space-1); +} +.name { + font: var(--ac-font-heading); +} +.meta { + font-size: var(--ac-font-size-small); + color: var(--ac-color-text-2); + margin-bottom: var(--ac-space-1); +} + +/* Legality: the badge opens the format's rules; the failed ones are listed below. */ +.legality { + padding: var(--ac-space-3); + border-radius: var(--ac-radius-md); + background: var(--ac-color-warning-soft); +} +.legality.ok { + background: var(--ac-color-success-soft); +} +.legality-head { + display: flex; + align-items: center; + gap: var(--ac-space-2); + min-height: var(--ac-hit-min); + margin: calc(-1 * var(--ac-space-2)) 0; + padding: 0; + border: 0; + background: none; + color: var(--ac-color-text-2); + font: var(--ac-font-small); + text-align: left; + cursor: pointer; +} +.legality-head:focus-visible { + outline: 2px solid var(--ac-color-focus); + outline-offset: 2px; + border-radius: var(--ac-radius-sm); +} +.details { + flex-grow: 1; + text-align: right; +} +.legality ul { + margin: 0; + padding-left: var(--ac-space-5); + font-size: var(--ac-font-size-small); + color: var(--ac-color-text); +} + +/* Rarities against the caps: icon, name, bar, count. */ +.limits { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--ac-space-3); +} +.limits li { + display: grid; + grid-template-columns: 28px 72px minmax(0, 1fr) 48px; + align-items: center; + gap: var(--ac-space-2); + font-size: var(--ac-font-size-small); +} +.limits img { + height: 14px; + width: auto; + max-width: 100%; + justify-self: center; + display: block; +} +.limits strong { + text-align: right; + font-variant-numeric: tabular-nums; +} +.limits .at strong { + color: var(--ac-color-success); +} +.limits .over strong { + color: var(--ac-color-danger); +} + +.counts { + margin: 0; + display: flex; + flex-wrap: wrap; + gap: var(--ac-space-1) var(--ac-space-4); + font-size: var(--ac-font-size-small); + color: var(--ac-color-text-2); +} +.counts strong { + color: var(--ac-color-text); +} diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-overview/deck-overview.overlay.ts b/plugins/rebuilder/app/src/app/features/editor/deck-overview/deck-overview.overlay.ts new file mode 100644 index 00000000..dc5ba37a --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/editor/deck-overview/deck-overview.overlay.ts @@ -0,0 +1,73 @@ +import { Component, computed, inject } from '@angular/core'; +import { illegalLabel, legalityCheckDetail, legalityCheckLabel, legalityRuleLabel } from '../../../core/deck-legality'; +import { rarityLimits } from '../../../core/deck-rules'; +import { DeckStore } from '../../../core/deck-store'; +import { formatInfo } from '../../../core/formats'; +import { AcButton } from '../../../ui/buttons'; +import { AcBadge } from '../../../ui/chips'; +import { AcProgressBar } from '../../../ui/containers'; +import { AcIcon } from '../../../ui/icon'; +import { AcCardArt, factionName, rarityLimitItems } from '../../../ui/metier'; +import { AcOverlayRef, AcOverlayService } from '../../../ui/overlay'; +import { editorLegality } from '../editor-legality'; + +/** What the summary leads to once closed: the hero picker, the legality details or the « Deck » view. */ +export type DeckOverviewAction = 'hero' | 'legality' | 'deck'; + +/** The bar of a capped rarity: green at the cap, red above it. */ +const BAR_TONES = { under: 'primary', at: 'success', over: 'danger' } as const; + +/** + * « Résumé du deck » on phones, from the app bar of the search view: the hero (and « Changer de héros »), the legality, + * the rarities against the format's caps and the counts per type. It follows the deck while open. + */ +@Component({ + selector: 'app-deck-overview', + imports: [AcButton, AcBadge, AcCardArt, AcIcon, AcProgressBar], + host: { class: 'ac-overlay-content' }, + templateUrl: './deck-overview.overlay.html', + styleUrl: './deck-overview.overlay.scss', +}) +export class DeckOverviewOverlay { + protected readonly ref = inject>(AcOverlayRef); + protected readonly deck = inject(DeckStore); + + protected readonly meta = computed(() => + [ + factionName(this.deck.hero()?.faction), + formatInfo(this.deck.format()).label, + this.deck.isPublic() ? $localize`:@@editor.public:Public` : $localize`:@@editor.private:Privé`, + ] + .filter(Boolean) + .join(' · '), + ); + protected readonly legality = computed(() => editorLegality(this.deck)); + protected readonly legal = computed(() => this.legality().state === 'legal'); + protected readonly legalityLabel = computed(() => + this.legal() ? $localize`:@@deck.page.legal:Légal` : illegalLabel(this.legality().rules.length + this.legality().errors.length), + ); + /** The failed rules, with their figures when the editor computed them (« Rares : 16 / 15 »). */ + protected readonly problems = computed(() => { + const l = this.legality(); + const failed = l.checks ? l.checks.filter((c) => !c.ok).map((c) => [legalityCheckLabel(c.rule), legalityCheckDetail(c)].filter(Boolean).join(' : ')) : l.rules.map(legalityRuleLabel); + return [...failed, ...l.errors]; + }); + /** Rares, Exalteds and Uniques against the format's caps: a bar each, green at the cap, red above it. */ + protected readonly limits = computed(() => + rarityLimitItems(rarityLimits(this.deck.status(), this.deck.format())).map((r) => ({ + ...r, + tone: BAR_TONES[r.state], + value: r.limit > 0 ? Math.min(100, (r.count / r.limit) * 100) : r.count > 0 ? 100 : 0, + })), + ); + protected readonly rarityTitle = computed(() => $localize`:@@editor.overview.rarities:Raretés · ${formatInfo(this.deck.format()).label}:format:`); + protected readonly changeHeroLabel = $localize`:@@ui.deckSummary.changeHero:Changer de héros`; + protected readonly noHero = $localize`:@@ui.deckSummary.noHero:Aucun héros`; +} + +export function openDeckOverview(overlay: AcOverlayService): AcOverlayRef { + return overlay.open(DeckOverviewOverlay, { + title: $localize`:@@editor.overview.title:Résumé du deck`, + width: 480, + }); +} diff --git a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html index a03f8414..efa70f97 100644 --- a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html +++ b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html @@ -1,6 +1,34 @@ @if (bp.compact()) { - + @let deckTitle = deckAppBar(); + + + @if (deckTitle) { + + } + @if (deckTitle) { + + } @if (deck.editable()) { } @@ -8,7 +36,8 @@ @for (i of deck.owned() === null && !deck.loadError() ? [0, 1] : []; track i) { } - @if (deck.owned() !== null && !deck.loadError()) { + + @if (deck.owned() !== null && !deck.loadError() && !deckTitle) { +
+ } @if (imageSource()) { diff --git a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.scss b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.scss index 5272e485..fbc74d21 100644 --- a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.scss +++ b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.scss @@ -35,6 +35,12 @@ ac-app-bar.static { --app-search-bottom-inset: calc(var(--ac-bottom-nav-height) + env(safe-area-inset-bottom)); } +/* The app bar stays on screen with the hero and the rarities (phones, editable deck): the search head sticks below it. */ +.search.under-app-bar { + --app-search-top: var(--ac-app-bar-height); + --app-search-safe-top: 0px; +} + .banner { display: flex; align-items: center; @@ -190,3 +196,83 @@ ac-app-bar.static { flex-direction: column; gap: var(--ac-space-6); } + +/* ---------- App bar of the search and « Aperçu » views (phones) ---------- */ +/* Projected into ac-app-bar: the hero opens « Choisir un héros », the title « Résumé du deck ». */ +.bar-hero { + position: relative; + flex-shrink: 0; + width: var(--ac-hit-min); + height: var(--ac-hit-min); + padding: 0; + border: 0; + background: none; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} +.bar-hero-art { + position: relative; + width: 36px; + height: 36px; + border-radius: var(--ac-radius-pill); + overflow: hidden; + background: var(--ac-color-track); + box-shadow: 0 0 0 2px var(--ac-color-surface), 0 0 0 3px var(--ac-color-border-control); +} +.bar-hero-swap { + position: absolute; + right: 0; + bottom: 0; + width: 18px; + height: 18px; + border-radius: var(--ac-radius-pill); + background: var(--ac-color-surface); + border: 1px solid var(--ac-color-border); + color: var(--ac-color-primary-strong); + display: inline-flex; + align-items: center; + justify-content: center; +} +.bar-deck { + flex-grow: 1; + min-width: 0; + overflow: hidden; + min-height: var(--ac-hit-min); + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 3px; + padding: 0 var(--ac-space-1); + border: 0; + border-radius: var(--ac-radius-md); + background: none; + color: var(--ac-color-text); + text-align: left; + cursor: pointer; +} +.bar-deck-name { + max-width: 100%; + font-size: var(--ac-font-size-heading); + font-weight: 800; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.bar-deck-sub { + max-width: 100%; + display: flex; + align-items: center; + gap: var(--ac-space-1); + font-size: var(--ac-font-size-small); + color: var(--ac-color-text-muted); + white-space: nowrap; +} +.bar-hero:focus-visible, +.bar-deck:focus-visible { + outline: 2px solid var(--ac-color-focus); + outline-offset: -2px; +} diff --git a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.ts b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.ts index aa15bf41..7c718525 100644 --- a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.ts +++ b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.ts @@ -8,6 +8,7 @@ import { AuthSession } from '../../../core/auth-session'; import { DeckStore } from '../../../core/deck-store'; import { GuestDeckService } from '../../../core/guest-deck.service'; import { formatInfo } from '../../../core/formats'; +import { rarityLimits } from '../../../core/deck-rules'; import { AcButton, AcIconButton, MENU_POSITIONS } from '../../../ui/buttons'; import { AcSegmented } from '../../../ui/fields'; import { AcIcon } from '../../../ui/icon'; @@ -15,12 +16,13 @@ import { AcDrawerHandle, AcDrawerState, AcDrawerTab, AcSkeleton, AcToast } from import { storedFlag } from '../../../core/stored-flag'; import { contentLocale } from '../../../core/locale'; import { localizedText, type Card } from '../../../core/models'; -import { AcSaveStatus } from '../../../ui/metier'; +import { AcCardArt, AcRarityLimits, AcSaveStatus, rarityLimitItems } from '../../../ui/metier'; import { AcBreakpointService } from '../../../ui/layout.services'; import { AcAppBar, AcBackButton, AcBottomNav, type AcBottomNavItem } from '../../../ui/nav'; import { AcOverlayService } from '../../../ui/overlay'; import { openDeckSettings } from '../../shared/deck-settings/deck-settings.overlay'; import { openHeroPicker } from '../../shared/hero-picker/hero-picker.overlay'; +import { openDeckOverview } from '../deck-overview/deck-overview.overlay'; import { EditorAltArts } from '../editor-alt-arts'; import { CardSearchStore } from '../../search/card-search.store'; import { CardSearch } from '../../search/card-search/card-search'; @@ -70,6 +72,8 @@ export type EditorAction = 'share'; AcIconButton, AcIcon, AcSaveStatus, + AcCardArt, + AcRarityLimits, CardSearch, DeckPanel, DeckPreview, @@ -156,6 +160,30 @@ export class EditorPage { private readonly apercuDrawer = new AcDrawerState(signal(false), 'end', this.drawerFocus); protected readonly deckDrawer = computed(() => (this.effectiveView() === 'apercu' ? this.apercuDrawer : this.searchDrawer)); protected readonly subtitle = computed(() => `${this.formatLabel()} · ${this.deck.isPublic() ? this.labels.public : this.labels.private}`); + /** + * Phones, search and « Aperçu » views of an editable deck: the app bar takes the hero (« Choisir un héros ») and, as its title, the deck + * name over the rarities against the format's caps (« Résumé du deck »), what the deck bar shows from 768 px. + */ + protected readonly deckAppBar = computed(() => this.bp.compact() && (this.view() === 'search' || this.view() === 'apercu') && this.deck.editable() && !this.deck.opening() && !this.deck.loadError()); + protected readonly rarityLimits = computed(() => rarityLimits(this.deck.status(), this.deck.format())); + protected readonly heroButtonLabel = computed(() => { + const name = this.deck.hero()?.name; + return name ? $localize`:@@editor.changeHeroOf:Changer de héros (${name}:name:)` : $localize`:@@ui.deckSummary.changeHero:Changer de héros`; + }); + /** The button's name replaces its content: it carries the rarities too, as the badges' labels. */ + /** With the deck title (`deckAppBar`), the app bar's own title and subtitle are left empty. */ + protected readonly appBarTitle = computed(() => { + if (this.deckAppBar()) return ''; + if (this.deck.opening()) return this.labels.loading; + return this.view() === 'deck' ? this.labels.myDeck : this.deck.name() || 'Deck'; + }); + protected readonly appBarSubtitle = computed(() => (this.deckAppBar() || this.view() === 'deck' || this.deck.opening() ? '' : this.subtitle())); + protected readonly overviewLabel = computed(() => + [ + $localize`:@@editor.overviewOf:Résumé du deck ${this.deck.name()}:name:`, + ...rarityLimitItems(this.rarityLimits()).map((r) => r.label), + ].join(', '), + ); protected readonly effectiveView = computed(() => (this.view() === 'deck' && !this.bp.compact() ? 'search' : this.view())); protected readonly base = computed(() => `/decks/${this.id()}/edit`); protected readonly navItems = computed(() => [ @@ -355,6 +383,15 @@ export class EditorPage { }); } + /** « Résumé du deck » (phones, from the app bar's title): it closes on the hero picker, the legality or the « Deck » view. */ + protected openOverview(): void { + openDeckOverview(this.overlay).afterClosed.subscribe((action) => { + if (action === 'hero') this.changeHero(); + else if (action === 'legality') this.showLegality(); + else if (action === 'deck') void this.router.navigateByUrl(`${this.base()}/deck`); + }); + } + /** * Tab closed, reloaded, or a site link followed: pending changes go out as a `keepalive` request * (it outlives the page); the browser asks for confirmation only when they may still be lost. diff --git a/plugins/rebuilder/app/src/app/features/search/card-search/card-search.scss b/plugins/rebuilder/app/src/app/features/search/card-search/card-search.scss index b34dff58..6286faae 100644 --- a/plugins/rebuilder/app/src/app/features/search/card-search/card-search.scss +++ b/plugins/rebuilder/app/src/app/features/search/card-search/card-search.scss @@ -1,6 +1,7 @@ /* The host page pads the content with `var(--ac-space-3) var(--ac-page-padding)` on compact screens; the sticky search head bleeds into that padding. `--app-search-bottom-inset` lifts the « back to top » - button above a bottom navigation. */ + button above a bottom navigation; `--app-search-top` sticks the head below a sticky bar of the host page, which then + keeps clear of the notch (`--app-search-safe-top: 0px`). */ :host { display: flex; flex-direction: column; @@ -9,7 +10,7 @@ .search-head { position: sticky; - top: var(--ac-page-top); + top: calc(var(--ac-page-top) + var(--app-search-top, 0px)); z-index: 15; margin: calc(-1 * var(--ac-space-3)) calc(-1 * var(--ac-page-padding)) 0; padding: var(--ac-space-3) var(--ac-page-padding) 0; @@ -24,7 +25,7 @@ background: var(--ac-color-surface); border-bottom: 1px solid var(--ac-color-border); box-shadow: var(--ac-shadow-e2); - padding-top: calc(var(--ac-space-2) + env(safe-area-inset-top)); + padding-top: calc(var(--ac-space-2) + var(--app-search-safe-top, env(safe-area-inset-top))); } .search-row { diff --git a/plugins/rebuilder/app/src/app/ui/chips/badge/badge.ts b/plugins/rebuilder/app/src/app/ui/chips/badge/badge.ts index a7e56b35..578cbe64 100644 --- a/plugins/rebuilder/app/src/app/ui/chips/badge/badge.ts +++ b/plugins/rebuilder/app/src/app/ui/chips/badge/badge.ts @@ -14,6 +14,7 @@ import type { AcTone } from '../../fields'; '[class.ac-badge--red]': "tone() === 'red'", '[class.ac-badge--orange]': "tone() === 'orange'", '[class.ac-badge--lg]': 'size() === 28', + '[class.ac-badge--dense]': 'dense()', }, templateUrl: './badge.html', }) @@ -21,4 +22,6 @@ export class AcBadge { readonly tone = input('blue'); readonly icon = input(undefined); readonly size = input<24 | 28>(24); + /** Tighter padding and gap, for a row of badges where room is short. */ + readonly dense = input(false); } diff --git a/plugins/rebuilder/app/src/app/ui/containers/progress-bar/progress-bar.ts b/plugins/rebuilder/app/src/app/ui/containers/progress-bar/progress-bar.ts index 86a83417..ba4d0711 100644 --- a/plugins/rebuilder/app/src/app/ui/containers/progress-bar/progress-bar.ts +++ b/plugins/rebuilder/app/src/app/ui/containers/progress-bar/progress-bar.ts @@ -10,6 +10,8 @@ import { Component, input } from '@angular/core'; 'aria-valuemax': '100', '[attr.aria-valuenow]': 'value()', '[attr.aria-label]': 'ariaLabel() || null', + '[class.ac-progress--success]': "tone() === 'success'", + '[class.ac-progress--danger]': "tone() === 'danger'", }, templateUrl: './progress-bar.html', styleUrl: './progress-bar.scss', @@ -17,4 +19,6 @@ import { Component, input } from '@angular/core'; export class AcProgressBar { readonly value = input(0); readonly ariaLabel = input(''); + /** A bar measured against a cap: `success` when it is reached, `danger` when it is exceeded. */ + readonly tone = input<'primary' | 'success' | 'danger'>('primary'); } diff --git a/plugins/rebuilder/app/src/app/ui/icon/icon.ts b/plugins/rebuilder/app/src/app/ui/icon/icon.ts index 92c27d6d..84cd90e6 100644 --- a/plugins/rebuilder/app/src/app/ui/icon/icon.ts +++ b/plugins/rebuilder/app/src/app/ui/icon/icon.ts @@ -13,6 +13,7 @@ export const AC_ICONS = { plus: '', minus: '', 'chevron-down': '', + 'arrow-left-right': '', 'chevron-up': '', 'chevron-left': '', 'chevron-right': '', diff --git a/plugins/rebuilder/app/src/app/ui/metier/card-art/card-art.html b/plugins/rebuilder/app/src/app/ui/metier/card-art/card-art.html index 085bcbb4..8f30bafa 100644 --- a/plugins/rebuilder/app/src/app/ui/metier/card-art/card-art.html +++ b/plugins/rebuilder/app/src/app/ui/metier/card-art/card-art.html @@ -3,6 +3,7 @@ } @if (visible() && src(); as s) { ); private readonly viewport = inject(AcViewportLoader); /** Art starts downloading once the tile is ~1.5 screens from the viewport (or immediately when eager). */ - protected readonly visible = signal(false); + private readonly inView = signal(false); readonly reference = input.required(); readonly faction = input(null); readonly placeholder = input($localize`:@@ui.cardArt.placeholder:Visuel de la carte`); @@ -47,11 +48,26 @@ export class AcCardArt implements OnDestroy { */ protected readonly loaded = linkedSignal({ source: this.src, computation: () => false }); protected readonly tint = computed(() => factionColor(this.faction())); + /** Eager art is in the first render: no frame of placeholder before it. */ + protected readonly visible = computed(() => this.eager() || this.inView()); + private readonly img = viewChild>('img'); + /** + * Eager art already in the browser's cache (the same hero after the editor's view changed, which renders the page + * again): shown at once, without the placeholder and the fade of an image that downloads. + */ + protected readonly instant = linkedSignal({ source: this.src, computation: () => false }); constructor() { afterNextRender(() => { - if (this.eager()) this.visible.set(true); - else this.viewport.observe(this.host.nativeElement, () => this.visible.set(true)); + if (!this.eager()) this.viewport.observe(this.host.nativeElement, () => this.inView.set(true)); + }); + // Eager art only (portraits): the tiles of a grid load lazily and keep their fade, without this check each. + afterRenderEffect(() => { + if (!this.eager()) return; + const img = this.img()?.nativeElement; + if (!img || this.loaded() || !img.complete || !img.naturalWidth) return; + this.instant.set(true); + this.loaded.set(true); }); } diff --git a/plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.html b/plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.html index d238174c..ce1557ea 100644 --- a/plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.html +++ b/plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.html @@ -1,3 +1,3 @@ @for (r of items(); track r.key) { - {{ r.count }}/{{ r.limit }} + {{ r.count }}/{{ r.limit }} } diff --git a/plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.scss b/plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.scss index 0e284e29..9e5338f1 100644 --- a/plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.scss +++ b/plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.scss @@ -4,8 +4,14 @@ gap: var(--ac-space-1-5); flex-shrink: 0; } +:host(.dense) { + gap: var(--ac-space-1); +} img { height: 13px; width: auto; display: block; } +:host(.dense) img { + height: 10px; +} diff --git a/plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.ts b/plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.ts index cfd21e4c..dd135f95 100644 --- a/plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.ts +++ b/plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.ts @@ -2,28 +2,43 @@ import { Component, computed, input } from '@angular/core'; import type { RarityLimit } from '../../../core/deck-rules'; import { AcBadge, RARITY_ICONS } from '../../chips'; +/** A capped rarity as shown: its icon, its name, `under` / `at` / `over` the cap, and « Rare : 12 sur 15 maximum ». */ +export interface RarityLimitItem extends RarityLimit { + icon: string; + name: string; + state: 'under' | 'at' | 'over'; + label: string; +} + +export function rarityLimitItems(limits: readonly RarityLimit[]): RarityLimitItem[] { + return limits.map((r) => { + const { src: icon, label: name } = RARITY_ICONS.find((i) => i.key === r.key)!; + return { + ...r, + icon, + name, + state: r.count > r.limit ? 'over' : r.count === r.limit ? 'at' : 'under', + label: $localize`:@@ui.rarityLimits.item:${name}:rarity: : ${r.count}:count: sur ${r.limit}:limit: maximum`, + }; + }); +} + +const TONES = { under: 'neutral', at: 'green', over: 'red' } as const; + /** * Rares, Exalteds and Uniques against the format's caps, one badge each: grey below the cap, green at it, red above. */ @Component({ selector: 'ac-rarity-limits', imports: [AcBadge], - host: { role: 'group', '[attr.aria-label]': 'groupLabel' }, + host: { role: 'group', '[attr.aria-label]': 'groupLabel', '[class.dense]': 'dense()' }, templateUrl: './rarity-limits.html', styleUrl: './rarity-limits.scss', }) export class AcRarityLimits { readonly limits = input.required(); + /** Dense badges and smaller icons: a phone's app bar. */ + readonly dense = input(false); protected readonly groupLabel = $localize`:@@ui.rarityLimits.label:Raretés limitées par le format`; - protected readonly items = computed(() => - this.limits().map((r) => { - const icon = RARITY_ICONS.find((i) => i.key === r.key)!; - return { - ...r, - icon: icon.src, - tone: r.count > r.limit ? ('red' as const) : r.count === r.limit ? ('green' as const) : ('neutral' as const), - label: $localize`:@@ui.rarityLimits.item:${icon.label}:rarity: : ${r.count}:count: sur ${r.limit}:limit: maximum`, - }; - }), - ); + protected readonly items = computed(() => rarityLimitItems(this.limits()).map((r) => ({ ...r, tone: TONES[r.state] }))); } diff --git a/plugins/rebuilder/app/src/app/ui/nav/app-bar/app-bar.html b/plugins/rebuilder/app/src/app/ui/nav/app-bar/app-bar.html index b78bb2f3..f2bd27bf 100644 --- a/plugins/rebuilder/app/src/app/ui/nav/app-bar/app-bar.html +++ b/plugins/rebuilder/app/src/app/ui/nav/app-bar/app-bar.html @@ -1,10 +1,14 @@
-
- {{ title() }} - @if (subtitle()) { - {{ subtitle() }} - } -
+ @if (title() || subtitle()) { +
+ {{ title() }} + @if (subtitle()) { + {{ subtitle() }} + } +
+ } + +
diff --git a/plugins/rebuilder/app/src/app/ui/nav/app-bar/app-bar.ts b/plugins/rebuilder/app/src/app/ui/nav/app-bar/app-bar.ts index da74d3cc..1699761e 100644 --- a/plugins/rebuilder/app/src/app/ui/nav/app-bar/app-bar.ts +++ b/plugins/rebuilder/app/src/app/ui/nav/app-bar/app-bar.ts @@ -2,7 +2,7 @@ import { Component, input } from '@angular/core'; /** * Application bar of compact screens (the site draws its own header above): projected `[leading]`, - * title / subtitle, projected `[actions]`. + * title / subtitle (or a projected `[titles]` when both are empty), projected `[actions]`. */ @Component({ selector: 'ac-app-bar', diff --git a/plugins/rebuilder/app/src/locale/messages.en.json b/plugins/rebuilder/app/src/locale/messages.en.json index e510f597..a5e86021 100644 --- a/plugins/rebuilder/app/src/locale/messages.en.json +++ b/plugins/rebuilder/app/src/locale/messages.en.json @@ -770,6 +770,14 @@ "ui.costCurve.reserve": "Reserve", "ui.costCurve.bucket": "Cost {$cost}: {$main} in hand, {$reserve} in reserve", "ui.costCurve.label": "Cost curve: hand and reserve", - "search.filters.favoritesLimited": "In favorites, only the faction, set and rarity filter the cards." + "search.filters.favoritesLimited": "In favorites, only the faction, set and rarity filter the cards.", + "editor.overview.title": "Deck summary", + "editor.overview.rarities": "Rarities · {$format}", + "editor.overview.rules": "Format rules", + "editor.overview.composition": "Composition", + "editor.overview.cards": "{VAR_PLURAL, plural, =1 {card} other {cards}}", + "editor.overview.openDeck": "Open the Deck tab", + "editor.changeHeroOf": "Change hero ({$name})", + "editor.overviewOf": "Deck summary {$name}" } } diff --git a/plugins/rebuilder/e2e/rebuilder.spec.ts b/plugins/rebuilder/e2e/rebuilder.spec.ts index 3abe2159..91989d96 100644 --- a/plugins/rebuilder/e2e/rebuilder.spec.ts +++ b/plugins/rebuilder/e2e/rebuilder.spec.ts @@ -80,6 +80,15 @@ const NEW_DECK = '/pages/deckbuilder'; const DECK = (id: string) => `/pages/deck?id=${id}`; const EDITOR = (id: string) => `/pages/deckbuilder?id=${id}`; /** The page URL is `path`, optionally followed by more query parameters (`&lang=…`) when `more`. */ +/** + * The id of the account deck the editor opened. Read from the URL rather than from the POST's body: Chrome may have + * dropped that body (the editor's images fill its buffer) by the time the test asks for it. + */ +async function editorDeckId(page: Page): Promise { + await expect(page).toHaveURL(/\/pages\/deckbuilder\?id=(?!guest-)[^&]+$/); + return new URL(page.url()).searchParams.get('id')!; +} + const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const at = (path: string, more = false) => new RegExp(`${escape(path)}${more ? '(&|$)' : '$'}`); @@ -102,10 +111,8 @@ test.describe('ReBuilder in the shell · signed in', () => { await page.getByRole('button', { name: FR.newDeck }).first().click(); const created = page.waitForResponse((r) => r.request().method() === 'POST' && new URL(r.url()).pathname === '/api/v1/services/decks/api/decks'); await createDeck(page, name); - const res = await created; - expect(res.status()).toBe(201); - const deck = (await res.json()) as { id: string }; - await expect(page).toHaveURL(at(EDITOR(deck.id))); + expect((await created).status()).toBe(201); + const deck = { id: await editorDeckId(page) }; // The save of the final state (hero + 2 cards): with the 400 ms autosave delay, a slow runner may save each card apart. const saved = page.waitForResponse((r) => { @@ -852,6 +859,73 @@ test.describe('ReBuilder in the shell · hero portrait', () => { }); }); +test.describe('ReBuilder in the shell · app bar of the search and « Aperçu » views (phones)', () => { + test('the hero opens « Choisir un héros », the title « Résumé du deck » with the rarities against the caps', { tag: '@mobile' }, async ({ page, compact }, testInfo) => { + test.skip(!compact, 'from 768 px the deck bar shows the hero and the rarities'); + await login(page, 'alice', `${DECKS}?lang=fr`); + await page.getByRole('button', { name: FR.newDeck }).first().click(); + await createDeck(page, `E2E barre ${testInfo.project.name} ${Date.now()}`); + await addTwoCards(page); + + // The app bar: the hero, then the deck name over the rarities the format caps. + const bar = page.locator('ac-app-bar'); + await expect(bar.getByRole('group', { name: 'Raretés limitées par le format' })).toContainText('/15'); + await evidence(page, testInfo, '60-app-bar-search'); + + // Down the results the bar stays, and the condensed search head sticks below it. + await page.mouse.wheel(0, 1500); + await expect(page.locator('.search-head.condensed')).toBeVisible(); + await expect(bar.getByRole('group', { name: 'Raretés limitées par le format' })).toBeInViewport(); + const barBox = await bar.boundingBox(); + const headBox = await page.locator('.search-head').boundingBox(); + expect(headBox!.y).toBeGreaterThanOrEqual(barBox!.y + barBox!.height - 1); + await evidence(page, testInfo, '62-app-bar-scrolled'); + await page.evaluate(() => window.scrollTo(0, 0)); + + await bar.getByRole('button', { name: /^Changer de héros/ }).click(); + const picker = page.getByRole('dialog', { name: 'Choisir un héros' }); + await expect(picker).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(picker).toBeHidden(); + + // « Résumé du deck »: hero, legality, a bar per capped rarity, counts per type. + await bar.getByRole('button', { name: /^Résumé du deck/ }).click(); + const overview = page.getByRole('dialog', { name: 'Résumé du deck' }); + await expect(overview).toContainText('Raretés · Standard All Uniques'); + await expect(overview.getByRole('progressbar', { name: /^Rare : \d+ sur 15 maximum$/ })).toBeVisible(); + await expect(overview).toContainText('2 cartes'); + await evidence(page, testInfo, '61-deck-overview'); + + // « Changer de héros » closes the summary for the picker (no window over another); the new hero shows in the bar. + await overview.getByRole('button', { name: 'Changer de héros' }).click(); + await expect(overview).toBeHidden(); + await expect(picker).toBeVisible(); + const other = picker.locator('ac-hero-tile button[aria-pressed="false"]').first(); + const name = ((await other.locator('.name').textContent()) ?? '').trim(); + await other.click(); + await picker.getByRole('button', { name: 'Choisir ce héros' }).click(); + await expect(picker).toBeHidden(); + await expect(bar.getByRole('button', { name: `Changer de héros (${name})` })).toBeVisible(); + + // « Ouvrir l’onglet Deck » leads to the deck view. + await bar.getByRole('button', { name: /^Résumé du deck/ }).click(); + await overview.getByRole('button', { name: 'Ouvrir l’onglet Deck' }).click(); + await expect(page.locator('ac-deck-summary')).toContainText(name); + + // « Aperçu » has the same bar, kept on screen down the deck. + await page.getByRole('navigation', { name: FR.deckNav }).getByRole('link', { name: /Aperçu/ }).click(); + await expect(bar.getByRole('group', { name: 'Raretés limitées par le format' })).toContainText('/15'); + await expect(bar.getByRole('button', { name: `Changer de héros (${name})` })).toBeVisible(); + await page.mouse.wheel(0, 800); + await expect(bar.getByRole('group', { name: 'Raretés limitées par le format' })).toBeInViewport(); + await evidence(page, testInfo, '63-app-bar-apercu'); + + // Back to « Recherche »: the page renders again, the hero's art comes from the cache at once (no placeholder, no fade). + await page.getByRole('navigation', { name: FR.deckNav }).getByRole('link', { name: /Recherche/ }).click(); + await expect(bar.locator('.bar-hero ac-card-art')).toHaveClass(/\binstant\b/); + }); +}); + test.describe('ReBuilder in the shell · deck page', () => { /** Creates a deck of alice's account through the relay (9 cards: not legal, the API says why), plus `extra` references. */ async function createServerDeck(page: Page, name: string, extra: string[] = []): Promise { @@ -1072,8 +1146,13 @@ test.describe('ReBuilder in the shell · guest', () => { }); test.describe('ReBuilder in the shell · « Partager » in the editor', () => { - /** « Partager »: a button on desktop, an icon in the app bar on mobile. */ - const shareButton = (page: Page) => page.locator('app-editor-page').getByRole('button', { name: 'Partager', exact: true }); + /** « Partager »: a button on desktop; on phones, in the app bar's « ⋯ » menu (the search view's title takes the room). */ + const share = async (page: Page, compact: boolean) => { + const editor = page.locator('app-editor-page'); + if (!compact) return editor.getByRole('button', { name: 'Partager', exact: true }).click(); + await page.locator('ac-app-bar').getByRole('button', { name: 'Plus d’actions' }).click(); + await page.getByRole('menuitem', { name: 'Partager' }).click(); + }; test('saves the deck first, then shares it', { tag: '@mobile' }, async ({ page, compact }, testInfo) => { const name = `E2E share ${testInfo.project.name} ${Date.now()}`; @@ -1081,15 +1160,15 @@ test.describe('ReBuilder in the shell · « Partager » in the editor', () => { await page.getByRole('button', { name: FR.newDeck }).first().click(); const created = page.waitForResponse((r) => r.request().method() === 'POST' && new URL(r.url()).pathname === '/api/v1/services/decks/api/decks'); await createDeck(page, name); - const deck = (await (await created).json()) as { id: string }; - await expect(page).toHaveURL(at(EDITOR(deck.id))); + expect((await created).status()).toBe(201); + const deck = { id: await editorDeckId(page) }; // « Partager » right after a change: no wait for the autosave delay, the change is sent first. const saved = page.waitForResponse((r) => r.request().method() === 'PATCH' && r.url().includes(`/api/decks/${deck.id}`) && r.ok()); await addTwoCards(page); // The deck bar counts the rarities the format caps (from 768 px). if (!compact) await expect(page.locator('app-deck-bar').getByRole('group', { name: 'Raretés limitées par le format' })).toContainText('/15'); - await shareButton(page).click(); + await share(page, compact); const patch = await saved; expect(((patch.request().postDataJSON() as { deckCards?: unknown[] }).deckCards ?? []).length).toBeGreaterThanOrEqual(2); const dialog = page.getByRole('dialog', { name: 'Partager ce deck' }); @@ -1110,14 +1189,14 @@ test.describe('ReBuilder in the shell · « Partager » in the editor', () => { } }); - test('a guest signs in to share: the deck moves to the account and the share window opens', async ({ page }, testInfo) => { + test('a guest signs in to share: the deck moves to the account and the share window opens', async ({ page, compact }, testInfo) => { const name = `E2E invité ${testInfo.project.name} ${Date.now()}`; await page.goto(`${NEW_DECK}?lang=fr`); await createDeck(page, name); await expect(page).toHaveURL(/\/pages\/deckbuilder\?id=guest-[^&]+$/); await addTwoCards(page); - await shareButton(page).click(); + await share(page, compact); const prompt = page.getByRole('dialog', { name: 'Connectez-vous pour partager' }); await expect(prompt).toContainText('Pour partager ce deck et l’enregistrer sur le serveur, connectez-vous.'); await evidence(page, testInfo, '41-editor-share-sign-in'); From df71cbc646fe5d6825b06ef55cbe243f7f180e61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89douard=20WILLISSECK?= Date: Wed, 7 Oct 2026 16:59:02 +0200 Subject: [PATCH 14/69] Re:Builder: faster on a slow network (editor usable in 8 s instead of 21 s on 3G, no layout shift) (#144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Re:Builder: announce the page's modules in (modulepreload), one round trip instead of five The build manifest lists, per page slug and language, the chunks a page imports before it can draw (esbuild metafile); the shell announces them with the entry modules and the shadow-root CSS. Co-Authored-By: Claude Opus 5.5 * Re:Builder: the page's data sooner on a slow network - Module preloads start once the page's stylesheets have loaded: the first paint keeps their bandwidth. - main.ts requests the deck of the deck page and of the editor while the modules download; the app takes that answer (prefetchInterceptor) instead of sending its own request. - Ownership check of the editor from papi/rebuilder/my-deck-ids (ids only, read server side) instead of downloading every deck of the account. - JSON of the API relay and of the plugin endpoints gzipped (Debian's mod_deflate leaves JSON out). Co-Authored-By: Claude Opus 5.5 * Re:Builder: no layout shift when the app replaces the skeleton, less art and code ahead on a slow network - Host runtime: the plugin's container and the server's placeholder share one grid cell until the first screen is drawn, so the placeholder is never pushed down (CLS ~1.0 -> ~0). - Decks list skeletons keyed by the list's length: decks arriving above them never move them. - Card art lookahead of half a screen on a 3G or slower connection (or data saver), instead of 2.5 screens. - Test hand, odds and calculators (with the CDK's drag and drop) in their own chunk, loaded with their view. - e2e: module preloads, one deck request, ownership from my-deck-ids, gzipped JSON, no layout shift. Co-Authored-By: Claude Opus 5.5 * Re:Builder: the prefetch reads its answer in full; skeleton e2e tests hold the scripts for real An unread error body kept the prefetched request open (no network idle on an unknown deck). The skeleton tests now turn the browser cache off, so the held main module is not served from the memory cache, and wait for the response only (a module script holds DOMContentLoaded). Co-Authored-By: Claude Opus 5.5 * Re:Builder: hashed build files cached for a year; A/B load-performance bench - plugins/rebuilder/.htaccess: Cache-Control immutable on main-, polyfills-, chunk-HASH.js and the hashed fonts (mod_headers): a repeat visit loads no module from the network. - tests/e2e/perf-ab.mjs: two versions of the site side by side on an emulated 3G / slow 4G phone. Co-Authored-By: Claude Opus 5.5 * Re:Builder: request the modules right after the skeleton, not from Started once the head's stylesheets had loaded, the module preloads took the bandwidth of the rest of the page and of the skeleton's stylesheet: the skeleton was painted ~0.3 s later than on preprod (screencast, 3G). The inline script now follows the placeholder, so it runs once the skeleton can be painted. e2e: the ownership test waits for the save before reloading. Co-Authored-By: Claude Opus 5.5 * e2e: the held-scripts tests unregister the site's service worker first Installed by the first visit, it answered main.js itself, out of the routes' reach: the skeleton test failed one run in three. The ownership test waits for the app bar's hero on phones, where « Partager » moved to the « ⋯ » menu (#143). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- api/v1/services/proxy.php | 2 + includes/_plugin_router.php | 2 + includes/json-gzip.php | 30 +++ includes/spa.php | 50 +++- js/altered-core-host.js | 9 +- plugins/README.html | 2 +- plugins/rebuilder/.htaccess | 7 + plugins/rebuilder/README.md | 27 +- .../app/docs/api-limitations/decks-api.md | 5 + plugins/rebuilder/app/package.json | 2 +- .../rebuilder/app/scripts/embed-manifest.mjs | 53 +++- .../rebuilder/app/src/app/core/api-urls.ts | 8 + .../rebuilder/app/src/app/core/deck-store.ts | 11 +- .../src/app/core/decks-api.service.spec.ts | 41 +++ .../app/src/app/core/decks-api.service.ts | 24 +- .../app/src/app/embed/embed.config.ts | 3 +- .../app/src/app/embed/prefetch.interceptor.ts | 26 ++ .../app/src/app/embed/prefetch.spec.ts | 86 ++++++ .../rebuilder/app/src/app/embed/prefetch.ts | 62 +++++ .../features/deck/deck-page/deck.page.html | 11 +- .../features/decks/decks-page/decks.page.ts | 10 +- .../editor/editor-page/editor.page.html | 15 +- .../src/app/ui/metier/card-art/card-art.ts | 14 +- plugins/rebuilder/app/src/main.ts | 3 + plugins/rebuilder/e2e/rebuilder.spec.ts | 85 +++++- plugins/rebuilder/papi/my-deck-ids.php | 69 +++++ plugins/rebuilder/plugin.json | 3 +- tests/SpaPreloadTest.php | 65 +++++ tests/e2e/perf-ab.mjs | 246 ++++++++++++++++++ 29 files changed, 939 insertions(+), 32 deletions(-) create mode 100644 includes/json-gzip.php create mode 100644 plugins/rebuilder/.htaccess create mode 100644 plugins/rebuilder/app/src/app/core/api-urls.ts create mode 100644 plugins/rebuilder/app/src/app/embed/prefetch.interceptor.ts create mode 100644 plugins/rebuilder/app/src/app/embed/prefetch.spec.ts create mode 100644 plugins/rebuilder/app/src/app/embed/prefetch.ts create mode 100644 plugins/rebuilder/papi/my-deck-ids.php create mode 100644 tests/SpaPreloadTest.php create mode 100644 tests/e2e/perf-ab.mjs diff --git a/api/v1/services/proxy.php b/api/v1/services/proxy.php index e6d83ac5..b54ceaa0 100644 --- a/api/v1/services/proxy.php +++ b/api/v1/services/proxy.php @@ -117,6 +117,8 @@ function serviceProxySend(string $method, string $url, array $headers, string $b if ($res === null) serviceProxyReply(502, ['error' => 'service_unreachable', 'service' => $service]); [$status, $type, $out] = $res; +require_once dirname(__DIR__, 3) . '/includes/json-gzip.php'; +jsonGzipStart(); http_response_code($status); header('Content-Type: ' . ($type !== '' ? $type : 'application/json')); echo $out; diff --git a/includes/_plugin_router.php b/includes/_plugin_router.php index f2ebdee4..34889185 100644 --- a/includes/_plugin_router.php +++ b/includes/_plugin_router.php @@ -36,5 +36,7 @@ } $GLOBALS['_ac_current_plugin_prefix'] = $_apiEntry['_table_prefix']; +require_once __DIR__ . '/json-gzip.php'; +jsonGzipStart(); include $_apiEntry['abs_file']; unset($GLOBALS['_ac_current_plugin_prefix']); diff --git a/includes/json-gzip.php b/includes/json-gzip.php new file mode 100644 index 00000000..ef92724e --- /dev/null +++ b/includes/json-gzip.php @@ -0,0 +1,30 @@ + (@font-face does not work in a shadow root) +// "documentCss": ["doc-HASH.css"], loaded in (@font-face does not work in a shadow root) +// "preload": { optional: modules the page needs before it can draw, requested together +// "*": ["chunk-HASH.js"], () so the browser fetches them with the entry +// "{slug}": [...], modules instead of discovering them import after import: "*" on every +// "lang:{en|fr}": [...] page, "{slug}" on the page served at that slug, "lang:…" in that language +// } // } const SPA_HOST_CONTRACT_VERSION = 1; @@ -48,7 +53,16 @@ function spaReadBuildManifest(string $pluginDir, string $entry, ?string &$error $files[$key][] = $f; } } - return ['base' => $baseRel, 'files' => $files]; + // Preloads are hints: an unusable entry is dropped, never an error. + $preload = []; + foreach ((is_array($m['preload'] ?? null) ? $m['preload'] : []) as $key => $list) { + if (!is_string($key) || !is_array($list)) continue; + foreach ($list as $f) { + $f = is_string($f) && substr($f, -3) === '.js' ? spaSafeRelPath($f) : null; + if ($f !== null) $preload[$key][] = $f; + } + } + return ['base' => $baseRel, 'files' => $files, 'preload' => $preload]; } /** @@ -63,13 +77,14 @@ function spaResolvePage(array $plugin, array $page): array { if (empty($page['entry'])) $error = 'missing "entry" in plugin.json'; $spa = ['mount' => ($page['mount'] ?? 'shadow') === 'light' ? 'light' : 'shadow', 'error' => $error, - 'assets_url' => '', 'js' => [], 'css' => [], 'document_css' => []]; + 'assets_url' => '', 'js' => [], 'css' => [], 'document_css' => [], 'preload' => []]; if ($build !== null) { $assets = BASE_URL . '/plugins/' . rawurlencode($id) . '/' . ($build['base'] !== '' ? $build['base'] . '/' : ''); $spa['assets_url'] = $assets; $spa['js'] = array_map(fn($f) => $assets . $f, $build['files']['js']); $spa['css'] = array_map(fn($f) => $assets . $f, $build['files']['css']); $spa['document_css'] = array_map(fn($f) => $assets . $f, $build['files']['documentCss']); + $spa['preload'] = array_map(fn($list) => array_map(fn($f) => $assets . $f, $list), $build['preload']); } return [ @@ -206,6 +221,27 @@ function spaRenderPlaceholder(array $page): void { echo '
'; } +/** + * What the page requests before the runtime or a module asks for it (spaRenderPage(), right after the placeholder): the + * entry modules and the manifest's `preload` modules for this page (slug and language) as , + * the shadow root's stylesheets as . + * Returns [['href' => …, 'as' => 'module' | 'style'], …], empty for a page that cannot render. + */ +function spaPreloads(array $page): array { + $spa = $page['spa']; + if ($spa['error'] !== null) return []; + $modules = $spa['js']; + foreach (['*', $page['slug'], 'lang:' . getUiLang()] as $key) { + $modules = array_merge($modules, $spa['preload'][$key] ?? []); + } + $out = []; + foreach (array_values(array_unique($modules)) as $href) $out[] = ['href' => $href, 'as' => 'module']; + if ($spa['mount'] === 'shadow') { + foreach ($spa['css'] as $href) $out[] = ['href' => $href, 'as' => 'style']; + } + return $out; +} + /** Body of an SPA page: mount point, host contract, runtime and plugin modules. */ function spaRenderPage(array $page): void { $spa = $page['spa']; @@ -226,6 +262,14 @@ function spaRenderPage(array $page): void {
+ + + diff --git a/js/altered-core-host.js b/js/altered-core-host.js index a3dcb91f..248c5d2d 100644 --- a/js/altered-core-host.js +++ b/js/altered-core-host.js @@ -121,10 +121,17 @@ var slot = shadow ? mountRoot.appendChild(document.createElement('slot')) : null; if (!shadow) el.appendChild(placeholder); container.style.minHeight = '0'; + // Container and placeholder share one grid cell: the first screen draws over the placeholder, which + // never moves (a placeholder pushed down by the first screen, even for the frame before it goes, is a + // layout shift for the browser). + el.style.display = 'grid'; + container.style.gridArea = placeholder.style.gridArea = '1 / 1'; + container.style.minWidth = placeholder.style.minWidth = '0'; var drawn = new ResizeObserver(function (entries) { if (entries[0].contentRect.height < 1) return; drawn.disconnect(); - container.style.minHeight = ''; + container.style.minHeight = container.style.gridArea = container.style.minWidth = ''; + el.style.display = ''; placeholder.remove(); if (slot) slot.remove(); }); diff --git a/plugins/README.html b/plugins/README.html index 3697dfaf..326ff90b 100644 --- a/plugins/README.html +++ b/plugins/README.html @@ -515,7 +515,7 @@

SPA plugins (manifest v2)

- + diff --git a/plugins/rebuilder/.htaccess b/plugins/rebuilder/.htaccess new file mode 100644 index 00000000..97f7d15e --- /dev/null +++ b/plugins/rebuilder/.htaccess @@ -0,0 +1,7 @@ +# Build files with a content hash in their name (dist/browser: main-HASH.js, polyfills-HASH.js, chunk-HASH.js, +# media/NAME-HASH.woff2): a new build has new names, so browsers keep them without asking again. + + + Header set Cache-Control "public, max-age=31536000, immutable" + + diff --git a/plugins/rebuilder/README.md b/plugins/rebuilder/README.md index 160c05d5..896c8cf4 100644 --- a/plugins/rebuilder/README.md +++ b/plugins/rebuilder/README.md @@ -14,9 +14,9 @@ its single **Decks** entry. The pages it replaces keep answering their own calls |---|---| | `plugin.json` | Manifest v2: SPA page and its `beta_slugs`, API endpoints, build and e2e declarations | | `meta.php` | Manifest `meta`: the deck's name as page title and its link preview (title, hero · format, decklist image, faction colour: core-altered-cards `includes/deck-preview/preview.php`, shared with the site's deck page) on `deck?id=` and `deckbuilder?id=` | -| `papi/` | PHP endpoints of the plugin (`/papi/rebuilder/…`, `AlteredCore.page.apiUrl`): `community-builders` (the site's community deckbuilders) | +| `papi/` | PHP endpoints of the plugin (`/papi/rebuilder/…`, `AlteredCore.page.apiUrl`): `community-builders` (the site's community deckbuilders), `my-deck-ids` (ids of the user's decks, for the editor's ownership check, without downloading every deck) | | `app/` | Angular sources (see `app/AGENTS.md` for the code rules, `app/design/COMPONENTS.md` for the components) | -| `app/src/main.ts`, `app/src/app/embed/` | Start-up: reads `window.AlteredCore`, routes of the decks section, host session, shadow-root overlays and styles | +| `app/src/main.ts`, `app/src/app/embed/` | Start-up: reads `window.AlteredCore`, requests the page's deck while the modules download (`prefetch.ts`), routes of the decks section, host session, shadow-root overlays and styles | | `app/src/embed/` | Global styles: `embed.scss` (shadow root, after the design system), `document.scss` (``: printed-card fonts) | | `e2e/` | Playwright scenarios run by CI against the full stack | | `dist/` | Build output (`npm run build`), not committed | @@ -31,7 +31,9 @@ npm run build # → ../dist/browser + ../dist/embed-manifest.json npm run lint && npm test ``` -`build` is the only build (`ng build`, production configuration, then `scripts/embed-manifest.mjs`). +`build` is the only build (`ng build --stats-json`, production configuration, then `scripts/embed-manifest.mjs`, +which also lists in the manifest's `preload` the chunks each page imports before it can draw, read from esbuild's +metafile: the shell requests them right after the skeleton). The app is not served on its own: run it in the site's stack (`docker-compose.stack.yml`). ## Design system @@ -103,6 +105,25 @@ English. `npm run lint` runs `npm run i18n:check`, which fails on a message with English translation, and on a translation left over. Vocabulary: the site's deck builder (`plugins/core-altered-cards/includes/deckbuilder/i18n.php`, `data/search_settings.json`). +## Load performance + +On a slow network the pages are bound by bytes and round trips. What keeps them fast: + +- The shell requests every module a page needs at once (manifest `preload`, see Build), once the skeleton can be + painted (its stylesheet loaded); nothing is discovered one import at a time. +- `main.ts` requests the page's deck while the modules download (`src/app/embed/prefetch.ts`). +- The editor learns whether the deck is the user's from `papi/my-deck-ids` (ids only), not from the account's full + deck list. +- JSON of the relay and of the plugin endpoints is gzipped (`includes/json-gzip.php`); the hashed build files are + cached for a year (`.htaccess`, with `mod_headers`). +- Card art loads half a screen ahead on 3G or with the data saver (`AcViewportLoader`), and the test hand's code + (with the CDK's drag and drop) only with its view (`@defer`). +- The runtime draws the first screen in the server skeleton's grid cell: no layout shift (CLS) when it replaces it. + +`tests/e2e/perf-ab.mjs` measures two versions of the site side by side on an emulated 3G / slow 4G phone (FCP, +LCP, app drawn, page ready, images on screen, TTI, CLS, bytes; cold, warm and just-deployed cache): see its header. +Run it before and after a change that touches the load path. + ## Known gaps - « Importer » › *Liste de cartes* creates a guest deck when signed out. diff --git a/plugins/rebuilder/app/docs/api-limitations/decks-api.md b/plugins/rebuilder/app/docs/api-limitations/decks-api.md index 48c26329..e9ca22da 100644 --- a/plugins/rebuilder/app/docs/api-limitations/decks-api.md +++ b/plugins/rebuilder/app/docs/api-limitations/decks-api.md @@ -108,6 +108,11 @@ la page suivante jusqu'à 50 fois, et chaque réponse contenait à nouveau tous `itemsPerPage` ou n'apporte aucun deck nouveau : une seule requête avec l'API actuelle. Pendant le chargement, « Mes decks » affiche « Chargement de vos decks… », des squelettes animés et « Mes decks · … » dans l'onglet. Filtres et tri restent côté client, sur la liste complète. L'API des decks n'est pas modifiée de notre côté. +Pour savoir si le deck ouvert appartient à l'utilisateur (`DeckStore.owned`, éditeur), l'app ne télécharge plus cette +liste complète (plusieurs centaines de ko pour un gros compte, des secondes en 3G pendant lesquelles l'éditeur restait +en lecture seule) : l'endpoint du plugin `GET /papi/rebuilder/my-deck-ids` (`papi/my-deck-ids.php`) la lit côté +serveur avec le token de la session et ne renvoie que les ids (`DecksApiService.mineIds`, repli sur la liste +complète en cas d'échec). **À faire côté backend.** Paginer `findByUser` (`LIMIT`/`OFFSET`, total pour `totalItems`) et y appliquer les filtres et le tri déclarés. « Mes decks » pourra alors charger page par page au défilement, comme l'onglet Communauté. diff --git a/plugins/rebuilder/app/package.json b/plugins/rebuilder/app/package.json index 63874b21..2451f34f 100644 --- a/plugins/rebuilder/app/package.json +++ b/plugins/rebuilder/app/package.json @@ -9,7 +9,7 @@ }, "scripts": { "ng": "ng", - "build": "ng build && node scripts/embed-manifest.mjs", + "build": "ng build --stats-json && node scripts/embed-manifest.mjs", "test": "ng test --watch=false", "lint": "ng lint && npm run i18n:check", "i18n:extract": "ng extract-i18n", diff --git a/plugins/rebuilder/app/scripts/embed-manifest.mjs b/plugins/rebuilder/app/scripts/embed-manifest.mjs index e45c666d..bbd2f244 100644 --- a/plugins/rebuilder/app/scripts/embed-manifest.mjs +++ b/plugins/rebuilder/app/scripts/embed-manifest.mjs @@ -1,8 +1,13 @@ -// Writes ../dist/embed-manifest.json after `ng build`: the files the +// Writes ../dist/embed-manifest.json after `ng build --stats-json`: the files the // AlteredCore shell loads (see includes/spa.php in the site). The polyfills and main modules have hashed names; // the non-injected style bundles do not, so they get a content hash as query string. +// +// `preload`: the modules each page needs before it can draw, which the shell requests all at once +// (, includes/spa.php). Without it the browser discovers them one level at a time (main → bootstrap → +// shared chunks → route component → its chunks), one round trip per level. Read from esbuild's metafile +// (../dist/stats.json), removed afterwards: it is not served. import { createHash } from 'node:crypto'; -import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -21,6 +26,49 @@ function versioned(file) { return `${file}?v=${hash}`; } +/** + * Page slugs of the site (the shell's `slug`, see plugin.json `beta_slugs`) → the route component they open + * (embed.routes.ts, legacy-url.serializer.ts). `*`: every page (the bootstrap module and what it imports). + */ +const PRELOAD_ENTRIES = { + '*': 'src/app/embed/bootstrap.ts', + decks: 'src/app/features/decks/decks-page/decks.page.ts', + deck: 'src/app/features/deck/deck-page/deck.page.ts', + deckbuilder: 'src/app/features/editor/editor-page/editor.page.ts', + // English interface: its translations, loaded before the app (main.ts). + 'lang:en': 'src/locale/messages.en.json', +}; + +function preloads() { + const statsFile = join(dist, 'stats.json'); + if (!existsSync(statsFile)) throw new Error('embed-manifest: ../dist/stats.json missing (ng build --stats-json)'); + const { outputs } = JSON.parse(readFileSync(statsFile, 'utf8')); + rmSync(statsFile); + const name = (path) => path.replace(/^.*\//, ''); + const byEntry = new Map(Object.entries(outputs).filter(([, o]) => o.entryPoint).map(([path, o]) => [o.entryPoint, name(path)])); + const imports = new Map( + Object.entries(outputs).map(([path, o]) => [name(path), (o.imports ?? []).filter((i) => i.kind === 'import-statement').map((i) => name(i.path))]), + ); + const initial = new Set([one(/^main-[A-Za-z0-9_-]+\.js$/), one(/^polyfills-[A-Za-z0-9_-]+\.js$/)]); + // A module, then everything it imports statically, depth first: dependencies before their importers. + const closure = (file, seen) => { + if (seen.has(file) || initial.has(file)) return []; + seen.add(file); + return [...(imports.get(file) ?? []).flatMap((dep) => closure(dep, seen)), file]; + }; + const common = new Set(); + const out = {}; + for (const [key, entry] of Object.entries(PRELOAD_ENTRIES)) { + const file = byEntry.get(entry); + if (!file) throw new Error(`embed-manifest: no output chunk for ${entry}`); + // The pages' lists leave out what `*` preloads already. + const list = closure(file, key === '*' ? common : new Set(common)); + for (const f of list) if (!files.includes(f)) throw new Error(`embed-manifest: ${f} not in ${browser}`); + out[key] = list; + } + return out; +} + const manifest = { version: 1, base: 'browser/', @@ -28,6 +76,7 @@ const manifest = { js: [one(/^polyfills-[A-Za-z0-9_-]+\.js$/), one(/^main-[A-Za-z0-9_-]+\.js$/)], css: [versioned(one(/^embed\.css$/))], documentCss: [versioned(one(/^document\.css$/))], + preload: preloads(), }; writeFileSync(join(dist, 'embed-manifest.json'), JSON.stringify(manifest, null, 2) + '\n'); console.log(`embed-manifest: ${JSON.stringify(manifest)}`); diff --git a/plugins/rebuilder/app/src/app/core/api-urls.ts b/plugins/rebuilder/app/src/app/core/api-urls.ts new file mode 100644 index 00000000..422e3f0c --- /dev/null +++ b/plugins/rebuilder/app/src/app/core/api-urls.ts @@ -0,0 +1,8 @@ +/** + * URL of a deck as DecksApiService.get() requests it (`urlWithParams`), `GET {decks}/api/decks/{id}?locale=…`: + * embed/prefetch.ts requests it before the app starts, and the app takes the prefetched answer only for the same + * string (api-urls.spec.ts). + */ +export function deckUrl(base: string, id: string, locale: string): string { + return `${base.replace(/\/$/, '')}/api/decks/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`; +} diff --git a/plugins/rebuilder/app/src/app/core/deck-store.ts b/plugins/rebuilder/app/src/app/core/deck-store.ts index dc48ec65..08220e94 100644 --- a/plugins/rebuilder/app/src/app/core/deck-store.ts +++ b/plugins/rebuilder/app/src/app/core/deck-store.ts @@ -130,16 +130,15 @@ export class DeckStore { readonly distinct = computed(() => this.lines().filter((l) => l.quantity > 0).length); readonly quantities = computed(() => new Map(this.lines().map((l) => [l.card.reference, l.quantity]))); /** - * Account decks of the signed-in user, to tell whether the open deck is theirs: the decks API - * does not say who owns a deck (`user` is `[]`), but `GET /api/decks` only lists the caller's. - * Fetched when the first server deck is opened and when the account changes, not for every deck - * opened: the list is large. Decks created from this tab meanwhile are in `createdIds`. + * Ids of the signed-in user's decks, to tell whether the open deck is theirs: the decks API + * does not say who owns a deck (`user` is `[]`), but `GET /api/decks` only lists the caller's + * (DecksApiService.mineIds). Fetched when the first server deck is opened and when the account + * changes, not for every deck opened. Decks created from this tab meanwhile are in `createdIds`. */ private readonly mineWanted = signal(false); private readonly mineIds = rxResource({ params: () => (this.mineWanted() && this.auth.isLoggedIn() ? { user: this.auth.username(), token: this.auth.token() } : undefined), - stream: () => - this.decksApi.listMine(1, 1000).pipe(map((body) => new Set((Array.isArray(body) ? body : (body.member ?? [])).map((d) => d.id)))), + stream: () => this.decksApi.mineIds(), }); /** Decks created on the account from this tab: theirs before the account list is refetched. */ private readonly createdIds = signal>(new Set()); diff --git a/plugins/rebuilder/app/src/app/core/decks-api.service.spec.ts b/plugins/rebuilder/app/src/app/core/decks-api.service.spec.ts index 2d6407aa..e073d107 100644 --- a/plugins/rebuilder/app/src/app/core/decks-api.service.spec.ts +++ b/plugins/rebuilder/app/src/app/core/decks-api.service.spec.ts @@ -2,6 +2,7 @@ import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { lastValueFrom } from 'rxjs'; +import { environment } from '../../environments/environment'; import { DecksApiService } from './decks-api.service'; import type { Deck } from './models'; @@ -55,3 +56,43 @@ describe('DecksApiService.listAllMine', () => { expect((await all).map((d) => d.id)).toEqual(['d1', 'd2']); }); }); + +describe('DecksApiService.mineIds', () => { + let http: HttpTestingController; + let api: DecksApiService; + const pluginApiUrl = environment.pluginApiUrl; + + beforeEach(() => { + TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] }); + http = TestBed.inject(HttpTestingController); + api = TestBed.inject(DecksApiService); + }); + afterEach(() => { + environment.pluginApiUrl = pluginApiUrl; + http.verify(); + }); + + const listRequest = () => http.expectOne((r) => r.url === `${api.baseUrl}/api/decks` && r.params.get('itemsPerPage') === '1000'); + + it('reads the ids from the plugin endpoint in the site', async () => { + environment.pluginApiUrl = '/papi/rebuilder/'; + const ids = lastValueFrom(api.mineIds()); + http.expectOne('/papi/rebuilder/my-deck-ids').flush({ ids: ['d1', 'd2', 3] }); + expect([...(await ids)]).toEqual(['d1', 'd2']); + }); + + it('falls back to the account list when the endpoint fails', async () => { + environment.pluginApiUrl = '/papi/rebuilder'; + const ids = lastValueFrom(api.mineIds()); + http.expectOne('/papi/rebuilder/my-deck-ids').flush({ error: 'service_unreachable' }, { status: 502, statusText: 'Bad Gateway' }); + listRequest().flush({ member: decks(1, 2) }); + expect([...(await ids)]).toEqual(['d1', 'd2']); + }); + + it('reads the account list outside the site', async () => { + environment.pluginApiUrl = ''; + const ids = lastValueFrom(api.mineIds()); + listRequest().flush(decks(1, 3)); + expect([...(await ids)]).toEqual(['d1', 'd2', 'd3']); + }); +}); diff --git a/plugins/rebuilder/app/src/app/core/decks-api.service.ts b/plugins/rebuilder/app/src/app/core/decks-api.service.ts index 4374bcec..0a4c9dd8 100644 --- a/plugins/rebuilder/app/src/app/core/decks-api.service.ts +++ b/plugins/rebuilder/app/src/app/core/decks-api.service.ts @@ -1,6 +1,6 @@ import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http'; import { Service, inject } from '@angular/core'; -import { EMPTY, Observable, catchError, expand, map, reduce, switchMap, throwError } from 'rxjs'; +import { EMPTY, Observable, catchError, defer, expand, map, reduce, switchMap, throwError } from 'rxjs'; import { environment } from '../../environments/environment'; import { AuthSession } from './auth-session'; import type { Deck, DeckWrite } from './models'; @@ -43,6 +43,28 @@ export class DecksApiService { private readonly auth = inject(AuthSession); readonly baseUrl = environment.decksApiUrl.replace(/\/$/, ''); + /** + * Ids of the caller's decks, to tell whether a deck is theirs. In the site, from the plugin's endpoint `my-deck-ids`, + * which reads the account list server side and sends the ids only: `GET /api/decks` sends every deck of the + * account in full, unpaginated (docs/api-limitations/decks-api.md), seconds of download on a slow network for a + * large account. Outside the site, or when the endpoint fails, from that list. + */ + mineIds(): Observable> { + const fromList = defer(() => this.listMine(1, 1000)).pipe( + map((body) => new Set((Array.isArray(body) ? body : (body.member ?? [])).map((d) => d.id))), + ); + if (!environment.pluginApiUrl) return fromList; + const url = `${environment.pluginApiUrl.replace(/\/?$/, '/')}my-deck-ids`; + return this.http.get<{ ids?: unknown }>(url, { headers: new HttpHeaders({ Accept: 'application/json' }) }).pipe( + map((body) => { + const ids = body?.ids; + if (!Array.isArray(ids)) throw new Error('my-deck-ids: no ids'); + return new Set(ids.filter((id): id is string => typeof id === 'string')); + }), + catchError(() => fromList), + ); + } + listMine(page = 1, itemsPerPage = 30): Observable { return this.send(() => this.http.get(`${this.baseUrl}/api/decks`, { diff --git a/plugins/rebuilder/app/src/app/embed/embed.config.ts b/plugins/rebuilder/app/src/app/embed/embed.config.ts index 06ac5786..2876e3fc 100644 --- a/plugins/rebuilder/app/src/app/embed/embed.config.ts +++ b/plugins/rebuilder/app/src/app/embed/embed.config.ts @@ -14,6 +14,7 @@ import { ALTERED_CORE, EMBED_MOUNT, type AlteredCoreHost, type AlteredCoreMount import { HostAuthSession } from './host-auth.session'; import { HostPageTitle } from './host-page-title'; import { LegacyUrlSerializer } from './legacy-url.serializer'; +import { prefetchInterceptor } from './prefetch.interceptor'; import { siteCsrfInterceptor } from './site-csrf.interceptor'; import { ShadowOverlayContainer } from './shadow-overlay-container'; import { ShadowStylesHost } from './shadow-styles.host'; @@ -30,7 +31,7 @@ export function embedConfig(host: AlteredCoreHost, mount: AlteredCoreMount): App { provide: UrlSerializer, useValue: new LegacyUrlSerializer() }, { provide: TitleStrategy, useClass: EmbedTitleStrategy }, { provide: PageTitle, useClass: HostPageTitle }, - provideHttpClient(withFetch(), withInterceptors([siteCsrfInterceptor])), + provideHttpClient(withFetch(), withInterceptors([siteCsrfInterceptor, prefetchInterceptor])), { provide: AuthSession, useClass: HostAuthSession }, { provide: DeckCreateFailurePrompt, useClass: OverlayDeckCreateFailurePrompt }, { provide: OverlayContainer, useClass: ShadowOverlayContainer }, diff --git a/plugins/rebuilder/app/src/app/embed/prefetch.interceptor.ts b/plugins/rebuilder/app/src/app/embed/prefetch.interceptor.ts new file mode 100644 index 00000000..2d744e67 --- /dev/null +++ b/plugins/rebuilder/app/src/app/embed/prefetch.interceptor.ts @@ -0,0 +1,26 @@ +import { HttpHeaders, HttpResponse, type HttpInterceptorFn } from '@angular/common/http'; +import { from, switchMap } from 'rxjs'; +import { takePrefetched } from './prefetch'; + +/** + * Answers a GET for JSON with the response `main.ts` prefetched for the same URL (embed/prefetch.ts), once. Without + * one, or when it failed, the request goes out as usual. + */ +export const prefetchInterceptor: HttpInterceptorFn = (req, next) => { + if (req.method !== 'GET' || req.responseType !== 'json') return next(req); + const answer = takePrefetched(req.urlWithParams); + if (!answer) return next(req); + return from(answer).pipe( + switchMap((got) => { + if (!got) return next(req); + let body: unknown; + try { + body = got.body === '' ? null : JSON.parse(got.body); + } catch { + return next(req); + } + const headers = new HttpHeaders(got.headers); + return [new HttpResponse({ body, headers, status: got.status, statusText: got.statusText, url: got.url || req.urlWithParams })]; + }), + ); +}; diff --git a/plugins/rebuilder/app/src/app/embed/prefetch.spec.ts b/plugins/rebuilder/app/src/app/embed/prefetch.spec.ts new file mode 100644 index 00000000..b8a04107 --- /dev/null +++ b/plugins/rebuilder/app/src/app/embed/prefetch.spec.ts @@ -0,0 +1,86 @@ +import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { firstValueFrom } from 'rxjs'; +import { deckUrl } from '../core/api-urls'; +import { DecksApiService } from '../core/decks-api.service'; +import type { AlteredCoreHost } from './host'; +import { clearPrefetched, prefetch, prefetchPage, takePrefetched } from './prefetch'; +import { prefetchInterceptor } from './prefetch.interceptor'; + +const realFetch = globalThis.fetch; +let fetched: string[]; + +function answer(status: number, body: unknown): void { + globalThis.fetch = ((url: string) => { + fetched.push(url); + return Promise.resolve(new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })); + }) as typeof fetch; +} + +const host = (slug: string) => ({ services: { decks: '/api/v1/services/decks' }, page: { slug } }) as unknown as AlteredCoreHost; + +describe('prefetch', () => { + let http: HttpTestingController; + let client: HttpClient; + + beforeEach(() => { + fetched = []; + clearPrefetched(); + TestBed.configureTestingModule({ providers: [provideHttpClient(withInterceptors([prefetchInterceptor])), provideHttpClientTesting()] }); + http = TestBed.inject(HttpTestingController); + client = TestBed.inject(HttpClient); + }); + afterEach(() => { + globalThis.fetch = realFetch; + http.verify(); + }); + + it('prefetches the URL DecksApiService.get() requests', () => { + const api = TestBed.inject(DecksApiService); + api.get('01a1-x y', 'fr').subscribe(); + const req = http.expectOne(() => true); + expect(req.request.urlWithParams).toBe(deckUrl(api.baseUrl, '01a1-x y', 'fr')); + req.flush({}); + }); + + it('prefetches the deck of the deck page and of the editor only, never a guest deck', () => { + answer(200, {}); + prefetchPage(host('deck'), '?id=abc&tab=main'); + prefetchPage(host('deckbuilder'), '?id=def'); + prefetchPage(host('deckbuilder'), '?id=guest-123'); + prefetchPage(host('deckbuilder'), ''); + prefetchPage(host('decks'), '?id=ghi'); + expect(fetched).toEqual([deckUrl('/api/v1/services/decks', 'abc', 'fr'), deckUrl('/api/v1/services/decks', 'def', 'fr')]); + }); + + it('answers the app with the prefetched response, once', async () => { + answer(200, { id: 'abc', name: 'Deck' }); + prefetch('/api/x?locale=fr'); + expect(await firstValueFrom(client.get('/api/x', { params: { locale: 'fr' } }))).toEqual({ id: 'abc', name: 'Deck' }); + http.expectNone('/api/x?locale=fr'); + + const again = firstValueFrom(client.get('/api/x', { params: { locale: 'fr' } })); + http.expectOne('/api/x?locale=fr').flush({ id: 'abc', name: 'Fresh' }); + expect(await again).toEqual({ id: 'abc', name: 'Fresh' }); + }); + + it('sends the app\'s own request when the prefetch failed', async () => { + answer(404, { title: 'Not Found' }); + prefetch('/api/missing'); + const res = firstValueFrom(client.get('/api/missing')); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve)); + http.expectOne('/api/missing').flush({ title: 'Not Found' }, { status: 404, statusText: 'Not Found' }); + await expect(res).rejects.toMatchObject({ status: 404 }); + }); + + it('leaves other methods and response types to the network', async () => { + answer(200, 'text'); + prefetch('/api/t'); + const text = firstValueFrom(client.get('/api/t', { responseType: 'text' })); + http.expectOne('/api/t').flush('from the network'); + expect(await text).toBe('from the network'); + expect(takePrefetched('/api/t')).toBeDefined(); + }); +}); diff --git a/plugins/rebuilder/app/src/app/embed/prefetch.ts b/plugins/rebuilder/app/src/app/embed/prefetch.ts new file mode 100644 index 00000000..1d99885e --- /dev/null +++ b/plugins/rebuilder/app/src/app/embed/prefetch.ts @@ -0,0 +1,62 @@ +import { deckUrl } from '../core/api-urls'; +import { contentLocale } from '../core/locale'; +import type { AlteredCoreHost } from './host'; + +/** A prefetched answer, read in full: an unread body would keep its request open. */ +export interface PrefetchedAnswer { + status: number; + statusText: string; + headers: Record; + url: string; + body: string; +} + +/** + * Requests started by `main.ts` while the app's modules download, so that their answers are on their way, or + * here, when the app asks for them: on a slow network the modules take seconds, and the page's data used to + * wait for them. `prefetchInterceptor` hands an answer to the app's request for the same URL, once; a failed or + * refused answer is dropped and the app sends its own request, which handles the error as usual. + * Kept out of Angular (no HttpClient): main.ts loads it before the app. + */ +const pending = new Map>(); + +export function prefetch(url: string): void { + if (pending.has(url)) return; + pending.set( + url, + fetch(url, { credentials: 'same-origin', headers: { Accept: 'application/json' } }) + .then(async (res) => { + const body = await res.text(); + if (!res.ok) return null; + const headers: Record = {}; + res.headers.forEach((value, name) => (headers[name] = value)); + return { status: res.status, statusText: res.statusText, headers, url: res.url, body }; + }) + .catch(() => null), + ); +} + +/** The prefetched answer for `url` (null when it failed), once; undefined when nothing was prefetched. */ +export function takePrefetched(url: string): Promise | undefined { + const answer = pending.get(url); + pending.delete(url); + return answer; +} + +/** Forgets every prefetched answer (tests). */ +export function clearPrefetched(): void { + pending.clear(); +} + +/** + * The first request of the page the site served, when it does not depend on the app's state: the deck of the deck + * page and of the editor (`deck?id=…`, `deckbuilder?id=…`, see LegacyUrlSerializer). Guest decks live in this + * browser. + */ +export function prefetchPage(host: AlteredCoreHost, search = location.search): void { + const decks = host.services.decks; + if (!decks || (host.page.slug !== 'deck' && host.page.slug !== 'deckbuilder')) return; + const id = new URLSearchParams(search).get('id'); + if (!id || id.startsWith('guest-')) return; + prefetch(deckUrl(decks, id, contentLocale())); +} diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.html b/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.html index 07e1855b..fe84db45 100644 --- a/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.html +++ b/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.html @@ -164,9 +164,14 @@

@if (deck.opening()) { } @else { - - - + + @defer (on immediate) { + + + + } @placeholder { + + } } } @case ('cartes') { diff --git a/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.ts b/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.ts index c8513de2..2330a900 100644 --- a/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.ts +++ b/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.ts @@ -119,8 +119,14 @@ export class DecksPage { const tab = this.tab(); return (tab === 'mine' && this.mineLoading()) || (tab === 'community' && this.communityLoading()) || (tab === 'contest' && this.contestLoading()); }); - /** Two rows of deck cards from 768 px, a screen of rows on phones. */ - protected readonly skeletons = computed(() => Array.from({ length: this.bp.compact() ? 5 : 10 }, (_, i) => i)); + /** + * Two rows of deck cards from 768 px, a screen of rows on phones. Keyed by the list's length: decks that arrive + * above them take new skeletons below, rather than pushing the same ones down (a layout shift for the browser). + */ + protected readonly skeletons = computed(() => { + const listed = this.list().length; + return Array.from({ length: this.bp.compact() ? 5 : 10 }, (_, i) => `${listed}-${i}`); + }); private readonly serverDecks = computed(() => (this.serverRes.hasValue() ? this.serverRes.value() : [])); protected readonly serverError = computed(() => { const err = this.serverRes.error(); diff --git a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html index efa70f97..5faecd0c 100644 --- a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html +++ b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html @@ -184,11 +184,16 @@ @if (deck.opening()) { } @else { -
- - - -
+ + @defer (on immediate) { +
+ + + +
+ } @placeholder { + + } } } @default never; diff --git a/plugins/rebuilder/app/src/app/ui/metier/card-art/card-art.ts b/plugins/rebuilder/app/src/app/ui/metier/card-art/card-art.ts index a8b7ed34..68b1b341 100644 --- a/plugins/rebuilder/app/src/app/ui/metier/card-art/card-art.ts +++ b/plugins/rebuilder/app/src/app/ui/metier/card-art/card-art.ts @@ -85,6 +85,18 @@ export class AcCardArt implements OnDestroy { * the lookahead zone for DWELL_MS, so tiles skipped by a fast flick never download their image. */ const DWELL_MS = 150; +/** Art loads up to half a screen above and a screen and a half below the viewport… */ +const LOOKAHEAD = '50% 0px 150% 0px'; +/** + * …or half a screen below on a slow connection (3G or slower, or data saver): there, the art of the next screens + * takes the bandwidth of the art on screen, seconds later on a 3G network. + */ +const SLOW_LOOKAHEAD = '0px 0px 50% 0px'; + +function slowNetwork(): boolean { + const connection = (globalThis.navigator as Navigator & { connection?: { effectiveType?: string; saveData?: boolean } } | undefined)?.connection; + return !!connection && (connection.saveData === true || ['slow-2g', '2g', '3g'].includes(connection.effectiveType ?? '')); +} @Service() export class AcViewportLoader { @@ -113,7 +125,7 @@ export class AcViewportLoader { ); } }, - { rootMargin: '50% 0px 150% 0px' }, + { rootMargin: slowNetwork() ? SLOW_LOOKAHEAD : LOOKAHEAD }, ); this.callbacks.set(el, onVisible); this.observer.observe(el); diff --git a/plugins/rebuilder/app/src/main.ts b/plugins/rebuilder/app/src/main.ts index 62ba0ab0..6c2eb093 100644 --- a/plugins/rebuilder/app/src/main.ts +++ b/plugins/rebuilder/app/src/main.ts @@ -7,6 +7,7 @@ import { setAssetBase } from './app/core/asset-url'; import { setUiLocale } from './app/core/i18n'; import { setContentLocale } from './app/core/locale'; import { readHost } from './app/embed/host'; +import { prefetchPage } from './app/embed/prefetch'; import { environment } from './environments/environment'; const host = readHost(); @@ -21,6 +22,8 @@ env.siteUrl = host.baseUrl ?? env.siteUrl; env.siteCsrf = host.csrf ?? env.siteCsrf; setAssetBase(host.page.assetsUrl); setContentLocale(host.lang); +// The page's data, requested while the app's modules download (embed/prefetch.ts). +prefetchPage(host); setUiLocale(host.lang) .then(() => import('./app/embed/bootstrap')) diff --git a/plugins/rebuilder/e2e/rebuilder.spec.ts b/plugins/rebuilder/e2e/rebuilder.spec.ts index 91989d96..abec9508 100644 --- a/plugins/rebuilder/e2e/rebuilder.spec.ts +++ b/plugins/rebuilder/e2e/rebuilder.spec.ts @@ -90,6 +90,19 @@ async function editorDeckId(page: Page): Promise { } const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** + * Sends the page's next requests to the network, where routes see them: the site's service worker (installed by the + * first visit, it would answer the scripts itself) is unregistered and the browser cache (Chromium) turned off. + */ +async function bypassCaches(page: Page): Promise { + await page.evaluate(async () => { + for (const registration of (await navigator.serviceWorker?.getRegistrations()) ?? []) await registration.unregister(); + }); + const cdp = await page.context().newCDPSession(page); + await cdp.send('Network.enable'); + await cdp.send('Network.setCacheDisabled', { cacheDisabled: true }); +} const at = (path: string, more = false) => new RegExp(`${escape(path)}${more ? '(&|$)' : '$'}`); test.beforeEach(async ({ page }) => setBeta(page, true)); @@ -191,7 +204,10 @@ test.describe('ReBuilder in the shell · signed in', () => { if (route.request().method() === 'GET') await deckAnswer; await route.continue(); }); - await page.goto(`${EDITOR(id)}&view=apercu&lang=fr`, { waitUntil: 'domcontentloaded' }); + // The editor's scripts are in this tab's memory cache since the deck was created: without the cache, they go + // through the routes above. A module script holds DOMContentLoaded: wait for the response only. + await bypassCaches(page); + await page.goto(`${EDITOR(id)}&view=apercu&lang=fr`, { waitUntil: 'commit' }); const placeholder = page.locator('.ac-spa-placeholder'); await expect(placeholder.locator('.ac-skeleton').filter({ visible: true }).first()).toBeVisible(); await expect(placeholder.getByRole('status')).toHaveText('Chargement…'); @@ -1254,3 +1270,70 @@ test.describe('ReBuilder in the shell · side panels of the editor', () => { await expect(editor.getByRole('button', { name: 'Masquer le deck' })).toBeFocused(); }); }); + +test.describe('ReBuilder in the shell · loading', () => { + test('requests its modules from , the deck once while they load, and the ownership from the deck ids', async ({ page, compact }) => { + await login(page, 'alice', `${NEW_DECK}?lang=fr`); + await createDeck(page, `E2E chargement ${Date.now()}`); + await expect(page).toHaveURL(/[?&]id=/); + const id = new URL(page.url()).searchParams.get('id')!; + + const requests: { url: string; method: string }[] = []; + page.on('request', (r) => requests.push({ url: r.url(), method: r.method() })); + const failed: string[] = []; + page.on('response', (r) => { + if (r.url().includes('/plugins/rebuilder/') && r.status() >= 400) failed.push(`${r.status()} ${r.url()}`); + }); + const deckAnswer = page.waitForResponse((r) => new URL(r.url()).pathname === `/api/v1/services/decks/api/decks/${id}` && r.request().method() === 'GET'); + // A fresh load of the editor: the deck was created in this tab before, now only the account says it is alice's. + requests.length = 0; + await page.goto(`${EDITOR(id)}&lang=fr`); + expect(await (await deckAnswer).headerValue('content-encoding')).toBe('gzip'); + // Once the account's deck ids are in, the owner's actions replace their skeletons: « Partager » on desktop, on phones + // the hero of the app bar (« Partager » moves to its « ⋯ » menu). + const editor = page.locator('app-editor-page'); + await expect(compact ? editor.locator('ac-app-bar').getByRole('button', { name: /^Changer de héros/ }) : editor.getByRole('button', { name: 'Partager', exact: true })).toBeVisible(); + const saved = page.waitForResponse((r) => r.request().method() === 'PATCH' && r.url().includes(`/api/decks/${id}`) && r.ok()); + await addTwoCards(page); + await expectDeckCount(page, compact, 2); + + // The page's modules, preloaded once its stylesheets are in: entry, bootstrap, shared chunks and the editor's. + expect(await page.locator('head link[rel="modulepreload"]').count()).toBeGreaterThanOrEqual(5); + expect(failed).toEqual([]); + const paths = requests.map((r) => ({ ...r, path: new URL(r.url).pathname, query: new URL(r.url).searchParams })); + expect(paths.filter((r) => r.method === 'GET' && r.path === `/api/v1/services/decks/api/decks/${id}`)).toHaveLength(1); + expect(paths.filter((r) => r.path === '/papi/rebuilder/my-deck-ids')).toHaveLength(1); + expect(paths.filter((r) => r.path === '/api/v1/services/decks/api/decks' && r.method === 'GET')).toHaveLength(0); + + // The test hand comes with its own chunk, when its view opens (once the two cards are saved). + await saved; + await page.goto(`${EDITOR(id)}&view=main&lang=fr`); + await expect(page.locator('app-editor-page').getByRole('list', { name: 'Main de départ' })).toBeVisible(); + }); + + test('draws its first screen in place of the server\'s skeleton, without moving it (layout shift)', { tag: '@mobile' }, async ({ page }) => { + await page.addInitScript(() => { + const w = window as unknown as { __cls: number }; + w.__cls = 0; + new PerformanceObserver((list) => { + for (const e of list.getEntries() as (PerformanceEntry & { value: number; hadRecentInput: boolean })[]) if (!e.hadRecentInput) w.__cls += e.value; + }).observe({ type: 'layout-shift', buffered: true }); + }); + // The app's scripts wait until the server's skeleton is on screen. + let releaseScripts!: () => void; + const scripts = new Promise((resolve) => (releaseScripts = resolve)); + await page.route(/\/plugins\/rebuilder\/dist\/browser\/main-[^/]+\.js/, async (route) => { + await scripts; + await route.continue(); + }); + await bypassCaches(page); + await page.goto(`${DECKS}?lang=fr`, { waitUntil: 'commit' }); + await expect(page.locator('.ac-spa-placeholder .ac-skeleton').filter({ visible: true }).first()).toBeVisible(); + await page.waitForTimeout(300); + releaseScripts(); + await expect(page.locator('.ac-spa-placeholder')).toHaveCount(0); + await expect(page.locator('ac-deck-card').first()).toBeVisible(); + await page.waitForTimeout(500); + expect(await page.evaluate(() => (window as unknown as { __cls: number }).__cls)).toBeLessThan(0.1); + }); +}); diff --git a/plugins/rebuilder/papi/my-deck-ids.php b/plugins/rebuilder/papi/my-deck-ids.php new file mode 100644 index 00000000..a2bc9b3b --- /dev/null +++ b/plugins/rebuilder/papi/my-deck-ids.php @@ -0,0 +1,69 @@ + 1, 'itemsPerPage' => 1000])); + $headers = ['Accept: application/json']; + if ($token !== null) $headers[] = 'Authorization: Bearer ' . $token; + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_ENCODING => '', + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 20, + CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, + ]); + $out = curl_exec($ch); + $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + if ($out === false || $status === 0) return null; + return [$status, json_decode((string)$out, true)]; +} + +$userId = (int)($_SESSION['user_id'] ?? 0); +if (!defined('KC_URL') || KC_URL === '' || !defined('DECKS_API_URL') || DECKS_API_URL === '' || $userId <= 0) { + rebuilderMyDeckIdsReply(401, ['error' => 'unauthenticated']); + return; +} + +// Release the session lock during the upstream call (the page's other requests of the same user). +$token = kc_get_access_token($userId) ?: null; +session_write_close(); +$res = rebuilderMyDeckIdsFetch($token); +// A token can be rejected before its expiry (revoked, clock skew): renew once and retry. +if ($res !== null && $res[0] === 401 && $token !== null) { + session_start(); + unset($_SESSION['kc_access_token'], $_SESSION['kc_access_token_exp']); + $token = kc_get_access_token($userId) ?: null; + session_write_close(); + if ($token !== null) $res = rebuilderMyDeckIdsFetch($token); +} + +if ($res === null) { + rebuilderMyDeckIdsReply(502, ['error' => 'service_unreachable']); + return; +} +[$status, $body] = $res; +if ($status !== 200 || !is_array($body)) { + rebuilderMyDeckIdsReply($status >= 400 ? $status : 502, ['error' => 'decks_api', 'status' => $status]); + return; +} +$decks = isset($body['member']) && is_array($body['member']) ? $body['member'] : $body; +$ids = []; +foreach ($decks as $deck) { + if (is_array($deck) && isset($deck['id']) && is_string($deck['id'])) $ids[] = $deck['id']; +} +rebuilderMyDeckIdsReply(200, ['ids' => $ids]); diff --git a/plugins/rebuilder/plugin.json b/plugins/rebuilder/plugin.json index f4e8c8d9..052897ca 100644 --- a/plugins/rebuilder/plugin.json +++ b/plugins/rebuilder/plugin.json @@ -24,7 +24,8 @@ ], "api": [ - { "endpoint": "community-builders", "file": "papi/community-builders.php", "methods": ["GET"] } + { "endpoint": "community-builders", "file": "papi/community-builders.php", "methods": ["GET"] }, + { "endpoint": "my-deck-ids", "file": "papi/my-deck-ids.php", "methods": ["GET"], "auth": "user" } ], "build": { diff --git a/tests/SpaPreloadTest.php b/tests/SpaPreloadTest.php new file mode 100644 index 00000000..9b01a0d0 --- /dev/null +++ b/tests/SpaPreloadTest.php @@ -0,0 +1,65 @@ + 'demo', '_dir' => $dir]; +$page = ['slug' => 'demo', 'type' => 'spa', 'entry' => 'dist/embed-manifest.json']; +$assets = '/plugins/demo/dist/browser/'; + +$writeManifest([ + 'version' => 1, 'base' => 'browser/', 'js' => ['polyfills-A.js', 'main-B.js'], 'css' => ['embed.css?v=1'], + 'documentCss' => ['document.css?v=2'], + 'preload' => [ + '*' => ['chunk-boot.js', 'chunk-shared.js'], + 'deck' => ['chunk-shared.js', 'chunk-deck.js'], + 'decks' => ['chunk-decks.js'], + 'lang:en' => ['chunk-en.js'], + 'bad' => ['../escape.js', '/abs.js', 'style.css', 42], + ], +]); + +// A beta page keeps the slug it is served at (pluginFindBetaPage()). +$deck = ['slug' => 'deck'] + spaResolvePage($plugin, $page); +$GLOBALS['__test_ui_lang'] = 'fr'; +assertSame([ + ['href' => $assets . 'polyfills-A.js', 'as' => 'module'], + ['href' => $assets . 'main-B.js', 'as' => 'module'], + ['href' => $assets . 'chunk-boot.js', 'as' => 'module'], + ['href' => $assets . 'chunk-shared.js', 'as' => 'module'], + ['href' => $assets . 'chunk-deck.js', 'as' => 'module'], + ['href' => $assets . 'embed.css?v=1', 'as' => 'style'], +], spaPreloads($deck), 'deck page, French: entry modules, "*" and its slug\'s modules once each, then the shadow root CSS'); + +$GLOBALS['__test_ui_lang'] = 'en'; +$hrefs = array_column(spaPreloads($deck), 'href'); +assertSame(true, in_array($assets . 'chunk-en.js', $hrefs, true), 'English: the translations module'); +assertSame(true, !in_array($assets . 'chunk-decks.js', $hrefs, true), 'another slug\'s modules are left out'); + +$bad = spaResolvePage($plugin, $page)['spa']['preload']['bad'] ?? []; +assertSame([], $bad, 'paths outside the build, absolute paths and non-modules are dropped'); + +// Light DOM: the plugin's CSS is not loaded by the runtime, no style preload. +$light = ['slug' => 'demo'] + spaResolvePage($plugin, ['mount' => 'light'] + $page); +assertSame([], array_values(array_filter(spaPreloads($light), fn($p) => $p['as'] === 'style')), 'light mount: no style preload'); + +// A manifest without `preload` (older builds) still renders: entry modules only. +$writeManifest(['version' => 1, 'js' => ['main.js'], 'css' => [], 'documentCss' => []]); +$plain = ['slug' => 'demo'] + spaResolvePage($plugin, $page); +assertSame([['href' => '/plugins/demo/dist/main.js', 'as' => 'module']], spaPreloads($plain), 'no preload key: entry modules only'); + +// A page that cannot render announces nothing. +@unlink($dir . '/dist/embed-manifest.json'); +$broken = ['slug' => 'demo'] + spaResolvePage($plugin, $page); +assertSame([], spaPreloads($broken), 'missing build: no preload'); + +@rmdir($dir . '/dist'); +@rmdir($dir); diff --git a/tests/e2e/perf-ab.mjs b/tests/e2e/perf-ab.mjs new file mode 100644 index 00000000..76e620b5 --- /dev/null +++ b/tests/e2e/perf-ab.mjs @@ -0,0 +1,246 @@ +// Load performance of Re:Builder's pages, two versions of the site side by side (A/B), on an emulated slow network. +// +// node perf-ab.mjs

- + diff --git a/plugins/rebuilder/e2e/rebuilder.spec.ts b/plugins/rebuilder/e2e/rebuilder.spec.ts index 2e229947..fa340e07 100644 --- a/plugins/rebuilder/e2e/rebuilder.spec.ts +++ b/plugins/rebuilder/e2e/rebuilder.spec.ts @@ -188,8 +188,7 @@ test.describe('ReBuilder in the shell · signed in', () => { test('shows the page skeleton while the scripts load, then the deck skeleton while the deck loads, never an empty deck', { tag: '@mobile' }, async ({ page }) => { await login(page, 'alice', `${NEW_DECK}?lang=fr`); await createDeck(page, `E2E skeleton ${Date.now()}`); - await expect(page).toHaveURL(/[?&]id=/); - const id = new URL(page.url()).searchParams.get('id')!; + const id = await editorDeckId(page); // The app's scripts and the deck are held: the server's skeleton stands for the page meanwhile. let releaseScripts!: () => void; @@ -1388,8 +1387,7 @@ test.describe('ReBuilder in the shell · loading', () => { test('requests its modules from , the deck once while they load, and the ownership from the deck ids', async ({ page, compact }) => { await login(page, 'alice', `${NEW_DECK}?lang=fr`); await createDeck(page, `E2E chargement ${Date.now()}`); - await expect(page).toHaveURL(/[?&]id=/); - const id = new URL(page.url()).searchParams.get('id')!; + const id = await editorDeckId(page); const requests: { url: string; method: string }[] = []; page.on('request', (r) => requests.push({ url: r.url(), method: r.method() })); @@ -1449,4 +1447,38 @@ test.describe('ReBuilder in the shell · loading', () => { await page.waitForTimeout(500); expect(await page.evaluate(() => (window as unknown as { __cls: number }).__cls)).toBeLessThan(0.1); }); + + test('keeps the server\'s skeleton until the page draws, never an empty frame in between', { tag: '@mobile' }, async ({ page }) => { + // A public deck seeded by the stack (docker/stack/seed-decks.php). + const [decks, manifestRes] = await Promise.all([ + page.request.get('/api/v1/services/decks/api/decks/public', { params: { itemsPerPage: 1 }, headers: { Accept: 'application/json' } }), + page.request.get('/plugins/rebuilder/dist/embed-manifest.json'), + ]); + const deck = ((await decks.json()) as { member: { id: string; name: string }[] }).member[0]; + + // The deck page's own modules are held: the app starts (its root, its router) but cannot draw the page yet. + const manifest = (await manifestRes.json()) as { preload: Record }; + const pageChunks = manifest.preload['deck'].filter((chunk) => !manifest.preload['*'].includes(chunk)); + expect(pageChunks.length).toBeGreaterThan(0); + let releasePage!: () => void; + const pageModules = new Promise((resolve) => (releasePage = resolve)); + await page.route((url) => pageChunks.some((chunk) => url.pathname.endsWith(`/${chunk}`)), async (route) => { + await pageModules; + await route.continue(); + }); + await bypassCaches(page); + await page.goto(`${DECK(deck.id)}&lang=fr`, { waitUntil: 'commit' }); + // The app's root is drawn: a placeholder taken for the first screen would go at the next frame. + await expect(page.locator('app-rebuilder-embed')).toBeAttached(); + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + await expect(page.locator('.ac-spa-placeholder .ac-skeleton').filter({ visible: true }).first()).toBeVisible(); + // The first paint waits for the skeleton (not the site header over an empty page): names its mount point. + const expectLink = page.locator('head link[rel="expect"][blocking="render"]'); + await expect(expectLink).toHaveAttribute('href', '#ac-spa-rebuilder'); + await expect(page.locator('#ac-spa-rebuilder > .ac-spa-placeholder')).toHaveCount(1); + + releasePage(); + await expect(page.locator('.ac-spa-placeholder')).toHaveCount(0); + await expect(page.locator('app-deck-page').getByText(deck.name).first()).toBeVisible(); + }); }); diff --git a/tests/SpaPreloadTest.php b/tests/SpaPreloadTest.php index 9b01a0d0..a580c7c7 100644 --- a/tests/SpaPreloadTest.php +++ b/tests/SpaPreloadTest.php @@ -47,6 +47,8 @@ function getUiLang(): string { return $GLOBALS['__test_ui_lang'] ?? 'fr'; } $bad = spaResolvePage($plugin, $page)['spa']['preload']['bad'] ?? []; assertSame([], $bad, 'paths outside the build, absolute paths and non-modules are dropped'); +assertSame('ac-spa-demo', spaMountId($deck), 'mount point id, named by the header\'s render-blocking '); + // Light DOM: the plugin's CSS is not loaded by the runtime, no style preload. $light = ['slug' => 'demo'] + spaResolvePage($plugin, ['mount' => 'light'] + $page); assertSame([], array_values(array_filter(spaPreloads($light), fn($p) => $p['as'] === 'style')), 'light mount: no style preload'); @@ -60,6 +62,7 @@ function getUiLang(): string { return $GLOBALS['__test_ui_lang'] ?? 'fr'; } @unlink($dir . '/dist/embed-manifest.json'); $broken = ['slug' => 'demo'] + spaResolvePage($plugin, $page); assertSame([], spaPreloads($broken), 'missing build: no preload'); +assertSame(null, spaMountId($broken), 'missing build: no mount point, nothing to wait for before the first paint'); @rmdir($dir . '/dist'); @rmdir($dir); From a29823d5dc89e6f7342c2a615e8c1395766ebd5f Mon Sep 17 00:00:00 2001 From: Yutsa Date: Thu, 8 Oct 2026 14:09:16 +0200 Subject: [PATCH 21/69] Default alt arts: one setting for the defaults, final prints per deck in Re:Builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ownership service's « Global » mode rewrote every deck with the global preferences, on the site and on Board Game Arena (apply-to-deck), so a deck could not keep prints of its own. The settings page now sets default alt arts only, and a deck stores the prints it uses: - Settings (plugin ownership): one mode, « Arts alternatifs par défaut », for cards and tokens. - A player still in « Global » is switched to « par deck » on their next visit (includes/func.alt-arts.php); their decks wait for their default alt arts until Re:Builder opens them (table alt_art_pending_decks). - Re:Builder: a card added from the search takes the default alt art of its copy; « Arts alternatifs » in the search lists the alt arts the player owns after their card, added as shown; the brush of a card changes its prints for the deck; « Arts par défaut » applies the defaults to the deck; the token window shows each token large with its illustrations. Import, a copy of someone else's deck and a hero picked without « Alt arts » take the defaults. - Local stack: docker-compose.ownership.yml runs the real AlteredOwnership service, with owned alt arts, default alt arts and a deck to switch from « Global ». Co-Authored-By: Claude Opus 5.5 --- README.md | 8 + docker-compose.ownership.yml | 108 ++++++++ docker/stack/config.stack.php | 5 +- docker/stack/ownership-mock/index.php | 5 +- docker/stack/ownership-real/config.php | 5 + .../stack/ownership-real/keycloak-scopes.sh | 19 ++ docker/stack/ownership-real/seed-decks.php | 79 ++++++ docker/stack/ownership-real/seed.sql | 48 ++++ includes/func.alt-arts.php | 78 ++++++ .../core-altered-cards/includes/functions.php | 3 + plugins/ownership/api/alt-art-pending.php | 32 +++ .../ownership/api/alt-art-preference-mode.php | 53 +--- plugins/ownership/api/alt-art-search.php | 11 - plugins/ownership/assets/style.css | 13 +- plugins/ownership/includes/functions.php | 8 +- plugins/ownership/includes/subnav.php | 4 +- plugins/ownership/pages/alt-arts.php | 117 +------- plugins/ownership/pages/ownership.php | 8 +- plugins/ownership/plugin.json | 5 +- plugins/rebuilder/README.md | 4 +- plugins/rebuilder/app/design/COMPONENTS.md | 3 +- plugins/rebuilder/app/docs/backend-api.md | 4 +- plugins/rebuilder/app/docs/legacy-parity.md | 6 +- .../app/src/app/core/alt-art-defaults.spec.ts | 109 ++++++++ .../app/src/app/core/alt-art-defaults.ts | 137 ++++++++++ .../app/src/app/core/deck-store.spec.ts | 94 +++++-- .../rebuilder/app/src/app/core/deck-store.ts | 138 ++++++---- plugins/rebuilder/app/src/app/core/models.ts | 2 + .../app/core/ownership-api.service.spec.ts | 52 ++-- .../app/src/app/core/ownership-api.service.ts | 77 ++---- .../features/deck/deck-board/deck-board.html | 4 +- .../features/deck/deck-board/deck-board.ts | 19 +- .../decks/equinox/equinox-import.spec.ts | 38 ++- .../features/decks/equinox/equinox-import.ts | 16 +- .../alt-art-picker.overlay.html | 29 ++ .../alt-art-picker.overlay.scss | 131 +++++++++ .../alt-art-picker/alt-art-picker.overlay.ts | 116 ++++++++ .../editor/deck-list-view/deck-list-view.html | 9 +- .../editor/deck-list-view/deck-list-view.scss | 5 + .../editor/deck-list-view/deck-list-view.ts | 11 +- .../editor/deck-panel/deck-panel.html | 2 +- .../editor/deck-preview/deck-preview.html | 4 +- .../editor/deck-preview/deck-preview.ts | 19 +- .../features/editor/editor-alt-arts.spec.ts | 92 +++++++ .../app/features/editor/editor-alt-arts.ts | 254 +++++++++++++++--- .../editor/editor-page/editor.page.html | 5 +- .../editor/editor-page/editor.page.ts | 8 +- .../editor/token-arts/token-arts.overlay.html | 29 +- .../editor/token-arts/token-arts.overlay.scss | 86 +++++- .../editor/token-arts/token-arts.overlay.ts | 78 +++++- .../search/card-search/card-search.html | 6 + .../search/card-search/card-search.ts | 8 +- .../search/search-results/search-results.html | 19 +- .../search/search-results/search-results.ts | 39 ++- .../shared/alt-art-slots/alt-art-slots.html | 23 -- .../shared/alt-art-slots/alt-art-slots.scss | 74 ----- .../alt-art-slots/alt-art-slots.spec.ts | 94 ------- .../shared/alt-art-slots/alt-art-slots.ts | 94 ------- .../shared/card-zoom/card-zoom.overlay.html | 27 +- .../shared/card-zoom/card-zoom.overlay.scss | 37 --- .../shared/card-zoom/card-zoom.overlay.ts | 57 +--- .../hero-picker/hero-picker.overlay.html | 14 +- .../shared/hero-picker/hero-picker.overlay.ts | 7 +- .../src/app/features/shared/new-deck.form.ts | 5 +- .../src/app/features/shared/new-deck.spec.ts | 3 +- .../shared/new-deck/new-deck.overlay.html | 16 +- .../shared/new-deck/new-deck.overlay.ts | 4 - plugins/rebuilder/app/src/app/ui/icon/icon.ts | 3 + .../app/ui/metier/card-pile/card-pile.html | 5 + .../app/ui/metier/card-pile/card-pile.scss | 31 +++ .../src/app/ui/metier/card-pile/card-pile.ts | 5 + .../app/ui/metier/card-tile/card-tile.html | 12 +- .../app/ui/metier/card-tile/card-tile.scss | 54 ++++ .../src/app/ui/metier/card-tile/card-tile.ts | 6 + .../rebuilder/app/src/locale/messages.en.json | 37 ++- plugins/rebuilder/e2e/rebuilder.spec.ts | 180 +++++++++---- sql/migration_009_alt_art_pending_decks.sql | 9 + sql/schema.sql | 11 + 78 files changed, 2116 insertions(+), 954 deletions(-) create mode 100644 docker-compose.ownership.yml create mode 100644 docker/stack/ownership-real/config.php create mode 100644 docker/stack/ownership-real/keycloak-scopes.sh create mode 100644 docker/stack/ownership-real/seed-decks.php create mode 100644 docker/stack/ownership-real/seed.sql create mode 100644 includes/func.alt-arts.php create mode 100644 plugins/ownership/api/alt-art-pending.php create mode 100644 plugins/rebuilder/app/src/app/core/alt-art-defaults.spec.ts create mode 100644 plugins/rebuilder/app/src/app/core/alt-art-defaults.ts create mode 100644 plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.html create mode 100644 plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.scss create mode 100644 plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.ts create mode 100644 plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.spec.ts delete mode 100644 plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.html delete mode 100644 plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.scss delete mode 100644 plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.spec.ts delete mode 100644 plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.ts create mode 100644 sql/migration_009_alt_art_pending_decks.sql diff --git a/README.md b/README.md index cc716b03..5fd54524 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,14 @@ docker compose -f docker-compose.yml -f docker-compose.stack.yml up -d --build - Site on http://localhost:8080 (`WEB_PORT` to change it), Keycloak users `alice` / `bob` (password `TestPassword1234`), decks API on http://localhost:8001. Playwright: `cd tests/e2e && npm ci && npx playwright test`. CI runs the same on every pull request (`.github/workflows/plugins-ci.yml`). +**Alt arts with the real ownership service.** The stack's digital ownership is a mock (`docker/stack/ownership-mock`). To try the default alt arts with [AlteredOwnership](https://github.com/Altered-Re-Union/AlteredOwnership) itself, built from GitHub (alice and bob own Axiom alt arts and have default alt arts; alice starts in the old « Global » mode, with a deck to see its switch): + +```bash +docker compose -f docker-compose.yml -f docker-compose.stack.yml -f docker-compose.ownership.yml up -d --build --wait +``` + +Sign in again after its first start (the service needs the `read-collection` scope in the site's tokens). Details in `docker-compose.ownership.yml` and `docker/stack/ownership-real/`. + **Like production.** To get the plugins active on https://altered.re and its public content (news, content pages, side menu, footer, fonts, logo, home page text, images): ```bash diff --git a/docker-compose.ownership.yml b/docker-compose.ownership.yml new file mode 100644 index 00000000..3896f652 --- /dev/null +++ b/docker-compose.ownership.yml @@ -0,0 +1,108 @@ +# Local testing of the alt arts with the real AlteredOwnership service (Altered-Re-Union/AlteredOwnership) instead of +# the mock (docker/stack/ownership-mock, which stays the collection API). On top of the full stack: +# +# docker compose -f docker-compose.yml -f docker-compose.stack.yml -f docker-compose.ownership.yml up -d --build --wait +# +# docker/stack/ownership-real/seed.sql gives alice and bob owned alt arts and default alt arts, and alice the « Global » +# mode (to try its switch to « par deck »); seed-decks.php a deck of hers built in that mode and one of bob's; +# keycloak-scopes.sh the `read-collection` scope the service requires in the site's tokens (sign in again after a first +# start). Service: http://localhost:8003 (health: /health). To try the switch again: +# docker compose -f docker-compose.yml -f docker-compose.stack.yml -f docker-compose.ownership.yml run --rm ownership-seed +# docker compose -f docker-compose.yml -f docker-compose.stack.yml -f docker-compose.ownership.yml run --rm alice-decks +services: + web: + volumes: + - ./docker/stack/ownership-real/config.php:/var/www/html/config.local.php:ro + depends_on: + ownership-seed: + condition: service_completed_successfully + + ownership-db: + image: postgres:16-alpine + environment: + POSTGRES_USER: ownership + POSTGRES_PASSWORD: ownership + POSTGRES_DB: ownershipdb + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ownership -d ownershipdb"] + interval: 3s + timeout: 3s + retries: 30 + + ownership-redis: + image: redis:7-alpine + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 3s + timeout: 3s + retries: 30 + + ownership-real: + # Pinned: bump the commit to follow the service. + image: alteredcore-stack-ownership + build: + context: https://github.com/Altered-Re-Union/AlteredOwnership.git#41e6248e643ccdb1161775cdec921441a8db91ec + dockerfile: build/Dockerfile + target: app + environment: + # Development: plain HTTP to Keycloak, /health mapped. The schema and the alt-art catalog come from its migrations. + ASPNETCORE_ENVIRONMENT: Development + ConnectionStrings__ownershipdb: Host=ownership-db;Database=ownershipdb;Username=ownership;Password=ownership + ConnectionStrings__cache: ownership-redis:6379 + Keycloak__Authority: http://auth.altered.local.gd:18080/realms/players + Keycloak__ClientId: main-site + Keycloak__ClientSecret: dev-main-site-secret + ExternalHosts__AuthBase: http://auth.altered.local.gd:18080 + ports: + - "127.0.0.1:8003:8080" + depends_on: + ownership-db: + condition: service_healthy + ownership-redis: + condition: service_healthy + keycloak: + condition: service_healthy + healthcheck: + # The image has no curl: HTTP over bash. + test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/8080 && printf 'GET /health HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && grep -q 'Healthy' <&3"] + interval: 5s + timeout: 5s + retries: 60 + + keycloak-scopes: + image: quay.io/keycloak/keycloak:26.0 + entrypoint: ["/bin/bash", "/setup/keycloak-scopes.sh"] + environment: + # kcadm is a plain JVM: JDK 21 dies with SIGILL on recent Apple Silicon (SVE), as the Keycloak service. + JAVA_TOOL_OPTIONS: "-XX:+IgnoreUnrecognizedVMOptions -XX:UseSVE=0" + volumes: + - ./docker/stack/ownership-real:/setup:ro + depends_on: + keycloak: + condition: service_healthy + + ownership-seed: + image: postgres:16-alpine + entrypoint: ["psql", "-v", "ON_ERROR_STOP=1", "-f", "/setup/seed.sql"] + environment: + PGHOST: ownership-db + PGUSER: ownership + PGPASSWORD: ownership + PGDATABASE: ownershipdb + volumes: + - ./docker/stack/ownership-real:/setup:ro + depends_on: + ownership-real: + condition: service_healthy + keycloak-scopes: + condition: service_completed_successfully + + alice-decks: + image: alteredcore-stack-web + entrypoint: ["php", "/var/www/html/docker/stack/ownership-real/seed-decks.php"] + volumes: + - .:/var/www/html + - ./docker/stack/ownership-real/config.php:/var/www/html/config.local.php:ro + depends_on: + web: + condition: service_healthy diff --git a/docker/stack/config.stack.php b/docker/stack/config.stack.php index 4bf04228..d3a0ecd3 100644 --- a/docker/stack/config.stack.php +++ b/docker/stack/config.stack.php @@ -35,8 +35,9 @@ define('DECKS_API_URL', 'http://decks-api:80'); define('CDN_URL', 'https://cdn.alteredcore.org'); // Mock of the digital-ownership service (docker/stack/ownership-mock): alt arts in the site and Re:Builder. It also -// answers the collection API's GET /api/collection (the real one only takes altered.re tokens). -define('OWNERSHIP_API_URL', 'http://ownership:8080'); +// answers the collection API's GET /api/collection (the real one only takes altered.re tokens). The real service +// (docker-compose.ownership.yml) defines it first. +if (!defined('OWNERSHIP_API_URL')) define('OWNERSHIP_API_URL', 'http://ownership:8080'); define('OWNERSHIP_WEB_URL', ''); define('COLLECTION_API_URL', 'http://ownership:8080'); define('COLLECTION_USE_API', false); diff --git a/docker/stack/ownership-mock/index.php b/docker/stack/ownership-mock/index.php index e6617e61..dfac5538 100644 --- a/docker/stack/ownership-mock/index.php +++ b/docker/stack/ownership-mock/index.php @@ -157,7 +157,10 @@ function owned(array $print, string $user): ?int { function options(array $f, string $user): array { $chosen = state($user)['slots'][$f['key']] ?? []; $slots = []; - for ($i = 0; $i < $f['copies']; $i++) $slots[] = ['slotIndex' => $i, 'reference' => $chosen[$i] ?? $f['prints'][0]['reference']]; + // As the service: slots numbered from 1, the plain print where the player chose nothing. + for ($i = 0; $i < $f['copies']; $i++) { + $slots[] = ['slotIndex' => $i + 1, 'reference' => $chosen[$i] ?? $f['prints'][0]['reference'], 'isExplicitChoice' => isset($chosen[$i])]; + } return key3($f) + [ 'options' => array_map(fn($p) => ['reference' => $p['reference'], 'ownedQuantity' => owned($p, $user)], $f['prints']), 'slots' => $slots, diff --git a/docker/stack/ownership-real/config.php b/docker/stack/ownership-real/config.php new file mode 100644 index 00000000..a386cb97 --- /dev/null +++ b/docker/stack/ownership-real/config.php @@ -0,0 +1,5 @@ +/dev/null + +# The image has no awk nor grep: bash reads the CSV. +scope= +while IFS=, read -r id name; do + if [ "$name" = read-collection ]; then scope=$id; fi +done < <($kc get client-scopes -r players --fields id,name --format csv --noquotes) +if [ -z "$scope" ]; then + scope=$($kc create client-scopes -r players -i -s name=read-collection -s protocol=openid-connect \ + -s 'attributes."include.in.token.scope"=true' -s 'attributes."display.on.consent.screen"=false') +fi +client=$($kc get clients -r players -q clientId=main-site --fields id --format csv --noquotes) +$kc update "clients/$client/default-client-scopes/$scope" -r players +echo "read-collection is a default scope of main-site" diff --git a/docker/stack/ownership-real/seed-decks.php b/docker/stack/ownership-real/seed-decks.php new file mode 100644 index 00000000..632c9190 --- /dev/null +++ b/docker/stack/ownership-real/seed-decks.php @@ -0,0 +1,79 @@ + $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => 20]); + if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + $res = curl_exec($ch); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + return [$code, is_string($res) ? $res : '']; +} + +/** Bearer headers of a player of the stack's realm; exits when Keycloak refuses. */ +function playerAuth(string $user): array { + [$code, $body] = seedHttp('POST', KC_URL . '/realms/' . KC_REALM . '/protocol/openid-connect/token', + ['Content-Type: application/x-www-form-urlencoded'], + http_build_query(['grant_type' => 'password', 'client_id' => KC_CLIENT_ID, 'client_secret' => KC_CLIENT_SECRET, + 'username' => $user, 'password' => 'TestPassword1234', 'scope' => 'openid'])); + $token = json_decode($body, true)['access_token'] ?? null; + if ($code !== 200 || !$token) { + fwrite(STDERR, "[alt-art-decks] no token for {$user} (HTTP {$code})\n"); + exit(1); + } + return ['Authorization: Bearer ' . $token, 'Accept: application/json', 'Content-Type: application/json']; +} +$api = rtrim(DECKS_API_URL, '/'); + +$line = fn(string $ref, int $qty) => ['cardReference' => $ref, 'quantity' => $qty]; +$decks = [ + [ + // Plain prints only: her default alt arts replace them (Vaike, Icare, Fée Clochette, the hero). + 'name' => 'Axiom (arts de base)', + 'deckCards' => [ + $line('ALT_ALIZE_B_AX_01_C', 1), // Sierra & Oddball + $line('ALT_ALIZE_B_AX_35_C', 3), // Vaike, l'Énergéticienne + $line('ALT_BISE_B_AX_56_C', 3), // Icare + $line('ALT_CORE_B_AX_09_C', 3), // Fée Clochette + $line('ALT_CORE_B_AX_22_C', 3), // Entraînement Mécanique + $line('ALT_CYCLONE_B_AX_74_C', 2), // Lucan, Léviathan Affamé + $line('ALT_CYCLONE_B_AX_76_C', 2), // Sceau Axiom + $line('ALT_EOLE_B_AX_106_C', 1), // Salamandre Furtive + $line('ALT_ALIZE_B_AX_46_C', 2), // Galeries Saisies par les Glaces + $line('ALT_CORE_B_AX_08_C', 3), // Récupérateur Axiom (one illustration) + ], + ], + [ + // Prints chosen in the deck: one Vaike on the plain art, an alt art not among her defaults. + 'name' => 'Axiom (arts choisis)', + 'deckCards' => [ + $line('ALT_ALIZE_B_AX_01_C', 1), + $line('ALT_ALIZE_A_AX_35_C', 2), + $line('ALT_ALIZE_B_AX_35_C', 1), + $line('ALT_CORE_A_AX_22_C', 1), + $line('ALT_CORE_B_AX_22_C', 2), + $line('ALT_CORE_B_AX_09_C', 2), + ], + ], +]; +$owners = ['alice', 'bob']; +$created = 0; +foreach ($decks as $i => $deck) { + $auth = playerAuth($owners[$i]); + [, $body] = seedHttp('GET', $api . '/api/decks?itemsPerPage=1000', $auth); + $mine = json_decode($body, true); + foreach (is_array($mine) ? ($mine['member'] ?? $mine) : [] as $existing) { + if (($existing['name'] ?? '') === $deck['name']) seedHttp('DELETE', $api . '/api/decks/' . rawurlencode($existing['id']), $auth); + } + $deck += ['format' => 'standard', 'isPublic' => false, 'isDraft' => true]; + [$code] = seedHttp('POST', $api . '/api/decks', $auth, json_encode($deck, JSON_UNESCAPED_UNICODE)); + if ($code === 201) $created++; + else fwrite(STDERR, "[alt-art-decks] {$deck['name']}: HTTP {$code}\n"); +} +echo "[alt-art-decks] {$created} decks created\n"; diff --git a/docker/stack/ownership-real/seed.sql b/docker/stack/ownership-real/seed.sql new file mode 100644 index 00000000..2406cfac --- /dev/null +++ b/docker/stack/ownership-real/seed.sql @@ -0,0 +1,48 @@ +-- docker-compose.ownership.yml: the players of the stack's realm (docker/stack/players-realm.json) in the real +-- AlteredOwnership service, once its migrations ran. Idempotent: run on every start, resets their mode, ownership and +-- default alt arts. +-- +-- alice: « Global » mode (the site switches it to « par deck » on her next visit and marks her decks to receive her +-- default alt arts). bob: « par deck » already. Both own the same Axiom alt arts, in various quantities, and have the +-- same default alt arts. +-- Most MUSUBI prints (Fée Clochette, Prestidigitatrice de l'Ouroboros, Le Roi-Grenouille…) are everyone's in unlimited +-- quantity: they need no ownership row. + +INSERT INTO "Users" ("Id", "KeycloakId", "CreatedAt", "AltArtPreferenceMode") +VALUES ('aaaaaaaa-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', now(), 'Global'), + ('aaaaaaaa-0000-0000-0000-000000000002', '22222222-2222-2222-2222-222222222222', now(), 'PerDeck') +ON CONFLICT ("KeycloakId") DO UPDATE SET "AltArtPreferenceMode" = EXCLUDED."AltArtPreferenceMode"; + +DELETE FROM "CardOwnerships" WHERE "UserId" IN ('aaaaaaaa-0000-0000-0000-000000000001', 'aaaaaaaa-0000-0000-0000-000000000002'); +INSERT INTO "CardOwnerships" ("UserId", "CardReference", "Quantity", "IsUnique") +SELECT u.id, ref, qty, false +FROM (VALUES ('aaaaaaaa-0000-0000-0000-000000000001'::uuid), ('aaaaaaaa-0000-0000-0000-000000000002'::uuid)) AS u(id), +(VALUES + ('ALT_ALIZE_A_AX_35_C', 2), -- Vaike, l'Énergéticienne: 2 of 3 copies + ('ALT_BISE_A_AX_56_C', 3), -- Icare: a full playset + ('ALT_CORE_A_AX_22_C', 1), -- Entraînement Mécanique: 1 copy + ('ALT_CYCLONE_A_AX_74_C', 2), -- Lucan, Léviathan Affamé + ('ALT_CYCLONE_A_AX_76_C', 1), -- Sceau Axiom + ('ALT_EOLECB_A_AX_106_C', 1), -- Salamandre Furtive (Evil Eye) + ('ALT_ALIZE_A_AX_46_C', 3), -- Galeries Saisies par les Glaces + ('ALT_CORE_P_AX_01_C', 1), -- Sierra & Oddball, promo hero + ('ALT_WCS25_P_AX_01_C', 1), -- Sierra & Oddball, WCS 2025 hero + ('ALT_WCS26_A_AX_31_C', 1), -- Scarabot token, WCS 2026 + ('ALT_MUSUBI_B_AX_30_R1', 1) -- Ruche des Scarabots, MUSUBI 2.0 (a tracked MUSUBI print) +) AS owned(ref, qty); + +-- Default alt arts (1st, 2nd, 3rd copy). Fée Clochette: the MUSUBI print for the first two copies. +DELETE FROM "UserCardArtPreferences" WHERE "UserId" IN ('aaaaaaaa-0000-0000-0000-000000000001', 'aaaaaaaa-0000-0000-0000-000000000002'); +INSERT INTO "UserCardArtPreferences" ("UserId", "FamilyId", "Faction", "Rarity", "SlotIndex", "PreferredReference") +SELECT u.id, f.family, f.faction, f.rarity, f.slot, f.ref +FROM (VALUES ('aaaaaaaa-0000-0000-0000-000000000001'::uuid), ('aaaaaaaa-0000-0000-0000-000000000002'::uuid)) AS u(id), +(VALUES + (64, 'AX', 'C', 1, 'ALT_ALIZE_A_AX_35_C'), + (64, 'AX', 'C', 2, 'ALT_ALIZE_A_AX_35_C'), + (165, 'AX', 'C', 1, 'ALT_BISE_A_AX_56_C'), + (165, 'AX', 'C', 2, 'ALT_BISE_A_AX_56_C'), + (165, 'AX', 'C', 3, 'ALT_BISE_A_AX_56_C'), + (304, 'AX', 'C', 1, 'ALT_MUSUBI_B_AX_09_C'), + (304, 'AX', 'C', 2, 'ALT_MUSUBI_B_AX_09_C'), + (69, 'AX', 'C', 1, 'ALT_CORE_P_AX_01_C') +) AS f(family, faction, rarity, slot, ref); diff --git a/includes/func.alt-arts.php b/includes/func.alt-arts.php new file mode 100644 index 00000000..5e1f9108 --- /dev/null +++ b/includes/func.alt-arts.php @@ -0,0 +1,78 @@ + 'PerDeck']); + if ($status !== 204) return false; + + if ($ids) { + $insert = getDB()->prepare(q("INSERT IGNORE INTO {alt_art_pending_decks} (user_id, deck_id) VALUES (:u, :d)")); + foreach ($ids as $id) $insert->execute([':u' => $userId, ':d' => $id]); + } + return true; +} + +/** Whether $deckId (a deck of $userId) waits for its default alt arts. */ +function altArtDeckPending(int $userId, string $deckId): bool { + $stmt = getDB()->prepare(q("SELECT 1 FROM {alt_art_pending_decks} WHERE user_id = :u AND deck_id = :d LIMIT 1")); + $stmt->execute([':u' => $userId, ':d' => $deckId]); + return (bool)$stmt->fetchColumn(); +} + +/** $deckId took its default alt arts (or does not need them any more). */ +function altArtClearPending(int $userId, string $deckId): void { + getDB()->prepare(q("DELETE FROM {alt_art_pending_decks} WHERE user_id = :u AND deck_id = :d")) + ->execute([':u' => $userId, ':d' => $deckId]); +} + +/** Ids of the decks of the token's player (decks API, every deck of the account in one page); `null` on an error. */ +function altArtUserDeckIds(string $token): ?array { + if (!defined('DECKS_API_URL') || !DECKS_API_URL) return []; + [$status, $body] = altArtHttp('GET', rtrim(DECKS_API_URL, '/') . '/api/decks?' . http_build_query(['page' => 1, 'itemsPerPage' => 1000]), $token); + if ($status !== 200 || !is_array($body)) return null; + $decks = isset($body['member']) && is_array($body['member']) ? $body['member'] : $body; + $ids = []; + foreach ($decks as $deck) { + if (is_array($deck) && isset($deck['id']) && is_string($deck['id'])) $ids[] = $deck['id']; + } + return $ids; +} + +/** JSON request with a bearer token: [status (0 when unreachable), decoded body or null]. */ +function altArtHttp(string $method, string $url, string $token, ?array $body = null): array { + $headers = ['Authorization: Bearer ' . $token, 'Accept: application/json']; + if ($body !== null) $headers[] = 'Content-Type: application/json'; + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 20, + CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, + ]); + if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); + $raw = curl_exec($ch); + $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + if ($raw === false) return [0, null]; + $decoded = json_decode((string)$raw, true); + return [$status, is_array($decoded) ? $decoded : null]; +} diff --git a/plugins/core-altered-cards/includes/functions.php b/plugins/core-altered-cards/includes/functions.php index 250be444..4e8571a0 100644 --- a/plugins/core-altered-cards/includes/functions.php +++ b/plugins/core-altered-cards/includes/functions.php @@ -277,6 +277,9 @@ function cacGetAltArtPreferenceMode(int $userId): string { if (is_array($data) && in_array($data['mode'] ?? null, ['PerDeck', 'Global'], true)) { $mode = $data['mode']; } + // Default alt arts replace the "Global" mode: a player still in it is switched once. + require_once dirname(__DIR__, 3) . '/includes/func.alt-arts.php'; + if ($mode === 'Global' && altArtLeaveGlobalMode($userId)) $mode = 'PerDeck'; } $_SESSION['alt_art_mode'] = $mode; diff --git a/plugins/ownership/api/alt-art-pending.php b/plugins/ownership/api/alt-art-pending.php new file mode 100644 index 00000000..b05d5100 --- /dev/null +++ b/plugins/ownership/api/alt-art-pending.php @@ -0,0 +1,32 @@ + → {"pending": bool} +// POST /papi/ownership/alt-art-pending {"deck": ""} → 204 +// Re:Builder, on opening one of the player's decks: whether it waits for the player's default alt arts (decks of a +// player switched from the « Global » alt-art mode, see includes/func.alt-arts.php), then that it took them. Reading +// the mode first (from the service, not the session's cache) switches a player still in « Global » (the deck then waits +// too). Manifest: "auth": "user". +require_once __DIR__ . '/../includes/functions.php'; +require_once dirname(__DIR__, 3) . '/includes/func.alt-arts.php'; + +header('Content-Type: application/json; charset=UTF-8'); +header('Cache-Control: private, no-store'); + +$userId = (int)($_SESSION['user_id'] ?? 0); +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; +$deckId = $method === 'GET' ? ($_GET['deck'] ?? '') : (pluginApiBody()['deck'] ?? ''); +if (!is_string($deckId) || !preg_match('/^[A-Za-z0-9-]{1,64}$/', $deckId)) { + http_response_code(400); + echo json_encode(['error' => 'deck']); + return; +} + +if ($method === 'POST') { + altArtClearPending($userId, $deckId); + http_response_code(204); + return; +} + +// Read again, not from the session's cache: a player still in « Global » is switched before their deck opens. +unset($_SESSION['alt_art_mode'], $_SESSION['alt_art_mode_at']); +ownGetAltArtPreferenceMode($userId); +echo json_encode(['pending' => altArtDeckPending($userId, $deckId)]); diff --git a/plugins/ownership/api/alt-art-preference-mode.php b/plugins/ownership/api/alt-art-preference-mode.php index 39725e6d..3bf3c989 100644 --- a/plugins/ownership/api/alt-art-preference-mode.php +++ b/plugins/ownership/api/alt-art-preference-mode.php @@ -1,8 +1,8 @@ ownGetAltArtPreferenceMode($userId)]); - exit; -} - -if ($method !== 'PUT') { - http_response_code(405); - echo json_encode(['error' => 'Method not allowed']); - exit; -} - -$body = json_decode(file_get_contents('php://input'), true); -if (!is_array($body)) $body = []; - -if (!csrfValid($body['csrf_token'] ?? null)) { - http_response_code(403); - echo json_encode(['error' => 'Invalid CSRF token']); - exit; -} - -$mode = $body['mode'] ?? null; -if (!in_array($mode, ['PerDeck', 'Global'], true)) { - http_response_code(400); - echo json_encode(['error' => 'mode must be "PerDeck" or "Global"']); - exit; -} - -[$status, $raw] = ownApiRequestRaw('PUT', '/api/alt-arts/preference-mode', $userId, ['mode' => $mode]); - -if ($status !== 204) { - http_response_code($status ?: 502); - echo json_encode(['error' => 'Ownership API error']); - exit; -} - -// Refresh the cache immediately so the rest of this session sees the new mode right -// away instead of waiting out ALT_ART_MODE_CACHE_TTL. -$_SESSION['alt_art_mode'] = $mode; -$_SESSION['alt_art_mode_at'] = time(); - -http_response_code(204); +echo json_encode(['mode' => ownGetAltArtPreferenceMode((int)($_SESSION['user_id'] ?? 0))]); diff --git a/plugins/ownership/api/alt-art-search.php b/plugins/ownership/api/alt-art-search.php index d5b077b7..d1aa6a01 100644 --- a/plugins/ownership/api/alt-art-search.php +++ b/plugins/ownership/api/alt-art-search.php @@ -39,17 +39,6 @@ if (is_string($ty) && $ty !== '') $types[] = $ty; } -// PerDeck mode: this page only manages token illustrations — regular card alt-arts are -// chosen per deck in the deckbuilder instead (see AltArtPreferenceMode). Enforced here, -// not just by which filter buttons pages/alt-arts.php renders, so a caller can't bypass -// it by requesting a non-token type directly. -if (!ownIsAltArtGlobalMode($userId)) { - $types = array_values(array_filter($types, fn($ty) => strpos($ty, 'TOKEN') === 0)); - if (!$types) { - $types = ['TOKEN', 'TOKEN_LANDMARK_PERMANENT', 'TOKEN_MANA']; - } -} - foreach ($types as $ty) { $parts[] = 'type[]=' . rawurlencode($ty); } diff --git a/plugins/ownership/assets/style.css b/plugins/ownership/assets/style.css index 5837bc56..57a9d875 100644 --- a/plugins/ownership/assets/style.css +++ b/plugins/ownership/assets/style.css @@ -419,7 +419,7 @@ margin: var(--ac-space-3) 0; } -/* Per-deck vs. global alt-art mode toggle + its explanation panel (pages/alt-arts.php) */ +/* Window over the page (the legacy deckbuilder's token illustrations, assets/deckbuilder/token-arts.js) */ .own-aa-modal-overlay { position: fixed; inset: 0; @@ -437,17 +437,8 @@ overflow-y: auto; box-shadow: var(--ac-shadow-dialog); } -.own-aa-modal .ac-card__meta { - margin-bottom: var(--ac-space-3); -} -/* Mode switch bar and search field (pages/alt-arts.php) */ -.own-aa-modebar { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--ac-space-2); -} +/* Search field (pages/alt-arts.php) */ .own-aa-search { max-width: 260px; } diff --git a/plugins/ownership/includes/functions.php b/plugins/ownership/includes/functions.php index 284ce117..08901948 100644 --- a/plugins/ownership/includes/functions.php +++ b/plugins/ownership/includes/functions.php @@ -64,9 +64,9 @@ function ownApiRequestRaw(string $method, string $path, int $userId, $body = nul * The player's alt-art preference mode ("PerDeck", the default, or "Global") — see * AltArtPreferenceMode on the ownership service. Cached in the session for * ALT_ART_MODE_CACHE_TTL seconds so pages that need it (alt-arts.php, the deckbuilder, - * the card-detail modal on every page) don't each cost a round trip; refreshed - * immediately whenever the mode is changed (see api/alt-art-preference-mode.php) rather - * than waiting out the TTL. + * the card-detail modal on every page) don't each cost a round trip. A player still in + * "Global" is switched to "PerDeck" here, once (see includes/func.alt-arts.php): default + * alt arts replace the mode. */ const ALT_ART_MODE_CACHE_TTL = 300; @@ -82,6 +82,8 @@ function ownGetAltArtPreferenceMode(int $userId): string { if (is_array($data) && in_array($data['mode'] ?? null, ['PerDeck', 'Global'], true)) { $mode = $data['mode']; } + require_once dirname(__DIR__, 3) . '/includes/func.alt-arts.php'; + if ($mode === 'Global' && altArtLeaveGlobalMode($userId)) $mode = 'PerDeck'; } $_SESSION['alt_art_mode'] = $mode; diff --git a/plugins/ownership/includes/subnav.php b/plugins/ownership/includes/subnav.php index 0990b973..6fb86912 100644 --- a/plugins/ownership/includes/subnav.php +++ b/plugins/ownership/includes/subnav.php @@ -14,8 +14,8 @@ // physical-collection tab elsewhere on the site (core-altered-cards/pages/collection.php); // keeping "Digital Ownership"/"Propriété numérique" here avoids the two being confused. $ownSubnavTxt = [ - 'en' => ['collection' => 'Digital Ownership', 'boosters' => 'Boosters', 'history' => 'History', 'altArts' => 'Alt Arts BGA', 'import' => 'Equinox Import'], - 'fr' => ['collection' => 'Propriété numérique', 'boosters' => 'Boosters', 'history' => 'Historique', 'altArts' => 'Alt Arts BGA', 'import' => 'Import Equinox'], + 'en' => ['collection' => 'Digital Ownership', 'boosters' => 'Boosters', 'history' => 'History', 'altArts' => 'Default alt arts', 'import' => 'Equinox Import'], + 'fr' => ['collection' => 'Propriété numérique', 'boosters' => 'Boosters', 'history' => 'Historique', 'altArts' => 'Arts alternatifs par défaut', 'import' => 'Import Equinox'], ][getUiLang()]; $ownBoosterCount = null; diff --git a/plugins/ownership/pages/alt-arts.php b/plugins/ownership/pages/alt-arts.php index ea4032f5..11b1433b 100644 --- a/plugins/ownership/pages/alt-arts.php +++ b/plugins/ownership/pages/alt-arts.php @@ -1,8 +1,9 @@ 'Could not save your choice.', 'loadMore' => 'Load more', 'markerHint' => 'Click a marker, then click a card to move it there.', - 'modePerDeck' => 'Set per deck', - 'modeGlobal' => 'Set globally', - 'modeInfoBtn' => 'What\'s the difference?', - 'modeInfoTitle' => 'Per deck vs. global illustrations', - 'modePerDeckTitle' => 'Per deck (default)', - 'modePerDeckDesc' => "Clicking a card no longer shows the illustration preference. Only tokens can be changed on this page. In the deckbuilder, you choose each card's and the hero's illustration individually while building the deck.", - 'modeGlobalTitle' => 'Global', - 'modeGlobalDesc' => 'Your illustration preferences are set here or by clicking a card, and apply automatically everywhere: opening a deck to edit it, duplicating it, importing it, retrieving it from BGA, and whenever you change a card\'s quantity. The deckbuilder no longer lets you change illustrations card by card, and there is no "apply preferences" button — it always applies.', - 'modeClose' => 'Close', - 'modeSaving' => 'Saving…', - 'modeSaveError' => 'Could not save your setting.', - 'perDeckNotice' => 'Per-deck mode is on: only token illustrations are managed here. Regular cards\' illustrations are chosen individually in each deck, in the deckbuilder.', + 'intro' => 'Choose the illustration each copy of a card takes when you add it to a deck (1st, 2nd and 3rd copy). In Re:Builder, the brush of a card changes its illustrations for one deck, and « Default alt arts » applies these choices to a deck again. A token\'s illustration is the same in all your decks.', ], 'fr' => [ 'unavailable' => 'Le service de propriété numérique n\'est pas configuré sur ce site.', @@ -58,18 +48,7 @@ 'saveError' => 'Impossible d\'enregistrer votre choix.', 'loadMore' => 'Charger plus', 'markerHint' => 'Cliquez un marqueur, puis une carte pour l\'y déplacer.', - 'modePerDeck' => 'Définir par deck', - 'modeGlobal' => 'Définir au global', - 'modeInfoBtn' => 'Quelle est la différence ?', - 'modeInfoTitle' => 'Illustrations par deck ou au global', - 'modePerDeckTitle' => 'Par deck (par défaut)', - 'modePerDeckDesc' => "Le changement des préférences d'une carte n'est pas visible lorsque l'on clique sur une carte. Seuls les jetons sont modifiables sur cette page. Dans le deckbuilder, vous choisissez l'illustration de chaque carte et du héros individuellement pendant la construction du deck.", - 'modeGlobalTitle' => 'Global', - 'modeGlobalDesc' => "Vos préférences d'illustration sont modifiables ici ou en cliquant sur une carte, et s'appliquent automatiquement partout : à l'ouverture d'un deck pour le modifier, à sa duplication, à son import, à sa récupération depuis BGA, et à chaque changement du nombre d'exemplaires d'une carte. Le deckbuilder ne permet plus de changer les illustrations carte par carte, et il n'y a plus de bouton \"appliquer mes préférences\" — c'est toujours appliqué.", - 'modeClose' => 'Fermer', - 'modeSaving' => 'Enregistrement…', - 'modeSaveError' => 'Impossible d\'enregistrer votre choix.', - 'perDeckNotice' => 'Le mode par deck est actif : seuls les jetons sont gérés ici. Les illustrations des cartes normales se choisissent individuellement dans chaque deck, dans le deckbuilder.', + 'intro' => 'Choisissez l\'illustration que prend chaque exemplaire d\'une carte quand vous l\'ajoutez à un deck (1er, 2e et 3e exemplaire). Dans Re:Builder, le pinceau d\'une carte change ses illustrations pour un deck, et « Arts par défaut » réapplique ces choix à un deck. L\'illustration d\'un jeton est la même dans tous vos decks.', ], ][getUiLang()]; @@ -77,9 +56,8 @@ $ownLoggedIn = kcIsLoggedIn(); $ownActiveTab = 'alt-arts'; -$userId = (int)($_SESSION['user_id'] ?? 0); -$altArtMode = ($ownEnabled && $ownLoggedIn) ? ownGetAltArtPreferenceMode($userId) : 'PerDeck'; -$isGlobalMode = $altArtMode === 'Global'; +// Reading the mode switches a player still in « Global » to « par deck » (default alt arts replace it). +if ($ownEnabled && $ownLoggedIn) ownGetAltArtPreferenceMode((int)($_SESSION['user_id'] ?? 0)); $cacDir = dirname(__DIR__, 2) . '/core-altered-cards'; $cacAvailable = is_file($cacDir . '/includes/functions.php'); @@ -100,13 +78,6 @@ // the type filter. $raritiesData = array_filter($raritiesData, fn($r) => ($r['gem'] ?? '') !== 'U'); -// Per-deck mode: this page only manages token illustrations — regular card alt-arts are -// chosen per deck in the deckbuilder instead. Enforced again server-side in -// api/alt-art-search.php; this is just what the type-filter row offers. -if (!$isGlobalMode) { - $typesData = array_filter($typesData, fn($code) => strpos($code, 'TOKEN') === 0, ARRAY_FILTER_USE_KEY); -} - $uiLang = getUiLang(); ?>
@@ -130,38 +101,7 @@ .filter-toggle below come from it. --> -
-
- - -
- -
- - -
- - - +

@@ -232,49 +172,12 @@ window.OWN_AA_CONFIG = { searchUrl: , setPreferenceUrl: , - preferenceModeUrl: , baseUrl: , cdnUrl: , lang: , markerImg: , csrfToken: , - isGlobalMode: , }; - (function () { - var cfg = window.OWN_AA_CONFIG; - var t = function (key, fallback) { - var dict = (window.OWN_I18N || {})[document.documentElement.lang] || {}; - return dict[key] || fallback; - }; - - var infoBtn = document.getElementById('own-aa-mode-info-btn'); - var infoModal = document.getElementById('own-aa-mode-info-modal'); - var infoClose = document.getElementById('own-aa-mode-info-close'); - infoBtn?.addEventListener('click', function () { infoModal.hidden = false; }); - infoClose?.addEventListener('click', function () { infoModal.hidden = true; }); - infoModal?.addEventListener('click', function (e) { if (e.target === infoModal) infoModal.hidden = true; }); - - var perDeckBtn = document.getElementById('own-aa-mode-perdeck'); - var globalBtn = document.getElementById('own-aa-mode-global'); - var setMode = function (mode) { - if ((mode === 'Global') === cfg.isGlobalMode) return; - [perDeckBtn, globalBtn].forEach(function (b) { b && (b.disabled = true); }); - fetch(cfg.preferenceModeUrl, { - method: 'PUT', - credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mode: mode, csrf_token: cfg.csrfToken }), - }).then(function (res) { - if (!res.ok) throw new Error('save failed'); - location.reload(); - }).catch(function () { - [perDeckBtn, globalBtn].forEach(function (b) { b && (b.disabled = false); }); - alert(t('modeSaveError', 'Could not save your setting.')); - }); - }; - perDeckBtn?.addEventListener('click', function () { setMode('PerDeck'); }); - globalBtn?.addEventListener('click', function () { setMode('Global'); }); - })(); diff --git a/plugins/ownership/pages/ownership.php b/plugins/ownership/pages/ownership.php index 112ccb77..2468e7f9 100644 --- a/plugins/ownership/pages/ownership.php +++ b/plugins/ownership/pages/ownership.php @@ -12,8 +12,8 @@ 'boosters_remaining' => '%d remaining', 'history' => 'History', 'history_d' => 'View every transaction that changed your digital ownership.', - 'altArts' => 'Alt Arts BGA', - 'altArts_d' => 'Choose which illustration is used for each copy you play on Board Game Arena.', + 'altArts' => 'Default alt arts', + 'altArts_d' => 'Choose the illustration each copy of a card takes when you add it to a deck.', 'import' => 'Equinox Import', 'import_d' => 'Request and/or import your digital ownership from the Equinox site (a secure site).', 'login_prompt' => 'Log in to manage your digital ownership.', @@ -30,8 +30,8 @@ 'boosters_remaining' => '%d restant(s)', 'history' => 'Historique', 'history_d' => 'Consultez toutes les transactions qui ont modifié vos propriétés.', - 'altArts' => 'Alt Arts BGA', - 'altArts_d' => 'Choisissez l\'illustration utilisée pour chaque exemplaire joué sur Board Game Arena.', + 'altArts' => 'Arts alternatifs par défaut', + 'altArts_d' => 'Choisissez l\'illustration que prend chaque exemplaire d\'une carte quand vous l\'ajoutez à un deck.', 'import' => 'Import Equinox', 'import_d' => 'Demandez et/ou importez vos propriétés numériques du site d\'Equinox (sur un site sécurisé).', 'login_prompt' => 'Connectez-vous pour gérer votre propriété numérique.', diff --git a/plugins/ownership/plugin.json b/plugins/ownership/plugin.json index 36b1b866..2a4e7038 100644 --- a/plugins/ownership/plugin.json +++ b/plugins/ownership/plugin.json @@ -12,7 +12,7 @@ {"slug": "ownership-collection", "file": "pages/collection.php", "title_en": "Collection", "title_fr": "Collection"}, {"slug": "boosters", "file": "pages/boosters.php", "title_en": "Boosters", "title_fr": "Boosters"}, {"slug": "ownership-history", "file": "pages/history.php", "title_en": "History", "title_fr": "Historique"}, - {"slug": "ownership-alt-arts", "file": "pages/alt-arts.php", "title_en": "Alt Arts BGA", "title_fr": "Alt Arts BGA"} + {"slug": "ownership-alt-arts", "file": "pages/alt-arts.php", "title_en": "Default alt arts", "title_fr": "Arts alternatifs par défaut"} ], "api": [ @@ -22,7 +22,8 @@ {"endpoint": "history-detail", "file": "api/history-detail.php"}, {"endpoint": "alt-art-search", "file": "api/alt-art-search.php"}, {"endpoint": "alt-art-set-preference", "file": "api/alt-art-set-preference.php"}, - {"endpoint": "alt-art-preference-mode", "file": "api/alt-art-preference-mode.php"} + {"endpoint": "alt-art-preference-mode", "file": "api/alt-art-preference-mode.php", "methods": ["GET"]}, + {"endpoint": "alt-art-pending", "file": "api/alt-art-pending.php", "methods": ["GET", "POST"], "auth": "user"} ], "assets": { diff --git a/plugins/rebuilder/README.md b/plugins/rebuilder/README.md index 896c8cf4..a071e4bc 100644 --- a/plugins/rebuilder/README.md +++ b/plugins/rebuilder/README.md @@ -89,8 +89,8 @@ decks API, opened by id. The local stack's decks API does not have them: their p « Importer » › *Export altered.gg* imports the decks of an altered.gg personal-data export (the Equinox ZIP) into the account, with the behaviour of the site's `equinox-deck-import` plugin: the ZIP is read -in the browser (`decks.csv`, `DecompressionStream`); with the « Global » alt-art preference of the -ownership API (relay `services.ownership`), the user's alt arts replace the exported cards; each deck is +in the browser (`decks.csv`, `DecompressionStream`); with the ownership service (relay `services.ownership`), the +cards and the hero take the user's default alt arts; each deck is created private with `POST /api/decks` through the relay, one per second, and a deck already in the account (same name, same cards) is skipped. A failed deck stops the queue until « Réessayer » (3 attempts, then it is given up); the import can be paused or cancelled. diff --git a/plugins/rebuilder/app/design/COMPONENTS.md b/plugins/rebuilder/app/design/COMPONENTS.md index 2babe668..1cb2295f 100644 --- a/plugins/rebuilder/app/design/COMPONENTS.md +++ b/plugins/rebuilder/app/design/COMPONENTS.md @@ -115,7 +115,8 @@ Classes et états des champs et du contrôle segmenté : `design-system/docs/com | Composant | Entrées | Sorties | Écrans | |---|---|---|---| -| `ac-card-tile` | `card`, `quantity`, `max` ; `blockedReason` (avec `max` à 0 et aucun exemplaire : badge « Interdite » à la place du « + », raison en `title` et en texte masqué, ex. Unique en format No Unique) ; affiche coût main / réserve, nom + type sur bandeau ; anneau `--ac-ring-selected` si `quantity > 0` ; `readonly` (« ×n ») ; `plain` (carte seule, sans « + » ni compteur) ; une référence unique (`…_U_n`) affiche `ac-unique-card` à la place de l'image ; en attendant l'image, un fond gris clair uni (`--ac-color-track`, `ac-card-art` en `neutral`), sans texte ni couleur de faction ; `zoomable` : le visuel est un bouton (curseur loupe, « Agrandir … ») ; `favorite: boolean | null` : étoile en haut à droite (visible au survol, au focus, en tactile, et toujours quand la carte est favorite ; `null` la masque) | `quantityChange`, `zoom` (clic sur le visuel, avec `zoomable`), `favoriteToggle` (clic sur l’étoile) | recherche, uniques, aperçu, zoom d’une carte | +| `ac-card-tile` | `card`, `quantity`, `max` ; `blockedReason` (avec `max` à 0 et aucun exemplaire : badge « Interdite » à la place du « + », raison en `title` et en texte masqué, ex. Unique en format No Unique) ; affiche coût main / réserve, nom + type sur bandeau ; anneau `--ac-ring-selected` si `quantity > 0` ; `readonly` (« ×n ») ; `plain` (carte seule, sans « + » ni compteur) ; une référence unique (`…_U_n`) affiche `ac-unique-card` à la place de l'image ; en attendant l'image, un fond gris clair uni (`--ac-color-track`, `ac-card-art` en `neutral`), sans texte ni couleur de faction ; `zoomable` : le visuel est un bouton (curseur loupe, « Agrandir … ») ; `favorite: boolean | null` : étoile en haut à droite (visible au survol, au focus, en tactile, et toujours quand la carte est favorite ; `null` la masque) ; `illustrations` : pinceau en haut à gauche, toujours visible (carte à plusieurs illustrations dans un deck modifiable) ; `note: string | null` : étiquette au-dessus des contrôles, visuel encadré (art alternatif possédé dans la recherche : « 2 possédées ») | `quantityChange`, `zoom` (clic sur le visuel, avec `zoomable`), `favoriteToggle` (clic sur l’étoile), `illustrate` (clic sur le pinceau) | recherche, uniques, aperçu, zoom d’une carte | +| `ac-card-pile` | `card`, `quantity` (cartes empilées, une par exemplaire jusqu’à 3, « ×n ») ; `max` ; `readonly` (sans stepper) ; `issues` (marqueur d’alerte, raisons en `title`) ; `illustrations` : pinceau en haut à droite de la carte de devant, toujours visible | `quantityChange` (stepper au survol, au focus, toujours en tactile), `zoom` (clic sur la pile), `illustrate` (clic sur le pinceau) | aperçu du deck (à partir de 768 px) | | `ac-unique-card` | `card` (réponse `/api/cards`, textes en chaîne ou en table de langues), `eager` ; face d'une Unique dessinée à partir de ses données, car aucune image par Unique n'est publiée : illustration Unique du CDN (`cards/assets/{SET}/{carte}_U.webp`, repli sans cadre puis dos de carte), coûts main / réserve, puissances Forêt / Montagne / Océan (icônes `assets/biome/`), texte (`{J}` `{R}` `{H}` `{D}` `{T}` en pastilles, `[mot-clé]` en gras, capacité de soutien sous un filet), numéro de collection en pied ; couleur de faction via `--ac-faction-*` ; tailles en `cqw`, lisibles comme une image de carte à la même largeur ; le cadre et le texte passent au-dessus de l'illustration ; sans coûts (ligne de deck invité rechargée) : illustration et nom seuls. L'aperçu d'un deck charge le texte imprimé (`mainEffect` / `echoEffect`) : conservé sur la ligne invité, sinon `POST /api/cards/batch` | — | tuiles Uniques (recherche, aperçu du deck) | | `ac-deck-row` | `card`, `quantity`, `max`, `readonly` (consultation : « ×3 » + coûts, sans stepper), `plain` (rareté, nom, coûts, sans quantité), `issues` (règles enfreintes par la ligne : nom en `--ac-color-danger` et icône `circle-alert` dont `aria-label` / `title` donnent les raisons), `blockedReason` (avec `max` à 0 et aucun exemplaire : badge « Interdite » à la place du stepper, raison en `title` et en texte masqué) | `quantityChange` | panneau deck, Deck mobile, decklist | | `ac-deck-section` | `title` (Personnages, Sorts…), `count`, `collapsible` | — | panneau deck, aperçu, cartes | diff --git a/plugins/rebuilder/app/docs/backend-api.md b/plugins/rebuilder/app/docs/backend-api.md index fbaa86be..3d164a44 100644 --- a/plugins/rebuilder/app/docs/backend-api.md +++ b/plugins/rebuilder/app/docs/backend-api.md @@ -135,10 +135,10 @@ Cookie + CSRF. Not required for the first milestone (this app is a parallel clie | `ownership-search` | Digital ownership scope | | `favorites-search` / `favorites-toggle` | Per-user favorites (local PHP DB) | | `deck-add-card` | Incremental add: GET deck + PATCH full `deckCards` | -| `deck-alt-arts` | Alt-art options for a card in a deck | +| `deck-alt-arts` | Families of illustrations of deck and search references: prints, copies owned, the player's default alt arts (`core/alt-art-defaults.ts`) | | `playset` / `playset-cards` | Physical playset dashboard | -Ownership plugin (when enabled): `/papi/ownership/alt-art-search`, `alt-art-set-preference`. +Ownership plugin (when enabled): `/papi/ownership/alt-art-search` (token illustrations), `alt-art-pending` (`GET ?deck=`: the deck of a player switched from the « Global » mode waits for their default alt arts; `POST {deck}`: it took them). Token illustrations are saved through the relay (`PUT /api/alt-arts/preferences`). ## Local stack diff --git a/plugins/rebuilder/app/docs/legacy-parity.md b/plugins/rebuilder/app/docs/legacy-parity.md index 84e1ecdb..13f6c42c 100644 --- a/plugins/rebuilder/app/docs/legacy-parity.md +++ b/plugins/rebuilder/app/docs/legacy-parity.md @@ -41,8 +41,8 @@ Légende : ✅ fait · 🟡 partiel · ❌ manquant. « Fait dans #106 » = corr | Stats : répartition par type, puissances moyennes (le plugin affiche des totaux) | ❌ écarté volontairement (jugé inutile) | | Main de test en mode jeu (mana, plateau, défausse, glisser-déposer) | ✅ (glisser-déposer du CDK, menu d’actions au clic ; masqué sur mobile) | | Calculateurs : vignettes et groupes par type dans le choix des cartes | ✅ | -| Duplication avec les illustrations préférées (mode Global) | ✅ | -| Étoile favori sur les cartes, widget possession / tilt dans le zoom | ✅ (favoris du site ; widget et tilt en mode Global, comme le site) | +| Duplication avec les illustrations préférées | ✅ (le deck d’un autre joueur prend les arts par défaut ; une copie de son propre deck garde ses illustrations) | +| Étoile favori sur les cartes, widget possession / tilt dans le zoom | ✅ (favoris du site) ; le widget et le tilt du mode Global disparaissent avec ce mode | | Groupes fins (Repères, Permanents d'expédition, Jetons) ; faction et puissances de toutes les cartes dans la decklist | 🟡 | ## Éditeur @@ -64,7 +64,7 @@ Légende : ✅ fait · 🟡 partiel · ❌ manquant. « Fait dans #106 » = corr | Onglets Collection physique, Possession numérique, Favoris | ✅ (endpoints du plugin core-altered-cards ; collection et possession demandent leurs API) | | Favoris : Uniques (rareté U) et filtre « Légales en » par défaut | ✅ (ajout Re:Builder ; liste Frontier lue dans `gameplayFormat`) | | Zoom d'une carte avec quantité et lien vers la fiche | ✅ (recherche et aperçu) | -| Illustrations : choix par carte, préférence Global, illustrations des jetons, avertissements de possession ; héros alt-art / sérialisés | ✅ (service ownership requis, sauf les héros alt-art) | +| Illustrations : choix par carte, préférence Global, illustrations des jetons, avertissements de possession ; héros alt-art / sérialisés | ✅ Arts par défaut à la place du mode Global : une carte ajoutée prend l’art par défaut de son exemplaire, le pinceau change les illustrations d’une carte pour ce deck, « Arts par défaut » réapplique les défauts, « Arts alternatifs » (recherche) montre les arts possédés (service ownership requis, sauf les héros alt-art) | | Toast « Annuler » après un ajout ou un retrait | ✅ | | Bandeau d'état avec progression vers le minimum | ❌ écarté volontairement (jugé inutile) | | Main de départ dans l'éditeur | ✅ (vue « Main de départ », `/edit/main`) | diff --git a/plugins/rebuilder/app/src/app/core/alt-art-defaults.spec.ts b/plugins/rebuilder/app/src/app/core/alt-art-defaults.spec.ts new file mode 100644 index 00000000..df9c32cf --- /dev/null +++ b/plugins/rebuilder/app/src/app/core/alt-art-defaults.spec.ts @@ -0,0 +1,109 @@ +import { addedCopyPrint, defaultPrints, familyPrints, linesWithDefaults, removedCopyPrint, sameCopies, withFamilyPrints } from './alt-art-defaults'; +import type { HydratedLine } from './models'; +import type { AltArtChoice } from './ownership-api.service'; + +const BASE = 'ALT_CORE_B_AX_09_C'; +const MUSUBI = 'ALT_MUSUBI_B_AX_09_C'; +const ALT = 'ALT_CORE_A_AX_09_C'; + +/** Fée Clochette: the plain print, an unlimited MUSUBI print, an alt art owned `owned` times; `slots` are the defaults. */ +function fee(slots: string[], owned = 2): AltArtChoice { + return { + family: { familyId: 304, faction: 'AX', rarity: 'C' }, + options: { + options: [ + { reference: BASE, ownedQuantity: null }, + { reference: MUSUBI, ownedQuantity: null }, + { reference: ALT, ownedQuantity: owned }, + ], + slots: slots.map((reference, i) => ({ slotIndex: i + 1, reference, isExplicitChoice: reference !== BASE })), + }, + }; +} + +const line = (reference: string, quantity: number): HydratedLine => ({ card: { reference, name: 'Fée Clochette' }, quantity }); + +describe('defaultPrints', () => { + it('gives copy i the default of slot i, the copies past the last slot the last one', () => { + expect(defaultPrints(fee([MUSUBI, ALT, BASE]), 3)).toEqual([MUSUBI, ALT, BASE]); + expect(defaultPrints(fee([MUSUBI]), 3)).toEqual([MUSUBI, MUSUBI, MUSUBI]); + }); + + it('falls back to the plain print once the owned copies are used', () => { + expect(defaultPrints(fee([ALT, ALT, ALT], 2), 3)).toEqual([ALT, ALT, BASE]); + expect(defaultPrints(fee([ALT, ALT, ALT], 0), 2)).toEqual([BASE, BASE]); + }); + + it('sorts the slots by their index (the service counts from 1, the mock from 0)', () => { + const choice = fee([]); + choice.options.slots = [ + { slotIndex: 2, reference: ALT }, + { slotIndex: 1, reference: MUSUBI }, + ]; + expect(defaultPrints(choice, 2)).toEqual([MUSUBI, ALT]); + }); +}); + +describe('addedCopyPrint', () => { + it('gives an added copy the next default the copies lack', () => { + const choice = fee([MUSUBI, ALT, BASE]); + expect(addedCopyPrint(choice, [])).toBe(MUSUBI); + expect(addedCopyPrint(choice, [MUSUBI])).toBe(ALT); + expect(addedCopyPrint(choice, [MUSUBI, ALT])).toBe(BASE); + }); + + it('keeps a print chosen in the deck: the next copy takes the first default not used yet', () => { + // The deck put its 1st copy on the alt art: the next copy takes the MUSUBI print of the 1st default. + expect(addedCopyPrint(fee([MUSUBI, ALT, BASE]), [ALT])).toBe(MUSUBI); + }); + + it('never gives more copies of a print than owned', () => { + // 2 alt arts owned, both in the deck already: the 3rd default (the alt art) falls back to the plain print. + expect(addedCopyPrint(fee([ALT, ALT, ALT], 2), [ALT, ALT])).toBe(BASE); + }); +}); + +describe('removedCopyPrint', () => { + it('removes the last copy by the defaults, then the plain print, keeping the prints chosen in the deck', () => { + const choice = fee([MUSUBI, ALT, BASE]); + expect(removedCopyPrint(choice, [MUSUBI, ALT, BASE])).toBe(BASE); + expect(removedCopyPrint(choice, [MUSUBI, ALT])).toBe(ALT); + // The deck chose the alt art for a copy: the copy on the default goes first. + expect(removedCopyPrint(fee([MUSUBI, MUSUBI, BASE]), [ALT, MUSUBI])).toBe(MUSUBI); + }); +}); + +describe('family lines', () => { + const members = new Set([BASE, MUSUBI]); + + it('reads the copies of a family and writes them back at the place of its first line', () => { + const lines = [line('OTHER', 1), line(BASE, 2), line('LAST', 1), line(MUSUBI, 1)]; + expect(familyPrints(lines, members)).toEqual([BASE, BASE, MUSUBI]); + const next = withFamilyPrints(lines, members, lines[1].card, [ALT, MUSUBI, MUSUBI]); + expect(next.map((l) => [l.card.reference, l.quantity])).toEqual([ + ['OTHER', 1], + [ALT, 1], + [MUSUBI, 2], + ['LAST', 1], + ]); + // A new print copies the card of the family, with its own reference. + expect(next[1].card.name).toBe('Fée Clochette'); + }); + + it('applies the defaults to every family, `null` when the deck has them already', () => { + const choice = fee([MUSUBI, MUSUBI, BASE]); + const choices = { [BASE]: choice, [MUSUBI]: choice }; + const out = linesWithDefaults([line(BASE, 3), line('OTHER', 2)], choices); + expect(out?.map((l) => [l.card.reference, l.quantity])).toEqual([ + [MUSUBI, 2], + [BASE, 1], + ['OTHER', 2], + ]); + expect(linesWithDefaults(out ?? [], choices)).toBeNull(); + }); + + it('compares copies whatever their order', () => { + expect(sameCopies([BASE, MUSUBI], [MUSUBI, BASE])).toBe(true); + expect(sameCopies([BASE, BASE], [MUSUBI, BASE])).toBe(false); + }); +}); diff --git a/plugins/rebuilder/app/src/app/core/alt-art-defaults.ts b/plugins/rebuilder/app/src/app/core/alt-art-defaults.ts new file mode 100644 index 00000000..c507cb84 --- /dev/null +++ b/plugins/rebuilder/app/src/app/core/alt-art-defaults.ts @@ -0,0 +1,137 @@ +import type { HydratedLine } from './models'; +import { familyKey, type AltArtChoice } from './ownership-api.service'; + +/** + * Default alt arts. The player picks once, on the site's « Arts alternatifs par défaut » page, which print each copy of + * a card takes (1st, 2nd, 3rd copy: the ownership service's slots); a deck only stores the prints it uses. The defaults + * apply when a card enters a deck and on « Appliquer les arts par défaut »; Board Game Arena plays the deck's prints + * (the base art for copies the player does not own). + */ + +/** The family's plain illustration, everyone's: the service lists it first. */ +export function basePrint(choice: AltArtChoice): string { + return choice.options.options[0]?.reference ?? ''; +} + +/** Copies the player owns of `reference`; `null` for unlimited (a print the service does not list, as a reprint). */ +export function ownedOf(choice: AltArtChoice, reference: string): number | null { + const option = choice.options.options.find((o) => o.reference === reference); + return option ? option.ownedQuantity : null; +} + +/** The family's slots, 1st copy first (the service numbers them from 1, the stack's mock from 0). */ +export function slotPrints(choice: AltArtChoice): string[] { + return [...choice.options.slots].sort((a, b) => a.slotIndex - b.slotIndex).map((s) => s.reference); +} + +/** + * The prints of `count` copies by the player's defaults: copy i takes slot i (the copies past the last slot take the + * last one), or the base print once the copies owned of that print are used. + */ +export function defaultPrints(choice: AltArtChoice, count: number): string[] { + const slots = slotPrints(choice); + const base = basePrint(choice); + const used = new Map(); + const out: string[] = []; + for (let i = 0; i < count; i++) { + const wanted = slots.length ? slots[Math.min(i, slots.length - 1)] : base; + const owned = ownedOf(choice, wanted); + const print = owned === null || (used.get(wanted) ?? 0) < owned ? wanted : base; + used.set(print, (used.get(print) ?? 0) + 1); + out.push(print); + } + return out; +} + +/** + * The print of a copy added to a family whose copies show `current`: the first default (by slot) the copies lack, + * so that a copy chosen in the deck stays and the next copies follow the defaults; the base print otherwise. + */ +export function addedCopyPrint(choice: AltArtChoice, current: readonly string[]): string { + const left = counts(current); + for (const print of defaultPrints(choice, current.length + 1)) { + const n = left.get(print) ?? 0; + if (n === 0) return print; + left.set(print, n - 1); + } + return basePrint(choice); +} + +/** + * The print of the copy removed from a family whose copies show `current`: the last copy's default when the deck has + * it, else the base print, else the last copy; a print chosen in the deck goes last. + */ +export function removedCopyPrint(choice: AltArtChoice, current: readonly string[]): string { + const last = defaultPrints(choice, current.length).at(-1); + if (last && current.includes(last)) return last; + const base = basePrint(choice); + return current.includes(base) ? base : (current.at(-1) ?? base); +} + +/** Copies by print, in the order of `prints`. */ +export function counts(prints: readonly string[]): Map { + const out = new Map(); + for (const p of prints) out.set(p, (out.get(p) ?? 0) + 1); + return out; +} + +/** A family's copies, one print a copy: its lines in deck order. */ +export function familyPrints(lines: readonly HydratedLine[], members: ReadonlySet): string[] { + return lines.filter((l) => members.has(l.card.reference)).flatMap((l) => Array(l.quantity).fill(l.card.reference)); +} + +/** + * `lines` with the family's lines (references in `members`) replaced by `prints`, at the place of its first line. The + * new lines copy the card of `template`, with their reference. + */ +export function withFamilyPrints(lines: readonly HydratedLine[], members: ReadonlySet, template: HydratedLine['card'], prints: readonly string[]): HydratedLine[] { + const replaced = [...counts(prints)].map(([reference, quantity]) => { + const existing = lines.find((l) => l.card.reference === reference); + // Another print: the template's card without its image (the art follows the reference). + return { card: existing?.card ?? (reference === template.reference ? template : { ...template, reference, imagePath: undefined }), quantity }; + }); + const at = lines.findIndex((l) => members.has(l.card.reference)); + const rest = lines.filter((l) => !members.has(l.card.reference)); + const index = at === -1 ? rest.length : lines.slice(0, at).filter((l) => !members.has(l.card.reference)).length; + return [...rest.slice(0, index), ...replaced, ...rest.slice(index)]; +} + +/** The deck's references grouped by family (multi-art families only), by family key. */ +export function familiesOf(references: Iterable, choices: Readonly>): Map> { + const out = new Map>(); + for (const ref of references) { + const choice = choices[ref]; + if (!choice) continue; + const key = familyKey(choice.family); + const set = out.get(key) ?? new Set(); + set.add(ref); + out.set(key, set); + } + return out; +} + +/** + * « Appliquer les arts par défaut »: every multi-art family of `lines` takes the player's defaults for its copies + * (`defaultPrints`). `null` when every line already matches. + */ +export function linesWithDefaults(lines: readonly HydratedLine[], choices: Readonly>): HydratedLine[] | null { + let out = [...lines]; + let changed = false; + for (const members of familiesOf(lines.map((l) => l.card.reference), choices).values()) { + const current = familyPrints(out, members); + const choice = choices[[...members][0]]; + const next = defaultPrints(choice, current.length); + if (sameCopies(current, next)) continue; + changed = true; + const template = out.find((l) => members.has(l.card.reference))!.card; + out = withFamilyPrints(out, members, template, next); + } + return changed ? out : null; +} + +/** Same copies of each print, in any order. */ +export function sameCopies(a: readonly string[], b: readonly string[]): boolean { + if (a.length !== b.length) return false; + const left = counts(a); + return [...counts(b)].every(([print, n]) => left.get(print) === n); +} diff --git a/plugins/rebuilder/app/src/app/core/deck-store.spec.ts b/plugins/rebuilder/app/src/app/core/deck-store.spec.ts index c9ad088a..bc4b7978 100644 --- a/plugins/rebuilder/app/src/app/core/deck-store.spec.ts +++ b/plugins/rebuilder/app/src/app/core/deck-store.spec.ts @@ -6,9 +6,9 @@ import { of, type Observable } from 'rxjs'; import { AuthSession } from './auth-session'; import { CardsApiService } from './cards-api.service'; import { DeckCreateFailurePrompt, type DeckCreateFailureChoice } from './deck-create-failure'; -import { DeckStore, distributeSlots, swapLines } from './deck-store'; +import { DeckStore, cardsWithDefaults } from './deck-store'; import { GUEST_DECKS_KEY, GuestDeckService } from './guest-deck.service'; -import { OwnershipApiService, type CardQuantity } from './ownership-api.service'; +import { OwnershipApiService, type AltArtChoice } from './ownership-api.service'; import type { Card, Deck } from './models'; import { localizedText } from './models'; @@ -719,25 +719,34 @@ describe('GuestDeckService', () => { }); }); -describe('DeckStore (signed in, « Global » alt arts)', () => { - it('duplicates with the preferred illustrations, as the site does', async () => { +describe('DeckStore (signed in, default alt arts)', () => { + const martengale: AltArtChoice = { + family: { familyId: 9, faction: 'LY', rarity: 'C' }, + options: { + options: [ + { reference: 'ALT_CORE_B_LY_04_C', ownedQuantity: null }, + { reference: 'ALT_CORE_A_LY_04_C', ownedQuantity: 3 }, + ], + slots: [1, 2, 3].map((slotIndex) => ({ slotIndex, reference: 'ALT_CORE_A_LY_04_C' })), + }, + }; + + function setup(mine: string[]) { localStorage.clear(); TestBed.configureTestingModule({ providers: [ provideHttpClient(), provideHttpClientTesting(), { provide: AuthSession, useValue: new SignedIn() }, - { - provide: OwnershipApiService, - useValue: { - globalAltArts: () => of(true), - applyAltArts: (cards: CardQuantity[]) => of(cards.map((c) => (c.cardReference === 'ALT_CORE_B_LY_04_C' ? { ...c, cardReference: 'ALT_CORE_A_LY_04_C' } : c))), - }, - }, + { provide: OwnershipApiService, useValue: { altArtChoices: (refs: string[]) => of(refs.includes('ALT_CORE_B_LY_04_C') ? { ALT_CORE_B_LY_04_C: martengale } : {}) } }, ], }); const store = TestBed.inject(DeckStore); const http = TestBed.inject(HttpTestingController); + return { store, http, mine: mine.map((id) => ({ id })) }; + } + + async function open(store: DeckStore, http: HttpTestingController, mine: { id: string }[]) { store.load('source'); TestBed.tick(); http.expectOne((r) => r.url.endsWith('/api/decks/source')).flush({ @@ -751,34 +760,63 @@ describe('DeckStore (signed in, « Global » alt arts)', () => { }); await Promise.resolve(); TestBed.tick(); - http.expectOne((r) => r.method === 'GET' && r.url.endsWith('/api/decks')).flush([]); + http.expectOne((r) => r.method === 'GET' && r.url.endsWith('/api/decks')).flush(mine); await settle(); + } + + it('copies someone else’s deck with the user’s default alt arts', async () => { + const { store, http, mine } = setup([]); + await open(store, http, mine); store.duplicate('Copie').subscribe(); const req = http.expectOne((r) => r.method === 'POST' && r.url.endsWith('/api/decks')); - expect(req.request.body.deckCards).toEqual([ - { cardReference: 'ALT_CORE_B_LY_03_C', quantity: 1 }, - { cardReference: 'ALT_CORE_A_LY_04_C', quantity: 2 }, + expect(req.request.body.deckCards.map((c: { cardReference: string; quantity: number }) => [c.cardReference, c.quantity])).toEqual([ + ['ALT_CORE_B_LY_03_C', 1], + ['ALT_CORE_A_LY_04_C', 2], ]); req.flush({ id: 'copy' }); http.verify(); }); -}); -describe('illustrations of the deck lines', () => { - const line = (reference: string, quantity: number) => ({ card: { reference, name: 'X' }, quantity }); + it('copies the user’s own deck with its illustrations', async () => { + const { store, http, mine } = setup(['source']); + await open(store, http, mine); + store.duplicate('Copie').subscribe(); + const req = http.expectOne((r) => r.method === 'POST' && r.url.endsWith('/api/decks')); + expect(req.request.body.deckCards.map((c: { cardReference: string }) => c.cardReference)).toEqual(['ALT_CORE_B_LY_03_C', 'ALT_CORE_B_LY_04_C']); + req.flush({ id: 'copy' }); + http.verify(); + }); - it('swaps a card for another print, merged with an existing line', () => { - expect(swapLines([line('A_B', 2), line('C', 1)], 'A_B', 'A_P').map((l) => [l.card.reference, l.quantity])).toEqual([['C', 1], ['A_P', 2]]); - expect(swapLines([line('A_B', 2), line('A_P', 1)], 'A_B', 'A_P').map((l) => [l.card.reference, l.quantity])).toEqual([['A_P', 3]]); + it('adds and removes the copies of a family by the defaults, and applies them to the whole deck', async () => { + const { store, http, mine } = setup(['source']); + await open(store, http, mine); + const card = store.lines().find((l) => l.card.reference === 'ALT_CORE_B_LY_04_C')!.card; + const members = new Set(['ALT_CORE_B_LY_04_C', 'ALT_CORE_A_LY_04_C']); + // 2 plain copies in the deck: the 3rd copy takes the 3rd default. + store.setFamilyQuantity(card, martengale, members, 3); + expect(store.lines().map((l) => [l.card.reference, l.quantity])).toEqual([ + ['ALT_CORE_B_LY_04_C', 2], + ['ALT_CORE_A_LY_04_C', 1], + ]); + expect(store.lastChange()).toMatchObject({ card: { reference: 'ALT_CORE_A_LY_04_C' }, quantity: 1, delta: 1 }); + expect(store.applyAltArtDefaults({ ALT_CORE_B_LY_04_C: martengale, ALT_CORE_A_LY_04_C: martengale })).toBe(true); + expect(store.lines().map((l) => [l.card.reference, l.quantity])).toEqual([['ALT_CORE_A_LY_04_C', 3]]); + store.setFamilyQuantity(card, martengale, members, 1); + expect(store.lines().map((l) => [l.card.reference, l.quantity])).toEqual([['ALT_CORE_A_LY_04_C', 1]]); + store.flush(); + http.match(() => true); }); +}); - it('spreads the copies of a family over the slots (Global mode)', () => { - const slots = new Map([ - ['A_B', { key: 'f', slots: ['A_P', 'A_B'] }], - ['A_P', { key: 'f', slots: ['A_P', 'A_B'] }], +describe('cardsWithDefaults', () => { + it('rewrites deck lines with the defaults, a new print copying its family’s line', () => { + const choice: AltArtChoice = { + family: { familyId: 1, faction: 'AX', rarity: 'C' }, + options: { options: [{ reference: 'ALT_B', ownedQuantity: null }, { reference: 'ALT_A', ownedQuantity: 1 }], slots: [{ slotIndex: 1, reference: 'ALT_A' }] }, + }; + expect(cardsWithDefaults([{ cardReference: 'ALT_B', quantity: 2, name: 'X' }], { ALT_B: choice })).toEqual([ + { cardReference: 'ALT_A', quantity: 1, name: 'X' }, + { cardReference: 'ALT_B', quantity: 1, name: 'X' }, ]); - const out = distributeSlots([line('A_B', 3), line('C', 1)], slots); - expect(out?.map((l) => [l.card.reference, l.quantity])).toEqual([['C', 1], ['A_P', 1], ['A_B', 2]]); - expect(distributeSlots(out ?? [], slots)).toBeNull(); }); }); diff --git a/plugins/rebuilder/app/src/app/core/deck-store.ts b/plugins/rebuilder/app/src/app/core/deck-store.ts index 9989a25d..91c57f58 100644 --- a/plugins/rebuilder/app/src/app/core/deck-store.ts +++ b/plugins/rebuilder/app/src/app/core/deck-store.ts @@ -13,7 +13,8 @@ import { formatInfo } from './formats'; import { cardToLine, deckStats, groupLines, heroOf, isHeroLine, lineToCard, mergeUniqueFace, uniqueNeedsPrintedEffect } from './deck-view'; import { DecksApiService } from './decks-api.service'; import { GuestDeckService } from './guest-deck.service'; -import { OwnershipApiService } from './ownership-api.service'; +import { OwnershipApiService, type AltArtChoice } from './ownership-api.service'; +import { addedCopyPrint, defaultPrints, familyPrints, linesWithDefaults, removedCopyPrint, sameCopies, withFamilyPrints } from './alt-art-defaults'; import { type Card, type Deck, @@ -246,7 +247,8 @@ export class DeckStore { * whether to try again or keep the deck in this browser; cancelling completes without a deck. */ createDeck(input: NewDeckInput): Observable { - if (!this.auth.isLoggedIn()) return of(this.create(input)); + if (!this.auth.isLoggedIn()) return of(this.create({ ...input, hero: plainHero(input.hero) })); + if (input.hero.defaultArt) return this.heroWithDefaultArt(input.hero).pipe(switchMap((hero) => this.createDeck({ ...input, hero }))); const description = input.description?.trim() ?? ''; const body: DeckWrite = { name: input.name.trim() || $localize`:@@core.deck.defaultName:Nouveau deck`, @@ -332,25 +334,53 @@ export class DeckStore { } /** - * « Choisir une illustration »: the copies of `card` take the print `reference` (another illustration of the same - * card), merged with that print's line when the deck has one already. + * The copies of a family (lines whose reference is in `members`) become `prints`, one print a copy: the brush of a + * card. New lines copy `template`. */ - swapReference(card: Card, reference: string): void { - if (!this.editable() || card.reference === reference) return; - this.lines.set(swapLines(this.lines(), card.reference, reference)); + setFamilyPrints(members: ReadonlySet, template: Card, prints: readonly string[]): void { + if (!this.editable()) return; + const current = familyPrints(this.lines(), members); + if (sameCopies(current, prints)) return; + this.lines.set(withFamilyPrints(this.lines(), members, template, prints)); + this.touch(); + } + + /** + * A family's copies as one card (a search result, « Arts alternatifs » off): an added copy takes the player's default + * alt art for its copy (`addedCopyPrint`), a removed one leaves the prints chosen in the deck (`removedCopyPrint`). + */ + setFamilyQuantity(card: Card, choice: AltArtChoice, members: ReadonlySet, quantity: number): void { + if (!this.editable()) return; + const prints = familyPrints(this.lines(), members); + const target = Math.max(0, Math.min(this.maxFor(card), Math.round(quantity))); + let changed: { reference: string; delta: number } | null = null; + while (prints.length < target) { + const reference = addedCopyPrint(choice, prints); + prints.push(reference); + changed = { reference, delta: 1 }; + } + while (prints.length > target) { + const reference = removedCopyPrint(choice, prints); + prints.splice(prints.lastIndexOf(reference), 1); + changed = { reference, delta: -1 }; + } + if (!changed) return; + const after = prints.filter((p) => p === changed.reference).length; + this.lastChange.set({ card: printOf(card, changed.reference), quantity: after, delta: changed.delta }); + this.lines.set(withFamilyPrints(this.lines(), members, card, prints)); this.touch(); } /** - * « Global » alt-art mode: every card of a multi-art family takes the player's preferred prints, copy after copy - * (the site's deck builder does it on load and after each change); the hero takes the first slot. `false` when - * nothing changes. + * « Appliquer les arts par défaut »: the copies of every multi-art card of the deck, and the hero, take the player's + * default alt arts (`choices`: families by reference). `false` when nothing changes. */ - applyAltArtSlots(slotsByRef: ReadonlyMap): boolean { + applyAltArtDefaults(choices: Readonly>): boolean { if (!this.editable()) return false; - const lines = distributeSlots(this.lines(), slotsByRef); + const lines = linesWithDefaults(this.lines(), choices); const hero = this.hero(); - const heroRef = hero ? slotsByRef.get(hero.reference)?.slots[0] : undefined; + const heroChoice = hero ? choices[hero.reference] : undefined; + const heroRef = heroChoice ? defaultPrints(heroChoice, 1)[0] : undefined; const heroChanged = !!hero && !!heroRef && heroRef !== hero.reference; if (!lines && !heroChanged) return false; if (lines) this.lines.set(lines); @@ -371,7 +401,18 @@ export class DeckStore { /** A blank `name` keeps the current one. */ updateSettings(settings: { hero?: DeckHero; format?: DeckFormat; isPublic?: boolean; name?: string; description?: string }): void { if (!this.editable()) return; - if (settings.hero) this.hero.set(settings.hero); + const hero = settings.hero; + if (hero) { + this.hero.set(plainHero(hero)); + // Then its default alt art, unless another hero was picked meanwhile. + if (hero.defaultArt) { + this.heroWithDefaultArt(hero).subscribe((h) => { + if (h.reference === hero.reference || this.hero()?.reference !== hero.reference) return; + this.hero.set(h); + this.touch(); + }); + } + } if (settings.format) this.format.set(settings.format); if (settings.isPublic !== undefined) this.isPublic.set(settings.isPublic); if (settings.name?.trim()) this.name.set(settings.name.trim()); @@ -397,6 +438,16 @@ export class DeckStore { return this.guests.create({ name, description, format: this.format(), isPublic: false, hero: this.hero(), deckCards: this.serializeLines() }).id; } + /** `hero` with the player's default alt art for it (itself without one, or on an error). */ + private heroWithDefaultArt(hero: DeckHero): Observable { + return this.ownership.altArtChoices([hero.reference]).pipe( + map((choices) => { + const choice = choices[hero.reference]; + return { ...plainHero(hero), reference: choice ? defaultPrints(choice, 1)[0] : hero.reference }; + }), + ); + } + /** * Copies the open deck, private, and emits the copy's id: on the account when the user is signed * in (like the site's « Dupliquer »), in this browser otherwise. Fails with a displayable message. @@ -413,9 +464,10 @@ export class DeckStore { ...(description ? { description } : {}), deckCards: this.serializeLines().map((l) => ({ cardReference: l.cardReference, quantity: l.quantity })), }; - // « Global » alt-art preference: the copy takes the user's preferred illustrations (the site's duplicate does too). - return this.ownership.globalAltArts().pipe( - switchMap((global) => (global ? this.ownership.applyAltArts(body.deckCards ?? []) : of(body.deckCards ?? []))), + // Someone else's deck: the copy takes the user's default alt arts (their own deck keeps its prints). + const cards = body.deckCards ?? []; + const withDefaults = this.owned() === true ? of(cards) : this.ownership.altArtChoices(cards.map((c) => c.cardReference)).pipe(map((choices) => cardsWithDefaults(cards, choices))); + return withDefaults.pipe( switchMap((deckCards) => this.decksApi.create({ ...body, deckCards })), map((created) => { this.createdIds.update((ids) => new Set([...ids, created.id])); @@ -831,43 +883,25 @@ export function displayName(card: Card): string { return localizedText(card.name, contentLocale()) || card.reference; } -/** `from`'s copies become `to`'s (merged with an existing `to` line). */ -export function swapLines(lines: HydratedLine[], from: string, to: string): HydratedLine[] { - const moving = lines.find((l) => l.card.reference === from); - if (!moving) return lines; - const existing = lines.find((l) => l.card.reference === to); - const rest = lines.filter((l) => l.card.reference !== from); - if (existing) return rest.map((l) => (l === existing ? { ...l, quantity: l.quantity + moving.quantity } : l)); - return [...rest, { card: { ...moving.card, reference: to }, quantity: moving.quantity }]; +/** The hero as the deck stores it (without how it was picked). */ +function plainHero(hero: DeckHero): DeckHero { + return { reference: hero.reference, name: hero.name, faction: hero.faction }; +} + +/** `card` as another print of the same card (`reference`). */ +function printOf(card: Card, reference: string): Card { + return reference === card.reference ? card : { ...card, reference, imagePath: undefined }; } /** - * The copies of each family (all its lines together) spread over the player's slots: copy i takes slot i, the copies - * past the last slot repeat it (the site's `distributeAcrossSlots`). `null` when every line already matches. + * Deck lines to write (reference and quantity, with the rest of the line) with the player's default alt arts, families + * by reference in `choices`: an imported deck, or a copy of someone else's deck, takes the user's own illustrations. A + * new print copies its family's first line. */ -export function distributeSlots(lines: HydratedLine[], slotsByRef: ReadonlyMap): HydratedLine[] | null { - const families = new Map(); - for (const l of lines) { - const f = slotsByRef.get(l.card.reference); - if (!f || !f.slots.length) continue; - const g = families.get(f.key) ?? { slots: f.slots, qty: 0, lines: [] }; - g.qty += l.quantity; - g.lines.push(l); - families.set(f.key, g); - } - let changed = false; - let out = lines; - for (const g of families.values()) { - const counts = new Map(); - for (let i = 0; i < g.qty; i++) { - const ref = g.slots[Math.min(i, g.slots.length - 1)]; - counts.set(ref, (counts.get(ref) ?? 0) + 1); - } - const same = g.lines.length === counts.size && g.lines.every((l) => counts.get(l.card.reference) === l.quantity); - if (same) continue; - changed = true; - const template = g.lines[0].card; - out = out.filter((l) => !g.lines.includes(l)).concat([...counts].map(([reference, quantity]) => ({ card: { ...template, reference }, quantity }))); - } - return changed ? out : null; +export function cardsWithDefaults(cards: readonly T[], choices: Readonly>): T[] { + // Each line rides along in its card: `withFamilyPrints` copies a family's first card for its new prints. + const lines = cards.map((line) => ({ card: { reference: line.cardReference, line } as Card & { line: T }, quantity: line.quantity })); + const next = linesWithDefaults(lines, choices); + if (!next) return [...cards]; + return next.map((l) => ({ ...(l.card as Card & { line: T }).line, cardReference: l.card.reference, quantity: l.quantity })); } diff --git a/plugins/rebuilder/app/src/app/core/models.ts b/plugins/rebuilder/app/src/app/core/models.ts index 7b93098e..4884cdb6 100644 --- a/plugins/rebuilder/app/src/app/core/models.ts +++ b/plugins/rebuilder/app/src/app/core/models.ts @@ -194,6 +194,8 @@ export interface DeckHero { reference: string; name: string; faction: string; + /** Picked without « Alt arts »: the deck takes the player's default alt art for it (`DeckStore`). */ + defaultArt?: boolean; } export interface DeckWrite { diff --git a/plugins/rebuilder/app/src/app/core/ownership-api.service.spec.ts b/plugins/rebuilder/app/src/app/core/ownership-api.service.spec.ts index 22feb171..8d689868 100644 --- a/plugins/rebuilder/app/src/app/core/ownership-api.service.spec.ts +++ b/plugins/rebuilder/app/src/app/core/ownership-api.service.spec.ts @@ -3,26 +3,7 @@ import { HttpTestingController, provideHttpClientTesting } from '@angular/common import { TestBed } from '@angular/core/testing'; import { lastValueFrom } from 'rxjs'; import { environment } from '../../environments/environment'; -import { OwnershipApiService, mergeAltArtLines } from './ownership-api.service'; - -describe('mergeAltArtLines', () => { - it('flattens the lines and sums repeated references', () => { - expect( - mergeAltArtLines([ - [{ reference: 'ALT_A_ALT', quantity: 2 }, { reference: 'ALT_A', quantity: 1 }], - [{ reference: 'ALT_A', quantity: 2 }], - ]), - ).toEqual([ - { cardReference: 'ALT_A_ALT', quantity: 2 }, - { cardReference: 'ALT_A', quantity: 3 }, - ]); - }); - - it('is null without usable lines', () => { - expect(mergeAltArtLines(undefined)).toBeNull(); - expect(mergeAltArtLines([[{ quantity: 1 }]])).toBeNull(); - }); -}); +import { OwnershipApiService } from './ownership-api.service'; describe('OwnershipApiService', () => { const env = environment as { ownershipApiUrl: string }; @@ -40,25 +21,24 @@ describe('OwnershipApiService', () => { env.ownershipApiUrl = ''; }); - it('reads the « Global » mode, and treats an error as per-deck', async () => { - const global = lastValueFrom(api.globalAltArts()); - http.expectOne('/api/v1/services/ownership/api/alt-arts/preference-mode').flush({ mode: 'Global' }); - expect(await global).toBe(true); - const failed = lastValueFrom(api.globalAltArts()); - http.expectOne('/api/v1/services/ownership/api/alt-arts/preference-mode').flush('down', { status: 502, statusText: 'Bad Gateway' }); + it('asks whether a deck waits for the default alt arts, and treats an error as no', async () => { + const env2 = environment as { siteUrl: string }; + env2.siteUrl = ''; + const pending = lastValueFrom(api.pendingDefaults('d1')); + const req = http.expectOne((r) => r.url === '/papi/ownership/alt-art-pending' && r.params.get('deck') === 'd1'); + req.flush({ pending: true }); + expect(await pending).toBe(true); + const failed = lastValueFrom(api.pendingDefaults('d1')); + http.expectOne((r) => r.url === '/papi/ownership/alt-art-pending').flush('no', { status: 401, statusText: 'Unauthorized' }); expect(await failed).toBe(false); }); - it('sends the cards as reference / quantity, and keeps them on an error', async () => { - const cards = [{ cardReference: 'ALT_A', quantity: 3 }]; - const applied = lastValueFrom(api.applyAltArts(cards)); - const req = http.expectOne('/api/v1/services/ownership/api/alt-arts/apply-to-deck'); + it('tells the site a deck took its default alt arts', async () => { + const done = lastValueFrom(api.clearPendingDefaults('d1'), { defaultValue: undefined }); + const req = http.expectOne('/papi/ownership/alt-art-pending'); expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual([{ reference: 'ALT_A', quantity: 3 }]); - req.flush({ lines: [[{ reference: 'ALT_A_ALT', quantity: 3 }]] }); - expect(await applied).toEqual([{ cardReference: 'ALT_A_ALT', quantity: 3 }]); - const kept = lastValueFrom(api.applyAltArts(cards)); - http.expectOne('/api/v1/services/ownership/api/alt-arts/apply-to-deck').flush('no', { status: 500, statusText: 'Error' }); - expect(await kept).toBe(cards); + expect(req.request.body).toMatchObject({ deck: 'd1' }); + req.flush(null, { status: 204, statusText: 'No Content' }); + await done; }); }); diff --git a/plugins/rebuilder/app/src/app/core/ownership-api.service.ts b/plugins/rebuilder/app/src/app/core/ownership-api.service.ts index 3c33c6bf..f0c43cde 100644 --- a/plugins/rebuilder/app/src/app/core/ownership-api.service.ts +++ b/plugins/rebuilder/app/src/app/core/ownership-api.service.ts @@ -4,11 +4,6 @@ import { Observable, catchError, map, of, tap } from 'rxjs'; import { environment } from '../../environments/environment'; import { contentLocale } from './locale'; -export interface CardQuantity { - cardReference: string; - quantity: number; -} - /** A multi-art family of the ownership catalog. */ export interface AltArtFamily { familyId: number; @@ -16,10 +11,13 @@ export interface AltArtFamily { rarity: string; } -/** The illustrations of a family (`ownedQuantity`: `null` = unlimited) and the player's copy slots (one per copy). */ +/** + * The illustrations of a family, the plain one first (`ownedQuantity`: `null` = unlimited), and the player's default + * alt arts: one slot a copy (1st, 2nd, 3rd), `isExplicitChoice` when the player chose it (else the plain one). + */ export interface AltArtOptions { options: { reference: string; ownedQuantity: number | null }[]; - slots: { slotIndex: number; reference: string }[]; + slots: { slotIndex: number; reference: string; isExplicitChoice?: boolean }[]; } export interface AltArtChoice { @@ -32,28 +30,19 @@ export function familyKey(f: AltArtFamily): string { } /** - * Digital ownership (`OWNERSHIP_API_URL`, through the site's relay `AlteredCore.services.ownership`): - * the user's alt-art preference. In « Global » mode, the cards of a new deck are swapped for the - * alt arts the user prefers and owns. Any error leaves the cards as they are: an import never fails - * because of this service. + * Digital ownership (`OWNERSHIP_API_URL`, through the site's relay `AlteredCore.services.ownership`): the families of + * illustrations, the copies the player owns and their default alt arts (`core/alt-art-defaults.ts`). Any error leaves + * the cards as they are: the deck never fails because of this service. */ @Service() export class OwnershipApiService { private readonly http = inject(HttpClient); readonly baseUrl = environment.ownershipApiUrl.replace(/\/$/, ''); + private readonly site = environment.siteUrl.replace(/\/$/, ''); private readonly version = signal(0); /** Bumped by each alt-art preference saved: what was read from the service before may be stale. */ readonly altArtVersion = this.version.asReadonly(); - /** `true` when the user applies their alt-art preferences to every deck. */ - globalAltArts(): Observable { - if (!this.baseUrl) return of(false); - return this.http.get<{ mode?: string }>(`${this.baseUrl}/api/alt-arts/preference-mode`).pipe( - map((body) => body?.mode === 'Global'), - catchError(() => of(false)), - ); - } - /** * Families and illustrations of `references`, by reference (none for a card with a single illustration), from the * site's `deck-alt-arts` (plugin core-altered-cards), which also finds the family of a reprint. @@ -61,7 +50,7 @@ export class OwnershipApiService { altArtChoices(references: string[]): Observable> { if (!this.baseUrl || !references.length) return of({}); const params = references.reduce((p, r) => p.append('ref[]', r), new HttpParams()); - const url = `${environment.siteUrl.replace(/\/$/, '')}/papi/core-altered-cards/deck-alt-arts`; + const url = `${this.site}/papi/core-altered-cards/deck-alt-arts`; return this.http.get<{ groups?: Record; options?: Record }>(url, { params }).pipe( map((res) => { const out: Record = {}; @@ -76,8 +65,8 @@ export class OwnershipApiService { } /** - * The token families with several illustrations (per-deck alt-art mode: tokens are not deck cards, their prints are a - * preference shared by every deck), from the site's `alt-art-search` (plugin ownership). + * The token families with several illustrations (tokens are not deck cards: their prints are a preference shared by + * every deck), from the site's `alt-art-search` (plugin ownership). */ tokenAltArts(): Observable<(AltArtChoice & { name: string })[]> { if (!this.baseUrl) return of([]); @@ -85,7 +74,7 @@ export class OwnershipApiService { .reduce((p, t) => p.append('type[]', t), new HttpParams()) .set('hideNonChoices', 'false') .set('locale', contentLocale()); - const url = `${environment.siteUrl.replace(/\/$/, '')}/papi/ownership/alt-art-search`; + const url = `${this.site}/papi/ownership/alt-art-search`; return this.http .get<{ families?: (AltArtFamily & { name?: string | Record })[]; options?: Record }>(url, { params }) .pipe( @@ -106,33 +95,23 @@ export class OwnershipApiService { .pipe(tap({ complete: () => this.version.update((v) => v + 1) })); } - /** The cards with the preferred alt arts, as `POST /api/alt-arts/apply-to-deck` returns them. */ - applyAltArts(cards: CardQuantity[]): Observable { - if (!this.baseUrl || !cards.length) return of(cards); - const body = cards.map((c) => ({ reference: c.cardReference, quantity: c.quantity })); - return this.http.post<{ lines?: unknown }>(`${this.baseUrl}/api/alt-arts/apply-to-deck`, body).pipe( - map((res) => mergeAltArtLines(res?.lines) ?? cards), - catchError(() => of(cards)), + /** + * Whether the player's deck `deckId` waits for their default alt arts (decks of a player switched from the « Global » + * mode, site's `alt-art-pending`, plugin ownership); `false` on an error. + */ + pendingDefaults(deckId: string): Observable { + if (!this.baseUrl) return of(false); + return this.http.get<{ pending?: boolean }>(`${this.site}/papi/ownership/alt-art-pending`, { params: { deck: deckId } }).pipe( + map((res) => res?.pending === true), + catchError(() => of(false)), ); } -} -/** - * `lines[i]` is the list of references standing for input line `i` (an owned alt art for part of - * the copies, the printed card for the rest): flattened, with repeated references summed. `null` - * when the response has no usable lines. - */ -export function mergeAltArtLines(lines: unknown): CardQuantity[] | null { - if (!Array.isArray(lines)) return null; - const sum = new Map(); - for (const group of lines) { - for (const line of Array.isArray(group) ? group : [group]) { - const { reference, quantity } = (line ?? {}) as { reference?: unknown; quantity?: unknown }; - // Quantities read like PHP's `(int)` (the site's importer): numeric strings count. - const qty = Math.trunc(Number(quantity)); - if (typeof reference !== 'string' || !reference || !Number.isFinite(qty)) continue; - sum.set(reference, (sum.get(reference) ?? 0) + qty); - } + /** The deck took its default alt arts: it no longer waits for them. */ + clearPendingDefaults(deckId: string): Observable { + // The site's CSRF token travels in the body, as for its other endpoints (`FavoritesService`). + return this.http + .post(`${this.site}/papi/ownership/alt-art-pending`, { deck: deckId, csrf_token: environment.siteCsrf }) + .pipe(catchError(() => of(undefined))); } - return sum.size ? [...sum].map(([cardReference, quantity]) => ({ cardReference, quantity })) : null; } diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.html b/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.html index d3f07a0b..97f3d28a 100644 --- a/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.html +++ b/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.html @@ -16,11 +16,13 @@

{{ g.label }} · {{ g.count }}

}
diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.ts b/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.ts index 1892e5db..fad58d66 100644 --- a/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.ts +++ b/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.ts @@ -1,5 +1,5 @@ import { Component, DestroyRef, ElementRef, afterNextRender, computed, effect, inject, input, signal, untracked } from '@angular/core'; -import { DeckStore } from '../../../core/deck-store'; +import { DeckStore, displayName } from '../../../core/deck-store'; import type { Card, HydratedLine } from '../../../core/models'; import { AcCardPile } from '../../../ui/metier'; import { AcSkeleton } from '../../../ui/containers'; @@ -7,6 +7,7 @@ import { AcOverlayService } from '../../../ui/overlay'; import { EditorAltArts } from '../../editor/editor-alt-arts'; import { lineIssues } from '../../editor/editor-legality'; import { openCardZoom } from '../../shared/card-zoom/card-zoom.overlay'; +import { openAltArtPicker } from '../../editor/alt-art-picker/alt-art-picker.overlay'; import { boardGroups, fitColumns, layoutBoard } from './board-layout'; /** Narrowest card the board shows, to keep it readable: a wider board gets more columns, so fewer rows. */ @@ -31,7 +32,7 @@ export class DeckBoard { protected readonly deck = inject(DeckStore); private readonly overlay = inject(AcOverlayService); /** Editor only: prints of the deck's cards (per-deck alt-art mode). */ - private readonly altArts = inject(EditorAltArts, { optional: true }); + protected readonly altArts = inject(EditorAltArts, { optional: true }); readonly readonly = input(false); /** The board's width, measured: the number of card columns follows it. */ private readonly width = signal(0); @@ -91,14 +92,24 @@ export class DeckBoard { return this.deck.quantities().get(card.reference) ?? 0; } + /** `card` has a brush (several illustrations, the deck editable). */ + protected canIllustrate(card: Card): boolean { + return this.editable() && !!this.altArts?.canIllustrate(card); + } + + /** The brush of a card: its illustrations in this deck. */ + protected illustrate(card: Card): void { + const data = this.altArts?.pickerFor(card); + if (data) openAltArtPicker(this.overlay, displayName(card), data); + } + /** The card large, with its copies and prints when the deck can be edited (the site's card lightbox). */ protected zoom(card: Card): void { const editable = this.editable(); - const choice = this.altArts?.choiceFor(card.reference) ?? null; openCardZoom(this.overlay, { card, quantity: editable ? { value: this.quantityOf(card), max: this.deck.maxFor(card), change: (n) => this.setQuantity(card, n) } : undefined, - illustrations: editable && choice ? { choice, pick: (ref) => this.deck.swapReference(card, ref) } : undefined, + illustrations: editable ? (this.altArts?.pickerFor(card) ?? undefined) : undefined, }); } } diff --git a/plugins/rebuilder/app/src/app/features/decks/equinox/equinox-import.spec.ts b/plugins/rebuilder/app/src/app/features/decks/equinox/equinox-import.spec.ts index 206fec88..b2662425 100644 --- a/plugins/rebuilder/app/src/app/features/decks/equinox/equinox-import.spec.ts +++ b/plugins/rebuilder/app/src/app/features/decks/equinox/equinox-import.spec.ts @@ -5,7 +5,7 @@ import { of, throwError, type Observable } from 'rxjs'; import { AuthSession } from '../../../core/auth-session'; import { DecksApiService } from '../../../core/decks-api.service'; import type { Deck, DeckWrite } from '../../../core/models'; -import { OwnershipApiService, type CardQuantity } from '../../../core/ownership-api.service'; +import { OwnershipApiService, type AltArtChoice } from '../../../core/ownership-api.service'; import { EquinoxImport } from './equinox-import'; /** A ZIP with one stored entry. */ @@ -62,8 +62,8 @@ describe('EquinoxImport', () => { let created: DeckWrite[]; let failNext: number; let mine: Deck[]; - let global: boolean; - let altArts: (cards: CardQuantity[]) => CardQuantity[]; + /** The player's families, by reference (their default alt arts). */ + let choices: Record; let mineError: number; function setup(): Harness { @@ -89,8 +89,7 @@ describe('EquinoxImport', () => { { provide: OwnershipApiService, useValue: { - globalAltArts: () => of(global), - applyAltArts: (cards: CardQuantity[]) => of(altArts(cards)), + altArtChoices: (refs: string[]) => of(Object.fromEntries(refs.filter((r) => choices[r]).map((r) => [r, choices[r]]))), }, }, ], @@ -104,9 +103,8 @@ describe('EquinoxImport', () => { vi.useFakeTimers(); failNext = 0; mine = []; - global = false; mineError = 0; - altArts = (cards) => cards.map((c) => ({ ...c, cardReference: `${c.cardReference}_ALT` })); + choices = {}; }); afterEach(() => vi.useRealTimers()); @@ -128,16 +126,22 @@ describe('EquinoxImport', () => { expect(cmp.phase()).toBe('done'); }); - it('applies the « Global » alt-art preference to the exported cards, not to the hero', async () => { - global = true; + it('gives the cards and the hero the player’s default alt arts, within the copies owned', async () => { + choices = { + ALT_A: family(1, ['ALT_A', 'ALT_A_ALT'], 2, ['ALT_A_ALT', 'ALT_A_ALT', 'ALT_A_ALT']), + ALT_HERO: family(2, ['ALT_HERO', 'ALT_HERO_P'], 1, ['ALT_HERO_P']), + }; const cmp = setup(); await run(cmp); - expect(created[0].deckCards).toEqual([{ cardReference: 'ALT_HERO', quantity: 1 }, { cardReference: 'ALT_A_ALT', quantity: 3 }]); + expect(created[0].deckCards).toEqual([ + { cardReference: 'ALT_HERO_P', quantity: 1 }, + { cardReference: 'ALT_A_ALT', quantity: 2 }, + { cardReference: 'ALT_A', quantity: 1 }, + ]); }); - it('keeps the exported cards when the alt-art answer holds an invalid card', async () => { - global = true; - altArts = (cards) => cards.map((c) => ({ ...c, quantity: 0 })); + it('keeps the exported cards when the defaults give an invalid card', async () => { + choices = { ALT_A: family(1, ['ALT_A', 'oops'], null, ['oops']) }; const cmp = setup(); await run(cmp); expect(created[0].deckCards).toEqual([{ cardReference: 'ALT_HERO', quantity: 1 }, { cardReference: 'ALT_A', quantity: 3 }]); @@ -215,3 +219,11 @@ describe('EquinoxImport', () => { expect(cmp.rows().filter((r) => r.status === 'cancelled').length).toBe(2 - importedWhilePaused); }); }); + +/** A family of `prints` (the plain one first, unlimited; the others owned `owned` times) with the default `slots`. */ +function family(id: number, prints: string[], owned: number | null, slots: string[]): AltArtChoice { + return { + family: { familyId: id, faction: 'AX', rarity: 'C' }, + options: { options: prints.map((reference, i) => ({ reference, ownedQuantity: i === 0 ? null : owned })), slots: slots.map((reference, i) => ({ slotIndex: i + 1, reference })) }, + }; +} diff --git a/plugins/rebuilder/app/src/app/features/decks/equinox/equinox-import.ts b/plugins/rebuilder/app/src/app/features/decks/equinox/equinox-import.ts index 26cea41d..8ee18e96 100644 --- a/plugins/rebuilder/app/src/app/features/decks/equinox/equinox-import.ts +++ b/plugins/rebuilder/app/src/app/features/decks/equinox/equinox-import.ts @@ -13,6 +13,7 @@ import { uiLocale } from '../../../core/i18n'; import { AcBreakpointService } from '../../../ui/layout.services'; import { type DeckCardRef, type EquinoxDeck, parseEquinoxCsv, sameDeck, validCards, withHero } from './equinox-csv'; import { OwnershipApiService } from '../../../core/ownership-api.service'; +import { cardsWithDefaults } from '../../../core/deck-store'; export type ImportStatus = 'pending' | 'current' | 'imported' | 'skipped' | 'failed' | 'failedFinal' | 'cancelled'; @@ -304,15 +305,12 @@ export class EquinoxImport { // A cancel, or a new file, while the request was in flight: its answer is dropped. const stale = () => this.cancelled() || run !== this.run; try { - // « Global » alt-art preference (read for each deck, as the site's importer does): the user's - // alt arts replace the exported cards, unless the answer holds an invalid card; the hero is - // added afterwards, as it was exported. - let exported = row.deck.cards; - if (await lastValueFrom(this.ownership.globalAltArts())) { - const applied = await lastValueFrom(this.ownership.applyAltArts(exported)); - if (validCards(applied)) exported = applied; - } - const deck = { name: row.deck.name, cards: withHero(row.deck.hero, exported) }; + // The cards and the hero take the user's default alt arts (`cardsWithDefaults`), unless that gives an invalid card. + let cards = withHero(row.deck.hero, row.deck.cards); + const choices = await lastValueFrom(this.ownership.altArtChoices(cards.map((c) => c.cardReference))); + const applied = cardsWithDefaults(cards, choices); + if (validCards(applied)) cards = applied; + const deck = { name: row.deck.name, cards }; let status: ImportStatus = 'skipped'; if (!(await this.alreadyThere(deck))) { await lastValueFrom( diff --git a/plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.html b/plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.html new file mode 100644 index 00000000..98b8eaaf --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.html @@ -0,0 +1,29 @@ +
+

Touchez une illustration pour la poser sur l’exemplaire sélectionné.

+
    + @for (s of slots(); track $index) { +
  • + {{ s.label }} + +
  • + } +
+

Illustrations disponibles

+
    + @for (p of pool(); track p.reference) { +
  • + +
  • + } +
+
+ diff --git a/plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.scss b/plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.scss new file mode 100644 index 00000000..f5f953ff --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.scss @@ -0,0 +1,131 @@ +.ac-overlay-body { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--ac-space-3); +} +.hint { + margin: 0; + text-align: center; + font-size: var(--ac-font-size-small); + color: var(--ac-color-text-muted); +} +ul { + margin: 0; + padding: 0; + list-style: none; +} +/* The copies, large: up to 200 px each, narrower on a phone. */ +.slots { + width: 100%; + display: grid; + grid-template-columns: repeat(var(--copies, 1), minmax(0, 200px)); + justify-content: center; + gap: var(--ac-space-2); + padding: var(--ac-space-2); + border-radius: var(--ac-radius-lg); + background: var(--ac-color-primary-tint); + box-sizing: border-box; +} +.slots li { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--ac-space-1); + min-width: 0; +} +.slot-label { + font-size: var(--ac-font-size-caption); + font-weight: 700; + color: var(--ac-color-text-muted); +} +.slot, +.print { + padding: 0; + border: 0; + background: none; + cursor: pointer; + font: inherit; + color: inherit; +} +.slot { + width: 100%; + border-radius: var(--ac-radius-md); + outline: 2px solid transparent; + outline-offset: 2px; +} +.slot img { + display: block; + width: 100%; + aspect-ratio: 5 / 7; + object-fit: cover; + border-radius: var(--ac-radius-md); +} +.slot.active { + outline-color: var(--ac-color-primary); +} +.pool-title { + margin: 0; + font-size: var(--ac-font-size-caption); + font-weight: 700; + color: var(--ac-color-text-muted); +} +/* The prints in one row, scrolled sideways when they do not fit. */ +.pool { + max-width: 100%; + display: flex; + justify-content: safe center; + gap: var(--ac-space-2); + overflow-x: auto; + padding-bottom: var(--ac-space-1); +} +.pool li { + flex-shrink: 0; +} +.print { + width: 126px; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--ac-space-1); + padding: var(--ac-space-2) var(--ac-space-1) var(--ac-space-1); + border: 1px solid var(--ac-color-border); + border-radius: var(--ac-radius-md); + background: var(--ac-color-surface); +} +.print img { + display: block; + width: 110px; + aspect-ratio: 5 / 7; + object-fit: cover; + border-radius: var(--ac-radius-sm); +} +.print.off { + cursor: not-allowed; +} +.print.off img { + opacity: 0.4; +} +.note { + font-size: var(--ac-font-size-caption); + color: var(--ac-color-text-muted); +} +.slot:focus-visible, +.print:focus-visible { + outline: 2px solid var(--ac-color-focus); + outline-offset: 2px; +} +.settings { + margin-right: auto; + align-self: center; + font-size: var(--ac-font-size-small); + color: var(--ac-color-link); +} +@media (max-width: 767px) { + .print { + width: 96px; + } + .print img { + width: 84px; + } +} diff --git a/plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.ts b/plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.ts new file mode 100644 index 00000000..8dec79a2 --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.ts @@ -0,0 +1,116 @@ +import { Component, computed, inject, signal } from '@angular/core'; +import { environment } from '../../../../environments/environment'; +import { counts, defaultPrints } from '../../../core/alt-art-defaults'; +import { cardImageUrl } from '../../../core/card-art'; +import type { AltArtChoice } from '../../../core/ownership-api.service'; +import { AcButton } from '../../../ui/buttons'; +import { AcOverlayRef, AcOverlayService } from '../../../ui/overlay'; + +export interface AltArtPickerData { + choice: AltArtChoice; + /** The family's copies in the deck, one print a copy. */ + prints: () => readonly string[]; + /** The family's copies take `prints`, one print a copy. */ + set: (prints: readonly string[]) => void; +} + +/** + * The brush of a card: the illustration of each of its copies in this deck. A touched illustration goes on the selected + * copy, then the next copy is selected; an illustration whose owned copies are all placed waits. « Arts par défaut » puts + * the player's default alt arts back. Each change is saved with the deck. + */ +@Component({ + selector: 'app-alt-art-picker', + imports: [AcButton], + host: { class: 'ac-overlay-content' }, + templateUrl: './alt-art-picker.overlay.html', + styleUrl: './alt-art-picker.overlay.scss', +}) +export class AltArtPickerOverlay { + protected readonly ref = inject>(AcOverlayRef); + private readonly data = this.ref.data!; + /** + * The copies, the ones on a default alt art in the order of the defaults (1st copy on the 1st default…) when the + * window opens; then each copy keeps its place while the illustrations change. + */ + protected readonly copies = signal(ordered(this.data.choice, this.data.prints())); + /** The selected copy: the next illustration touched goes on it. */ + protected readonly active = signal(0); + protected readonly slots = computed(() => this.copies().map((reference, i) => ({ reference, src: cardImageUrl(reference), label: copyLabel(i + 1) }))); + protected readonly pool = computed(() => { + const copies = this.copies(); + const active = this.active(); + return this.data.choice.options.options.map((o, i) => { + const placed = copies.filter((c) => c === o.reference).length; + // Taken by the other copies: the selected copy gives its own print back when it changes. + const elsewhere = copies.filter((c, j) => c === o.reference && j !== active).length; + const owned = o.ownedQuantity; + return { + reference: o.reference, + src: cardImageUrl(o.reference), + label: printLabel(i + 1), + off: owned !== null && elsewhere >= owned, + unowned: owned === 0, + note: owned === null ? unlimited() : owned === 0 ? notOwned() : placedLabel(placed, owned), + }; + }); + }); + protected readonly isDefault = computed(() => this.copies().join() === defaultPrints(this.data.choice, this.copies().length).join()); + /** The site's « Arts alternatifs par défaut » page (plugin ownership). */ + protected readonly settingsUrl = `${environment.siteUrl.replace(/\/$/, '')}/pages/ownership-alt-arts`; + + protected select(index: number): void { + this.active.set(index); + } + + protected place(reference: string, off: boolean): void { + const copies = this.copies(); + if (off || !copies.length) return; + const active = this.active(); + const next = copies.map((c, i) => (i === active ? reference : c)); + this.copies.set(next); + this.active.set((active + 1) % next.length); + this.data.set(next); + } + + protected resetToDefaults(): void { + const next = defaultPrints(this.data.choice, this.copies().length); + this.copies.set(next); + this.active.set(0); + this.data.set(next); + } +} + +/** `prints` with the copies on a default alt art first, in the order of the defaults, then the others. */ +function ordered(choice: AltArtChoice, prints: readonly string[]): string[] { + const left = counts(prints); + const out: string[] = []; + for (const d of defaultPrints(choice, prints.length)) { + const n = left.get(d) ?? 0; + if (!n) continue; + out.push(d); + left.set(d, n - 1); + } + for (const [print, n] of left) for (let i = 0; i < n; i++) out.push(print); + return out; +} + +const copyLabel = (n: number) => $localize`:@@altArt.marker:Exemplaire ${n}:n:`; +const printLabel = (n: number) => $localize`:@@altArt.tile:Illustration ${n}:n:`; +const unlimited = () => $localize`:@@editor.altArtPicker.unlimited:Illimitée`; +const notOwned = () => $localize`:@@editor.altArtPicker.notOwned:Non possédée`; +const placedLabel = (placed: number, owned: number) => + placed > 1 + ? $localize`:@@editor.altArtPicker.placedMany:${placed}:placed: / ${owned}:owned: placées` + : $localize`:@@editor.altArtPicker.placedOne:${placed}:placed: / ${owned}:owned: placée`; + +/** The brush of a card of the deck, in its own window (sheet on a phone). */ +export function openAltArtPicker(overlay: AcOverlayService, name: string, data: AltArtPickerData): AcOverlayRef { + const copies = data.prints().length; + return overlay.open(AltArtPickerOverlay, { + title: name, + subtitle: copies > 1 ? $localize`:@@editor.altArtPicker.copiesMany:${copies}:n: exemplaires dans ce deck` : $localize`:@@editor.altArtPicker.copiesOne:1 exemplaire dans ce deck`, + data, + width: 1000, + }); +} diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.html b/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.html index be5ee0dc..aefb4e0f 100644 --- a/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.html +++ b/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.html @@ -30,8 +30,11 @@ -@if (!readonly() && altArts?.canChooseTokens()) { - +@if (!readonly() && altArts?.canChoose()) { +
+ + +
} @for (g of deck.groups(); track g.id) { @@ -39,7 +42,7 @@ (); readonly changeHero = output(); + /** A message for the page's notice (« Arts par défaut »). */ + readonly notice = output(); protected readonly statsOpen = signal(true); protected readonly formatLabel = computed(() => formatInfo(this.deck.format()).label); protected readonly legality = computed(() => editorLegality(this.deck)); @@ -37,4 +39,11 @@ export class DeckListView { protected chooseTokenArts(): void { openTokenArts(this.overlay); } + + protected applyDefaults(): void { + if (!this.altArts) return; + confirmAltArtDefaults(this.overlay, this.altArts).subscribe((message) => { + if (message) this.notice.emit(message); + }); + } } diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.html b/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.html index 9e7c9f51..b7105476 100644 --- a/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.html +++ b/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.html @@ -15,7 +15,7 @@ appearance="panel" [card]="l.card" [quantity]="l.quantity" - [max]="deck.maxFor(l.card)" + [max]="altArts?.maxFor(l.card, false) ?? deck.maxFor(l.card)" [issues]="issues().get(l.card.reference) ?? noIssues" [warning]="altArts?.warnings()?.get(l.card.reference) ?? null" [readonly]="!deck.editable()" diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.html b/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.html index 361cad6c..7ef94862 100644 --- a/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.html +++ b/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.html @@ -32,11 +32,13 @@ diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.ts b/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.ts index e453222f..56cb7e71 100644 --- a/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.ts +++ b/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.ts @@ -1,5 +1,4 @@ import { Component, computed, inject, input, signal } from '@angular/core'; -import { DeckStore } from '../../../core/deck-store'; import { AcButton } from '../../../ui/buttons'; import { AcSkeleton } from '../../../ui/containers'; import { AcCardTile, AcDeckSection } from '../../../ui/metier'; @@ -7,7 +6,9 @@ import { AcOverlayService } from '../../../ui/overlay'; import { FavoritesService } from '../../../core/favorites.service'; import { EditorAltArts } from '../editor-alt-arts'; import type { Card } from '../../../core/models'; +import { DeckStore, displayName } from '../../../core/deck-store'; import { openCardZoom } from '../../shared/card-zoom/card-zoom.overlay'; +import { openAltArtPicker } from '../alt-art-picker/alt-art-picker.overlay'; /** « Aperçu » / « Cartes »: count line, « Tout replier », one section per type with tiles. */ @Component({ @@ -21,7 +22,7 @@ export class DeckPreview { private readonly overlay = inject(AcOverlayService); protected readonly favorites = inject(FavoritesService); /** Editor only: prints of the deck's cards (per-deck alt-art mode). */ - private readonly altArts = inject(EditorAltArts, { optional: true }); + protected readonly altArts = inject(EditorAltArts, { optional: true }); readonly readonly = input(false); /** Three columns on compact (consultation « Cartes »). */ readonly dense = input(false); @@ -44,14 +45,24 @@ export class DeckPreview { /** A tile's visual: the card large, with its copies when the deck can be edited (the site's card lightbox). */ protected zoom(card: Card): void { const editable = !this.readonly() && this.deck.editable(); - const choice = this.altArts?.choiceFor(card.reference) ?? null; openCardZoom(this.overlay, { card, quantity: editable ? { value: this.deck.quantities().get(card.reference) ?? 0, max: this.deck.maxFor(card), change: (n) => this.deck.setQuantity(card, n) } : undefined, - illustrations: editable && choice ? { choice, pick: (ref) => this.deck.swapReference(card, ref) } : undefined, + illustrations: editable ? (this.altArts?.pickerFor(card) ?? undefined) : undefined, }); } + /** `card` has a brush (several illustrations, the deck editable). */ + protected canIllustrate(card: Card): boolean { + return !this.readonly() && !!this.altArts?.canIllustrate(card); + } + + /** The brush of a card: its illustrations in this deck. */ + protected illustrate(card: Card): void { + const data = this.altArts?.pickerFor(card); + if (data) openAltArtPicker(this.overlay, displayName(card), data); + } + toggleAll(): void { this.closed.set(this.allClosed() ? new Set() : new Set(this.deck.groups().map((g) => g.id))); } diff --git a/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.spec.ts b/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.spec.ts new file mode 100644 index 00000000..e8bb8463 --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.spec.ts @@ -0,0 +1,92 @@ +import { TestBed } from '@angular/core/testing'; +import { signal } from '@angular/core'; +import { Subject, of } from 'rxjs'; +import { AuthSession } from '../../core/auth-session'; +import { DeckStore } from '../../core/deck-store'; +import type { Card, HydratedLine } from '../../core/models'; +import { OwnershipApiService, type AltArtChoice } from '../../core/ownership-api.service'; +import { EditorAltArts } from './editor-alt-arts'; + +const BASE = 'ALT_CORE_B_AX_04_C'; +const ALT = 'ALT_DUSTERTOP_P_AX_04_C'; +const NONE = 'ALT_CORE_A_AX_04_C'; +const kelon: AltArtChoice = { + family: { familyId: 302, faction: 'AX', rarity: 'C' }, + options: { + options: [ + { reference: BASE, ownedQuantity: null }, + { reference: ALT, ownedQuantity: 2 }, + { reference: NONE, ownedQuantity: 0 }, + ], + slots: [1, 2, 3].map((slotIndex) => ({ slotIndex, reference: ALT })), + }, +}; +const card = (reference: string): Card => ({ reference, name: 'Élémentaire de Kélon' }); + +describe('EditorAltArts', () => { + let answers: Subject>; + let lines: ReturnType>; + let familyAdds: number[]; + + function setup(): EditorAltArts { + answers = new Subject(); + lines = signal([]); + familyAdds = []; + TestBed.configureTestingModule({ + providers: [ + EditorAltArts, + { provide: AuthSession, useValue: { isLoggedIn: () => true } }, + { provide: OwnershipApiService, useValue: { baseUrl: '/ownership', altArtChoices: () => answers, pendingDefaults: () => of(false) } }, + { + provide: DeckStore, + useValue: { + lines, + hero: signal(null), + editable: signal(true), + deckId: signal('d1'), + isGuest: signal(false), + maxFor: () => 3, + quantityOf: (ref: string) => lines().find((l) => l.card.reference === ref)?.quantity ?? 0, + setQuantity: () => undefined, + setFamilyQuantity: (_card: Card, _choice: AltArtChoice, _members: Set, n: number) => familyAdds.push(n), + }, + }, + ], + }); + return TestBed.inject(EditorAltArts); + } + + it('waits for a card’s family before adding a copy, so that it takes its default alt art', () => { + const alt = setup(); + alt.request([BASE]); + alt.setQuantity(card(BASE), 1, true); + expect(familyAdds).toEqual([]); + answers.next({ [BASE]: kelon }); + expect(familyAdds).toEqual([1]); + }); + + it('follows a card with the alt arts the player owns, unlimited ones included, labelled', () => { + const alt = setup(); + alt.request([BASE]); + answers.next({ [BASE]: kelon }); + const { cards, notes } = alt.withOwnedPrints([card(BASE), card('OTHER')]); + expect(cards.map((c) => c.reference)).toEqual([BASE, ALT, 'OTHER']); + expect(notes.get(ALT)).toBe('2 possédées'); + // A print the results show already is not added twice. + expect(alt.withOwnedPrints([card(BASE), card(ALT)]).cards.map((c) => c.reference)).toEqual([BASE, ALT]); + }); + + it('gives a print the copies its family leaves, and a family card the whole limit', () => { + const alt = setup(); + alt.request([BASE]); + answers.next({ [BASE]: kelon }); + lines.set([ + { card: card(BASE), quantity: 2 }, + { card: card(ALT), quantity: 1 }, + ]); + expect(alt.maxFor(card(ALT), false)).toBe(1); + expect(alt.maxFor(card(BASE), false)).toBe(2); + expect(alt.maxFor(card(BASE), true)).toBe(3); + expect(alt.familyQuantity(card(BASE))).toBe(3); + }); +}); diff --git a/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.ts b/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.ts index b709744b..085662d0 100644 --- a/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.ts +++ b/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.ts @@ -1,39 +1,61 @@ -import { Service, computed, effect, inject, untracked } from '@angular/core'; -import { rxResource, toSignal } from '@angular/core/rxjs-interop'; -import { of } from 'rxjs'; +import { Service, computed, effect, inject, signal, untracked } from '@angular/core'; +import { type Observable, filter, map, of, switchMap, take } from 'rxjs'; +import { toObservable } from '@angular/core/rxjs-interop'; +import { AuthSession } from '../../core/auth-session'; +import { basePrint, familyPrints } from '../../core/alt-art-defaults'; +import { storedFlag } from '../../core/stored-flag'; import { DeckStore } from '../../core/deck-store'; +import type { Card } from '../../core/models'; import { OwnershipApiService, familyKey, type AltArtChoice } from '../../core/ownership-api.service'; +import type { AltArtPickerData } from './alt-art-picker/alt-art-picker.overlay'; +import { openConfirm } from '../shared/confirm/confirm.overlay'; +import type { AcOverlayService } from '../../ui/overlay'; + +/** References asked to the site at once (`deck-alt-arts`, a GET with one parameter a reference). */ +const CHUNK = 60; /** - * Illustrations in the editor, as the site's deck builder: in « Global » alt-art mode the deck follows the player's - * preferred prints; otherwise each card's print can be chosen, and a print used more times than owned is flagged - * (Board Game Arena shows the base art for the missing copies). Needs the ownership service; nothing happens without. + * Illustrations in the editor (`core/alt-art-defaults.ts`): a card added from the search takes the player's default alt + * art for its copy, the brush of a card changes its prints for this deck, « Appliquer les arts par défaut » rewrites the + * deck with the defaults, and a print used more times than owned is flagged (Board Game Arena shows the base art for the + * missing copies). Needs the ownership service and a signed-in player; nothing happens without. */ @Service({ autoProvided: false }) export class EditorAltArts { private readonly deck = inject(DeckStore); private readonly ownership = inject(OwnershipApiService); - readonly global = toSignal(this.ownership.globalAltArts(), { initialValue: false }); - - /** References of the deck (hero included), sorted: the families are fetched again only when they change. */ - private readonly refs = computed( - () => { - const refs = this.deck.lines().filter((l) => l.quantity > 0).map((l) => l.card.reference); - const hero = this.deck.hero()?.reference; - return [...new Set(hero ? [...refs, hero] : refs)].sort(); - }, - { equal: (a, b) => a.join() === b.join() }, - ); - private readonly choicesRes = rxResource({ - params: () => (this.ownership.baseUrl && this.deck.editable() ? this.refs() : undefined), - stream: ({ params }) => (params.length ? this.ownership.altArtChoices(params) : of({} as Record)), + private readonly auth = inject(AuthSession); + readonly enabled = computed(() => !!this.ownership.baseUrl && this.auth.isLoggedIn()); + + /** Family of each reference asked so far (`null`: one illustration only); the prints of a family share it. */ + private readonly known = signal>(new Map()); + private readonly asked = new Set(); + /** Changes waiting for the family of their reference (a card added before its family arrived). */ + private readonly waiting = new Map void)[]>(); + + /** References of the deck (hero included). */ + private readonly deckRefs = computed(() => { + const refs = this.deck.lines().filter((l) => l.quantity > 0).map((l) => l.card.reference); + const hero = this.deck.hero()?.reference; + return hero ? [...refs, hero] : refs; + }); + /** Families of the deck's references, by reference. */ + readonly choices = computed>(() => { + const known = this.known(); + const out: Record = {}; + for (const ref of this.deckRefs()) { + const choice = known.get(ref); + if (choice) out[ref] = choice; + } + return out; }); - readonly choices = computed>(() => (this.choicesRes.hasValue() ? this.choicesRes.value() : {})); + /** Every reference of the deck has been asked to the service. */ + readonly ready = computed(() => this.deckRefs().every((r) => this.known().has(r))); + private readonly ready$ = toObservable(this.ready).pipe(filter(Boolean), take(1)); - /** Per-deck mode: why a line's print is short (owned copies under the copies of the deck), by reference. */ + /** Why a line's print is short (owned copies under the copies of the deck), by reference. */ readonly warnings = computed(() => { const out = new Map(); - if (this.global()) return out; const choices = this.choices(); for (const l of this.deck.lines()) { const owned = choices[l.card.reference]?.options.options.find((o) => o.reference === l.card.reference)?.ownedQuantity; @@ -44,28 +66,186 @@ export class EditorAltArts { return out; }); + /** Search, « Arts alternatifs » on: each card is followed by the alt arts the player owns, added as they are. */ + readonly searchPrints = storedFlag('rebuilder.search.altArtPrints', false); + + /** « Arts des jetons » and « Arts par défaut »: on an editable deck. */ + readonly canChoose = computed(() => this.enabled() && this.deck.editable()); + constructor() { - // Global mode: the deck takes the preferred prints once the families are known (and after each change). effect(() => { - const choices = this.choices(); - if (!this.global() || !Object.keys(choices).length) return; - const slots = new Map( - Object.entries(choices).map(([ref, c]) => [ref, { key: familyKey(c.family), slots: [...c.options.slots].sort((a, b) => a.slotIndex - b.slotIndex).map((s) => s.reference) }]), - ); - untracked(() => this.deck.applyAltArtSlots(slots)); + const refs = this.deckRefs(); + if (this.enabled() && this.deck.editable()) untracked(() => this.request(refs)); }); - // Global mode: a preference saved meanwhile (in the card zoom) is fetched again, for the deck to follow it. - const seen = this.ownership.altArtVersion(); + // A deck of a player switched from the « Global » mode takes their default alt arts the first time it opens here. + const checked = new Set(); effect(() => { - if (this.ownership.altArtVersion() !== seen && untracked(this.global)) untracked(() => this.choicesRes.reload()); + const id = this.deck.deckId(); + if (!id || !this.enabled() || this.deck.isGuest() || this.deck.editable() !== true || checked.has(id)) return; + checked.add(id); + untracked(() => + this.ownership + .pendingDefaults(id) + .pipe( + filter(Boolean), + switchMap(() => this.ready$), + filter(() => this.deck.deckId() === id), + ) + .subscribe(() => { + this.deck.applyAltArtDefaults(this.choices()); + this.ownership.clearPendingDefaults(id).subscribe(); + }), + ); }); } - /** « Choisir les arts des jetons »: per-deck mode with the ownership service, on an editable deck. */ - readonly canChooseTokens = computed(() => !!this.ownership.baseUrl && !this.global() && this.deck.editable()); + /** Asks the families of `refs` not asked yet (search results, deck lines). */ + request(refs: readonly string[]): void { + if (!this.enabled()) return; + const missing = [...new Set(refs)].filter((r) => !this.asked.has(r)); + for (let i = 0; i < missing.length; i += CHUNK) { + const chunk = missing.slice(i, i + CHUNK); + chunk.forEach((r) => this.asked.add(r)); + this.ownership.altArtChoices(chunk).subscribe((found) => { + this.known.update((m) => { + const next = new Map(m); + for (const ref of chunk) next.set(ref, found[ref] ?? next.get(ref) ?? null); + // Every print of a family is known with it (a print added by the brush or the search). + for (const choice of Object.values(found)) { + for (const o of choice.options.options) if (!next.get(o.reference)) next.set(o.reference, choice); + } + return next; + }); + for (const ref of chunk) { + const run = this.waiting.get(ref); + this.waiting.delete(ref); + run?.forEach((f) => f()); + } + }); + } + } - /** Per-deck mode: the prints a card of the deck can take (`null`: one illustration only, or Global mode). */ + /** The family of `reference` (`null`: one illustration, or not known yet). */ choiceFor(reference: string): AltArtChoice | null { - return this.global() ? null : this.choices()[reference] ?? null; + return this.known().get(reference) ?? null; + } + + /** References of the deck's lines in the family of `choice`. */ + members(choice: AltArtChoice): Set { + const key = familyKey(choice.family); + const known = this.known(); + return new Set( + this.deck + .lines() + .map((l) => l.card.reference) + .filter((r) => { + const c = known.get(r); + return !!c && familyKey(c.family) === key; + }), + ); + } + + /** + * Search results with « Arts alternatifs » on: after a card's plain print, the alt arts the player owns (unlimited + * ones too), unless the results show them already; `notes` labels them (« 2 possédées »). + */ + withOwnedPrints(cards: readonly Card[]): { cards: Card[]; notes: Map } { + const notes = new Map(); + const shown = new Set(cards.map((c) => c.reference)); + const out: Card[] = []; + for (const card of cards) { + out.push(card); + const choice = this.choiceFor(card.reference); + if (!choice || basePrint(choice) !== card.reference) continue; + for (const o of choice.options.options) { + if (o.reference === card.reference || shown.has(o.reference) || o.ownedQuantity === 0) continue; + shown.add(o.reference); + out.push({ ...card, reference: o.reference, imagePath: undefined }); + notes.set(o.reference, o.ownedQuantity === null ? unlimitedNote() : ownedNote(o.ownedQuantity)); + } + } + return { cards: out, notes }; + } + + /** + * Copies `card` can have: a print shares its family's limit with the other prints in the deck (never under the copies + * it has); a family card (`asFamily`, its plain print) has the whole limit. + */ + maxFor(card: Card, asFamily: boolean): number { + const max = this.deck.maxFor(card); + const choice = this.choiceFor(card.reference); + if (!choice || (asFamily && basePrint(choice) === card.reference)) return max; + const others = familyPrints(this.deck.lines(), this.members(choice)).filter((r) => r !== card.reference).length; + return Math.max(this.deck.quantityOf(card.reference), max - others); + } + + /** Copies of `card`'s family in the deck, or of `card` alone. */ + familyQuantity(card: Card): number { + const choice = this.choiceFor(card.reference); + if (!choice) return this.deck.quantityOf(card.reference); + return familyPrints(this.deck.lines(), this.members(choice)).length; + } + + /** `card` stands for its whole family: the plain print of a multi-art card. */ + isFamilyCard(card: Card): boolean { + const choice = this.choiceFor(card.reference); + return !!choice && basePrint(choice) === card.reference; + } + + /** Copies of `card` from the search: by the defaults for a family card (`setFamilyQuantity`), that print otherwise. */ + setQuantity(card: Card, quantity: number, asFamily: boolean): void { + // Asked but not answered yet: the change waits for the family, so that the copy takes its default alt art. + if (asFamily && this.asked.has(card.reference) && !this.known().has(card.reference)) { + this.waiting.set(card.reference, [...(this.waiting.get(card.reference) ?? []), () => this.setQuantity(card, quantity, asFamily)]); + return; + } + const choice = this.choiceFor(card.reference); + if (asFamily && choice && basePrint(choice) === card.reference) this.deck.setFamilyQuantity(card, choice, this.members(choice), quantity); + else this.deck.setQuantity(card, quantity); + } + + /** `card` has a brush: several illustrations, in an editable deck. */ + canIllustrate(card: Card): boolean { + return this.canChoose() && (this.choiceFor(card.reference)?.options.options.length ?? 0) > 1; } + + /** The brush of `card` (`canIllustrate`), `null` otherwise. */ + pickerFor(card: Card): AltArtPickerData | null { + const choice = this.choiceFor(card.reference); + if (!choice || !this.canIllustrate(card)) return null; + return { + choice, + prints: () => familyPrints(this.deck.lines(), this.members(choice)), + set: (prints) => this.deck.setFamilyPrints(this.members(choice), card, prints), + }; + } + + /** « Appliquer les arts par défaut », once the deck's families are known: `false` when the deck already has them. */ + applyDefaults(): Observable { + return this.ready$.pipe(map(() => this.deck.applyAltArtDefaults(this.choices()))); + } +} + +const unlimitedNote = () => $localize`:@@search.altArts.unlimited:Illimitée`; +const ownedNote = (n: number) => (n > 1 ? $localize`:@@search.altArts.ownedMany:${n}:n: possédées` : $localize`:@@search.altArts.ownedOne:${n}:n: possédée`); + +/** + * « Arts par défaut » (deck bar, « Mon deck »): after a confirmation, the deck takes the player's default alt arts. + * Emits the message to show, `null` when cancelled. + */ +export function confirmAltArtDefaults(overlay: AcOverlayService, altArts: EditorAltArts): Observable { + return openConfirm(overlay, { + title: $localize`:@@editor.altArtDefaults.title:Appliquer vos arts par défaut ?`, + message: $localize`:@@editor.altArtDefaults.message:Chaque carte du deck, et le héros, prend vos arts alternatifs par défaut. Les illustrations choisies pour ce deck sont remplacées.`, + confirmLabel: $localize`:@@editor.altArtDefaults.confirm:Appliquer`, + icon: 'wand-sparkles', + }).pipe( + switchMap((ok) => (ok ? altArts.applyDefaults() : of(null))), + map((applied) => { + if (applied === null) return null; + return applied + ? $localize`:@@editor.altArtDefaults.applied:Arts par défaut appliqués.` + : $localize`:@@editor.altArtDefaults.unchanged:Le deck a déjà vos arts par défaut.`; + }), + ); } diff --git a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html index 397a71c0..e2320b35 100644 --- a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html +++ b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html @@ -138,8 +138,9 @@ @if (deck.editable()) { } - @if (altArts.canChooseTokens()) { + @if (altArts.canChoose()) { + }
@@ -178,7 +179,7 @@ } } @case ('deck') { - + } @case ('main') { @if (deck.opening()) { diff --git a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.ts b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.ts index 7c718525..956a336d 100644 --- a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.ts +++ b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.ts @@ -23,7 +23,7 @@ import { AcOverlayService } from '../../../ui/overlay'; import { openDeckSettings } from '../../shared/deck-settings/deck-settings.overlay'; import { openHeroPicker } from '../../shared/hero-picker/hero-picker.overlay'; import { openDeckOverview } from '../deck-overview/deck-overview.overlay'; -import { EditorAltArts } from '../editor-alt-arts'; +import { EditorAltArts, confirmAltArtDefaults } from '../editor-alt-arts'; import { CardSearchStore } from '../../search/card-search.store'; import { CardSearch } from '../../search/card-search/card-search'; import { DeckListView } from '../deck-list-view/deck-list-view'; @@ -333,6 +333,12 @@ export class EditorPage { openTokenArts(this.overlay); } + protected applyAltArtDefaults(): void { + confirmAltArtDefaults(this.overlay, this.altArts).subscribe((message) => { + if (message) this.flash(message); + }); + } + /** The deck's image: copied, saved or opened (reduced bar: the button and its « ⋯ » menu). */ protected async image(action: DeckImageAction): Promise { const source = this.imageSource(); diff --git a/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.html b/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.html index e3e93964..0bca8488 100644 --- a/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.html +++ b/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.html @@ -1,12 +1,25 @@
-

Les préférences d’illustration des jetons sont communes à tous les decks : elles ne font pas partie de la liste de cartes de ce deck.

- @if (families(); as list) { - @for (f of list; track f.family.familyId + f.family.faction + f.family.rarity) { -
- @if (f.name) { -

{{ f.name }}

- } - +

Choisissez une illustration par jeton : elle s’applique à tous ses exemplaires, dans tous vos decks.

+ @if (tokens(); as list) { + @for (t of list; track t.key) { +
+ +
+

{{ t.name }}

+
+ @for (p of t.prints; track p.reference) { + + } +
+ {{ t.count }} + @if (error(); as e) { + @if (e.key === t.key) { + + } + } +
} @empty {

Aucun jeton n’a plus d’une illustration.

diff --git a/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.scss b/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.scss index 0bc3894d..eb00387e 100644 --- a/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.scss +++ b/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.scss @@ -8,8 +8,90 @@ font-size: var(--ac-font-size-small); color: var(--ac-color-text-muted); } +/* One token: its illustration large, then the illustrations to choose from. */ +.token { + display: flex; + gap: var(--ac-space-4); + padding: var(--ac-space-3); + border: 1px solid var(--ac-color-border); + border-radius: var(--ac-radius-card); + background: var(--ac-color-surface); +} +.preview { + flex-shrink: 0; + align-self: center; + width: 200px; + height: auto; + aspect-ratio: 5 / 7; + object-fit: cover; + border-radius: var(--ac-radius-control); + box-shadow: var(--ac-shadow-card); +} +.side { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--ac-space-2); +} h3 { - margin: 0 0 var(--ac-space-2); + margin: 0; font-size: var(--ac-font-size-body); - font-weight: 700; + font-weight: 800; +} +.prints { + display: flex; + flex-wrap: wrap; + gap: var(--ac-space-2); +} +.print { + width: 84px; + padding: 0; + border: 3px solid transparent; + border-radius: var(--ac-radius-md); + background: var(--ac-color-track); + cursor: pointer; + transition: transform 0.12s; +} +.print img { + display: block; + width: 100%; + aspect-ratio: 5 / 7; + object-fit: cover; + border-radius: var(--ac-radius-sm); +} +.print.on { + border-color: var(--ac-color-primary); + transform: translateY(-4px); +} +.print:disabled { + cursor: not-allowed; +} +.print:disabled img { + opacity: 0.35; +} +.print:focus-visible { + outline: 2px solid var(--ac-color-focus); + outline-offset: 2px; +} +.error { + margin: 0; + font-size: var(--ac-font-size-caption); + color: var(--ac-color-danger); +} +@media (max-width: 767px) { + .token { + gap: var(--ac-space-3); + } + .preview { + width: 110px; + } + .print { + width: 64px; + } +} +@media (prefers-reduced-motion: reduce) { + .print { + transition: none; + } } diff --git a/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.ts b/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.ts index 47f76285..65c8f0a4 100644 --- a/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.ts +++ b/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.ts @@ -1,32 +1,88 @@ -import { Component, inject } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; +import { Component, computed, inject, signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { catchError, of } from 'rxjs'; -import { OwnershipApiService } from '../../../core/ownership-api.service'; +import { slotPrints } from '../../../core/alt-art-defaults'; +import { cardImageUrl } from '../../../core/card-art'; +import { OwnershipApiService, familyKey, type AltArtChoice } from '../../../core/ownership-api.service'; import { AcButton } from '../../../ui/buttons'; import { AcOverlayRef, AcOverlayService } from '../../../ui/overlay'; -import { AltArtSlots } from '../../shared/alt-art-slots/alt-art-slots'; -/** « Illustrations des jetons »: the prints of the tokens, a preference shared by every deck (the site's window). */ +/** + * « Illustrations des jetons »: one illustration a token, shared by every deck (a token is not a deck card), saved as + * the player's preference on the ownership service at once. A large preview of the chosen one, then the others. + */ @Component({ selector: 'app-token-arts', - imports: [AltArtSlots, AcButton], + imports: [AcButton], host: { class: 'ac-overlay-content' }, templateUrl: './token-arts.overlay.html', styleUrl: './token-arts.overlay.scss', }) export class TokenArtsOverlay { protected readonly ref = inject(AcOverlayRef); + private readonly ownership = inject(OwnershipApiService); /** `undefined` while loading, `[]` when no token has several illustrations (or on an error). */ - protected readonly families = toSignal( - inject(OwnershipApiService) - .tokenAltArts() - .pipe(catchError(() => of([]))), - ); + private readonly families = toSignal(this.ownership.tokenAltArts().pipe(catchError(() => of([])))); + /** Illustrations chosen here, by family key (before the service confirms). */ + private readonly chosen = signal>>({}); + /** A token whose choice was refused, with the reason. */ + protected readonly error = signal<{ key: string; message: string } | null>(null); + private readonly saving = new Set(); + + protected readonly tokens = computed(() => { + const list = this.families(); + if (!list) return undefined; + const chosen = this.chosen(); + return list.map((f, i) => { + const key = familyKey(f.family); + const current = chosen[key] ?? slotPrints(f)[0] ?? f.options.options[0]?.reference ?? ''; + return { + key, + family: f, + name: f.name || tokenLabel(i + 1), + current, + src: cardImageUrl(current), + count: availableLabel(f.options.options.length), + prints: f.options.options.map((o, j) => ({ + reference: o.reference, + src: cardImageUrl(o.reference), + on: o.reference === current, + unowned: o.ownedQuantity === 0, + label: o.ownedQuantity === 0 ? notOwnedLabel(j + 1) : printLabel(j + 1), + })), + }; + }); + }); + + protected pick(token: { key: string; family: AltArtChoice; current: string }, reference: string): void { + if (reference === token.current || this.saving.has(token.key)) return; + const before = this.chosen(); + this.chosen.set({ ...before, [token.key]: reference }); + this.error.set(null); + this.saving.add(token.key); + this.ownership.setAltArtPreference(token.family.family, [reference]).subscribe({ + next: () => this.saving.delete(token.key), + complete: () => this.saving.delete(token.key), + error: (err: unknown) => { + this.saving.delete(token.key); + this.chosen.set(before); + this.error.set({ key: token.key, message: err instanceof HttpErrorResponse && err.status === 409 ? notEnoughCopies() : saveFailed() }); + }, + }); + } } +const tokenLabel = (n: number) => $localize`:@@editor.tokenArts.token:Jeton ${n}:n:`; +const printLabel = (n: number) => $localize`:@@altArt.tile:Illustration ${n}:n:`; +const notOwnedLabel = (n: number) => $localize`:@@editor.tokenArts.notOwned:Illustration ${n}:n: (non possédée)`; +const availableLabel = (n: number) => $localize`:@@editor.tokenArts.available:${n}:n: illustrations disponibles`; +const notEnoughCopies = () => $localize`:@@altArt.notEnoughCopies:Vous n’avez pas assez d’exemplaires de cet art alternatif.`; +const saveFailed = () => $localize`:@@altArt.saveError:Impossible d’enregistrer votre choix.`; + export function openTokenArts(overlay: AcOverlayService): AcOverlayRef { return overlay.open(TokenArtsOverlay, { title: $localize`:@@editor.tokenArts.title:Illustrations des jetons`, - width: 640, + width: 760, }); } diff --git a/plugins/rebuilder/app/src/app/features/search/card-search/card-search.html b/plugins/rebuilder/app/src/app/features/search/card-search/card-search.html index 9893d3fe..10257f00 100644 --- a/plugins/rebuilder/app/src/app/features/search/card-search/card-search.html +++ b/plugins/rebuilder/app/src/app/features/search/card-search/card-search.html @@ -70,6 +70,9 @@
{{ totalLabel() }} + @if (printsSwitch() && altArts; as a) { + + } @if (sortable()) { } @@ -96,6 +99,9 @@ } + @if (printsSwitch() && altArts; as a) { + + } @if (sortable()) { } diff --git a/plugins/rebuilder/app/src/app/features/search/card-search/card-search.ts b/plugins/rebuilder/app/src/app/features/search/card-search/card-search.ts index 199b8fb4..cd0dad54 100644 --- a/plugins/rebuilder/app/src/app/features/search/card-search/card-search.ts +++ b/plugins/rebuilder/app/src/app/features/search/card-search/card-search.ts @@ -10,7 +10,7 @@ import { AcButton, AcIconButton } from '../../../ui/buttons'; import { AcCount, AcFilterBar } from '../../../ui/chips'; import { AcDrawerHandle, AcDrawerState, AcDrawerTab } from '../../../ui/containers'; import { storedFlag } from '../../../core/stored-flag'; -import { AcInput, AcSegmented, AcSelect } from '../../../ui/fields'; +import { AcInput, AcSegmented, AcSelect, AcSwitch } from '../../../ui/fields'; import { AcBreakpointService } from '../../../ui/layout.services'; import { AcTabs } from '../../../ui/nav'; import { AcOverlayService } from '../../../ui/overlay'; @@ -19,6 +19,7 @@ import { openEffectEditor } from '../effect-editor/effect-editor.overlay'; import { FiltersPanel } from '../filters-panel/filters-panel'; import { openFiltersSheet } from '../filters-sheet/filters-sheet.overlay'; import { SearchResults } from '../search-results/search-results'; +import { EditorAltArts } from '../../editor/editor-alt-arts'; export interface CardSourceTab { id: CardSource; @@ -48,7 +49,7 @@ const TEXT_FILTERS = ['q', 'mainCost', 'recallCost', 'forestPower', 'mountainPow */ @Component({ selector: 'app-card-search', - imports: [AcTabs, AcSegmented, AcSelect, AcInput, AcIconButton, AcButton, AcCount, AcFilterBar, AcDrawerHandle, AcDrawerTab, FiltersPanel, SearchResults], + imports: [AcTabs, AcSegmented, AcSelect, AcInput, AcIconButton, AcButton, AcCount, AcFilterBar, AcDrawerHandle, AcDrawerTab, FiltersPanel, SearchResults, AcSwitch], host: { '[class.compact]': 'bp.compact()' }, templateUrl: './card-search.html', styleUrl: './card-search.scss', @@ -60,6 +61,8 @@ export class CardSearch { private readonly uniquesApi = inject(UniquesApiService); protected readonly bp = inject(AcBreakpointService); protected readonly search = inject(CardSearchStore); + /** Editor: « Arts alternatifs » (the alt arts the player owns, after their card). */ + protected readonly altArts = inject(EditorAltArts, { optional: true }); private readonly injector = inject(Injector); private readonly el = inject>(ElementRef).nativeElement; @@ -72,6 +75,7 @@ export class CardSearch { readonly ready = input(true); /** Card browser: faction filter, no quantity controls on the cards. */ readonly browse = input(false); + protected readonly printsSwitch = computed(() => !this.browse() && !!this.altArts?.enabled()); protected readonly source = toSignal( this.route.queryParamMap.pipe(map((q) => (q.get('source') as CardSource) || 'all')), diff --git a/plugins/rebuilder/app/src/app/features/search/search-results/search-results.html b/plugins/rebuilder/app/src/app/features/search/search-results/search-results.html index ef847918..93aee270 100644 --- a/plugins/rebuilder/app/src/app/features/search/search-results/search-results.html +++ b/plugins/rebuilder/app/src/app/features/search/search-results/search-results.html @@ -14,20 +14,21 @@
} @else { @if (layout() === 'grid') { - + @for (c of grid.slice(); track c.reference) { } @else {
- @for (c of search.cards(); track c.reference) { + @for (c of cards(); track c.reference) { }
diff --git a/plugins/rebuilder/app/src/app/features/search/search-results/search-results.ts b/plugins/rebuilder/app/src/app/features/search/search-results/search-results.ts index 2c6eab1e..ec22c751 100644 --- a/plugins/rebuilder/app/src/app/features/search/search-results/search-results.ts +++ b/plugins/rebuilder/app/src/app/features/search/search-results/search-results.ts @@ -1,4 +1,4 @@ -import { Component, computed, inject, input } from '@angular/core'; +import { Component, computed, effect, inject, input, untracked } from '@angular/core'; import { RouterLink } from '@angular/router'; import { isAccountSource } from '../../../core/card-filters'; import { addBlockedReason } from '../../../core/deck-rules'; @@ -13,6 +13,7 @@ import { AcOverlayService } from '../../../ui/overlay'; import { FavoritesService } from '../../../core/favorites.service'; import { openCardZoom } from '../../shared/card-zoom/card-zoom.overlay'; import { CardSearchStore } from '../card-search.store'; +import { EditorAltArts } from '../../editor/editor-alt-arts'; @Component({ selector: 'app-search-results', @@ -26,11 +27,45 @@ export class SearchResults { protected readonly auth = inject(AuthSession); private readonly overlay = inject(AcOverlayService); protected readonly favorites = inject(FavoritesService); + /** Editor: illustrations (default alt arts, « Arts alternatifs »). */ + private readonly altArts = inject(EditorAltArts, { optional: true }); readonly layout = input<'grid' | 'list'>('grid'); /** Card browser: cards without quantity controls. */ readonly browse = input(false); protected readonly accountTab = computed(() => isAccountSource(this.search.source())); protected readonly skeletons = computed(() => Array.from({ length: this.search.cards().length ? 3 : 6 })); + /** « Arts alternatifs » on: cards added as shown; off: a card's plain print stands for all its prints. */ + private readonly prints = computed(() => !this.browse() && !!this.altArts?.enabled() && this.altArts.searchPrints()); + /** The results, with the owned alt arts after their card when « Arts alternatifs » is on. */ + private readonly shown = computed(() => (this.prints() && this.altArts ? this.altArts.withOwnedPrints(this.search.cards()) : { cards: this.search.cards(), notes: new Map() })); + protected readonly cards = computed(() => this.shown().cards); + + constructor() { + // The families of the results: the copies a card counts and the alt arts the player owns. + effect(() => { + const cards = this.search.cards(); + if (!this.browse() && this.altArts?.enabled()) untracked(() => this.altArts?.request(cards.map((c) => c.reference))); + }); + } + + protected noteOf(card: Card): string | null { + return this.shown().notes.get(card.reference) ?? null; + } + + /** Copies in the deck: the whole family for a plain print with « Arts alternatifs » off, that print otherwise. */ + protected quantityOf(card: Card): number { + if (this.altArts && !this.prints() && this.altArts.isFamilyCard(card)) return this.altArts.familyQuantity(card); + return this.deck.quantities().get(card.reference) ?? 0; + } + + protected maxOf(card: Card): number { + return this.altArts ? this.altArts.maxFor(card, !this.prints()) : this.deck.maxFor(card); + } + + protected setQuantity(card: Card, quantity: number): void { + if (this.altArts) this.altArts.setQuantity(card, quantity, !this.prints()); + else this.deck.setQuantity(card, quantity); + } /** Why a card cannot be added in the deck's format (a Unique in a No Unique format). */ protected blockedReason(card: Card): string | null { @@ -42,7 +77,7 @@ export class SearchResults { const editable = !this.browse() && this.deck.editable() && !this.blockedReason(card); openCardZoom(this.overlay, { card, - quantity: editable ? { value: this.deck.quantities().get(card.reference) ?? 0, max: this.deck.maxFor(card), change: (n) => this.deck.setQuantity(card, n) } : undefined, + quantity: editable ? { value: this.quantityOf(card), max: this.maxOf(card), change: (n) => this.setQuantity(card, n) } : undefined, }); } diff --git a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.html b/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.html deleted file mode 100644 index 37eaa8ac..00000000 --- a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.html +++ /dev/null @@ -1,23 +0,0 @@ -
    - @for (t of tiles(); track t.reference) { -
  • - - @if (t.markers.length) { - - @if (single()) { - - } @else { - @for (m of t.markers; track m.slotIndex) { - - } - } - - } -
  • - } -
-@if (error(); as e) { - -} diff --git a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.scss b/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.scss deleted file mode 100644 index 43e0d629..00000000 --- a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.scss +++ /dev/null @@ -1,74 +0,0 @@ -:host { - display: block; -} -.strip { - margin: 0; - padding: 0 0 var(--ac-space-1); - list-style: none; - display: flex; - gap: var(--ac-space-2); - overflow-x: auto; -} -li { - position: relative; - flex-shrink: 0; -} -.tile { - display: block; - width: 64px; - padding: 0; - border: 0; - border-radius: var(--ac-radius-md); - overflow: hidden; - background: var(--ac-color-track); - cursor: pointer; -} -.tile img { - display: block; - width: 100%; - aspect-ratio: 5 / 7; - object-fit: cover; -} -.tile.unowned { - opacity: 0.35; - cursor: not-allowed; -} -.tile:focus-visible, -.marker:focus-visible { - outline: 2px solid var(--ac-color-primary); - outline-offset: 2px; -} -.markers { - position: absolute; - left: 50%; - bottom: var(--ac-space-1); - transform: translateX(-50%); - display: flex; - gap: 2px; -} -.marker { - width: 18px; - height: 18px; - padding: 0; - border: 1px solid var(--ac-color-on-strong); - border-radius: var(--ac-radius-pill); - background: var(--ac-color-overlay-control); - color: var(--ac-color-on-strong); - font-size: 10px; - font-weight: 700; -} -button.marker { - cursor: pointer; -} -span.marker { - display: grid; - place-items: center; -} -.marker.active { - background: var(--ac-color-primary); -} -.error { - margin: var(--ac-space-2) 0 0; - font-size: var(--ac-font-size-caption); - color: var(--ac-color-danger); -} diff --git a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.spec.ts b/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.spec.ts deleted file mode 100644 index 4f62b055..00000000 --- a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.spec.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { HttpErrorResponse } from '@angular/common/http'; -import { TestBed } from '@angular/core/testing'; -import { of, throwError } from 'rxjs'; -import { OwnershipApiService, type AltArtChoice } from '../../../core/ownership-api.service'; -import { AltArtSlots } from './alt-art-slots'; - -const CHOICE: AltArtChoice = { - family: { familyId: 7, faction: 'AX', rarity: 'COMMON' }, - options: { - options: [ - { reference: 'ALT_CORE_B_AX_04_C', ownedQuantity: null }, - { reference: 'ALT_CORE_A_AX_04_C', ownedQuantity: 2 }, - { reference: 'ALT_CORE_P_AX_04_C', ownedQuantity: 0 }, - ], - slots: [0, 1, 2].map((slotIndex) => ({ slotIndex, reference: 'ALT_CORE_B_AX_04_C' })), - }, -}; - -describe('AltArtSlots', () => { - function setup(result: 'ok' | 409, choice: AltArtChoice = CHOICE) { - const saved: string[][] = []; - TestBed.configureTestingModule({ - providers: [ - { - provide: OwnershipApiService, - useValue: { - setAltArtPreference: (_f: unknown, refs: string[]) => { - saved.push(refs); - return result === 'ok' ? of(undefined) : throwError(() => new HttpErrorResponse({ status: 409, error: [{ reference: 'ALT_CORE_A_AX_04_C', requested: 3, owned: 2 }] })); - }, - }, - }, - ], - }); - const fixture = TestBed.createComponent(AltArtSlots); - fixture.componentRef.setInput('choice', choice); - fixture.detectChanges(); - const el = fixture.nativeElement as HTMLElement; - return { fixture, el, saved }; - } - - it('moves the copies onto an owned illustration in order, from the first, and saves the slots', () => { - const { fixture, el, saved } = setup('ok'); - el.querySelectorAll('.tile')[1].click(); - fixture.detectChanges(); - expect(saved).toEqual([['ALT_CORE_A_AX_04_C', 'ALT_CORE_B_AX_04_C', 'ALT_CORE_B_AX_04_C']]); - expect([...el.querySelectorAll('li')[1].querySelectorAll('.marker')].map((m) => m.textContent?.trim())).toEqual(['1']); - expect(el.querySelectorAll('.tile')[2].disabled).toBe(true); - }); - - it('puts the copy back with the reason when the site refuses', () => { - const { fixture, el } = setup(409); - el.querySelectorAll('.tile')[1].click(); - fixture.detectChanges(); - expect(el.querySelectorAll('li')[1].querySelectorAll('.marker')).toHaveLength(0); - expect(el.querySelector('.error')?.textContent?.trim()).toBe('Vous n’avez pas assez d’exemplaires de cet art alternatif.'); - }); - - it('says the player lacks copies, without saving, when they pick an illustration once more than they own', () => { - const { fixture, el, saved } = setup('ok'); - const altArt = el.querySelectorAll('.tile')[1]; - altArt.click(); - fixture.detectChanges(); - altArt.click(); - fixture.detectChanges(); - expect(saved).toHaveLength(2); - altArt.click(); - fixture.detectChanges(); - expect(saved).toHaveLength(2); - expect([...el.querySelectorAll('li')[1].querySelectorAll('.marker')].map((m) => m.textContent?.trim())).toEqual(['1', '2']); - expect(el.querySelector('.error')?.textContent?.trim()).toBe('Vous n’avez pas assez d’exemplaires de cet art alternatif.'); - }); - - it('numbers the copies 1, 2, 3 whatever the API\'s slot indexes', () => { - const fromOne = { ...CHOICE, options: { ...CHOICE.options, slots: [1, 2, 3].map((slotIndex) => ({ slotIndex, reference: 'ALT_CORE_B_AX_04_C' })) } }; - const { el } = setup('ok', fromOne); - const markers = [...el.querySelectorAll('li:first-child .marker')]; - expect(markers.map((m) => m.textContent?.trim())).toEqual(['1', '2', '3']); - expect(markers.map((m) => m.getAttribute('aria-label'))).toEqual(['Exemplaire 1', 'Exemplaire 2', 'Exemplaire 3']); - }); - - it('shows one marker without a number for a family with one copy (token, hero)', () => { - const token = { ...CHOICE, options: { ...CHOICE.options, slots: [{ slotIndex: 1, reference: 'ALT_CORE_A_AX_04_C' }] } }; - const { fixture, el, saved } = setup('ok', token); - const marker = el.querySelector('li:nth-child(2) .marker'); - expect(marker?.textContent?.trim()).toBe(''); - expect(marker?.getAttribute('aria-label')).toBe('Illustration choisie'); - expect(el.querySelectorAll('.marker')).toHaveLength(1); - el.querySelectorAll('.tile')[0].click(); - fixture.detectChanges(); - expect(saved).toEqual([['ALT_CORE_B_AX_04_C']]); - expect(el.querySelector('li:nth-child(1) .marker')).not.toBeNull(); - }); -}); diff --git a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.ts b/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.ts deleted file mode 100644 index 6f5c5749..00000000 --- a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { Component, computed, inject, input, linkedSignal, signal } from '@angular/core'; -import { HttpErrorResponse } from '@angular/common/http'; -import { finalize } from 'rxjs'; -import { cardImageUrl } from '../../../core/card-art'; -import { OwnershipApiService, type AltArtChoice } from '../../../core/ownership-api.service'; -import { AcIcon } from '../../../ui/icon'; - -/** - * The illustrations of a card family with the player's copies on them (one marker a copy), as the site's alt-art - * widget in the card lightbox: an owned illustration takes the active marker, the active marker then moves on; the - * site saves the choice and a refusal puts the marker back, with the reason. Markers are numbered by copy (1, 2, 3), - * not by the API's `slotIndex`; a family with one copy (hero, token) has a single marker without a number. - */ -@Component({ - selector: 'app-alt-art-slots', - imports: [AcIcon], - templateUrl: './alt-art-slots.html', - styleUrl: './alt-art-slots.scss', -}) -export class AltArtSlots { - private readonly ownership = inject(OwnershipApiService); - readonly choice = input.required(); - protected readonly slots = linkedSignal(() => [...this.choice().options.slots].sort((a, b) => a.slotIndex - b.slotIndex)); - /** The first copy moves first, then the next ones (the site's widget), so successive picks fill copies 1, 2, 3. */ - protected readonly active = linkedSignal(() => this.slots()[0]?.slotIndex ?? 0); - protected readonly single = computed(() => this.slots().length === 1); - protected readonly error = signal(null); - /** A save is running: the tiles wait for it, so that saves never cross (a late refusal would undo a later choice). */ - protected readonly saving = signal(false); - protected readonly tiles = computed(() => { - const copies = this.slots().map((s, i) => ({ ...s, copy: i + 1 })); - return this.choice().options.options.map((o) => { - const markers = copies.filter((s) => s.reference === o.reference); - return { - reference: o.reference, - src: cardImageUrl(o.reference), - owned: o.ownedQuantity === null || o.ownedQuantity > 0, - /** Every owned copy of this illustration is already on a copy of the deck. */ - full: o.ownedQuantity !== null && markers.length >= o.ownedQuantity, - markers, - }; - }); - }); - protected readonly tileLabel = (n: number) => $localize`:@@altArt.tile:Illustration ${n}:n:`; - protected readonly markerLabel = (n: number) => $localize`:@@altArt.marker:Exemplaire ${n}:n:`; - protected readonly chosenLabel = $localize`:@@altArt.chosen:Illustration choisie`; - - /** An owned illustration: the active marker (or the next one not already there) moves onto it. */ - protected pick({ reference, owned, full }: { reference: string; owned: boolean; full: boolean }): void { - if (!owned || this.saving()) return; - const slots = this.slots(); - const start = Math.max(0, slots.findIndex((s) => s.slotIndex === this.active())); - let moved: { slotIndex: number; reference: string } | undefined; - for (let i = 0; i < slots.length; i++) { - const candidate = slots[(start + i) % slots.length]; - if (candidate.reference !== reference) { - moved = candidate; - break; - } - } - if (!moved) return; - if (full) { - this.error.set(notEnoughCopies()); - return; - } - const before = slots; - const beforeActive = this.active(); - const next = slots.map((s) => (s.slotIndex === moved.slotIndex ? { ...s, reference } : s)); - const index = next.findIndex((s) => s.slotIndex === moved.slotIndex); - this.slots.set(next); - this.active.set(next[(index + 1) % next.length].slotIndex); - this.error.set(null); - this.saving.set(true); - this.ownership.setAltArtPreference(this.choice().family, next.map((s) => s.reference)).pipe(finalize(() => this.saving.set(false))).subscribe({ - error: (err: unknown) => { - this.slots.set(before); - this.active.set(beforeActive); - this.error.set(saveError(err)); - }, - }); - } - - protected selectMarker(slotIndex: number, event: Event): void { - event.stopPropagation(); - this.active.set(slotIndex); - } -} - -const notEnoughCopies = () => $localize`:@@altArt.notEnoughCopies:Vous n’avez pas assez d’exemplaires de cet art alternatif.`; - -/** A refusal for copies the player does not own (409) says so; any other error, that the choice was not saved. */ -function saveError(err: unknown): string { - return err instanceof HttpErrorResponse && err.status === 409 ? notEnoughCopies() : $localize`:@@altArt.saveError:Impossible d’enregistrer votre choix.`; -} diff --git a/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.html b/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.html index 8addf639..24572340 100644 --- a/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.html +++ b/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.html @@ -1,33 +1,18 @@
-
- +
+
- - @if (altArts() || data.quantity || (data.illustrations && prints().length > 1)) { + + @if (data.quantity || data.illustrations) {
- @if (altArts(); as choice) { - - } @if (data.quantity; as q) {
Dans le deck
} - @if (data.illustrations && prints().length > 1) { - - @if (choosing()) { -
    - @for (p of prints(); track p.reference) { -
  • - -
  • - } -
- - } + @if (data.illustrations) { + }
} diff --git a/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.scss b/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.scss index e64cd66d..1dd7bcb9 100644 --- a/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.scss +++ b/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.scss @@ -23,14 +23,11 @@ /* Never smaller than 40 % of the screen: below that, the window scrolls. */ min-height: min(40dvh, 100cqw * 7 / 5); container-type: size; - perspective: 900px; } .card { display: block; width: min(100cqw, 100cqh * 5 / 7); margin-inline: auto; - transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)); - transition: transform 0.12s ease-out; } .qty { display: flex; @@ -40,37 +37,3 @@ font-size: var(--ac-font-size-small); font-weight: 700; } -.prints { - margin: 0; - padding: 0; - list-style: none; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(72px, 1fr)); - gap: var(--ac-space-2); -} -.print { - display: block; - width: 100%; - padding: 0; - border: 2px solid transparent; - border-radius: var(--ac-radius-md); - overflow: hidden; - background: var(--ac-color-track); - cursor: pointer; -} -.print img { - display: block; - width: 100%; - aspect-ratio: 5 / 7; - object-fit: cover; -} -.print.selected { - border-color: var(--ac-color-primary); -} -.print.unowned { - opacity: 0.5; -} -.print:focus-visible { - outline: 2px solid var(--ac-color-primary); - outline-offset: 2px; -} diff --git a/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.ts b/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.ts index a807304b..d7fc749e 100644 --- a/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.ts +++ b/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.ts @@ -1,80 +1,51 @@ import { Component, computed, inject, signal } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { map, of, switchMap } from 'rxjs'; import { environment } from '../../../../environments/environment'; import { contentLocale } from '../../../core/locale'; -import { cardImageUrl } from '../../../core/card-art'; import type { Card } from '../../../core/models'; import { localizedText } from '../../../core/models'; import { AcButton, AcStepper } from '../../../ui/buttons'; import { AcCardTile } from '../../../ui/metier'; -import { OwnershipApiService, type AltArtChoice } from '../../../core/ownership-api.service'; -import { AltArtSlots } from '../alt-art-slots/alt-art-slots'; import { AcOverlayRef, AcOverlayService } from '../../../ui/overlay'; +import { openAltArtPicker, type AltArtPickerData } from '../../editor/alt-art-picker/alt-art-picker.overlay'; export interface CardZoomData { card: Card; /** Editor: copies in the deck, changed from the window (as the site's deck builder lightbox). */ quantity?: { value: number; max: number; change: (quantity: number) => void }; - /** Editor, per-deck alt-art mode: « Choisir une illustration » for a card of the deck (the site's lightbox). */ - illustrations?: { choice: AltArtChoice; pick: (reference: string) => void }; + /** Editor, a card with several illustrations: « Choisir les illustrations » opens its brush in place of this window. */ + illustrations?: AltArtPickerData; } /** A card shown large, with a link to its sheet on the site (the site's card lightbox); in the editor, its copies. */ @Component({ selector: 'app-card-zoom', - imports: [AcCardTile, AcStepper, AcButton, AltArtSlots], + imports: [AcCardTile, AcStepper, AcButton], host: { class: 'ac-overlay-content' }, templateUrl: './card-zoom.overlay.html', styleUrl: './card-zoom.overlay.scss', }) export class CardZoomOverlay { private readonly ref = inject>(AcOverlayRef); - private readonly ownership = inject(OwnershipApiService); + private readonly overlay = inject(AcOverlayService); protected readonly data = this.ref.data; - /** - * « Global » alt-art preference (as on the site): the card tilts under the pointer and its illustrations show with the - * player's copies, `null` otherwise or for a card with one illustration. - */ - private readonly global = toSignal(this.ownership.globalAltArts(), { initialValue: false }); - protected readonly altArts = toSignal( - this.ownership.globalAltArts().pipe( - switchMap((global) => (global ? this.ownership.altArtChoices([this.data.card.reference]) : of>({}))), - map((choices) => choices[this.data.card.reference] ?? null), - ), - { initialValue: null }, - ); - protected readonly tilt = signal({ x: 0, y: 0 }); - protected readonly choosing = signal(false); - protected readonly chosen = signal(this.data.card.reference); - protected readonly prints = computed(() => - (this.data.illustrations?.choice.options.options ?? []).map((o) => ({ reference: o.reference, src: cardImageUrl(o.reference), unowned: o.ownedQuantity === 0 })), - ); - protected readonly printLabel = (n: number) => $localize`:@@altArt.tile:Illustration ${n}:n:`; - - /** Unowned prints stay selectable: Board Game Arena shows the base art for the missing copies (as on the site). */ - protected confirmPrint(): void { - const ref = this.chosen(); - if (ref !== this.data.card.reference) this.data.illustrations?.pick(ref); - this.ref.close(); - } protected readonly quantity = signal(this.data.quantity?.value ?? 0); protected readonly name = computed(() => localizedText(this.data.card.name, contentLocale()) || this.data.card.reference); /** The site's card sheet (`/pages/card`, plugin core-altered-cards). */ protected readonly detailUrl = `${environment.siteUrl.replace(/\/$/, '')}/pages/card?ref=${encodeURIComponent(this.data.card.reference)}&card_lang=${contentLocale()}`; - protected onTilt(event: PointerEvent, el: HTMLElement): void { - if (!this.global() || event.pointerType === 'touch') return; - const r = el.getBoundingClientRect(); - const x = (event.clientX - r.left) / r.width - 0.5; - const y = (event.clientY - r.top) / r.height - 0.5; - this.tilt.set({ x: Math.round(-y * 16), y: Math.round(x * 16) }); - } - protected setQuantity(n: number): void { this.quantity.set(n); this.data.quantity?.change(n); } + + /** The brush is a window of its own (the zoom has no frame for it): the zoom closes first, never two windows. */ + protected chooseIllustrations(): void { + const data = this.data.illustrations; + if (!data) return; + const name = this.name(); + this.ref.close(); + openAltArtPicker(this.overlay, name, data); + } } export function openCardZoom(overlay: AcOverlayService, data: CardZoomData): AcOverlayRef { diff --git a/plugins/rebuilder/app/src/app/features/shared/hero-picker/hero-picker.overlay.html b/plugins/rebuilder/app/src/app/features/shared/hero-picker/hero-picker.overlay.html index 695817d7..1977cdc9 100644 --- a/plugins/rebuilder/app/src/app/features/shared/hero-picker/hero-picker.overlay.html +++ b/plugins/rebuilder/app/src/app/features/shared/hero-picker/hero-picker.overlay.html @@ -3,14 +3,12 @@

Le héros détermine votre faction et les cartes disponibles.

} - @if (!global()) { -
- - @if (altArts()) { - - } -
- } +
+ + @if (altArts()) { + + } +
>(AcOverlayRef); protected readonly bp = inject(AcBreakpointService); private readonly load = injectHeroes(); protected readonly altArts = signal(false); protected readonly serialized = signal(false); protected readonly heroes = computed(() => heroChoices(this.load.heroes(), { altArts: this.altArts(), serialized: this.serialized() })); - /** « Alt arts » is hidden in « Global » alt-art mode: the preferred print comes from the player's preferences. */ - protected readonly global = toSignal(this.ownership.globalAltArts(), { initialValue: false }); protected readonly error = this.load.error; protected readonly picked = signal(this.ref.data?.selected ?? null); protected readonly faction = signal(this.ref.data?.selected?.faction || 'AX'); @@ -107,7 +103,8 @@ export class HeroPickerOverlay { confirm(): void { const p = this.picked(); - if (p) this.ref.close({ reference: p.reference, name: p.name, faction: p.faction }); + // Without « Alt arts », the hero takes the player's default alt art. + if (p) this.ref.close({ reference: p.reference, name: p.name, faction: p.faction, ...(this.altArts() ? {} : { defaultArt: true }) }); } } diff --git a/plugins/rebuilder/app/src/app/features/shared/new-deck.form.ts b/plugins/rebuilder/app/src/app/features/shared/new-deck.form.ts index c89cfd32..ddc5b815 100644 --- a/plugins/rebuilder/app/src/app/features/shared/new-deck.form.ts +++ b/plugins/rebuilder/app/src/app/features/shared/new-deck.form.ts @@ -24,9 +24,10 @@ export class NewDeckForm { readonly ready = computed(() => !!this.hero() && !!this.name().trim()); private generatedName = ''; - selectHero(hero: DeckHero | null): void { + /** `defaultArt`: picked without « Alt arts », the hero takes the player's default alt art. */ + selectHero(hero: DeckHero | null, defaultArt = false): void { if (!hero) return; - this.hero.set({ reference: hero.reference, name: hero.name, faction: hero.faction }); + this.hero.set({ reference: hero.reference, name: hero.name, faction: hero.faction, ...(defaultArt ? { defaultArt } : {}) }); const current = this.name(); if (!current.trim() || current === this.generatedName) { this.generatedName = $localize`:@@newDeck.defaultName:Deck ${hero.name}:hero:`; diff --git a/plugins/rebuilder/app/src/app/features/shared/new-deck.spec.ts b/plugins/rebuilder/app/src/app/features/shared/new-deck.spec.ts index e17e3ce3..4e897b07 100644 --- a/plugins/rebuilder/app/src/app/features/shared/new-deck.spec.ts +++ b/plugins/rebuilder/app/src/app/features/shared/new-deck.spec.ts @@ -131,7 +131,8 @@ describe('NewDeckOverlay', () => { input.value = 'Mon deck'; input.dispatchEvent(new Event('input')); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); - expect(closed).toEqual([{ name: 'Mon deck', hero: TREYST, format: 'standard', isPublic: false, description: '' }]); + // Picked without « Alt arts »: the deck takes the player's default alt art for the hero. + expect(closed).toEqual([{ name: 'Mon deck', hero: { ...TREYST, defaultArt: true }, format: 'standard', isPublic: false, description: '' }]); }); }); diff --git a/plugins/rebuilder/app/src/app/features/shared/new-deck/new-deck.overlay.html b/plugins/rebuilder/app/src/app/features/shared/new-deck/new-deck.overlay.html index edeb49a7..8f217a5e 100644 --- a/plugins/rebuilder/app/src/app/features/shared/new-deck/new-deck.overlay.html +++ b/plugins/rebuilder/app/src/app/features/shared/new-deck/new-deck.overlay.html @@ -13,14 +13,12 @@ controls="new-deck-heroes" [(active)]="faction" /> - @if (!global()) { -
- - @if (altArts()) { - - } -
- } +
+ + @if (altArts()) { + + } +
diff --git a/plugins/rebuilder/app/src/app/features/shared/new-deck/new-deck.overlay.ts b/plugins/rebuilder/app/src/app/features/shared/new-deck/new-deck.overlay.ts index 88704b33..86fc2401 100644 --- a/plugins/rebuilder/app/src/app/features/shared/new-deck/new-deck.overlay.ts +++ b/plugins/rebuilder/app/src/app/features/shared/new-deck/new-deck.overlay.ts @@ -7,8 +7,6 @@ import { AcBreakpointService } from '../../../ui/layout.services'; import { AcFactionTabs, AcHeroSelector, factionName } from '../../../ui/metier'; import { AcOverlayRef, AcOverlayService } from '../../../ui/overlay'; import { heroChoices, injectHeroes } from '../hero-picker/hero-picker.overlay'; -import { OwnershipApiService } from '../../../core/ownership-api.service'; -import { toSignal } from '@angular/core/rxjs-interop'; import { NewDeckForm, type NewDeckResult } from '../new-deck.form'; export type { NewDeckResult } from '../new-deck.form'; @@ -34,7 +32,6 @@ export const VISIBILITY_OPTIONS = [ styleUrl: './new-deck.overlay.scss', }) export class NewDeckOverlay { - private readonly ownership = inject(OwnershipApiService); private readonly destroyRef = inject(DestroyRef); protected readonly ref = inject>(AcOverlayRef); protected readonly bp = inject(AcBreakpointService); @@ -50,7 +47,6 @@ export class NewDeckOverlay { protected readonly altArts = signal(false); protected readonly serialized = signal(false); protected readonly heroes = computed(() => heroChoices(this.load.heroes(), { altArts: this.altArts(), serialized: this.serialized() })); - protected readonly global = toSignal(this.ownership.globalAltArts(), { initialValue: false }); protected readonly error = this.load.error; readonly form = new NewDeckForm(); diff --git a/plugins/rebuilder/app/src/app/ui/icon/icon.ts b/plugins/rebuilder/app/src/app/ui/icon/icon.ts index 84cd90e6..d2b5d64b 100644 --- a/plugins/rebuilder/app/src/app/ui/icon/icon.ts +++ b/plugins/rebuilder/app/src/app/ui/icon/icon.ts @@ -26,6 +26,9 @@ export const AC_ICONS = { pencil: '', 'sliders-horizontal': '', moon: '', + brush: '', + 'wand-sparkles': '', + images: '', layers: '', 'layout-grid': '', menu: '', diff --git a/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.html b/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.html index 41dfdd63..e40c68a9 100644 --- a/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.html +++ b/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.html @@ -14,6 +14,11 @@ @if (issues().length && quantity() > 0) { } +@if (illustrations()) { + +} @if (quantity() > 1) { } diff --git a/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.scss b/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.scss index 0dd02ea4..1fd6286f 100644 --- a/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.scss +++ b/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.scss @@ -74,6 +74,37 @@ font: var(--ac-font-caption); pointer-events: none; } +/* Brush: top right of the front card, always shown (the card has several illustrations). */ +.illustrate { + position: absolute; + top: calc(var(--pile-behind, 0) * var(--pile-offset) + var(--ac-space-1-5)); + right: var(--ac-space-1-5); + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + border-radius: var(--ac-radius-md); + background: var(--ac-color-surface); + color: var(--ac-color-primary); + box-shadow: var(--ac-shadow-fab); + cursor: pointer; +} +.illustrate::after { + content: ''; + position: absolute; + left: 50%; + top: 50%; + width: max(100%, var(--ac-hit-min)); + height: max(100%, var(--ac-hit-min)); + transform: translate(-50%, -50%); +} +.illustrate:focus-visible { + outline: 2px solid var(--ac-color-primary); + outline-offset: 2px; +} /* The stepper: over the card while it is hovered or holds the focus (always shown on touch screens). */ .stepper { position: absolute; diff --git a/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.ts b/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.ts index 42098a3b..84b24ef1 100644 --- a/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.ts +++ b/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.ts @@ -22,6 +22,7 @@ import { AcUniqueCard } from '../unique-card/unique-card'; '[class.zero]': 'quantity() === 0', '[class.issue]': 'issues().length > 0 && quantity() > 0', '[class.editable]': '!readonly()', + '[style.--pile-behind]': 'behind().length', }, templateUrl: './card-pile.html', styleUrl: './card-pile.scss', @@ -36,6 +37,9 @@ export class AcCardPile { readonly quantityChange = output(); /** The card was clicked: the page opens it large. */ readonly zoom = output(); + /** Brush over the front card (a card with several illustrations, in an editable deck): emits `illustrate`. */ + readonly illustrations = input(false); + readonly illustrate = output(); protected readonly name = computed(() => localizedText(this.card().name, contentLocale()) || this.card().reference); protected readonly unique = computed(() => isUniqueReference(this.card().reference)); @@ -49,4 +53,5 @@ export class AcCardPile { }); protected readonly copiesLabel = computed(() => $localize`:@@ui.cardTile.copiesInDeck:Exemplaires de ${this.name()}:name: dans le deck`); protected readonly issueLabel = computed(() => this.issues().join(' · ')); + protected readonly illustrateLabel = computed(() => $localize`:@@ui.cardTile.illustrate:Choisir les illustrations de ${this.name()}:name:`); } diff --git a/plugins/rebuilder/app/src/app/ui/metier/card-tile/card-tile.html b/plugins/rebuilder/app/src/app/ui/metier/card-tile/card-tile.html index f6bd00af..bbad3ab1 100644 --- a/plugins/rebuilder/app/src/app/ui/metier/card-tile/card-tile.html +++ b/plugins/rebuilder/app/src/app/ui/metier/card-tile/card-tile.html @@ -6,14 +6,22 @@ } @if (zoomable()) { - } @else { -
+
} +@if (note(); as n) { + {{ n }} +} +@if (illustrations()) { + +} @if (favorite() !== null) {
diff --git a/plugins/rebuilder/app/AGENTS.md b/plugins/rebuilder/app/AGENTS.md index 4a5b2ce7..8a50cf76 100644 --- a/plugins/rebuilder/app/AGENTS.md +++ b/plugins/rebuilder/app/AGENTS.md @@ -104,7 +104,9 @@ composants s'adaptent à la taille d'écran et à la densité : pas d'écrans « 1. `npm run build` et `npm run lint` sans erreur, `npm test` vert ; `php tests/run.php` à la racine du repo (couleurs en dur, points de rupture). 2. E2E dans le site (`tests/e2e` + `plugins/rebuilder/e2e`) sur la stack `docker-compose.stack.yml` : voir - `../README.md`. + `../README.md`. Un changement de ce qui s'affiche pendant un chargement (squelette, données ou images qui arrivent + après le premier écran) : `npm run loading` et une timeline avant / après (`npm run loading:timeline`) dans + `tests/e2e`, voir `AGENTS.md` à la racine § Checks. 3. Capture Playwright de l'écran embarqué à 1440×900 et 390×844. 4. Recherche de valeurs en dur (`#[0-9a-f]{3,6}`, `px` de hauteur de contrôle) hors `design-system/tokens/tokens.css`. diff --git a/plugins/rebuilder/app/design/COMPONENTS.md b/plugins/rebuilder/app/design/COMPONENTS.md index 2babe668..73dab62c 100644 --- a/plugins/rebuilder/app/design/COMPONENTS.md +++ b/plugins/rebuilder/app/design/COMPONENTS.md @@ -101,7 +101,7 @@ Classes et états des champs et du contrôle segmenté : `design-system/docs/com | Composant | Entrées | Écrans | |---|---|---| | `ac-card`, CSS DS (`card.css`) | `padding: 'md' \| 'sm' \| 'none'` | panneaux, sections de deck | -| `[acSkeleton]`, CSS DS (`feedback.css` : `ac-skeleton`) | `acSkeleton`: `''` (bloc) \| `text` \| `title` \| `control` \| `control-sm` \| `circle` \| `card` \| `panel`, `width?` (`--ac-skeleton-width`) ; `aria-hidden` (la zone qui charge porte `aria-busy` et un texte `role=status`) | barre du deck, vues de l’éditeur et de la page deck pendant l’ouverture du deck (`app-hand-skeleton` pour la main de départ), résultats de recherche, actions tant que le propriétaire est inconnu | +| `[acSkeleton]`, CSS DS (`feedback.css` : `ac-skeleton`) | `acSkeleton`: `''` (bloc) \| `text` \| `title` \| `control` \| `control-sm` \| `badge` \| `circle` \| `card` \| `panel`, `width?` (`--ac-skeleton-width`) ; `aria-hidden` (la zone qui charge porte `aria-busy` et un texte `role=status`) | barre du deck, vues de l’éditeur et de la page deck pendant l’ouverture du deck (`app-hand-skeleton` pour la main de départ), résultats de recherche, actions tant que le propriétaire est inconnu | | `ac-progress-bar`, CSS DS (`feedback.css` : `ac-progress`) | `value` (0–100), `ariaLabel`, `tone` (`primary` \| `success` : plafond atteint \| `danger` : dépassé) ; `role=progressbar` | import de l'export altered.gg, résumé du deck (raretés) | | `ac-toast` | contenu projeté, `actionLabel?` ; `role=status`, en bas au centre (au-dessus de la barre de navigation en compact), fond inversé | `action` | ajout / retrait d’une carte dans l’éditeur (« Annuler ») | | `ac-collapsible` | `title`, `open = model(false)`, contenu projeté | Stats (ouvertes par défaut : `[(open)]` à `true`) | diff --git a/plugins/rebuilder/app/src/app/core/deck-store.ts b/plugins/rebuilder/app/src/app/core/deck-store.ts index 9989a25d..6d577a1c 100644 --- a/plugins/rebuilder/app/src/app/core/deck-store.ts +++ b/plugins/rebuilder/app/src/app/core/deck-store.ts @@ -157,6 +157,12 @@ export class DeckStore { return this.mineIds.hasValue() ? this.mineIds.value().has(id) : null; }); readonly editable = computed(() => this.owned() === true); + /** + * The editor draws the quantity steppers unless the deck is known not to be the user's: its owner (the account's deck + * ids) often comes after the deck, and a read-only « ×1 » replaced by the steppers a moment later looks broken. A + * change waits for `editable()` all the same (setQuantity…). + */ + readonly steppersShown = computed(() => this.owned() !== false); private saveTimer: ReturnType | null = null; /** Bumped by each change; a save only marks the deck clean when no change came after what it sent. */ diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.ts b/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.ts index 1892e5db..dec394b5 100644 --- a/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.ts +++ b/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.ts @@ -36,7 +36,8 @@ export class DeckBoard { /** The board's width, measured: the number of card columns follows it. */ private readonly width = signal(0); - protected readonly editable = computed(() => !this.readonly() && this.deck.editable()); + /** Steppers on the piles (the editor's board): DeckStore.steppersShown. */ + protected readonly editable = computed(() => !this.readonly() && this.deck.steppersShown()); /** Cards brought to 0 with a pile's stepper since this view opened, by reference: they keep their place, faded. */ private readonly removed = signal(new Map()); /** The groups, sorted: recomputed when the cards change, not when the board is resized. */ @@ -78,6 +79,7 @@ export class DeckBoard { * illustration swapped in the zoom, the preferred prints of Global mode) leaves no faded pile behind. */ protected setQuantity(card: Card, quantity: number): void { + if (!this.deck.editable()) return; this.deck.setQuantity(card, quantity); this.removed.update((map) => { const next = new Map(map); diff --git a/plugins/rebuilder/app/src/app/features/deck/decklist-table/decklist-table.html b/plugins/rebuilder/app/src/app/features/deck/decklist-table/decklist-table.html index ef666eb4..5db3da11 100644 --- a/plugins/rebuilder/app/src/app/features/deck/decklist-table/decklist-table.html +++ b/plugins/rebuilder/app/src/app/features/deck/decklist-table/decklist-table.html @@ -13,7 +13,7 @@

{{ group().label }}

@for (r of rows(); track r.ref) {
×{{ r.qty }} - + {{ r.main }} {{ r.res }} diff --git a/plugins/rebuilder/app/src/app/features/decks/new-deck-page/new-deck.page.scss b/plugins/rebuilder/app/src/app/features/decks/new-deck-page/new-deck.page.scss index 2b4ab9dd..5e0d534e 100644 --- a/plugins/rebuilder/app/src/app/features/decks/new-deck-page/new-deck.page.scss +++ b/plugins/rebuilder/app/src/app/features/decks/new-deck-page/new-deck.page.scss @@ -1,5 +1,7 @@ +/* The page's height, also on phones where the form covers the screen and the backdrop stays empty: the page has a first + screen in the flow, which tells the site's runtime to take the server's skeleton away (js/altered-core-host.js). */ .skeleton { - display: none; + height: calc(100dvh - var(--ac-sticky-top)); } @media (min-width: 768px) { .skeleton { @@ -7,7 +9,6 @@ flex-direction: column; gap: var(--ac-space-4); padding: var(--ac-space-6); - height: calc(100dvh - var(--ac-sticky-top)); } .bar { height: 34px; diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.html b/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.html index be5ee0dc..088dc205 100644 --- a/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.html +++ b/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.html @@ -4,6 +4,9 @@ } + @if (!readonly() && altArts && altArts.tokensChoice() !== false) { + + } '; }; -/** Phones: app bar (back, title and subtitle, icon buttons). */ -$appBar = function (int $icons, bool $back = true) use ($sk): string { - return '
' . ($back ? $sk('ac-skeleton--circle rbph-icon') : '') - . '
' . $sk('ac-skeleton--title', 1, '65%') . ($back ? $sk('ac-skeleton--text', 1, '45%') : '') . '
' - . $sk('ac-skeleton--circle rbph-icon', $icons) . '
'; +/** + * Phones: app bar as the app draws it while the page opens (ui/nav/app-bar, editor and deck pages): the back button, + * the hero in skeleton (editor, search and « Aperçu »), `$titles`, the editor's save status (an empty slot while idle), + * `$icons` actions in skeleton. + */ +$appBar = function (string $titles, int $icons, bool $back = true, bool $hero = false, bool $save = false) use ($sk): string { + return '
' + . ($back ? '' . ac_icon('chevron-left') . '' : '') + . ($hero ? '' : '') + . '
' . $titles . '
' + . '
' . ($save ? '' : '') . $sk('ac-skeleton--circle rbph-icon', $icons) . '
'; }; +/** App bar titles: a known title, or the deck's name over its rarities (three badges) or over a line of text. */ +$barTitle = fn(string $text): string => '' . h($text) . ''; +$barDeck = fn(bool $rarities): string => $sk('ac-skeleton--title', 1, '70%') + . ($rarities ? '' . $sk('ac-skeleton--badge', 3, '52px') . '' : $sk('ac-skeleton--text', 1, '50%')); /** Phones: bottom navigation with `$n` entries. */ $bottomNav = function (int $n) use ($sk): string { return '
' . str_repeat('' . $sk('ac-skeleton--circle') . $sk('ac-skeleton--text') . '', $n) . '
'; @@ -75,7 +85,9 @@ return '
' . $sk('ac-skeleton--text', 1, '140px') . '
' . $sk('ac-skeleton--card', 4) . '
'; }; ?> - + here holds the paint of everything after it until it downloads, the site header + // alone on screen meanwhile on a first visit. ?> +
KeyDescription
pages[].typeoptionalphp (default) or spa. An SPA page has no file.
pages[].entryspaBuild manifest written by the plugin's build (relative to the plugin): {"version":1, "base":"browser/", "js":["main-HASH.js"], "css":["embed.css?v=…"], "documentCss":["document.css?v=…"]}. js: ES modules, in order. css: loaded inside the shadow root. documentCss: loaded in <head> (@font-face is ignored in a shadow root).
pages[].entryspaBuild manifest written by the plugin's build (relative to the plugin): {"version":1, "base":"browser/", "js":["main-HASH.js"], "css":["embed.css?v=…"], "documentCss":["document.css?v=…"]}. js: ES modules, in order. css: loaded inside the shadow root. documentCss: loaded in <head> (@font-face is ignored in a shadow root). Optional preload: {"*":["chunk-HASH.js"], "{slug}":[…], "lang:en":[…]}, the modules a page needs before it can draw (those its entry modules import, directly or not), requested together with the entry modules and the css files (<link rel="modulepreload">, added by an inline script right after the placeholder: it runs once the stylesheets above it have loaded, so the skeleton's first paint keeps their bandwidth): * on every page, {slug} on the page served at that slug, lang:… in that interface language. Without it, the browser finds the chunks one import at a time, one round trip per level.
pages[].mountoptionalshadow (default): the plugin renders in a Shadow DOM, so Bootstrap and css/style.css do not reach it and its styles do not reach the site. light: rendered in the page.
pages[].placeholderoptionalSPA: PHP file rendered by the server inside the mount point, with $slug (the requested page) and $subPath in scope: what the page shows while its scripts load, usually a skeleton of the screen built with ac-skeleton (design system, feedback). The runtime keeps it in view until the plugin draws its first screen, so a slow connection never shows an empty page. Without it, a generic skeleton (rebuilder: a skeleton of the decks list, deck page or editor, desktop and phone).
pages[].metaoptionalSPA: PHP file included before the site header, with $subPath (the client route under /pages/{slug}/) in scope. It may set $pageTitle, $pageDescription and $pageImage, so a client route has its own title and link preview (rebuilder: the deck's name on decks/{id}).
pages[].typeoptionalphp (default) or spa. An SPA page has no file.
pages[].entryspaBuild manifest written by the plugin's build (relative to the plugin): {"version":1, "base":"browser/", "js":["main-HASH.js"], "css":["embed.css?v=…"], "documentCss":["document.css?v=…"]}. js: ES modules, in order. css: loaded inside the shadow root. documentCss: loaded in <head> (@font-face is ignored in a shadow root). Optional preload: {"*":["chunk-HASH.js"], "{slug}":[…], "lang:en":[…]}, the modules a page needs before it can draw (those its entry modules import, directly or not), requested together with the entry modules and the css files (<link rel="modulepreload">, added by an inline script right after the placeholder: it runs once the stylesheets above it have loaded, so the skeleton's first paint keeps their bandwidth): * on every page, {slug} on the page served at that slug, lang:… in that interface language. Without it, the browser finds the chunks one import at a time, one round trip per level.
pages[].mountoptionalshadow (default): the plugin renders in a Shadow DOM, so Bootstrap and css/style.css do not reach it and its styles do not reach the site. light: rendered in the page.
pages[].placeholderoptionalSPA: PHP file rendered by the server inside the mount point, with $slug (the requested page) and $subPath in scope: what the page shows while its scripts load, usually a skeleton of the screen built with ac-skeleton (design system, feedback). The runtime keeps it in view until the plugin draws its first screen, so a slow connection never shows an empty page. Without it, a generic skeleton (rebuilder: a skeleton of the decks list, deck page or editor, desktop and phone).
pages[].placeholderoptionalSPA: PHP file rendered by the server inside the mount point, with $slug (the requested page) and $subPath in scope: what the page shows while its scripts load, usually a skeleton of the screen built with ac-skeleton (design system, feedback). The runtime keeps it in view until the plugin draws its first screen, so a slow connection never shows an empty page. It goes once the mount's container takes some height: the first screen needs content in the page flow (its own skeleton while its data loads), not only a fixed or absolute overlay. Without it, a generic skeleton (rebuilder: a skeleton of the decks list, deck page or editor, desktop and phone).
pages[].metaoptionalSPA: PHP file included before the site header, with $subPath (the client route under /pages/{slug}/) in scope. It may set $pageTitle, $pageDescription and $pageImage, so a client route has its own title and link preview (rebuilder: the deck's name on decks/{id}).
pages[].fullwidthoptionalFull-width layout. Default true for SPA pages; false keeps the page in the site's content width (rebuilder).
menuoptionalSuggested navigation entries, inserted on activation when no item has the same URL. Admins edit or hide them afterwards (Admin → Navigation). An entry targets page (URL /pages/{page}), plus path for a client route of an SPA page (/pages/{page}/{path}), or an explicit url. An entry with children is a dropdown (no URL of its own); its missing children are added under it. An entry with parent_url goes inside the existing top-level menu that has this URL (e.g. /pages/decks), last unless sort_order is set; at the top level when the site has no such menu.
+ + + + + + + + + + + $c): + $uses = count(array_filter($spots, function (array $s) use ($cid): bool { return $s['creature'] === $cid; })); ?> + + + + + + + + + +
ImagesIdNameSpotsActions
+ + + <?= h($variant) ?> + + No image: cotton ball + + + + Edit + +
+ + + + +
+ +
+ +
+ +
+

+ + + +
+
+ + > +

Lowercase letters, digits, dashes. Not shown to players.

+
+
+ + +
+
+ + +
+
+
+
+

+ Square images with a transparent background. Saved as px WebP copies in + uploads// when the server can. +

+
+ + Cancel +
+
+ + +

Spots

+
+ +

No spot yet.

+ + + + + + + + + + + + + + + + $s): ?> + + + + + + + + + + + + +
IdCreaturePagesTriggerCodePlayersChangedActions
(missing)' ?> + Edit +
+ + + + +
+
+ +
+ + +
+

+ + + +
+
+ + > +

Finds are recorded by spot id: keep it once players have found the spot.

+
+
+ + +
+
+ + +

Empty: no code, the hunt alone.

+
+
+ + +

+ Page slugs separated by spaces: index for the home page, the end of /pages/<slug> + otherwise. *: every page (a link of the menu). +

+
+
+ + +
+
+ + +

+ Event: the name a page sends (test-hand: Re:Builder's « Main de départ » tab; + tournament-heroes: the heroes « Détail » of a tournament). Link: the start of its address. +

+
+
+
+ + Cancel +
+
+ + + +

Check a player

+
+ + + + +
+ +

No account with this email or username.

+ + +

+ + + · / +

+
+ + + + + + + + + +
+
+ + +

Found them all ()

+
+ +

Nobody yet.

+ + + + + + + + + + + + + + + + + + +
PlayerEmailCompleted
+ +
diff --git a/plugins/hidden-creatures/assets/cotton-fall.js b/plugins/hidden-creatures/assets/cotton-fall.js new file mode 100644 index 00000000..cf8991bf --- /dev/null +++ b/plugins/hidden-creatures/assets/cotton-fall.js @@ -0,0 +1,505 @@ +/* + * cotton-fall 🌾 — effet décoratif "drop-in". + * Des boules de coton (PNG) tombent lentement le long de la PAGE (pas de l'écran), + * avec dérive latérale + rotation erratiques. Au clic : swap PNG + poussée vers le + * haut + spin inversé amorti, puis retour à l'état standard. + * + * Intégration : + * + * + * + * Web Component autonome + Shadow DOM → zéro conflit de styles avec le site hôte. + * + * Copie AlteredCore (plugins/hidden-creatures), changements marqués « AlteredCore » : + * - attribut `once` : chaque boule traverse une fois puis disparaît (pas de retour en + * haut) ; la dernière partie, l'élément émet `cotton-fall-end` et se retire ; + * - attribut `spawn="viewport"` : les boules partent du haut de l'écran visible + * (au lieu d'être étalées sur toute la page) ; + * - l'overlay est replié avant chaque mesure, sinon une page qui raccourcit garde + * sa hauteur (l'overlay comptait dans scrollHeight). + */ +(() => { + 'use strict'; + + // ──────────────────────────────────────────────────────────────────────── + // 5. Bloc de constantes unique — TOUT se règle ici. + // (Chaque valeur est aussi surchargeable par attribut / objet JS, cf. §2.) + // ──────────────────────────────────────────────────────────────────────── + const SPEED = { + // --- Chute (vertical) --- + FALL_SECONDS_PER_SCREEN : 30, // 1 écran parcouru en 15 s → "lente" + + // --- Dérive latérale (horizontal) --- + DRIFT_AMPLITUDE : 0.50, // amplitude max ~12% de la largeur d'écran + DRIFT_WANDER : 0.6, // "nervosité" de la dérive (marche aléatoire amortie) + DRIFT_RESTORE : 0.1, // force de rappel vers l'intérieur (anti-sortie latérale) + + // --- Rotation standard --- + ROT_SPEED_MIN : 10, // °/s (lente) + ROT_SPEED_MAX : 24, // °/s + ROT_WOBBLE : 4, // variation aléatoire lente de la vitesse angulaire + + // --- Clic : poussée verticale --- + CLICK_PUSH_SPEED : 0.4, // impulsion vers le haut, en écrans/s + CLICK_GRAVITY : 0.4, // écrans/s² qui ramène à la vitesse de chute normale + + // --- Clic : spin --- + CLICK_SPIN_SPEED : 540, // °/s au moment du clic (~2 tours/s ≈ "rapide") + CLICK_SPIN_DAMPING: 2.5, // vitesse de retour à la rotation normale (amortissement) + }; + + const TIMING = { + CLICK_IMG_DURATION: 800, // ms d'affichage du PNG "clic" + ONCE_REDUCED_MS : 10000, // AlteredCore : durée d'affichage d'une boule `once` en reduced-motion + }; + + const LAYOUT = { + DENSITY_SCREENS_PER_BALL: 2, // ~1 boule / n hauteurs d'écran + MIN_COUNT : 1, // au moins n boule, même sur page courte + SIZE : 200, // px (taille d'affichage de référence) + SIZE_VARIATION : 0.2, // ±20% (effet de profondeur) + HIT_RADIUS : 80, // % : rayon du disque cliquable (clip circulaire → + // les coins transparents du PNG ne captent plus le clic). + // 50 = disque inscrit ; baisser (~46) pour coller à la matière. + }; + + const ASSETS = { + imgNormal : null, // URL du PNG boule de coton (placeholder si null) + imgClick : null, // URL du PNG "clic" (placeholder si null) + }; + + const A11Y = { + respectReducedMotion : true, // prefers-reduced-motion → swap PNG sans poussée/spin + pauseWhenHidden : true, // pause quand l'onglet est inactif + }; + + const Z_INDEX = 2147483000; + + // Regroupe les défauts pour la résolution de config (défauts < objet JS < attributs). + const DEFAULTS = { SPEED, TIMING, LAYOUT, ASSETS, A11Y, Z_INDEX }; + + // ──────────────────────────────────────────────────────────────────────── + // Utilitaires + // ──────────────────────────────────────────────────────────────────────── + const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); + const rand = (lo, hi) => lo + Math.random() * (hi - lo); + const sign = (v) => (v < 0 ? -1 : 1); + + // Placeholders intégrés (SVG data-URI) pour développer/tester sans les vrais PNG. + const placeholder = (accent) => { + // Petit amas de cercles blancs flous → "boule de coton". + const svg = ` + + + + + + + + + + + + + + + + + + + + ${accent ? '' : ''} + `; + return 'data:image/svg+xml;utf8,' + encodeURIComponent(svg.trim()); + }; + const PLACEHOLDER_NORMAL = placeholder(false); + const PLACEHOLDER_CLICK = placeholder(true); + + // ──────────────────────────────────────────────────────────────────────── + // Le composant + // ──────────────────────────────────────────────────────────────────────── + class CottonFall extends HTMLElement { + constructor() { + super(); + this._shadow = this.attachShadow({ mode: 'open' }); + this._balls = []; + this._raf = 0; + this._last = 0; + this._running = false; + this._paused = false; + this._reduced = false; + + // Liaisons stables pour add/removeEventListener. + this._onFrame = this._onFrame.bind(this); + this._onResize = this._onResize.bind(this); + this._onVisibility = this._onVisibility.bind(this); + this._onMotionPref = this._onMotionPref.bind(this); + this._resizeTimer = 0; + } + + // ── Cycle de vie ────────────────────────────────────────────────────── + connectedCallback() { + this._cfg = this._readConfig(); + + // Structure Shadow DOM : un overlay (:host reste "transparent" au flux). + this._shadow.innerHTML = ` + +
+ `; + this._overlay = this._shadow.querySelector('.overlay'); + this._overlay.style.zIndex = String(this._cfg.zIndex); + // Rayon du disque cliquable (hérité par les .ball via variable CSS). + this._overlay.style.setProperty('--hit-radius', this._cfg.hitRadius + '%'); + + // Reduced motion. + this._mql = window.matchMedia('(prefers-reduced-motion: reduce)'); + this._reduced = this._cfg.respectReducedMotion && this._mql.matches; + if (this._mql.addEventListener) this._mql.addEventListener('change', this._onMotionPref); + else if (this._mql.addListener) this._mql.addListener(this._onMotionPref); // vieux Safari + + this._measure(); + this._syncCount(true); // 1re population : étalée sur la page (désynchronisée) + // AlteredCore : en reduced-motion, une boule `once` ne bouge pas → elle part après un délai. + if (this._cfg.once && this._reduced) this._endTimer = setTimeout(() => this._end(), TIMING.ONCE_REDUCED_MS); + + // Observers / listeners. + this._ro = new ResizeObserver(() => this._onResize()); + this._ro.observe(document.body); + window.addEventListener('resize', this._onResize, { passive: true }); + if (this._cfg.pauseWhenHidden) { + document.addEventListener('visibilitychange', this._onVisibility); + } + + this._start(); + } + + disconnectedCallback() { + this._stop(); + if (this._ro) this._ro.disconnect(); + window.removeEventListener('resize', this._onResize); + document.removeEventListener('visibilitychange', this._onVisibility); + if (this._mql) { + if (this._mql.removeEventListener) this._mql.removeEventListener('change', this._onMotionPref); + else if (this._mql.removeListener) this._mql.removeListener(this._onMotionPref); + } + clearTimeout(this._resizeTimer); + clearTimeout(this._endTimer); + this._balls.forEach(b => clearTimeout(b.clickTimer)); + this._balls = []; + if (this._overlay) this._overlay.innerHTML = ''; + } + + // ── Config : défauts < objet JS global < attributs (les attributs priment) ── + _readConfig() { + const g = (typeof window !== 'undefined' && window.COTTON_FALL) || {}; + const attr = (name) => (this.hasAttribute(name) ? this.getAttribute(name) : undefined); + // pick : attribut > objet JS > défaut ; `parse` convertit la chaîne d'attribut. + const pick = (attrName, jsVal, def, parse = (s) => s) => { + const a = attr(attrName); + if (a !== undefined && a !== '') return parse(a); + if (jsVal !== undefined && jsVal !== null) return jsVal; + return def; + }; + const num = (s) => Number(s); + + return { + // Assets + imgNormal : pick('img-normal', g.imgNormal, ASSETS.imgNormal) || PLACEHOLDER_NORMAL, + imgClick : pick('img-click', g.imgClick, ASSETS.imgClick) || PLACEHOLDER_CLICK, + // Layout / densité + size : pick('size', g.size, LAYOUT.SIZE, num), + sizeVariation: pick('size-variation', g.sizeVariation, LAYOUT.SIZE_VARIATION, num), + density : pick('density', g.density, LAYOUT.DENSITY_SCREENS_PER_BALL, num), + minCount : pick('min-count', g.minCount, LAYOUT.MIN_COUNT, num), + hitRadius : pick('hit-radius', g.hitRadius, LAYOUT.HIT_RADIUS, num), + // Vitesses (surchargeables) + fallSeconds : pick('fall-seconds', g.fallSeconds, SPEED.FALL_SECONDS_PER_SCREEN, num), + driftAmp : pick('drift-amplitude', g.driftAmplitude, SPEED.DRIFT_AMPLITUDE, num), + driftWander : pick('drift-wander', g.driftWander, SPEED.DRIFT_WANDER, num), + driftRestore : pick('drift-restore', g.driftRestore, SPEED.DRIFT_RESTORE, num), + rotMin : pick('rot-min', g.rotMin, SPEED.ROT_SPEED_MIN, num), + rotMax : pick('rot-max', g.rotMax, SPEED.ROT_SPEED_MAX, num), + // Divers + zIndex : pick('z-index', g.zIndex, Z_INDEX, num), + // AlteredCore + once : this.hasAttribute('once'), + spawn : pick('spawn', g.spawn, 'page'), + // A11Y + respectReducedMotion: g.respectReducedMotion !== undefined ? g.respectReducedMotion : A11Y.respectReducedMotion, + pauseWhenHidden : g.pauseWhenHidden !== undefined ? g.pauseWhenHidden : A11Y.pauseWhenHidden, + }; + } + + // ── Mesures (liées à la page, cf. §3) ───────────────────────────────── + _measure() { + const de = document.documentElement; + // AlteredCore : l'overlay replié ne compte plus dans scrollHeight (page qui raccourcit). + this._overlay.style.height = '0px'; + this._vw = window.innerWidth; // hauteur/largeur d'écran (unités relatives) + this._vh = window.innerHeight; + this._docW = de.clientWidth; // largeur du contenu (sans scrollbar) + this._docH = Math.max(de.scrollHeight, document.body.scrollHeight, this._vh); + // L'overlay épouse le document → défile avec la page. + this._overlay.style.width = this._docW + 'px'; + this._overlay.style.height = this._docH + 'px'; + } + + // ── Densité & nombre de boules (§6) ─────────────────────────────────── + _targetCount() { + const perBall = this._vh * this._cfg.density; + const n = Math.round(this._docH / perBall); + return Math.max(this._cfg.minCount, n); + } + + _syncCount(initial) { + if (this._cfg.once && !initial) return; // AlteredCore : une boule `once` ne revient pas + const target = this._targetCount(); + // Initial : étalées sur la hauteur de page. Ajouts ultérieurs : entrent par le haut. + while (this._balls.length < target) this._addBall(!initial); + while (this._balls.length > target) this._removeBall(); + } + + // Point d'ancrage horizontal tiré dans une bande INTÉRIEURE (jamais collé aux bords) : + // on garde une marge liée à l'amplitude de dérive → la boule a de la place des 2 côtés. + _pickXPref(size) { + const span = Math.max(0, this._docW - size); + const amp = this._cfg.driftAmp * this._vw; + const margin = Math.min(amp, span * 0.5); // au plus la moitié : bande toujours valide + if (span - 2 * margin <= 0) return span / 2; // page très étroite → centre + return rand(margin, span - margin); + } + + _makeBall(spawnTop) { + const size = this._cfg.size * (1 + rand(-1, 1) * this._cfg.sizeVariation); + const depth = size / this._cfg.size; // >1 : plus grosse ⇒ "plus proche" + const omegaBase = rand(this._cfg.rotMin, this._cfg.rotMax) * (Math.random() < 0.5 ? -1 : 1); + const xPref = this._pickXPref(size); + + const b = { + size, depth, omegaBase, + x: xPref, + xPref, + u: rand(-0.4, 0.4), // offset de dérive normalisé [-1,1] + uv: 0, // vitesse de dérive normalisée + y: this._spawnY(size, spawnTop), + vy: this._fallSpeed() * depth, + angle: rand(0, 360), + omega: omegaBase, + wobbleFreq: rand(0.2, 0.6), // rad/s (variation lente) + wobblePhase: rand(0, Math.PI * 2), + el: null, + clickTimer: 0, + showingClick: false, + }; + + const el = document.createElement('img'); + el.className = 'ball'; + el.src = this._cfg.imgNormal; + el.alt = ''; + el.setAttribute('aria-hidden', 'true'); + el.draggable = false; + el.style.width = size + 'px'; + el.style.height = size + 'px'; + el.addEventListener('click', (e) => { e.preventDefault(); this._onBallClick(b); }); + b.el = el; + this._render(b); + return b; + } + + // AlteredCore : `spawn="viewport"` → juste au-dessus de l'écran visible (dedans en + // reduced-motion, la boule ne bougeant pas). + _spawnY(size, spawnTop) { + if (this._cfg.spawn === 'viewport') { + return this._reduced ? window.scrollY + rand(0, Math.max(0, this._vh - size)) : window.scrollY - size; + } + return spawnTop ? -size : rand(0, this._docH); + } + + _addBall(spawnTop) { + const b = this._makeBall(spawnTop); + this._overlay.appendChild(b.el); + this._balls.push(b); + } + + _removeBall() { + const b = this._balls.pop(); + if (!b) return; + clearTimeout(b.clickTimer); + if (b.el && b.el.parentNode) b.el.parentNode.removeChild(b.el); + } + + _fallSpeed() { + return this._vh / this._cfg.fallSeconds; // px/s + } + + // ── Clic (§8) ───────────────────────────────────────────────────────── + _onBallClick(b) { + // 8.1 Swap PNG (temporisé) — conservé même en reduced-motion. + b.el.src = this._cfg.imgClick; + b.showingClick = true; + clearTimeout(b.clickTimer); + b.clickTimer = setTimeout(() => { + b.showingClick = false; + b.el.src = this._cfg.imgNormal; + }, this._cfg.timingClickImg || TIMING.CLICK_IMG_DURATION); + + if (this._reduced) return; // en reduced-motion : pas de poussée ni de spin. + + // 8.2 Poussée verticale (vers le haut, en écrans/s). + b.vy = -SPEED.CLICK_PUSH_SPEED * this._vh; + // 8.3 Spin au clic : rapide, sens opposé à la rotation courante. + b.omega = SPEED.CLICK_SPIN_SPEED * -sign(b.omega || b.omegaBase); + } + + // ── Boucle d'animation (§7) ─────────────────────────────────────────── + _start() { + if (this._running) return; + this._running = true; + this._last = 0; + this._raf = requestAnimationFrame(this._onFrame); + } + + _stop() { + this._running = false; + if (this._raf) cancelAnimationFrame(this._raf); + this._raf = 0; + } + + _onFrame(now) { + if (!this._running) return; + if (this._last === 0) this._last = now; + let dt = (now - this._last) / 1000; + this._last = now; + // Garde-fou : onglet revenu au premier plan, alt-tab… → pas de saut géant. + if (dt > 0.05) dt = 0.05; + + if (!this._reduced) this._step(dt); + + this._raf = requestAnimationFrame(this._onFrame); + } + + _step(dt) { + const fall = this._fallSpeed(); + const gravity = SPEED.CLICK_GRAVITY * this._vh; // px/s² + const amp = this._cfg.driftAmp * this._vw; // px (borne latérale) + const now = performance.now() / 1000; + + for (const b of this._balls) { + // 7.1 Vertical : gravité qui ramène vy vers la vitesse de chute (jamais au-delà). + const cruise = fall * b.depth; + b.vy = Math.min(b.vy + gravity * dt, cruise); + b.y += b.vy * dt; + + // AlteredCore : `once` → sortie en bas = fin de la boule. + if (b.y > this._docH && this._cfg.once) { + b.gone = true; + continue; + } + // Wrap : sorti en bas → respawn en haut, désynchronisé. + if (b.y > this._docH) { + b.y = -b.size; + b.vy = cruise; + b.xPref = this._pickXPref(b.size); + b.u = rand(-0.4, 0.4); + b.uv = 0; + } + + // 7.2 Latéral : marche aléatoire amortie (Ornstein-Uhlenbeck), bornée. + const noise = rand(-1, 1); + b.uv += (this._cfg.driftWander * noise - this._cfg.driftRestore * b.u) * dt; + b.uv *= (1 - Math.min(1, this._cfg.driftRestore * dt)); // amortissement + b.u += b.uv * dt; + if (b.u > 1) { b.u = 1; b.uv = Math.min(0, b.uv); } // rappel doux, pas de choc + if (b.u < -1) { b.u = -1; b.uv = Math.max(0, b.uv); } + // Amplitude effective = place réellement dispo de CE côté (rappel implicite près + // des bords) → x reste dans [0, docW-size] sans mur ni collage sur le bord. + const span = Math.max(0, this._docW - b.size); + b.xPref = clamp(b.xPref, 0, span); // sûreté si la page a rétréci + const room = Math.max(0, Math.min(amp, b.xPref, span - b.xPref)); + b.x = b.xPref + b.u * room; + + // 7.3 Rotation : omega amorti vers l'omega normal (avec wobble lent). + // Au repos → converge vers omegaNormal ; après clic → revient en douceur. + const wobble = Math.sin(now * b.wobbleFreq + b.wobblePhase) * SPEED.ROT_WOBBLE; + const omegaNormal = b.omegaBase + wobble * sign(b.omegaBase); + b.omega += (omegaNormal - b.omega) * SPEED.CLICK_SPIN_DAMPING * dt; + b.angle += b.omega * dt; + + this._render(b); + } + + if (this._cfg.once && this._balls.some(b => b.gone)) { + this._balls = this._balls.filter((b) => { + if (!b.gone) return true; + clearTimeout(b.clickTimer); + b.el.remove(); + return false; + }); + if (!this._balls.length) this._end(); + } + } + + // AlteredCore : plus aucune boule `once` → l'élément prévient et se retire. + _end() { + this.dispatchEvent(new CustomEvent('cotton-fall-end')); + this.remove(); + } + + _render(b) { + // transform seul → accélération GPU, pas de reflow (§10). + b.el.style.transform = + `translate3d(${b.x.toFixed(2)}px, ${b.y.toFixed(2)}px, 0) rotate(${b.angle.toFixed(2)}deg)`; + } + + // ── Handlers ────────────────────────────────────────────────────────── + _onResize() { + clearTimeout(this._resizeTimer); + this._resizeTimer = setTimeout(() => { + this._measure(); + this._syncCount(); + }, 120); // léger debounce + } + + _onVisibility() { + if (document.hidden) { + this._stop(); + } else if (this._cfg.pauseWhenHidden) { + this._start(); + } + } + + _onMotionPref(e) { + this._reduced = this._cfg.respectReducedMotion && e.matches; + } + } + + // Expose les défauts (introspection / réglage global éventuel). + CottonFall.DEFAULTS = DEFAULTS; + + if (!customElements.get('cotton-fall')) { + customElements.define('cotton-fall', CottonFall); + } +})(); diff --git a/plugins/hidden-creatures/assets/global_head.php b/plugins/hidden-creatures/assets/global_head.php new file mode 100644 index 00000000..59185961 --- /dev/null +++ b/plugins/hidden-creatures/assets/global_head.php @@ -0,0 +1,60 @@ + $spot) { + $creature = $config['creatures'][$spot['creature']] ?? null; + if ($creature === null || !array_intersect([$page, '*'], $spot['pages'])) continue; + hcMarkSeen((string)$id); + $spots[] = [ + 'id' => (string)$id, + 'when' => $spot['when'], + 'name' => $creature['name_' . $lang] ?: $creature['name_fr'] ?: $creature['name_en'], + 'normal' => $image($creature['normal']), + 'click' => $image($creature['click']), + ]; + } + if (!$spots) return; + + $text = [ + 'fr' => [ + 'found' => 'Trouvé : {name} ! {count}/{total}', + 'again' => '{name}, déjà trouvé ({count}/{total})', + 'code' => '· Code : {code}', + 'login' => 'Connecte-toi pour qu’il compte.', + 'copy' => 'Copier', + ], + 'en' => [ + 'found' => 'Found: {name}! {count}/{total}', + 'again' => '{name}, already found ({count}/{total})', + 'code' => '· Code: {code}', + 'login' => 'Sign in for it to count.', + 'copy' => 'Copy', + ], + ][$lang]; + $data = [ + 'spots' => $spots, + 'all' => hcSpotIds(), + 'api' => BASE_URL . '/papi/hidden-creatures/find', + 'csrf' => csrfToken(), + 'signedIn' => kcIsLoggedIn(), + 'text' => $text, + ]; + $json = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP; + $mtime = function (string $f): int { return (int)@filemtime(__DIR__ . '/' . $f); }; + ?> + + + + elements. A click reports the find (papi/find.php: recorded for a signed-in user, the spot's + * contest code) and shows the count: the account's finds when signed in, this browser's otherwise. + */ +(function () { + 'use strict'; + + var config = window.acHiddenCreatures; + if (!config || !config.spots || !config.spots.length) return; + + var STORAGE_KEY = 'acHiddenCreatures'; + + function foundIds() { + try { + var ids = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); + return Array.isArray(ids) ? ids : []; + } catch (e) { + return []; + } + } + + /** Records finds in this browser; true when `id` is new. */ + function markFound(id, others) { + var ids = foundIds(); + var isNew = ids.indexOf(id) === -1; + [id].concat(others || []).forEach(function (one) { if (ids.indexOf(one) === -1) ids.push(one); }); + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(ids)); } catch (e) { /* private mode: not kept */ } + return isNew; + } + + function format(template, values) { + return template.replace(/\{(\w+)\}/g, function (all, key) { return key in values ? values[key] : all; }); + } + + /** POST papi/find.php: its reply, or null (network, not seen…). */ + function report(spot) { + if (!window.fetch) return Promise.resolve(null); + return fetch(config.api, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': config.csrf }, + body: JSON.stringify({ spot: spot.id }), + }).then(function (res) { return res.ok ? res.json() : null; }).catch(function () { return null; }); + } + + function announce(spot, reply) { + var text = config.text; + var account = reply && Array.isArray(reply.found); + // Signed in: the account's finds, kept in this browser too. Otherwise: this browser's. + var isNew = account ? reply.new : markFound(spot.id); + if (account) markFound(spot.id, reply.found); + var ids = account ? reply.found : foundIds(); + var count = ids.filter(function (id) { return config.all.indexOf(id) !== -1; }).length; + var message = format(isNew ? text.found : text.again, { name: spot.name, count: count, total: config.all.length }); + var code = reply && reply.code; + if (code) message += ' ' + format(text.code, { code: code }); + if (code && !config.signedIn) message += ' ' + text.login; + window.acToast(message, code ? { + duration: 12000, + actionLabel: text.copy, + onAction: function () { if (navigator.clipboard) navigator.clipboard.writeText(code).catch(function () {}); }, + } : { duration: 4000 }); + } + + /** A spot's creature: falling through the page in a loop, or (`once`) across the screen a single time. */ + function release(spot, once) { + var el = document.createElement('cotton-fall'); + // No image (none picked in the admin yet): the component's cotton ball. + if (spot.normal) el.setAttribute('img-normal', spot.normal); + if (spot.click) el.setAttribute('img-click', spot.click); + // One creature: a ball per 1000 screens, at least one. 200 px, less on a phone (30 % of the width). + el.setAttribute('density', '1000'); + el.setAttribute('size', String(Math.round(Math.min(200, window.innerWidth * 0.3)))); + el.setAttribute('min-count', '1'); + if (once) { + el.setAttribute('once', ''); + el.setAttribute('spawn', 'viewport'); + } + var announced = false; + // A click on the creature, retargeted from the component's shadow DOM. + el.addEventListener('click', function () { + if (announced || !window.acToast) return; + announced = true; + report(spot).then(function (reply) { announce(spot, reply); }); + }); + document.body.appendChild(el); + } + + config.spots.forEach(function (spot) { + if (spot.when === 'load') { + release(spot, false); + } else if (spot.when === 'scroll-end') { + var onScroll = function () { + var de = document.documentElement; + if (window.scrollY + window.innerHeight < de.scrollHeight - 100) return; + window.removeEventListener('scroll', onScroll); + release(spot, true); + }; + window.addEventListener('scroll', onScroll, { passive: true }); + } else if (spot.when.indexOf('event:') === 0) { + var name = spot.when.slice(6); + var onSpot = function (e) { + if (e.detail !== name) return; + window.removeEventListener('ac-hidden-spot', onSpot); + release(spot, true); + }; + window.addEventListener('ac-hidden-spot', onSpot); + } else if (spot.when.indexOf('link:') === 0) { + followLink(spot, spot.when.slice(5)); + } + }); + + /** + * `link:`: a click on a link to leaves the site, so the creature waits (localStorage) for the visitor's + * return: the next page, or this one back from the back-forward cache. A link to a new tab: here, right away. + */ + function followLink(spot, url) { + var key = STORAGE_KEY + ':waiting:' + spot.id; + var waiting = function () { try { return !!localStorage.getItem(key); } catch (e) { return false; } }; + var setWaiting = function (on) { + try { if (on) localStorage.setItem(key, '1'); else localStorage.removeItem(key); } catch (e) { /* not kept */ } + }; + var released = false; + var releaseOnce = function () { + if (released) return; + released = true; + setWaiting(false); + release(spot, true); + }; + if (waiting()) releaseOnce(); + window.addEventListener('pageshow', function (e) { if (e.persisted && waiting()) releaseOnce(); }); + document.addEventListener('click', function (e) { + var link = e.target.closest && e.target.closest('a[href]'); + if (!link || link.href.indexOf(url) !== 0) return; + if (link.target === '_blank') releaseOnce(); + else if (!released) setWaiting(true); + }, true); + } +})(); diff --git a/plugins/hidden-creatures/e2e/fixtures.php b/plugins/hidden-creatures/e2e/fixtures.php new file mode 100644 index 00000000..ab22fcac --- /dev/null +++ b/plugins/hidden-creatures/e2e/fixtures.php @@ -0,0 +1,21 @@ + { + const all = await page.evaluate(() => (window as unknown as { acHiddenCreatures?: Config }).acHiddenCreatures?.all ?? []); + test.skip(!all.includes('e2e-load'), 'hidden-creatures or its e2e fixtures are not on this stack'); +} + +/** The creature of the page: an in 's shadow DOM. Clicked by event, it never stops moving. */ +const creature = (page: Page) => page.locator('cotton-fall img.ball'); + +type Reply = { code: string | null; new?: boolean; found?: string[] }; + +async function clickCreature(page: Page): Promise { + const reply = page.waitForResponse((res) => res.url().endsWith(FIND)); + await creature(page).first().dispatchEvent('click'); + const res = await reply; + expect(res.status()).toBe(200); + return (await res.json()) as Reply; +} + +test('a visitor finds a creature, counted in their browser, with the spot\'s code', async ({ page }, testInfo) => { + await page.goto(LOAD_PAGE); + await skipWithoutFixtures(page); + await expect(creature(page)).toHaveCount(1); + const normal = await creature(page).getAttribute('src'); + + const reply = await clickCreature(page); + expect(reply).toEqual({ code: 'E2E-CODE' }); + const toast = page.locator('.ac-toast'); + await expect(toast).toContainText(new RegExp(`^Trouvé : ${NAME} ! 1/\\d+ · Code : E2E-CODE Connecte-toi`)); + await expect(toast.getByRole('button', { name: 'Copier' })).toBeVisible(); + // The clicked image (here the cotton ball's own). + await expect(creature(page)).not.toHaveAttribute('src', normal!); + await evidence(page, testInfo, 'hidden-creatures-found'); + + // Again on the next visit: already found, same count. + await page.reload(); + await clickCreature(page); + await expect(toast).toContainText(new RegExp(`^${NAME}, déjà trouvé \\(1/\\d+\\)`)); +}); + +test('a spot only counts once its page was shown', async ({ page }) => { + await page.goto(LOAD_PAGE); + await skipWithoutFixtures(page); + const status = await page.evaluate(async (url) => { + const { csrf } = (window as unknown as { acHiddenCreatures: Config }).acHiddenCreatures; + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf }, + body: JSON.stringify({ spot: 'e2e-test-hand' }), + }); + return res.status; + }, FIND); + expect(status).toBe(409); +}); + +test('an event spot: the creature crosses the screen when the page sends the event', async ({ page }) => { + await page.goto(EVENT_PAGE); + await skipWithoutFixtures(page); + await expect(creature(page)).toHaveCount(0); + await page.evaluate(() => window.dispatchEvent(new CustomEvent('ac-hidden-spot', { detail: 'e2e' }))); + await expect(creature(page)).toHaveCount(1); +}); + +test('a link spot: the creature is there when the visitor comes back from the site', async ({ page, context }) => { + await context.route('https://example.test/**', (route) => route.fulfill({ contentType: 'text/html', body: '

Elsewhere

' })); + await page.goto(EVENT_PAGE); + await skipWithoutFixtures(page); + const addLink = (target: string) => page.evaluate((t) => { + const a = Object.assign(document.createElement('a'), { href: 'https://example.test/', textContent: 'Elsewhere', target: t }); + a.id = 'e2e-link'; + document.body.append(a); + }, target); + + // Same tab: on the site again, back from the other one. + await addLink(''); + await page.locator('#e2e-link').dispatchEvent('click'); + await page.waitForURL('https://example.test/'); + await page.goBack(); + await expect(creature(page)).toHaveCount(1); + // Released once: not on the next page. + await page.goto(EVENT_PAGE); + await expect(creature(page)).toHaveCount(0); + + // A new tab: right away on the page the visitor left. + await addLink('_blank'); + const popup = page.waitForEvent('popup'); + await page.locator('#e2e-link').dispatchEvent('click'); + await (await popup).close(); + await expect(creature(page)).toHaveCount(1); +}); + +test('a signed-in player finds the Test hand creature, then another: recorded on the account', async ({ page, compact }) => { + await setBeta(page, true); + await login(page, 'alice', LOAD_PAGE); + await skipWithoutFixtures(page); + const id = await page.goto('/pages/decks?lang=fr').then(() => createServerDeck(page, `E2E creatures ${Date.now()}`)); + + // Not there before the tab opens. + await page.goto(`/pages/deck?id=${id}`); + await expect(page.locator('app-deck-page')).toBeVisible(); + await expect(creature(page)).toHaveCount(0); + if (compact) await page.getByRole('navigation', { name: 'Consultation du deck' }).getByRole('link', { name: 'Main' }).click(); + else await page.getByRole('tab', { name: 'Main de départ' }).click(); + await expect(creature(page)).toHaveCount(1); + const fromTestHand = await clickCreature(page); + expect(fromTestHand.found).toContain('e2e-test-hand'); + + await page.goto(LOAD_PAGE); + const fromNews = await clickCreature(page); + expect(fromNews.found).toEqual(expect.arrayContaining(['e2e-load', 'e2e-test-hand'])); + const total = await page.evaluate(() => (window as unknown as { acHiddenCreatures: Config }).acHiddenCreatures.all.length); + // « Trouvé : … ! 2/4 · Code » the first time, « …, déjà trouvé (2/4) · Code » on a later run (alice keeps her finds). + await expect(page.locator('.ac-toast__message')).toHaveText(new RegExp(`${fromNews.found!.length}/${total}\\)? · Code : E2E-CODE$`)); +}); diff --git a/plugins/hidden-creatures/inc/functions.php b/plugins/hidden-creatures/inc/functions.php new file mode 100644 index 00000000..9216364b --- /dev/null +++ b/plugins/hidden-creatures/inc/functions.php @@ -0,0 +1,187 @@ + name_fr, name_en, normal, click, updated_at) and their spots (id => creature, pages, when, + * code, updated_at), read once per request. Empty when the tables do not exist (plugin files without its install). + */ +function hcConfig(bool $reload = false): array { + static $config = null; + if ($config !== null && !$reload) return $config; + $config = ['creatures' => [], 'spots' => []]; + try { + $db = getDB(); + foreach ($db->query(hcq("SELECT * FROM {creatures} ORDER BY id")) as $c) { + $config['creatures'][$c['id']] = [ + 'name_fr' => $c['name_fr'], + 'name_en' => $c['name_en'], + 'normal' => $c['image_normal'], + 'click' => $c['image_click'], + 'updated_at' => $c['updated_at'], + ]; + } + foreach ($db->query(hcq("SELECT * FROM {spots} ORDER BY id")) as $s) { + $config['spots'][$s['id']] = [ + 'creature' => $s['creature_id'], + 'pages' => hcSplitPages($s['pages']), + 'when' => $s['rule'], + 'code' => $s['code'], + 'updated_at' => $s['updated_at'], + ]; + } + } catch (PDOException $e) { + // Tables not installed: no spots. + } + return $config; +} + +function hcSplitPages(string $pages): array { + return array_values(array_filter(preg_split('/[\s,]+/', trim($pages)) ?: [], 'strlen')); +} + +/** Spot ids: the counter's total. */ +function hcSpotIds(): array { + return array_map('strval', array_keys(hcConfig()['spots'])); +} + +/** The session saw this spot's page (global_head.php): a find can only count after that. */ +function hcMarkSeen(string $spot): void { + $_SESSION['hc_seen'][$spot] = time(); +} + +function hcSeen(string $spot): bool { + return !empty($_SESSION['hc_seen'][$spot]); +} + +/** spot => code, for the spots that have one. */ +function hcSpotCodes(): array { + return array_filter(array_map(function (array $s): string { return $s['code']; }, hcConfig()['spots']), 'strlen'); +} + +// ─── Admin: creatures and spots ───────────────────────────────────────────── + +function hcValidId(string $id): bool { + return (bool)preg_match('/^[a-z0-9][a-z0-9-]{0,63}$/', $id); +} + +/** Errors of a spot form, [] when valid. `rule` is the stored form: load, scroll-end, event:, link:. */ +function hcSpotErrors(string $id, string $creature, array $pages, string $rule, string $code): array { + $errors = []; + if (!hcValidId($id)) $errors[] = 'Spot id: lowercase letters, digits and dashes.'; + if (!isset(hcConfig()['creatures'][$creature])) $errors[] = 'Unknown creature.'; + if (!$pages) $errors[] = 'At least one page.'; + foreach ($pages as $page) { + if ($page !== '*' && !preg_match('/^[a-z0-9_-]+$/', $page)) $errors[] = "Page « $page »: a slug (index, news-detail…) or *."; + } + [$type, $value] = array_pad(explode(':', $rule, 2), 2, ''); + if (!in_array($type, HC_RULES, true)) $errors[] = 'Unknown trigger.'; + if ($type === 'event' && !preg_match('/^[a-z0-9-]+$/', $value)) $errors[] = 'Event name: lowercase letters, digits and dashes.'; + if ($type === 'link' && !preg_match('#^https?://\S+$#', $value)) $errors[] = 'Link: an address starting with http:// or https://.'; + if (mb_strlen($code) > 100) $errors[] = 'Code: 100 characters at most.'; + return $errors; +} + +function hcSaveSpot(string $id, string $creature, array $pages, string $rule, string $code): void { + getDB()->prepare(hcq( + "INSERT INTO {spots} (id, creature_id, pages, rule, code) VALUES (:id, :c, :p, :r, :code)" + . " ON DUPLICATE KEY UPDATE creature_id = VALUES(creature_id), pages = VALUES(pages), rule = VALUES(rule), code = VALUES(code)" + ))->execute([':id' => $id, ':c' => $creature, ':p' => implode(' ', $pages), ':r' => $rule, ':code' => $code]); + hcConfig(true); +} + +function hcDeleteSpot(string $id): void { + getDB()->prepare(hcq("DELETE FROM {spots} WHERE id = :id"))->execute([':id' => $id]); + hcConfig(true); +} + +function hcSaveCreature(string $id, string $nameFr, string $nameEn, string $normal, string $click): void { + getDB()->prepare(hcq( + "INSERT INTO {creatures} (id, name_fr, name_en, image_normal, image_click) VALUES (:id, :fr, :en, :n, :c)" + . " ON DUPLICATE KEY UPDATE name_fr = VALUES(name_fr), name_en = VALUES(name_en)," + . " image_normal = VALUES(image_normal), image_click = VALUES(image_click)" + ))->execute([':id' => $id, ':fr' => $nameFr, ':en' => $nameEn, ':n' => $normal, ':c' => $click]); + hcConfig(true); +} + +/** Deletes a creature no spot uses; false otherwise. */ +function hcDeleteCreature(string $id): bool { + foreach (hcConfig()['spots'] as $spot) { + if ($spot['creature'] === $id) return false; + } + getDB()->prepare(hcq("DELETE FROM {creatures} WHERE id = :id"))->execute([':id' => $id]); + hcConfig(true); + return true; +} + +/** + * An image picked in the media library (uploads/…), made a square WebP of HC_IMAGE_SIZE px in uploads/hidden-creatures/ + * when GD can: the stored path. An image made here already, or one GD cannot read or write, is kept as it is; a path + * outside uploads/ gives ''. + */ +function hcPrepareImage(string $path, string $creature, string $variant): string { + $path = ltrim(trim($path), '/'); + if ($path === '') return ''; + $root = dirname(__DIR__, 3); + $uploads = realpath($root . '/uploads'); + $source = realpath($root . '/' . $path); + if (!$uploads || !$source || strpos($source, $uploads . DIRECTORY_SEPARATOR) !== 0 || !is_file($source)) return ''; + if (preg_match('#^uploads/' . HC_IMAGE_FOLDER . '/[a-z0-9-]+-(normal|click)-[0-9a-f]{8}\.webp$#', $path)) return $path; + if (!function_exists('imagewebp') || !function_exists('imagecreatefromstring')) return $path; + + $image = @imagecreatefromstring((string)file_get_contents($source)); + if (!$image) return $path; + $w = imagesx($image); + $h = imagesy($image); + $scale = min(1, HC_IMAGE_SIZE / max($w, $h)); + $dw = max(1, (int)round($w * $scale)); + $dh = max(1, (int)round($h * $scale)); + $out = imagecreatetruecolor(HC_IMAGE_SIZE, HC_IMAGE_SIZE); + imagealphablending($out, false); + imagesavealpha($out, true); + imagefill($out, 0, 0, imagecolorallocatealpha($out, 0, 0, 0, 127)); + // Centred in the square, transparent around a non-square image. + imagecopyresampled($out, $image, (int)((HC_IMAGE_SIZE - $dw) / 2), (int)((HC_IMAGE_SIZE - $dh) / 2), 0, 0, $dw, $dh, $w, $h); + + $dir = $root . '/uploads/' . HC_IMAGE_FOLDER; + if (!is_dir($dir) && !@mkdir($dir, 0755, true)) return $path; + $name = $creature . '-' . $variant . '-' . substr(md5($path . filemtime($source)), 0, 8) . '.webp'; + $ok = imagewebp($out, $dir . '/' . $name, 85); + imagedestroy($image); + imagedestroy($out); + return $ok ? 'uploads/' . HC_IMAGE_FOLDER . '/' . $name : $path; +} + +// ─── Finds ────────────────────────────────────────────────────────────────── + +/** A user's finds, spot => found_at, for the spots that still exist. */ +function hcUserFinds(int $userId): array { + $stmt = getDB()->prepare(hcq("SELECT spot, found_at FROM {finds} WHERE user_id = :u ORDER BY found_at")); + $stmt->execute([':u' => $userId]); + $finds = []; + foreach ($stmt as $row) { + if (in_array($row['spot'], hcSpotIds(), true)) $finds[$row['spot']] = $row['found_at']; + } + return $finds; +} + +/** Records a find; true the first time. */ +function hcRecordFind(int $userId, string $spot): bool { + $stmt = getDB()->prepare(hcq("INSERT IGNORE INTO {finds} (user_id, spot) VALUES (:u, :spot)")); + $stmt->execute([':u' => $userId, ':spot' => $spot]); + return $stmt->rowCount() > 0; +} diff --git a/plugins/hidden-creatures/papi/find.php b/plugins/hidden-creatures/papi/find.php new file mode 100644 index 00000000..f8d4df10 --- /dev/null +++ b/plugins/hidden-creatures/papi/find.php @@ -0,0 +1,28 @@ + 'unknown_spot']); + return; +} +if (!hcSeen($_hcSpot)) { + http_response_code(409); + echo json_encode(['error' => 'not_seen']); + return; +} + +$_hcReply = ['code' => (hcSpotCodes()[$_hcSpot] ?? '') ?: null]; +$_hcUserId = kcIsLoggedIn() ? (int)($_SESSION['user_id'] ?? 0) : 0; +if ($_hcUserId > 0) { + $_hcReply['new'] = hcRecordFind($_hcUserId, $_hcSpot); + $_hcReply['found'] = array_keys(hcUserFinds($_hcUserId)); +} +echo json_encode($_hcReply, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); diff --git a/plugins/hidden-creatures/plugin.json b/plugins/hidden-creatures/plugin.json new file mode 100644 index 00000000..f48463d0 --- /dev/null +++ b/plugins/hidden-creatures/plugin.json @@ -0,0 +1,35 @@ +{ + "$schema": "../plugin.schema.json", + "id": "hidden-creatures", + "name": "Hidden Creatures", + "version": "1.0.0", + "description": "Illustrations hidden on some pages of the site, falling through the page; a click counts the find (recorded for signed-in players) and shows the spot's contest code. Creatures, spots, codes and finds in the admin.", + "author": "AlteredCore", + "icon": "sparkles", + "table_prefix": "hc", + + "api": [ + { "endpoint": "find", "file": "papi/find.php", "methods": ["POST"] } + ], + + "admin": [ + { + "section": "hidden-creatures", + "file": "admin/hunt.php", + "label_en": "Hidden creatures", + "label_fr": "Créatures cachées", + "icon": "sparkles" + } + ], + + "assets": { + "global_php": ["assets/global_head.php"] + }, + + "sql": "sql/install.sql", + "tables": ["creatures", "spots", "finds"], + + "tests": { + "e2e": "e2e" + } +} diff --git a/plugins/hidden-creatures/sql/install.sql b/plugins/hidden-creatures/sql/install.sql new file mode 100644 index 00000000..9171eaa4 --- /dev/null +++ b/plugins/hidden-creatures/sql/install.sql @@ -0,0 +1,26 @@ +CREATE TABLE IF NOT EXISTS {creatures} ( + `id` VARCHAR(64) NOT NULL PRIMARY KEY, + `name_fr` VARCHAR(100) NOT NULL DEFAULT '', + `name_en` VARCHAR(100) NOT NULL DEFAULT '', + `image_normal` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'uploads/… ; empty: the component''s cotton ball', + `image_click` VARCHAR(255) NOT NULL DEFAULT '', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS {spots} ( + `id` VARCHAR(64) NOT NULL PRIMARY KEY, + `creature_id` VARCHAR(64) NOT NULL, + `pages` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'page slugs separated by spaces, * for all', + `rule` VARCHAR(255) NOT NULL DEFAULT 'load' COMMENT 'load | scroll-end | event: | link:', + `code` VARCHAR(100) NOT NULL DEFAULT '' COMMENT 'contest code shown on a find', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY `creature_id` (`creature_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS {finds} ( + `user_id` INT NOT NULL, + `spot` VARCHAR(64) NOT NULL, + `found_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`user_id`, `spot`), + KEY `spot` (`spot`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/plugins/rebuilder/app/src/app/features/deck/test-hand/test-hand.ts b/plugins/rebuilder/app/src/app/features/deck/test-hand/test-hand.ts index f947c508..4ac951ce 100644 --- a/plugins/rebuilder/app/src/app/features/deck/test-hand/test-hand.ts +++ b/plugins/rebuilder/app/src/app/features/deck/test-hand/test-hand.ts @@ -1,5 +1,5 @@ import { CdkDrag, CdkDragPlaceholder, CdkDropList, CdkDropListGroup, type CdkDragDrop } from '@angular/cdk/drag-drop'; -import { Component, computed, effect, inject, input, signal, untracked } from '@angular/core'; +import { Component, afterNextRender, computed, effect, inject, input, signal, untracked } from '@angular/core'; import { uiLocale } from '../../../core/i18n'; import { contentLocale } from '../../../core/locale'; import { localizedText } from '../../../core/models'; @@ -87,6 +87,8 @@ export class TestHand { effect(() => { if (this.compact()) untracked(() => this.game.set(null)); }); + // For the site's hidden creatures (plugins/hidden-creatures): a spot can wait for this event. No effect otherwise. + afterNextRender(() => window.dispatchEvent(new CustomEvent('ac-hidden-spot', { detail: 'test-hand' }))); } newHand(): void { diff --git a/plugins/tournament-reports/assets/app.js b/plugins/tournament-reports/assets/app.js index fc45e439..d95aef02 100644 --- a/plugins/tournament-reports/assets/app.js +++ b/plugins/tournament-reports/assets/app.js @@ -1053,7 +1053,11 @@ }); var btn = el.querySelector('.tr-chart-detail-btn'); var modalTitle = TR_TXT.chart_hero_modal_title || title; - if (btn) btn.addEventListener('click', function () { openChartModal(allSegments, modalTitle); }); + if (btn) btn.addEventListener('click', function () { + openChartModal(allSegments, modalTitle); + // For the site's hidden creatures (plugins/hidden-creatures): a spot can wait for this event. + window.dispatchEvent(new CustomEvent('ac-hidden-spot', { detail: 'tournament-heroes' })); + }); } /* ── Filters (GameApi tournaments only) ───────────────────────────────── */ From ed32d04e07bd2131fb7325f4590c588a08f24ff7 Mon Sep 17 00:00:00 2001 From: Alexandre Wavelet Date: Wed, 21 Oct 2026 21:53:29 +0200 Subject: [PATCH 64/69] CI: lint and test under PHP 7.4 and 8.4 until preprod has switched Prod moves to PHP 8.4.27 first while preprod keeps 7.4 until its own runtime switch, so the PHP job runs on both versions to catch code that would only break one of them. The ownership mock of the e2e stack moves to 8.4 for consistency. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/plugins-ci.yml | 10 +++++++--- docker-compose.stack.yml | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/plugins-ci.yml b/.github/workflows/plugins-ci.yml index a15e50f9..f8635661 100644 --- a/.github/workflows/plugins-ci.yml +++ b/.github/workflows/plugins-ci.yml @@ -1,7 +1,7 @@ name: Plugins CI # Checks every pull request against preprod / main, separately from deploy.yml: -# • php -l on all PHP (8.4, the production version); +# • php -l on all PHP (8.4, the production version; 7.4 kept until preprod has switched); # • each plugin's tests, found through bin/plugins.php (plugin.json "tests" / "build", # or the conventions tests/run.php, **/*.test.mjs, e2e/*.spec.ts); # • front-end plugins ("build" in plugin.json): install, lint, unit tests, build; @@ -92,14 +92,18 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" php: - name: PHP lint and plugin PHP tests + name: PHP lint and plugin PHP tests (${{ matrix.php }}) needs: plan runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.4'] # 7.4: preprod runtime until its switch — drop after it steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: - php-version: '8.4' + php-version: ${{ matrix.php }} coverage: none - name: php -l run: | diff --git a/docker-compose.stack.yml b/docker-compose.stack.yml index 6632d365..1ad1d3a2 100644 --- a/docker-compose.stack.yml +++ b/docker-compose.stack.yml @@ -38,7 +38,7 @@ services: # Stand-in for AlteredOwnership (alt-art families, preferences, preference mode), reached by the # site only (OWNERSHIP_API_URL); built on the production cards API. ownership: - image: php:8.3-cli-alpine + image: php:8.4-cli-alpine command: ["php", "-S", "0.0.0.0:8080", "/app/index.php"] volumes: - ./docker/stack/ownership-mock:/app:ro From ef3e3e5dc1f985be446b466663a54c2177764709 Mon Sep 17 00:00:00 2001 From: Alexandre Wavelet Date: Thu, 22 Oct 2026 02:04:48 +0200 Subject: [PATCH 65/69] CI: drop the PHP 7.4 lint now that preprod runs 8.4.27 The PHP job ran on 7.4 and 8.4 while preprod kept PHP 7.4 after prod had switched. Both environments now run 8.4.27, so 7.4 no longer guards any runtime: the job goes back to a single 8.4 run, and the docs stop asking for 7.4-compatible code. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/plugins-ci.yml | 10 +++------- CONTRIBUTING.md | 2 +- README.md | 2 +- design-system/AGENTS.md | 2 +- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/.github/workflows/plugins-ci.yml b/.github/workflows/plugins-ci.yml index f8635661..a15e50f9 100644 --- a/.github/workflows/plugins-ci.yml +++ b/.github/workflows/plugins-ci.yml @@ -1,7 +1,7 @@ name: Plugins CI # Checks every pull request against preprod / main, separately from deploy.yml: -# • php -l on all PHP (8.4, the production version; 7.4 kept until preprod has switched); +# • php -l on all PHP (8.4, the production version); # • each plugin's tests, found through bin/plugins.php (plugin.json "tests" / "build", # or the conventions tests/run.php, **/*.test.mjs, e2e/*.spec.ts); # • front-end plugins ("build" in plugin.json): install, lint, unit tests, build; @@ -92,18 +92,14 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" php: - name: PHP lint and plugin PHP tests (${{ matrix.php }}) + name: PHP lint and plugin PHP tests needs: plan runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - php: ['7.4', '8.4'] # 7.4: preprod runtime until its switch — drop after it steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: - php-version: ${{ matrix.php }} + php-version: '8.4' coverage: none - name: php -l run: | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 46eafd38..a8525af1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -109,7 +109,7 @@ git push origin dev:main ## Code conventions -- **PHP 8.4.** Production runs PHP 8.4.27 (Docker image php:8.4.27-apache). Until preprod has switched too, the preprod CI also lints under 7.4; keep new code free of PHP 8.5-only features. +- **PHP 8.4.** Production and preprod run PHP 8.4.27 (Docker image php:8.4.27-apache). PHP 8 syntax is fine; keep new code free of PHP 8.5-only features. - **No framework, no Composer.** No build step, no external dependencies. The project is intentionally plain PHP — keep it that way. - **Comments in English.** - **Interface: the design system.** Every screen uses `design-system/` (read its `README.md`): colours, sizes, radii and shadows come from the `--ac-*` tokens (`design-system/tokens/tokens.css`, the only file with raw values), components are the `ac-*` classes, icons are Lucide (`ac_icon('name')`). Light/dark and pointer/touch are handled by the tokens: no `[data-theme]` rule in page, theme or plugin CSS. CSS files: (1) `design-system/`: tokens and shared components; (2) `css/style.css`: shell and core pages; (3) `themes/azure/`: header, menu and footer templates and their CSS; (4) `plugins/*/assets/*.css`: plugin-specific layout, built from tokens. `php tests/run.php` rejects hard-coded colours. Designing with Claude (`/design`): `design-system/WORKFLOW.md`. diff --git a/README.md b/README.md index 16c2f6db..ecbe2f3a 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Pull requests that introduce external dependencies or non-standard server requir ## Requirements - Apache with `mod_rewrite` enabled -- PHP 8.4 (8.2 and 8.3 also run the code; 7.4 is no longer supported once both environments have switched) +- PHP 8.4 (8.2 and 8.3 also run the code) - MariaDB 10.x --- diff --git a/design-system/AGENTS.md b/design-system/AGENTS.md index c56fe20d..d78cc09e 100644 --- a/design-system/AGENTS.md +++ b/design-system/AGENTS.md @@ -19,6 +19,6 @@ before changing anything under `design-system/` or building a screen. or the script, then `npm ci && npm run build` in `design-system/icons/`, never the outputs. - Bridges (`css/bridges/`) exist for pages not migrated yet. Do not add new legacy names; when a page is migrated, it uses `ac-*` classes and `--ac-*` tokens directly. -- PHP helpers (`php/ui.php`) must stay PHP 7.4 compatible until preprod has switched to PHP 8.4 (production runs php:8.4.27). +- PHP helpers (`php/ui.php`) run on PHP 8.4 (production and preprod run php:8.4.27). - After a change: `php tests/run.php` (Docker: `docker compose exec web php tests/run.php`), then look at `/pages/design-system` in both themes. From eed52a97eb6329b104c8059009737e61875ad6a9 Mon Sep 17 00:00:00 2001 From: Yutsa Date: Thu, 22 Oct 2026 12:58:26 +0200 Subject: [PATCH 66/69] =?UTF-8?q?Leaving=20=C2=AB=20Global=20=C2=BB:=20bes?= =?UTF-8?q?t=20effort,=20a=20deck=20that=20cannot=20be=20written=20no=20lo?= =?UTF-8?q?nger=20keeps=20the=20player=20in=20=C2=AB=20Global=20=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../ownership/api/alt-art-leave-global.php | 2 +- plugins/ownership/pages/alt-arts.php | 19 ++++++-------- plugins/rebuilder/e2e/rebuilder.spec.ts | 25 +++++++++++++++++++ 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/plugins/ownership/api/alt-art-leave-global.php b/plugins/ownership/api/alt-art-leave-global.php index 7ac5956c..f659800d 100644 --- a/plugins/ownership/api/alt-art-leave-global.php +++ b/plugins/ownership/api/alt-art-leave-global.php @@ -3,7 +3,7 @@ // POST /papi/ownership/alt-art-leave-global {"deck": ""} → {"changed": bool} // « Global » → « par deck » (pages/alt-arts.php): the page lists the player's decks, writes the defaults in each one // (what Board Game Arena showed of it, see includes/func.alt-arts.php) and drops its brush choices, then sets the mode -// (api/alt-art-preference-mode.php). +// (api/alt-art-preference-mode.php) even when some decks could not be written: best effort. // A deck is only written while the player is in « Global » (409 otherwise). Manifest: "auth": "user". require_once __DIR__ . '/../includes/functions.php'; require_once dirname(__DIR__, 3) . '/includes/func.alt-arts.php'; diff --git a/plugins/ownership/pages/alt-arts.php b/plugins/ownership/pages/alt-arts.php index 5abb115e..43596f62 100644 --- a/plugins/ownership/pages/alt-arts.php +++ b/plugins/ownership/pages/alt-arts.php @@ -48,7 +48,6 @@ 'toPerDeckConfirm' => 'Switch to per deck', 'cancel' => 'Cancel', 'progress' => 'Decks updated: %1$s / %2$s', - 'leaveError' => 'A deck could not be updated: you stay in global mode. Try again in a moment.', 'modeSaveError' => 'Could not save your setting.', ], 'fr' => [ @@ -86,7 +85,6 @@ 'toPerDeckConfirm' => 'Passer en mode par deck', 'cancel' => 'Annuler', 'progress' => 'Decks mis à jour : %1$s / %2$s', - 'leaveError' => 'Un deck n\'a pas pu être mis à jour : vous restez en mode global. Réessayez dans un instant.', 'modeSaveError' => 'Impossible d\'enregistrer votre choix.', ], ][getUiLang()]; @@ -259,7 +257,8 @@ isGlobalMode: , }; // « Par deck » / « Global »: after a confirmation. Leaving « Global » first writes the defaults in every deck - // (api/alt-art-leave-global.php), and the mode only changes once they all are. + // (api/alt-art-leave-global.php), as far as it can: a deck that cannot be written, or a list of decks that cannot + // be read, does not keep the player in « Global ». (function () { var cfg = window.OWN_AA_CONFIG; var txt = window.OWN_I18N[]; @@ -294,7 +293,7 @@ error.querySelector('div').textContent = message; error.hidden = false; }; - // Four decks at a time; the first error stops the others. + // Four decks at a time; a deck that fails is skipped. Never fails. var writeDecks = function () { return request('GET', cfg.leaveGlobalUrl).then(function (res) { var decks = (res.decks || []).slice(); @@ -305,16 +304,14 @@ var next = function () { var deck = decks.shift(); if (deck === undefined) return Promise.resolve(); - return request('POST', cfg.leaveGlobalUrl, { deck: deck }).then(function () { + var step = function () { setProgress(++done, total); return next(); - }, function (e) { - decks.length = 0; - throw e; - }); + }; + return request('POST', cfg.leaveGlobalUrl, { deck: deck }).then(step, step); }; return Promise.all([next(), next(), next(), next()]); - }); + }).then(null, function () {}); }; confirmBtn.addEventListener('click', function () { @@ -326,7 +323,7 @@ return request('PUT', cfg.preferenceModeUrl, { mode: mode }).then(null, failWith(txt.modeSaveError)); }; var steps = cfg.isGlobalMode - ? writeDecks().then(function () { return saveMode('PerDeck'); }, failWith(txt.leaveError)) + ? writeDecks().then(function () { return saveMode('PerDeck'); }) : saveMode('Global'); steps.then(function () { location.reload(); }).catch(fail); }); diff --git a/plugins/rebuilder/e2e/rebuilder.spec.ts b/plugins/rebuilder/e2e/rebuilder.spec.ts index 7a06c373..71a53b96 100644 --- a/plugins/rebuilder/e2e/rebuilder.spec.ts +++ b/plugins/rebuilder/e2e/rebuilder.spec.ts @@ -1389,6 +1389,31 @@ test.describe('ReBuilder in the shell · deck page', () => { await ownership(page, 'PUT', '/api/alt-arts/preference-mode', { mode: 'PerDeck' }); } }); + + test('leaving « Global » switches to « par deck » even when decks cannot be written', async ({ page, compact }) => { + test.skip(compact, 'the mode is the account’s: one project at a time'); + await login(page, 'bob', `${DECKS}?lang=fr`); + await createServerDeck(page, `E2E leave global failing ${Date.now()}`); + expect((await ownership(page, 'PUT', '/api/alt-arts/preference-mode', { mode: 'Global' })).status).toBe(204); + // Every deck fails: best effort, the mode changes all the same. + let tried = 0; + await page.route('**/papi/ownership/alt-art-leave-global', (route) => { + if (route.request().method() !== 'POST') return route.continue(); + tried++; + return route.fulfill({ status: 502, json: { error: 'write' } }); + }); + try { + await page.goto('/pages/ownership-alt-arts?lang=fr'); + await page.getByRole('button', { name: 'Par deck', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: 'Passer en mode par deck ?' }); + await dialog.getByRole('button', { name: 'Passer en mode par deck' }).click(); + await expect(page.getByRole('button', { name: 'Par deck', exact: true })).toHaveAttribute('aria-pressed', 'true', { timeout: 60_000 }); + expect(tried).toBeGreaterThan(0); + } finally { + await page.goto(`${DECKS}?lang=fr`); + await ownership(page, 'PUT', '/api/alt-arts/preference-mode', { mode: 'PerDeck' }); + } + }); }); test('shows the whole card window on a short phone screen (browser bars): the card shrinks, the buttons stay visible', { tag: '@mobile' }, async ({ page, compact }, testInfo) => { From d69bd5dcc8331527a188ae9a96f4dd8d9e31f8cc Mon Sep 17 00:00:00 2001 From: Yutsa Date: Thu, 22 Oct 2026 13:26:33 +0200 Subject: [PATCH 67/69] Never show the signed-in user's e-mail address in full MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A content creator filming the site leaked their address: the homepage newsletter field was filled with the account's e-mail. - Newsletter: the field is always empty; signed-in users get « Use my account address », which takes the address from the session. - Design system: ac-tooltip and ac-masked (tooltip.css), dsMaskEmail() and ac_masked_email(): c•••@gmail.com, the full address in a tooltip on hover or focus. - Account menu, account page and the hello-world demo page show the address masked. Co-Authored-By: Claude Opus 5.5 --- design-system/css/components/tooltip.css | 82 +++++++++++++++++++++ design-system/docs/components/README.md | 1 + design-system/docs/components/tooltip.md | 64 ++++++++++++++++ design-system/php/ui.php | 23 ++++++ includes/newsletter_sub.php | 32 +++++--- pages/account.php | 4 +- pages/design-system.php | 11 +++ plugins/hello-world/pages/hellopagelock.php | 2 +- tests/DesignSystemTest.php | 14 ++++ tests/e2e/newsletter-email.spec.ts | 45 +++++++++++ themes/azure/nav.php | 2 +- themes/azure/style.css | 2 +- 12 files changed, 268 insertions(+), 14 deletions(-) create mode 100644 design-system/css/components/tooltip.css create mode 100644 design-system/docs/components/tooltip.md create mode 100644 tests/e2e/newsletter-email.spec.ts diff --git a/design-system/css/components/tooltip.css b/design-system/css/components/tooltip.css new file mode 100644 index 00000000..164571cc --- /dev/null +++ b/design-system/css/components/tooltip.css @@ -0,0 +1,82 @@ +/* + * AlteredCore design system — tooltip and masked value. + * + * ac-tooltip: a short text shown next to its host on hover (devices that can hover), and on focus (keyboard, or a tap + * on touch screens, where the host takes focus because it has tabindex="0"). + * ac-masked: a value shown partly hidden (an e-mail address), the full value in a tooltip. + */ + +.ac-tooltip-host { + position: relative; +} + +.ac-tooltip { + position: absolute; + left: 50%; + bottom: calc(100% + var(--ac-space-2)); + z-index: var(--ac-z-overlay); + transform: translateX(-50%); + padding: var(--ac-space-1-5) var(--ac-space-2-5); + border-radius: var(--ac-radius-sm); + background: var(--ac-color-inverse); + color: var(--ac-color-on-inverse); + box-shadow: var(--ac-shadow-e2); + font: var(--ac-font-small); + font-weight: 400; + white-space: nowrap; + pointer-events: none; + opacity: 0; + visibility: hidden; + transition: opacity var(--ac-duration-fast), visibility var(--ac-duration-fast); +} + +/* Arrow pointing at the host. */ +.ac-tooltip::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: var(--ac-space-1-5) solid transparent; + border-top-color: var(--ac-color-inverse); +} + +/* Aligned on the host's start edge instead of centred: a host near the left edge of a narrow box. */ +.ac-tooltip--start { + left: 0; + transform: none; +} +.ac-tooltip--start::after { + left: var(--ac-space-3); +} + +/* Under the host: a host at the top of a box that clips its content (a scrolling menu). */ +.ac-tooltip--below { + top: calc(100% + var(--ac-space-2)); + bottom: auto; +} +.ac-tooltip--below::after { + top: auto; + bottom: 100%; + border-top-color: transparent; + border-bottom-color: var(--ac-color-inverse); +} + +.ac-tooltip-host:focus .ac-tooltip, +.ac-tooltip-host:focus-within .ac-tooltip { + opacity: 1; + visibility: visible; +} + +@media (hover: hover) { + .ac-tooltip-host:hover .ac-tooltip { + opacity: 1; + visibility: visible; + } +} + +.ac-masked { + display: inline-block; + border-bottom: 1px dashed var(--ac-color-border-dashed); + cursor: help; +} diff --git a/design-system/docs/components/README.md b/design-system/docs/components/README.md index 5278d262..e05f240a 100644 --- a/design-system/docs/components/README.md +++ b/design-system/docs/components/README.md @@ -16,6 +16,7 @@ One page per component family. Each page matches one file of `css/components/` ( | [drawer.md](drawer.md) | `drawer.css` | Drawer handle and tab: hide and show a side panel | | [navigation.md](navigation.md) | `navigation.css` | Tabs, breadcrumb, pagination, menu, contents (`ac-toc`) | | [feedback.md](feedback.md) | `feedback.css` | Notice, toast, scrim, dialog, spinner, blocking loader, progress bar, table | +| [tooltip.md](tooltip.md) | `tooltip.css` | Tooltip, masked value (e-mail address shown partly hidden); `ac_masked_email()` | | [bootstrap-bridge.md](bootstrap-bridge.md) | `bridges/bootstrap.css` | Which Bootstrap classes are restyled, and when to use them | Tokens (colours, type scale, spacing, radii, shadows, density, layout, z-index): diff --git a/design-system/docs/components/tooltip.md b/design-system/docs/components/tooltip.md new file mode 100644 index 00000000..81132d65 --- /dev/null +++ b/design-system/docs/components/tooltip.md @@ -0,0 +1,64 @@ +# Tooltip and masked value + +`css/components/tooltip.css`. + +A tooltip shows a short text next to an element when the pointer is over it or when it has focus. +A masked value shows an e-mail address partly hidden (`c•••@gmail.com`), the full address in a +tooltip: anyone watching the screen (a stream, a video) cannot read it, the user can by pointing at +it. + +Do not put anything the user needs to act on in a tooltip (it cannot be reached on every device); +do not use a tooltip to label an icon-only button: use `aria-label` (and `title`, see +[button.md](button.md)). + +## Masked e-mail — `ac_masked_email()` + +Every e-mail address of the signed-in user shown on a page goes through it (account menu, account +page, newsletter). + +```php +

Vous recevez la newsletter à l'adresse .

+ +``` + +It renders: + +```html +c•••@gmail.comcamille.durand@gmail.com +``` + +`dsMaskEmail($email)` returns the masked text alone: the first character of the local part, `•••`, +then `@` and the domain; anything that is not an address gives `•••`. + +## Tooltip — `ac-tooltip` + +Put the tooltip inside its host. The host gets `ac-tooltip-host` and, when it is not a control +already, `tabindex="0"` so that the keyboard and a tap on a touch screen can show it. + +```php + + 3 + Main cost, reserve cost in brackets + +``` + +| Class | Effect | +|---|---| +| `ac-tooltip-host` | `position: relative`; shows its `ac-tooltip` on hover (devices that can hover) and on focus (`:focus`, `:focus-within`). | +| `ac-tooltip` | Inverse bubble, small text on one line, centred above the host, with an arrow. Hidden otherwise. | +| `ac-tooltip--start` | Aligned on the host's start edge instead of centred (a host near the edge of a narrow box). | +| `ac-tooltip--below` | Under the host (a host at the top of a box that clips or scrolls, like a menu). | +| `ac-masked` | Dashed underline and a help cursor: says that there is more to see. Used with `ac-tooltip-host`. | + +## Accessibility + +- `role="tooltip"` on the bubble, `aria-describedby` from the host to it: screen readers read the + full value as the host's description. +- The host is focusable (`tabindex="0"`), so the tooltip shows on keyboard focus, and on touch + screens after a tap (no hover there). It hides when focus leaves. +- Contrast: `--ac-color-on-inverse` on `--ac-color-inverse` is ≥ 4.5:1 in both themes. + +## Frameworks + +- **Angular / React**: render the same markup; give each bubble a unique id for + `aria-describedby`. diff --git a/design-system/php/ui.php b/design-system/php/ui.php index ba9970fb..c6b6fe57 100644 --- a/design-system/php/ui.php +++ b/design-system/php/ui.php @@ -131,6 +131,29 @@ function dsResolveIcon(string $ref, ?array &$extraClasses = null): ?string { * The Altered glyph font (fak fa-collection…) is not an SVG icon: those references are * rendered as unchanged. Unknown names render nothing. */ +/** + * E-mail address with the local part hidden after its first character: "camille@gmail.com" → "c•••@gmail.com". + * Anything that is not an address becomes "•••". + */ +function dsMaskEmail(string $email): string { + $at = strrpos($email, '@'); + if ($at === false || $at === 0 || $at === strlen($email) - 1) return '•••'; + return mb_substr($email, 0, 1, 'UTF-8') . '•••' . substr($email, $at); +} + +/** + * Masked e-mail address (ac-masked) with the full address in a tooltip, shown on hover or focus: an address on screen + * must not be readable by whoever watches it (a stream, a video), only by the user who points at it. + * $tooltipClass: ac-tooltip variants ('ac-tooltip--start', 'ac-tooltip--below'). + */ +function ac_masked_email(string $email, string $tooltipClass = ''): string { + static $n = 0; + $id = 'ac-masked-' . (++$n); + $e = function (string $s): string { return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); }; + return '' . $e(dsMaskEmail($email)) + . '' . $e($email) . ''; +} + function ac_icon(string $ref, string $class = '', ?string $label = null): string { if (preg_match('/(^|\s)(fak|fa-kit)(\s|$)/', $ref)) { return ''; diff --git a/includes/newsletter_sub.php b/includes/newsletter_sub.php index 173db96c..06265287 100644 --- a/includes/newsletter_sub.php +++ b/includes/newsletter_sub.php @@ -20,7 +20,9 @@ if (!csrfValid($_POST['csrf_token'] ?? '')) { flash('Invalid form token. Please try again.', 'error'); } else { - $nlEmail = trim($_POST['nl_email'] ?? ''); + // « Utiliser l'adresse de mon compte »: the address comes from the session, never from the page, so the page + // never has to show it. + $nlEmail = !empty($_POST['nl_use_account']) ? newsletterAccountEmail() : trim($_POST['nl_email'] ?? ''); if (!filter_var($nlEmail, FILTER_VALIDATE_EMAIL)) { flash($nlLang === 'fr' ? 'Veuillez saisir une adresse e-mail valide.' @@ -59,6 +61,14 @@ redirect(BASE_URL . '/'); } +/** E-mail address of the signed-in account (Keycloak or local), '' when signed out. */ +function newsletterAccountEmail(): string +{ + if (!empty($_SESSION['kc_logged_in'])) return (string)($_SESSION['kc_email'] ?? ''); + if (!empty($_SESSION['local_logged_in'])) return (string)($_SESSION['local_email'] ?? ''); + return ''; +} + function renderNewsletterBlock(): void { $txt = [ @@ -68,6 +78,7 @@ function renderNewsletterBlock(): void 'placeholder' => 'Your email address', 'btn' => 'Subscribe', 'privacy' => 'Your email is only used to send you news. No spam, unsubscribe anytime.', + 'use_account' => 'Use my account address', ], 'fr' => [ 'title' => 'Restez informé', @@ -75,15 +86,12 @@ function renderNewsletterBlock(): void 'placeholder' => 'Votre adresse e-mail', 'btn' => 'S\'abonner', 'privacy' => 'Votre e-mail est uniquement utilisé pour vous envoyer les actualités. Pas de spam, désinscription à tout moment.', + 'use_account' => 'Utiliser l\'adresse de mon compte', ], ][getUiLang()] ?? []; - $email = ''; - if (!empty($_SESSION['kc_logged_in'])) { - $email = $_SESSION['kc_email'] ?? ''; - } elseif (!empty($_SESSION['local_logged_in'])) { - $email = $_SESSION['local_email'] ?? ''; - } + // The field is never filled with the account's address: someone filming or streaming the homepage would show it. + $hasAccountEmail = filter_var(newsletterAccountEmail(), FILTER_VALIDATE_EMAIL) !== false; ?>
@@ -100,13 +108,19 @@ function renderNewsletterBlock(): void
-
+ +
+ +
+

diff --git a/pages/account.php b/pages/account.php index d37e60f5..40e18945 100644 --- a/pages/account.php +++ b/pages/account.php @@ -170,7 +170,7 @@

-
+
@@ -250,7 +250,7 @@ class="btn btn-outline-secondary btn-sm">

-

' . h($accountEmail) . '') : $txt['newsletter_off'] ?>

+

' . ac_masked_email($accountEmail) . '') : $txt['newsletter_off'] ?>

diff --git a/pages/design-system.php b/pages/design-system.php index 8ff0ac1c..802414f0 100644 --- a/pages/design-system.php +++ b/pages/design-system.php @@ -555,6 +555,17 @@
+
+

Tooltip, masked value

+
+

Signed in as : point at the address, or focus it (ac_masked_email() renders span.ac-masked.ac-tooltip-host).

+
+ Start-aligned Aligned on the start edge + Below Under its host +
+
+
+

Icons — Lucide

PHP ac_icon('name'), site JS acIcon('name'), Angular <ac-icon name="…">. All names: lucide.dev/icons.

diff --git a/plugins/hello-world/pages/hellopagelock.php b/plugins/hello-world/pages/hellopagelock.php index 87bd4377..eb268fdc 100644 --- a/plugins/hello-world/pages/hellopagelock.php +++ b/plugins/hello-world/pages/hellopagelock.php @@ -81,7 +81,7 @@ - + diff --git a/tests/DesignSystemTest.php b/tests/DesignSystemTest.php index 6a2fb473..8334110f 100644 --- a/tests/DesignSystemTest.php +++ b/tests/DesignSystemTest.php @@ -225,3 +225,17 @@ function dsStrayHovers(string $path, string $root): array { assertSame(true, dsToken('--ac-color-primary', 'dark') !== null && dsToken('--ac-color-primary', 'dark') !== dsToken('--ac-color-primary'), "dsToken() reads the dark theme block"); assertSame('#1050aa', dsToken('--ac-header-border'), "dsToken() resolves a var() value (light header border = primary-strong)"); assertSame(dsToken('--ac-color-primary-strong', 'dark'), dsToken('--ac-color-link', 'dark'), "dsToken() resolves a var() value in the dark theme"); + +// ---- Masked e-mail address (tooltip.css) ---- +assertSame('c•••@gmail.com', dsMaskEmail('camille.durand@gmail.com'), 'dsMaskEmail keeps the first character and the domain'); +assertSame('é•••@exemple.fr', dsMaskEmail('élodie@exemple.fr'), 'dsMaskEmail keeps a multibyte first character whole'); +foreach (['', 'camille', '@gmail.com', 'camille@'] as $notAnAddress) { + assertSame('•••', dsMaskEmail($notAnAddress), 'dsMaskEmail hides ' . var_export($notAnAddress, true) . ' entirely'); +} +$masked = ac_masked_email('a@x.fr', 'ac-tooltip--below'); +assertSame(true, strpos($masked, 'a•••@x.fr') === false, 'ac_masked_email escapes the address'); + +// ---- The newsletter field is never filled with the account's address ---- +$nl = (string)file_get_contents($root . '/includes/newsletter_sub.php'); +assertSame(0, preg_match('/name="nl_email"[^>]*value=/s', $nl), 'the homepage newsletter field has no value'); diff --git a/tests/e2e/newsletter-email.spec.ts b/tests/e2e/newsletter-email.spec.ts new file mode 100644 index 00000000..c1a423db --- /dev/null +++ b/tests/e2e/newsletter-email.spec.ts @@ -0,0 +1,45 @@ +import { test, expect, login } from './fixtures'; + +// The signed-in user's e-mail address is never readable on screen (a creator filming the site leaked theirs through +// the prefilled newsletter field): masked, the full address only in a tooltip on hover or focus. + +const EMAIL = 'alice@example.test'; + +/** Text of the page without the tooltips: what a viewer of the screen can read. */ +async function visibleText(page: import('./fixtures').Page): Promise { + return page.evaluate(() => { + const body = document.body.cloneNode(true) as HTMLElement; + body.querySelectorAll('.ac-tooltip, script, style').forEach((el) => el.remove()); + return body.textContent ?? ''; + }); +} + +test('the homepage newsletter field is empty, and the account address subscribes without being shown', async ({ page }) => { + await login(page, 'alice', '/'); + const field = page.locator('input[name="nl_email"]'); + await expect(field).toHaveValue(''); + expect(await visibleText(page)).not.toContain(EMAIL); + + await page.locator('button[name="nl_use_account"]').click(); + await expect(page.getByRole('alert')).toContainText(/Subscription confirmed|already subscribed|Inscription confirmée|déjà inscrite/); +}); + +test('the account menu and page show the address masked, in full in a tooltip', async ({ page, compact }) => { + await login(page, 'alice', '/pages/account'); + expect(await visibleText(page)).not.toContain(EMAIL); + + const masked = page.locator('.ac-masked').filter({ visible: true }).first(); + await expect(masked).toContainText('a•••@example.test'); + const tooltip = masked.locator('.ac-tooltip'); + await expect(tooltip).toBeHidden(); + await masked.focus(); + await expect(tooltip).toBeVisible(); + await expect(tooltip).toHaveText(EMAIL); + + if (compact) return; // the account menu below is the desktop header's + await page.locator('#azAccountBtn').click(); + const menuMasked = page.locator('#azAccountMenu .ac-masked'); + await expect(menuMasked).toContainText('a•••@example.test'); + await menuMasked.hover(); + await expect(menuMasked.locator('.ac-tooltip')).toBeVisible(); +}); diff --git a/themes/azure/nav.php b/themes/azure/nav.php index 0141d48a..257346d6 100644 --- a/themes/azure/nav.php +++ b/themes/azure/nav.php @@ -221,7 +221,7 @@ class="nav-link "
- +
From 37929867edc038d7afe52b03dfb80a6abc3e4368 Mon Sep 17 00:00:00 2001 From: Yutsa Date: Thu, 22 Oct 2026 13:27:08 +0200 Subject: [PATCH 69/69] E2E evidence of the masked address (homepage, account page and menu) Co-Authored-By: Claude Opus 5.5 --- tests/e2e/newsletter-email.spec.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/e2e/newsletter-email.spec.ts b/tests/e2e/newsletter-email.spec.ts index c1a423db..9feef835 100644 --- a/tests/e2e/newsletter-email.spec.ts +++ b/tests/e2e/newsletter-email.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, login } from './fixtures'; +import { test, expect, login, evidence } from './fixtures'; // The signed-in user's e-mail address is never readable on screen (a creator filming the site leaked theirs through // the prefilled newsletter field): masked, the full address only in a tooltip on hover or focus. @@ -14,17 +14,19 @@ async function visibleText(page: import('./fixtures').Page): Promise { }); } -test('the homepage newsletter field is empty, and the account address subscribes without being shown', async ({ page }) => { +test('the homepage newsletter field is empty, and the account address subscribes without being shown', async ({ page }, testInfo) => { await login(page, 'alice', '/'); const field = page.locator('input[name="nl_email"]'); await expect(field).toHaveValue(''); + await field.scrollIntoViewIfNeeded(); + await evidence(page, testInfo, 'home-newsletter'); expect(await visibleText(page)).not.toContain(EMAIL); await page.locator('button[name="nl_use_account"]').click(); await expect(page.getByRole('alert')).toContainText(/Subscription confirmed|already subscribed|Inscription confirmée|déjà inscrite/); }); -test('the account menu and page show the address masked, in full in a tooltip', async ({ page, compact }) => { +test('the account menu and page show the address masked, in full in a tooltip', async ({ page }, testInfo) => { await login(page, 'alice', '/pages/account'); expect(await visibleText(page)).not.toContain(EMAIL); @@ -35,11 +37,12 @@ test('the account menu and page show the address masked, in full in a tooltip', await masked.focus(); await expect(tooltip).toBeVisible(); await expect(tooltip).toHaveText(EMAIL); + await evidence(page, testInfo, 'account-page'); - if (compact) return; // the account menu below is the desktop header's await page.locator('#azAccountBtn').click(); const menuMasked = page.locator('#azAccountMenu .ac-masked'); await expect(menuMasked).toContainText('a•••@example.test'); - await menuMasked.hover(); + await menuMasked.focus(); await expect(menuMasked.locator('.ac-tooltip')).toBeVisible(); + await evidence(page, testInfo, 'account-menu'); });