Merge pull request #95 from jcreek/feat/shareable-pokedex

This commit is contained in:
Josh Creek
2026-09-14 15:29:59 +01:00
committed by GitHub
26 changed files with 1206 additions and 243 deletions
+4
View File
@@ -83,6 +83,10 @@ jobs:
bdd: bdd:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
# Serve the committed sprites so offline sync doesn't download every Living Dex sprite from
# GitHub on each run.
env:
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER: 'true'
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- uses: actions/setup-node@v5 - uses: actions/setup-node@v5
+5 -34
View File
@@ -10,7 +10,8 @@
"dependencies": { "dependencies": {
"@supabase/ssr": "^0.1.0", "@supabase/ssr": "^0.1.0",
"@supabase/supabase-js": "^2.42.0", "@supabase/supabase-js": "^2.42.0",
"nanoid": "^5.0.4" "nanoid": "^5.0.4",
"sharp": "^0.33.4"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.55.1", "@playwright/test": "1.55.1",
@@ -36,7 +37,6 @@
"postcss": "^8.4.38", "postcss": "^8.4.38",
"prettier": "^3.1.1", "prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2", "prettier-plugin-svelte": "^3.1.2",
"sharp": "^0.33.4",
"supabase": "2.72.7", "supabase": "2.72.7",
"svelte": "^4.2.8", "svelte": "^4.2.8",
"svelte-check": "^3.6.2", "svelte-check": "^3.6.2",
@@ -46,7 +46,7 @@
"vitest": "^1.0.4" "vitest": "^1.0.4"
}, },
"engines": { "engines": {
"node": ">=18.13.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
} }
}, },
"node_modules/@aashutoshrathi/word-wrap": { "node_modules/@aashutoshrathi/word-wrap": {
@@ -2118,7 +2118,6 @@
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
"integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -2698,7 +2697,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2721,7 +2719,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2744,7 +2741,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2761,7 +2757,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2778,7 +2773,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2795,7 +2789,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2812,7 +2805,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2829,7 +2821,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2846,7 +2837,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2863,7 +2853,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2880,7 +2869,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2903,7 +2891,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2926,7 +2913,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2949,7 +2935,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2972,7 +2957,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2995,7 +2979,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3018,7 +3001,6 @@
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -3038,7 +3020,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later", "license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3058,7 +3039,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later", "license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5315,7 +5295,6 @@
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
"dev": true,
"dependencies": { "dependencies": {
"color-convert": "^2.0.1", "color-convert": "^2.0.1",
"color-string": "^1.9.0" "color-string": "^1.9.0"
@@ -5328,7 +5307,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
}, },
@@ -5339,14 +5317,12 @@
"node_modules/color-name": { "node_modules/color-name": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
"dev": true
}, },
"node_modules/color-string": { "node_modules/color-string": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"dev": true,
"dependencies": { "dependencies": {
"color-name": "^1.0.0", "color-name": "^1.0.0",
"simple-swizzle": "^0.2.2" "simple-swizzle": "^0.2.2"
@@ -5742,7 +5718,6 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -7140,8 +7115,7 @@
"node_modules/is-arrayish": { "node_modules/is-arrayish": {
"version": "0.3.2", "version": "0.3.2",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
"integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="
"dev": true
}, },
"node_modules/is-bigint": { "node_modules/is-bigint": {
"version": "1.0.4", "version": "1.0.4",
@@ -9484,7 +9458,6 @@
"version": "7.7.3", "version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@@ -9547,7 +9520,6 @@
"version": "0.33.5", "version": "0.33.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@@ -9703,7 +9675,6 @@
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
"integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
"dev": true,
"dependencies": { "dependencies": {
"is-arrayish": "^0.3.1" "is-arrayish": "^0.3.1"
} }
+3 -3
View File
@@ -68,7 +68,6 @@
"postcss": "^8.4.38", "postcss": "^8.4.38",
"prettier": "^3.1.1", "prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2", "prettier-plugin-svelte": "^3.1.2",
"sharp": "^0.33.4",
"supabase": "2.72.7", "supabase": "2.72.7",
"svelte": "^4.2.8", "svelte": "^4.2.8",
"svelte-check": "^3.6.2", "svelte-check": "^3.6.2",
@@ -81,9 +80,10 @@
"dependencies": { "dependencies": {
"@supabase/ssr": "^0.1.0", "@supabase/ssr": "^0.1.0",
"@supabase/supabase-js": "^2.42.0", "@supabase/supabase-js": "^2.42.0",
"nanoid": "^5.0.4" "nanoid": "^5.0.4",
"sharp": "^0.33.4"
}, },
"engines": { "engines": {
"node": ">=18.13.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
} }
} }
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { CatchRecord } from '$lib/models/CatchRecord'; import type { CatchRecord } from '$lib/models/CatchRecord';
import type { SharedCatchStatus } from '$lib/models/SharedPokedex';
import type { CatchInformationItem, PokedexEntry } from '$lib/models/PokedexEntry'; import type { CatchInformationItem, PokedexEntry } from '$lib/models/PokedexEntry';
import PokemonSprite from '../PokemonSprite.svelte'; import PokemonSprite from '../PokemonSprite.svelte';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
@@ -11,9 +12,11 @@
export let showShiny: boolean; export let showShiny: boolean;
export let userId: string | null = null; export let userId: string | null = null;
export let pokedexId: string; export let pokedexId: string;
export let readOnly = false;
export let sharedCatchStatus: SharedCatchStatus | null = null;
// Create a default catch record if none exists // Create a default catch record if none exists
$: if (!catchRecord) { $: if (!readOnly && !catchRecord) {
catchRecord = { catchRecord = {
_id: '', // Empty string, not temp ID - will be created by server _id: '', // Empty string, not temp ID - will be created by server
userId: userId || '', userId: userId || '',
@@ -36,10 +39,12 @@
): value is CatchInformationItem => typeof value !== 'string'; ): value is CatchInformationItem => typeof value !== 'string';
function updateCatchRecord(source: UpdateCatchSource) { function updateCatchRecord(source: UpdateCatchSource) {
if (readOnly) return;
dispatch('updateCatch', { pokedexEntry, catchRecord, source }); dispatch('updateCatch', { pokedexEntry, catchRecord, source });
} }
function onCaughtChange() { function onCaughtChange() {
if (readOnly) return;
if (!catchRecord) return; if (!catchRecord) return;
// Mutually exclusive with "needs to evolve" // Mutually exclusive with "needs to evolve"
if (catchRecord.caught) { if (catchRecord.caught) {
@@ -49,6 +54,7 @@
} }
function onNeedsToEvolveChange() { function onNeedsToEvolveChange() {
if (readOnly) return;
if (!catchRecord) return; if (!catchRecord) return;
// Mutually exclusive with "caught" // Mutually exclusive with "caught"
if (catchRecord.haveToEvolve) { if (catchRecord.haveToEvolve) {
@@ -95,78 +101,94 @@
{/if} {/if}
</div> </div>
{#if catchRecord} {#if readOnly || catchRecord}
<div <div
class="dex-column catch-record-container bg-base-100 text-base-content rounded-lg p-4 mb-4 md:mb-0" class="dex-column catch-record-container bg-base-100 text-base-content rounded-lg p-4 mb-4 md:mb-0"
> >
<div class="flex items-center"> {#if readOnly}
<div class="form-control"> <h3 class="text-lg font-semibold mb-2">Progress</h3>
<label class="cursor-pointer label"> <dl class="grid grid-cols-2 gap-x-4 gap-y-2">
<span class="block font-bold mr-2">Caught:</span> <dt>Caught</dt>
<input <dd class="font-semibold">{sharedCatchStatus?.caught ? 'Yes' : 'No'}</dd>
type="checkbox" <dt>Needs to evolve</dt>
bind:checked={catchRecord.caught} <dd class="font-semibold">{sharedCatchStatus?.haveToEvolve ? 'Yes' : 'No'}</dd>
class="checkbox checkbox-primary" <dt>In HOME</dt>
on:change={onCaughtChange} <dd class="font-semibold">{sharedCatchStatus?.inHome ? 'Yes' : 'No'}</dd>
/> {#if pokedexEntry.canGigantamax && showForms}
</label> <dt>Has Gigantamaxed</dt>
</div> <dd class="font-semibold">{sharedCatchStatus?.hasGigantamaxed ? 'Yes' : 'No'}</dd>
</div> {/if}
<div class="flex items-center"> </dl>
<div class="form-control"> {:else if catchRecord}
<label class="cursor-pointer label">
<span class="block font-bold mr-2">Needs to evolve:</span>
<input
type="checkbox"
bind:checked={catchRecord.haveToEvolve}
class="checkbox checkbox-primary"
on:change={onNeedsToEvolveChange}
/>
</label>
</div>
</div>
<div class="flex items-center">
<div class="form-control">
<label class="cursor-pointer label">
<span class="block font-bold mr-2">In Home:</span>
<input
type="checkbox"
bind:checked={catchRecord.inHome}
class="checkbox checkbox-primary"
on:change={() => updateCatchRecord('toggle')}
/>
</label>
</div>
</div>
{#if pokedexEntry.canGigantamax && showForms}
<div class="flex items-center"> <div class="flex items-center">
<div class="form-control"> <div class="form-control">
<label class="cursor-pointer label"> <label class="cursor-pointer label">
<span class="block font-bold mr-2">Has Gigantamaxed:</span> <span class="block font-bold mr-2">Caught:</span>
<input <input
type="checkbox" type="checkbox"
bind:checked={catchRecord.hasGigantamaxed} bind:checked={catchRecord.caught}
class="checkbox checkbox-primary"
on:change={onCaughtChange}
/>
</label>
</div>
</div>
<div class="flex items-center">
<div class="form-control">
<label class="cursor-pointer label">
<span class="block font-bold mr-2">Needs to evolve:</span>
<input
type="checkbox"
bind:checked={catchRecord.haveToEvolve}
class="checkbox checkbox-primary"
on:change={onNeedsToEvolveChange}
/>
</label>
</div>
</div>
<div class="flex items-center">
<div class="form-control">
<label class="cursor-pointer label">
<span class="block font-bold mr-2">In Home:</span>
<input
type="checkbox"
bind:checked={catchRecord.inHome}
class="checkbox checkbox-primary" class="checkbox checkbox-primary"
on:change={() => updateCatchRecord('toggle')} on:change={() => updateCatchRecord('toggle')}
/> />
</label> </label>
</div> </div>
</div> </div>
{#if pokedexEntry.canGigantamax && showForms}
<div class="flex items-center">
<div class="form-control">
<label class="cursor-pointer label">
<span class="block font-bold mr-2">Has Gigantamaxed:</span>
<input
type="checkbox"
bind:checked={catchRecord.hasGigantamaxed}
class="checkbox checkbox-primary"
on:change={() => updateCatchRecord('toggle')}
/>
</label>
</div>
</div>
{/if}
<p>
<label
class="block font-bold mb-1"
for={`personalNotesInput-${catchRecord._id || pokedexEntry._id}`}>Notes:</label
>
<textarea
bind:value={catchRecord.personalNotes}
id={`personalNotesInput-${catchRecord._id || pokedexEntry._id}`}
class="textarea textarea-bordered w-full"
style="min-height: 120px;"
on:input={() => updateCatchRecord('notes')}
on:change={() => updateCatchRecord('notes-blur')}
></textarea>
</p>
{/if} {/if}
<p>
<label
class="block font-bold mb-1"
for={`personalNotesInput-${catchRecord._id || pokedexEntry._id}`}>Notes:</label
>
<textarea
bind:value={catchRecord.personalNotes}
id={`personalNotesInput-${catchRecord._id || pokedexEntry._id}`}
class="textarea textarea-bordered w-full"
style="min-height: 120px;"
on:input={() => updateCatchRecord('notes')}
on:change={() => updateCatchRecord('notes-blur')}
></textarea>
</p>
</div> </div>
{/if} {/if}
@@ -2,12 +2,17 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { CatchRecord } from '$lib/models/CatchRecord'; import type { CatchRecord } from '$lib/models/CatchRecord';
import type { CombinedData } from '$lib/models/CombinedData'; import type { CombinedData } from '$lib/models/CombinedData';
import type { SharedCatchStatus, SharedCombinedData } from '$lib/models/SharedPokedex';
import { calculateBoxPlacement } from '$lib/utils/boxPlacement'; import { calculateBoxPlacement } from '$lib/utils/boxPlacement';
import PokemonSprite from '$lib/components/PokemonSprite.svelte'; import PokemonSprite from '$lib/components/PokemonSprite.svelte';
import Tooltip from '$lib/components/Tooltip.svelte'; import Tooltip from '$lib/components/Tooltip.svelte';
export let showShiny = false; export let showShiny = false;
export let combinedData: CombinedData[] | null; type DisplayData = CombinedData | SharedCombinedData;
type DisplayStatus = CatchRecord | SharedCatchStatus | null;
export let combinedData: DisplayData[] | null;
export let readOnly = false;
export let boxNumbers: number[] = []; export let boxNumbers: number[] = [];
export let creatingRecords = false; export let creatingRecords = false;
export let totalRecordsCreated = 0; export let totalRecordsCreated = 0;
@@ -18,7 +23,7 @@
export let markBoxAsInHome: (boxNumber: number) => void = () => {}; export let markBoxAsInHome: (boxNumber: number) => void = () => {};
export let markBoxAsNotInHome: (boxNumber: number) => void = () => {}; export let markBoxAsNotInHome: (boxNumber: number) => void = () => {};
export let createCatchRecords = () => {}; export let createCatchRecords = () => {};
export let onPokemonClick: (pokemon: CombinedData) => void = () => {}; export let onPokemonClick: (pokemon: DisplayData) => void = () => {};
let filterNotCaught = false; let filterNotCaught = false;
let filterNeedsToEvolve = false; let filterNeedsToEvolve = false;
@@ -36,7 +41,7 @@
return () => window.removeEventListener('click', close); return () => window.removeEventListener('click', close);
}); });
let filteredCombinedData: CombinedData[] = []; let filteredCombinedData: DisplayData[] = [];
let filteredTotal = 0; let filteredTotal = 0;
let overallTotal = 0; let overallTotal = 0;
let overallCaughtCount = 0; let overallCaughtCount = 0;
@@ -47,7 +52,7 @@
let filtersActive = false; let filtersActive = false;
let filtersKey = ''; let filtersKey = '';
function normalizedStatus(catchRecord: CatchRecord | null) { function normalizedStatus(catchRecord: DisplayStatus) {
return { return {
caught: !!catchRecord?.caught, caught: !!catchRecord?.caught,
needsToEvolve: !!catchRecord?.haveToEvolve, needsToEvolve: !!catchRecord?.haveToEvolve,
@@ -55,7 +60,7 @@
}; };
} }
function matchesFilters(catchRecord: CatchRecord | null) { function matchesFilters(catchRecord: DisplayStatus) {
const status = normalizedStatus(catchRecord); const status = normalizedStatus(catchRecord);
if (!filtersActive) return true; if (!filtersActive) return true;
@@ -148,7 +153,7 @@
boxViewLayout === 'comfortable' ? 1 : boxViewLayout === 'compact' ? 0.6 : 0.45; boxViewLayout === 'comfortable' ? 1 : boxViewLayout === 'compact' ? 0.6 : 0.45;
$: spriteSizePx = boxViewLayout === 'comfortable' ? 64 : boxViewLayout === 'compact' ? 52 : 44; $: spriteSizePx = boxViewLayout === 'comfortable' ? 64 : boxViewLayout === 'compact' ? 52 : 44;
function cellStatusClasses(catchRecord: CatchRecord | null) { function cellStatusClasses(catchRecord: DisplayStatus) {
// Keep borders/layout unchanged; rely on clearer fills + badges instead. // Keep borders/layout unchanged; rely on clearer fills + badges instead.
if (catchRecord?.caught) { if (catchRecord?.caught) {
// Match legend (green-600) while keeping sprites readable. // Match legend (green-600) while keeping sprites readable.
@@ -161,7 +166,7 @@
return ''; return '';
} }
function statusLabel(catchRecord: CatchRecord | null) { function statusLabel(catchRecord: DisplayStatus) {
const parts: string[] = []; const parts: string[] = [];
if (catchRecord?.caught) parts.push('Caught'); if (catchRecord?.caught) parts.push('Caught');
if (catchRecord?.haveToEvolve) parts.push('Needs to evolve'); if (catchRecord?.haveToEvolve) parts.push('Needs to evolve');
@@ -169,7 +174,7 @@
return parts.length ? parts.join(', ') : 'Not caught'; return parts.length ? parts.join(', ') : 'Not caught';
} }
function cellBackgroundColourStyle(index: number, catchRecord: CatchRecord | null) { function cellBackgroundColourStyle(index: number, catchRecord: DisplayStatus) {
if (catchRecord?.caught || catchRecord?.haveToEvolve) { if (catchRecord?.caught || catchRecord?.haveToEvolve) {
return ''; return '';
} else { } else {
@@ -353,88 +358,88 @@
<div class="mb-8"> <div class="mb-8">
<div class="flex flex-wrap items-center justify-between gap-3 mb-4 relative z-20"> <div class="flex flex-wrap items-center justify-between gap-3 mb-4 relative z-20">
<h2 class="text-xl font-bold">Box {boxNumber}</h2> <h2 class="text-xl font-bold">Box {boxNumber}</h2>
<div class="relative"> {#if !readOnly}<div class="relative">
<button <button
type="button" type="button"
class="btn btn-sm btn-outline relative z-[210]" class="btn btn-sm btn-outline relative z-[210]"
aria-label="Open bulk actions menu" aria-label="Open bulk actions menu"
aria-haspopup="menu" aria-haspopup="menu"
aria-controls={bulkMenuId} aria-controls={bulkMenuId}
aria-expanded={openBulkMenuForBox === boxNumber} aria-expanded={openBulkMenuForBox === boxNumber}
on:click={(event) => { on:click={(event) => {
event.stopPropagation(); event.stopPropagation();
openBulkMenuForBox = openBulkMenuForBox === boxNumber ? null : boxNumber; openBulkMenuForBox = openBulkMenuForBox === boxNumber ? null : boxNumber;
}} }}
on:keydown={(event) => { on:keydown={(event) => {
if (event.key === 'Escape') openBulkMenuForBox = null; if (event.key === 'Escape') openBulkMenuForBox = null;
}} }}
>
⋯
</button>
{#if openBulkMenuForBox === boxNumber}
<ul
id={bulkMenuId}
class="menu bg-base-100 rounded-box absolute right-0 mt-2 z-[220] w-56 p-2 shadow border border-base-300"
> >
<li> ⋯
<button </button>
type="button"
on:click|stopPropagation={() => { {#if openBulkMenuForBox === boxNumber}
markBoxAsNotCaught(boxNumber); <ul
openBulkMenuForBox = null; id={bulkMenuId}
}} class="menu bg-base-100 rounded-box absolute right-0 mt-2 z-[220] w-56 p-2 shadow border border-base-300"
> >
Mark box as Not caught <li>
</button> <button
</li> type="button"
<li> on:click|stopPropagation={() => {
<button markBoxAsNotCaught(boxNumber);
type="button" openBulkMenuForBox = null;
on:click|stopPropagation={() => { }}
markBoxAsCaught(boxNumber); >
openBulkMenuForBox = null; Mark box as Not caught
}} </button>
> </li>
Mark box as Caught <li>
</button> <button
</li> type="button"
<li> on:click|stopPropagation={() => {
<button markBoxAsCaught(boxNumber);
type="button" openBulkMenuForBox = null;
on:click|stopPropagation={() => { }}
markBoxAsNeedsToEvolve(boxNumber); >
openBulkMenuForBox = null; Mark box as Caught
}} </button>
> </li>
Mark box as Needs to evolve <li>
</button> <button
</li> type="button"
<li> on:click|stopPropagation={() => {
<button markBoxAsNeedsToEvolve(boxNumber);
type="button" openBulkMenuForBox = null;
on:click|stopPropagation={() => { }}
markBoxAsInHome(boxNumber); >
openBulkMenuForBox = null; Mark box as Needs to evolve
}} </button>
> </li>
Mark box as In HOME <li>
</button> <button
</li> type="button"
<li> on:click|stopPropagation={() => {
<button markBoxAsInHome(boxNumber);
type="button" openBulkMenuForBox = null;
on:click|stopPropagation={() => { }}
markBoxAsNotInHome(boxNumber); >
openBulkMenuForBox = null; Mark box as In HOME
}} </button>
> </li>
Mark box as Not in HOME <li>
</button> <button
</li> type="button"
</ul> on:click|stopPropagation={() => {
{/if} markBoxAsNotInHome(boxNumber);
</div> openBulkMenuForBox = null;
}}
>
Mark box as Not in HOME
</button>
</li>
</ul>
{/if}
</div>{/if}
</div> </div>
<div class="grid grid-cols-6"> <div class="grid grid-cols-6">
{#each BOX_POSITIONS as positionInBox} {#each BOX_POSITIONS as positionInBox}
@@ -583,7 +588,9 @@
If you're seeing this, you probably haven't created your Pokédex data yet. Please do so by If you're seeing this, you probably haven't created your Pokédex data yet. Please do so by
clicking this button. clicking this button.
</p> </p>
<button class="btn" on:click={createCatchRecords}>Create Pokédex data</button> {#if !readOnly}
<button class="btn" on:click={createCatchRecords}>Create Pokédex data</button>
{/if}
{/if} {/if}
{:else} {:else}
<div class="min-w-max mx-auto"> <div class="min-w-max mx-auto">
+2
View File
@@ -1,5 +1,6 @@
export interface Pokedex { export interface Pokedex {
_id: string; _id: string;
shareToken: string;
userId: string; userId: string;
name: string; name: string;
description: string; description: string;
@@ -13,6 +14,7 @@ export interface Pokedex {
export interface PokedexDB { export interface PokedexDB {
id: string; id: string;
shareToken: string;
userId: string; userId: string;
name: string; name: string;
description: string; description: string;
+36
View File
@@ -0,0 +1,36 @@
import type { PokedexEntry } from './PokedexEntry';
export interface SharedCatchStatus {
pokemonId: string;
caught: boolean;
haveToEvolve: boolean;
inHome: boolean;
hasGigantamaxed: boolean;
}
export interface SharedPokedexMetadata {
name: string;
description: string;
isLivingDex: boolean;
isShinyDex: boolean;
isOriginDex: boolean;
isFormDex: boolean;
gameScope: string | null;
dexScopes: string[];
}
export interface SharedPokedexRpcData extends SharedPokedexMetadata {
catchStatuses: SharedCatchStatus[];
}
export interface SharedCombinedData {
pokedexEntry: PokedexEntry;
catchRecord: SharedCatchStatus | null;
}
export interface SharedPokedexData extends SharedPokedexMetadata {
combinedData: SharedCombinedData[];
total: number;
caught: number;
completionPercentage: number;
}
@@ -10,6 +10,7 @@ class PokedexRepository {
private transform(db: PokedexDB, dexScopes: string[] = []): Pokedex { private transform(db: PokedexDB, dexScopes: string[] = []): Pokedex {
return { return {
_id: db.id, _id: db.id,
shareToken: db.shareToken,
userId: db.userId, userId: db.userId,
name: db.name, name: db.name,
description: db.description || '', description: db.description || '',
+88
View File
@@ -0,0 +1,88 @@
import sharp from 'sharp';
import type { SharedPokedexData } from '$lib/models/SharedPokedex';
export const SHARE_PREVIEW_WIDTH = 1200;
export const SHARE_PREVIEW_HEIGHT = 630;
export function escapeXml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
export function truncatePreviewText(value: string, maximumLength: number): string {
const normalized = value.replace(/\s+/g, ' ').trim();
if (normalized.length <= maximumLength) return normalized;
return `${normalized.slice(0, Math.max(0, maximumLength - 1)).trimEnd()}…`;
}
function previewBadges(shared: SharedPokedexData): string[] {
return [
shared.isLivingDex && 'Living',
shared.isShinyDex && 'Shiny',
shared.isOriginDex && 'Origin',
shared.isFormDex && 'Form',
shared.gameScope || 'All Games'
].filter((value): value is string => Boolean(value));
}
export function buildSharePreviewSvg(shared: SharedPokedexData): string {
const name = escapeXml(truncatePreviewText(shared.name, 48));
const description = escapeXml(truncatePreviewText(shared.description, 92));
const badges = previewBadges(shared).slice(0, 5);
const badgeMarkup = badges
.map((badge, index) => {
const label = escapeXml(truncatePreviewText(badge, 22));
const width = Math.max(112, Math.min(220, 44 + badge.length * 15));
const previousWidth = badges
.slice(0, index)
.reduce((sum, value) => sum + Math.max(112, Math.min(220, 44 + value.length * 15)) + 16, 0);
return `<g transform="translate(${76 + previousWidth} 270)">
<rect width="${width}" height="52" rx="26" fill="#fee2e2" />
<text x="${width / 2}" y="34" text-anchor="middle" class="badge">${label}</text>
</g>`;
})
.join('');
const progressWidth = Math.round(
(870 * Math.min(100, Math.max(0, shared.completionPercentage))) / 100
);
return `<svg xmlns="http://www.w3.org/2000/svg" width="${SHARE_PREVIEW_WIDTH}" height="${SHARE_PREVIEW_HEIGHT}" viewBox="0 0 ${SHARE_PREVIEW_WIDTH} ${SHARE_PREVIEW_HEIGHT}">
<defs>
<linearGradient id="background" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#7f1d1d" />
<stop offset="1" stop-color="#dc2626" />
</linearGradient>
</defs>
<style>
.title { font: 700 64px system-ui, -apple-system, sans-serif; fill: #fff; }
.description { font: 400 27px system-ui, -apple-system, sans-serif; fill: #fecaca; }
.badge { font: 650 22px system-ui, -apple-system, sans-serif; fill: #991b1b; }
.progress { font: 750 52px system-ui, -apple-system, sans-serif; fill: #fff; }
.percent { font: 800 82px system-ui, -apple-system, sans-serif; fill: #fff; }
.brand { font: 650 24px system-ui, -apple-system, sans-serif; fill: #fecaca; letter-spacing: 1px; }
</style>
<rect width="1200" height="630" fill="url(#background)" />
<circle cx="1070" cy="90" r="190" fill="#fff" opacity=".08" />
<circle cx="1070" cy="90" r="62" fill="none" stroke="#fff" stroke-width="26" opacity=".16" />
<path d="M880 90h380" stroke="#fff" stroke-width="26" opacity=".16" />
<text x="76" y="118" class="brand">LIVING DEX TRACKER</text>
<text x="76" y="205" class="title">${name}</text>
${description ? `<text x="76" y="246" class="description">${description}</text>` : ''}
${badgeMarkup}
<text x="76" y="425" class="progress">${shared.caught} of ${shared.total} Pokémon caught</text>
<text x="1090" y="425" text-anchor="end" class="percent">${shared.completionPercentage}%</text>
<rect x="76" y="472" width="870" height="28" rx="14" fill="#450a0a" opacity=".65" />
<rect x="76" y="472" width="${progressWidth}" height="28" rx="14" fill="#fff" />
<text x="76" y="574" class="brand">pokedex.jcreek.co.uk</text>
</svg>`;
}
export async function renderSharePreview(shared: SharedPokedexData): Promise<Buffer> {
return sharp(Buffer.from(buildSharePreviewSvg(shared)))
.png()
.toBuffer();
}
+69
View File
@@ -0,0 +1,69 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
import type {
SharedCatchStatus,
SharedPokedexData,
SharedPokedexRpcData
} from '$lib/models/SharedPokedex';
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export function isShareToken(value: string): boolean {
return UUID_PATTERN.test(value);
}
export function calculateSharedProgress(statuses: SharedCatchStatus[], total: number) {
const caught = statuses.reduce(
(sum, status) => sum + (status.caught || status.haveToEvolve ? 1 : 0),
0
);
return {
caught,
completionPercentage: total === 0 ? 0 : Math.round((caught / total) * 100)
};
}
export async function loadSharedPokedex(
supabase: SupabaseClient,
shareToken: string
): Promise<SharedPokedexData | null> {
if (!isShareToken(shareToken)) return null;
const { data, error } = await supabase.rpc('get_shared_pokedex', {
p_share_token: shareToken
});
if (error || !data) return null;
const shared = data as SharedPokedexRpcData;
const repo = new CombinedDataRepository(supabase, null, null);
const entries = await repo.findAllCombinedData(
'',
shared.isFormDex,
'',
shared.gameScope || '',
shared.dexScopes
);
const statuses = new Map(shared.catchStatuses.map((status) => [status.pokemonId, status]));
const combinedData = entries.map(({ pokedexEntry }) => ({
pokedexEntry,
catchRecord: statuses.get(pokedexEntry._id) ?? null
}));
const visibleStatuses = combinedData.flatMap(({ catchRecord }) =>
catchRecord ? [catchRecord] : []
);
const progress = calculateSharedProgress(visibleStatuses, combinedData.length);
return {
name: shared.name,
description: shared.description,
isLivingDex: shared.isLivingDex,
isShinyDex: shared.isShinyDex,
isOriginDex: shared.isOriginDex,
isFormDex: shared.isFormDex,
gameScope: shared.gameScope,
dexScopes: shared.dexScopes,
combinedData,
total: combinedData.length,
...progress
};
}
+2
View File
@@ -158,6 +158,8 @@ export function startOfflineSync(getUserId: () => string | null): () => void {
}, 1_000); }, 1_000);
}; };
// Best effort: ask the browser not to evict the offline artwork cache under storage pressure.
void navigator.storage?.persist?.().catch(() => undefined);
window.addEventListener(SYNC_EVENT, schedule); window.addEventListener(SYNC_EVENT, schedule);
window.addEventListener('online', schedule); window.addEventListener('online', schedule);
schedule(); schedule();
-7
View File
@@ -123,13 +123,6 @@
name="description" name="description"
content="A free and open source web app to track completion of a living Pokédex, which works offline." content="A free and open source web app to track completion of a living Pokédex, which works offline."
/> --> /> -->
<meta property="og:title" content="Living Dex Tracker - A free Pokédex completion tool" />
<meta property="og:url" content="https://pokedex.jcreek.co.uk" />
<meta
property="og:description"
content="A free and open source web app to track completion of a living Pokédex, which works offline."
/>
<link rel="canonical" href="https://pokedex.jcreek.co.uk" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<!-- <link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" /> --> <!-- <link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" /> -->
</svelte:head> </svelte:head>
+8
View File
@@ -62,6 +62,14 @@
name="description" name="description"
content="A free, open source tool to track your Living Pokédex progress. Join thousands of trainers worldwide in completing their collection." content="A free, open source tool to track your Living Pokédex progress. Join thousands of trainers worldwide in completing their collection."
/> />
<link rel="canonical" href="https://pokedex.jcreek.co.uk" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Living Dex Tracker - A free Pokédex completion tool" />
<meta property="og:url" content="https://pokedex.jcreek.co.uk" />
<meta
property="og:description"
content="A free and open source web app to track completion of a living Pokédex, which works offline."
/>
</svelte:head> </svelte:head>
{#if isCheckingSession} {#if isCheckingSession}
+93 -2
View File
@@ -17,6 +17,7 @@
import type { Pokedex } from '$lib/models/Pokedex'; import type { Pokedex } from '$lib/models/Pokedex';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { requestOfflineSync } from '$lib/stores/offlineSync'; import { requestOfflineSync } from '$lib/stores/offlineSync';
import type { SharedCombinedData } from '$lib/models/SharedPokedex';
export let data: PageData; export let data: PageData;
@@ -49,6 +50,10 @@
let boxNumbers: number[] = []; let boxNumbers: number[] = [];
let showModal = false; let showModal = false;
let selectedPokemon: CombinedData | null = null; let selectedPokemon: CombinedData | null = null;
let showShareModal = false;
let shareUrl = '';
let shareFeedback = '';
let nativeShareSupported = false;
let catchWriteQueue: ReturnType<typeof createCatchRecordWriteQueue> | null = null; let catchWriteQueue: ReturnType<typeof createCatchRecordWriteQueue> | null = null;
let catchWriteQueueKey: string | null = null; let catchWriteQueueKey: string | null = null;
@@ -148,11 +153,47 @@
resetExportState(); resetExportState();
}); });
function openPokemonModal(pokemon: CombinedData) { function openPokemonModal(pokemon: CombinedData | SharedCombinedData) {
selectedPokemon = pokemon; selectedPokemon = pokemon as CombinedData;
showModal = true; showModal = true;
} }
function openShareModal() {
if (!pokedex?.shareToken || !browser) return;
shareUrl = `${window.location.origin}/shared/${pokedex.shareToken}`;
shareFeedback = '';
showShareModal = true;
}
function closeShareModal() {
showShareModal = false;
shareFeedback = '';
}
async function copyShareLink() {
try {
await navigator.clipboard.writeText(shareUrl);
shareFeedback = 'Link copied';
} catch {
shareFeedback = 'Copy failed — select the link above to copy it manually.';
}
}
async function sharePokedex() {
if (!nativeShareSupported || !pokedex) return;
try {
await navigator.share({
title: pokedex.name,
text: `See my ${pokedex.name} progress on Living Dex Tracker.`,
url: shareUrl
});
} catch (error) {
if (!(error instanceof DOMException && error.name === 'AbortError')) {
shareFeedback = 'Sharing failed. You can copy the link instead.';
}
}
}
function closePokemonModal() { function closePokemonModal() {
showModal = false; showModal = false;
selectedPokemon = null; selectedPokemon = null;
@@ -438,6 +479,7 @@
onMount(() => { onMount(() => {
if (!browser) return; if (!browser) return;
nativeShareSupported = typeof navigator.share === 'function';
const flushKeepalive = () => { const flushKeepalive = () => {
if (!catchWriteQueue) return; if (!catchWriteQueue) return;
@@ -617,6 +659,20 @@
Save failed (will retry) Save failed (will retry)
</div> </div>
{/if} {/if}
<button type="button" class="btn btn-primary btn-sm" on:click={openShareModal}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
d="M15 8a3 3 0 1 0-2.83-4L7.91 6.13a3 3 0 0 0 0 1.74L12.17 10A3 3 0 1 0 13 8.59L8.83 6.5 13 4.41A3 3 0 0 0 15 8Zm0 10a3 3 0 1 0-2.83-4L7.91 11.87a3 3 0 1 0 0 1.74L12.17 15.7A3 3 0 0 0 15 18Z"
/>
</svg>
Share
</button>
<a href="/my-pokedexes" class="btn btn-outline btn-sm"> <a href="/my-pokedexes" class="btn btn-outline btn-sm">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -667,4 +723,39 @@
/> />
</PokedexModal> </PokedexModal>
{/if} {/if}
{#if showShareModal}
<div class="modal modal-open" role="dialog" aria-modal="true" aria-labelledby="share-title">
<div class="modal-box">
<h2 id="share-title" class="font-bold text-xl">Share {pokedex.name}</h2>
<p class="py-3 text-sm text-base-content/70">
Anyone with this link can view live progress. Personal notes are never shared.
</p>
<label class="label" for="share-url"><span class="label-text">Read-only link</span></label>
<input
id="share-url"
class="input input-bordered w-full"
value={shareUrl}
readonly
on:focus={(event) => event.currentTarget.select()}
/>
{#if shareFeedback}
<p class="text-sm mt-2" role="status">{shareFeedback}</p>
{/if}
<div class="modal-action">
<button type="button" class="btn btn-ghost" on:click={closeShareModal}>Close</button>
<button type="button" class="btn btn-outline" on:click={copyShareLink}>Copy link</button>
{#if nativeShareSupported}
<button type="button" class="btn btn-primary" on:click={sharePokedex}>Share…</button>
{/if}
</div>
</div>
<button
class="modal-backdrop"
type="button"
aria-label="Close share dialog"
on:click={closeShareModal}
></button>
</div>
{/if}
{/if} {/if}
+19
View File
@@ -0,0 +1,19 @@
import { error } from '@sveltejs/kit';
import { loadSharedPokedex } from '$lib/services/SharedPokedexService';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params, url, setHeaders }) => {
const shared = await loadSharedPokedex(locals.supabase, params.token);
if (!shared) throw error(404, 'Shared Pokédex not found');
setHeaders({
'Referrer-Policy': 'no-referrer',
'X-Robots-Tag': 'noindex, nofollow'
});
const canonicalUrl = `${url.origin}/shared/${params.token}`;
return {
shared,
canonicalUrl,
previewUrl: `${canonicalUrl}/preview.png`
};
};
+103
View File
@@ -0,0 +1,103 @@
<script lang="ts">
import type { CombinedData } from '$lib/models/CombinedData';
import type { SharedCombinedData } from '$lib/models/SharedPokedex';
import type { PageData } from './$types';
import { calculateBoxNumbers } from '$lib/utils/boxPlacement';
import PokedexViewBoxes from '$lib/components/pokedex/PokedexViewBoxes.svelte';
import PokedexModal from '$lib/components/pokedex/PokedexModal.svelte';
import PokedexEntryCatchRecord from '$lib/components/pokedex/PokedexEntryCatchRecord.svelte';
export let data: PageData;
$: shared = data.shared;
$: boxNumbers = calculateBoxNumbers(shared.combinedData.length);
$: description = `${shared.caught} of ${shared.total} Pokémon caught (${shared.completionPercentage}% complete).`;
let selectedPokemon: SharedCombinedData | null = null;
let showModal = false;
function openPokemonModal(pokemon: SharedCombinedData) {
selectedPokemon = pokemon;
showModal = true;
}
function handlePokemonClick(pokemon: CombinedData | SharedCombinedData) {
openPokemonModal(pokemon as SharedCombinedData);
}
function closePokemonModal() {
showModal = false;
selectedPokemon = null;
}
$: typeBadges = [
shared.isLivingDex && 'Living',
shared.isShinyDex && 'Shiny',
shared.isOriginDex && 'Origin',
shared.isFormDex && 'Form'
].filter(Boolean);
</script>
<svelte:head>
<title>{shared.name} - Living Dex Tracker</title>
<meta name="description" content={description} />
<meta name="robots" content="noindex, nofollow" />
<meta name="referrer" content="no-referrer" />
<link rel="canonical" href={data.canonicalUrl} />
<meta property="og:type" content="website" />
<meta property="og:title" content={`${shared.name} - Living Dex Tracker`} />
<meta property="og:description" content={description} />
<meta property="og:url" content={data.canonicalUrl} />
<meta property="og:image" content={data.previewUrl} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content={`${shared.name} Pokédex progress: ${description}`} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={`${shared.name} - Living Dex Tracker`} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={data.previewUrl} />
</svelte:head>
<div class="container mx-auto p-4 max-w-screen-2xl">
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<div class="flex flex-col gap-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="card-title text-3xl">{shared.name}</h1>
<span class="badge badge-outline badge-lg">Read-only shared Pokédex</span>
</div>
<div class="flex flex-wrap gap-2">
{#each typeBadges as badge}
<span class="badge badge-primary badge-lg">{badge}</span>
{/each}
<span class="badge badge-ghost badge-lg">{shared.gameScope || 'All Games'}</span>
</div>
{#if shared.description}<p class="text-base-content/70">{shared.description}</p>{/if}
<p class="text-lg font-semibold">
{shared.caught} of {shared.total} Pokémon caught · {shared.completionPercentage}% complete
</p>
</div>
</div>
</div>
<PokedexViewBoxes
readOnly
showShiny={shared.isShinyDex}
combinedData={shared.combinedData}
{boxNumbers}
onPokemonClick={handlePokemonClick}
/>
</div>
{#if showModal && selectedPokemon}
<PokedexModal isOpen={showModal} onClose={closePokemonModal}>
<PokedexEntryCatchRecord
readOnly
pokedexEntry={selectedPokemon.pokedexEntry}
catchRecord={null}
sharedCatchStatus={selectedPokemon.catchRecord}
showOrigins={shared.isOriginDex}
showForms={shared.isFormDex}
showShiny={shared.isShinyDex}
pokedexId=""
/>
</PokedexModal>
{/if}
@@ -0,0 +1,18 @@
import { error } from '@sveltejs/kit';
import { loadSharedPokedex } from '$lib/services/SharedPokedexService';
import { renderSharePreview } from '$lib/services/SharePreviewService';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ locals, params }) => {
const shared = await loadSharedPokedex(locals.supabase, params.token);
if (!shared) throw error(404, 'Shared Pokédex not found');
const image = await renderSharePreview(shared);
return new Response(image, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=300, stale-while-revalidate=600',
'Content-Length': String(image.byteLength)
}
});
};
+74 -39
View File
@@ -1,6 +1,7 @@
const OFFLINE_CACHE_PREFIX = 'livingdex-offline-'; const OFFLINE_CACHE_PREFIX = 'livingdex-offline-';
const OFFLINE_META_CACHE = `${OFFLINE_CACHE_PREFIX}meta-v1`; const OFFLINE_META_CACHE = `${OFFLINE_CACHE_PREFIX}meta-v1`;
const OFFLINE_META_URL = '/__offline/current'; const OFFLINE_META_URL = '/__offline/current';
const ARTWORK_FETCH_TIMEOUT_MS = 15_000;
let offlineEpoch = 0; let offlineEpoch = 0;
let offlineOperation = Promise.resolve(); let offlineOperation = Promise.resolve();
let claimedUserId = null; let claimedUserId = null;
@@ -15,8 +16,15 @@ function dataCacheName(userId, generation) {
return `${OFFLINE_CACHE_PREFIX}data-v1-${userId}-${generation}`; return `${OFFLINE_CACHE_PREFIX}data-v1-${userId}-${generation}`;
} }
function artworkCacheName(userId, generation) { // Sprites never change at a given URL, so each user keeps one artwork cache that is topped up
return `${OFFLINE_CACHE_PREFIX}art-v1-${userId}-${generation}`; // incrementally instead of being rebuilt on every sync.
function artworkCacheName(userId) {
return `${OFFLINE_CACHE_PREFIX}art-v2-${userId}`;
}
// Covers both the local `/sprites-small/...` folder and the GitHub-hosted copy of it.
function isSpriteUrl(url) {
return /\/sprites(-small)?\//.test(url.pathname) && url.pathname.endsWith('.webp');
} }
async function currentOfflineMeta() { async function currentOfflineMeta() {
@@ -38,22 +46,39 @@ async function notifyOfflineDataCleared() {
for (const client of windows) client.postMessage({ type: 'OFFLINE_DATA_CLEARED' }); for (const client of windows) client.postMessage({ type: 'OFFLINE_DATA_CLEARED' });
} }
async function cacheArtwork(cache, urls) { async function cacheArtwork(cache, urls, isCurrent) {
const wanted = new Set(urls.map((url) => new URL(url, self.location.origin).href));
const cachedRequests = await cache.keys();
const cached = new Set(cachedRequests.map((request) => request.url));
await Promise.all(
cachedRequests
.filter((request) => !wanted.has(request.url))
.map((request) => cache.delete(request))
);
const missing = [...wanted].filter((url) => !cached.has(url));
let next = 0; let next = 0;
let failed = 0; let failed = 0;
const workers = Array.from({ length: Math.min(6, urls.length) }, async () => { const workers = Array.from({ length: Math.min(6, missing.length) }, async () => {
for (;;) { for (;;) {
const index = next++; const index = next++;
if (index >= urls.length) return; if (index >= missing.length || !isCurrent()) return;
const url = urls[index]; const url = missing[index];
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), ARTWORK_FETCH_TIMEOUT_MS);
try { try {
// CORS rather than no-cors: opaque responses are padded to several MB each for storage
// quota, which a full Living Dex of artwork would exhaust.
const response = await fetch(url, { const response = await fetch(url, {
mode: url.startsWith(self.location.origin) ? 'same-origin' : 'no-cors' mode: url.startsWith(self.location.origin) ? 'same-origin' : 'cors',
signal: controller.signal
}); });
if (!response.ok && response.type !== 'opaque') throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
await cache.put(url, response); await cache.put(url, response);
} catch { } catch {
failed++; failed++;
} finally {
clearTimeout(timeout);
} }
} }
}); });
@@ -98,11 +123,12 @@ self.addEventListener('message', (event) => {
if (event.data?.type !== 'SYNC_OFFLINE_SNAPSHOT') return; if (event.data?.type !== 'SYNC_OFFLINE_SNAPSHOT') return;
const syncEpoch = offlineEpoch; const syncEpoch = offlineEpoch;
const syncUserId = claimedUserId; const syncUserId = claimedUserId;
const isCurrent = () => syncEpoch === offlineEpoch;
event.waitUntil( event.waitUntil(
queueOfflineOperation(async () => { queueOfflineOperation(async () => {
let nextData; let nextData;
let nextArtwork; let committed = false;
try { try {
const snapshot = event.data.snapshot; const snapshot = event.data.snapshot;
if (!snapshot || snapshot.version !== 1 || typeof snapshot.userId !== 'string') { if (!snapshot || snapshot.version !== 1 || typeof snapshot.userId !== 'string') {
@@ -114,6 +140,13 @@ self.addEventListener('message', (event) => {
const previousMeta = await currentOfflineMeta(); const previousMeta = await currentOfflineMeta();
if (previousMeta?.userId && previousMeta.userId !== snapshot.userId) if (previousMeta?.userId && previousMeta.userId !== snapshot.userId)
await clearOfflineData(); await clearOfflineData();
// Per-sync artwork caches of opaque responses could fill the whole storage quota, so drop
// them before writing anything new.
await Promise.all(
(await caches.keys())
.filter((name) => name.startsWith(`${OFFLINE_CACHE_PREFIX}art-v1-`))
.map((name) => caches.delete(name))
);
const timestamp = String(snapshot.generatedAt).replace(/[^0-9]/g, ''); const timestamp = String(snapshot.generatedAt).replace(/[^0-9]/g, '');
const generation = `${timestamp}-${crypto.randomUUID()}`; const generation = `${timestamp}-${crypto.randomUUID()}`;
@@ -125,18 +158,12 @@ self.addEventListener('message', (event) => {
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}) })
); );
if (!isCurrent())
nextArtwork = artworkCacheName(snapshot.userId, generation);
const artworkCache = await caches.open(nextArtwork);
const failedArtwork = await cacheArtwork(
artworkCache,
Array.from(new Set(event.data.artworkUrls ?? []))
);
if (syncEpoch !== offlineEpoch) {
await Promise.all([caches.delete(nextData), caches.delete(nextArtwork)]);
throw new Error('Offline synchronization was superseded by an account change'); throw new Error('Offline synchronization was superseded by an account change');
}
// Commit the collection before any artwork so a slow or failing sprite download can never
// prevent the offline copy from being saved.
const artworkCache = artworkCacheName(snapshot.userId);
const metaCache = await caches.open(OFFLINE_META_CACHE); const metaCache = await caches.open(OFFLINE_META_CACHE);
await metaCache.put( await metaCache.put(
OFFLINE_META_URL, OFFLINE_META_URL,
@@ -145,40 +172,41 @@ self.addEventListener('message', (event) => {
userId: snapshot.userId, userId: snapshot.userId,
generatedAt: snapshot.generatedAt, generatedAt: snapshot.generatedAt,
dataCache: nextData, dataCache: nextData,
artworkCache: nextArtwork artworkCache
}), }),
{ {
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
} }
) )
); );
if (syncEpoch !== offlineEpoch) { if (!isCurrent()) {
const current = await currentOfflineMeta(); const current = await currentOfflineMeta();
if (current?.dataCache === nextData) await metaCache.delete(OFFLINE_META_URL); if (current?.dataCache === nextData) await metaCache.delete(OFFLINE_META_URL);
await Promise.all([caches.delete(nextData), caches.delete(nextArtwork)]);
throw new Error('Offline synchronization was superseded by an account change'); throw new Error('Offline synchronization was superseded by an account change');
} }
if (previousMeta?.artworkCache && previousMeta.artworkCache !== nextArtwork) { committed = true;
await caches.delete(previousMeta.artworkCache);
}
if (previousMeta?.dataCache && previousMeta.dataCache !== nextData) {
await caches.delete(previousMeta.dataCache);
}
const currentCaches = await caches.keys(); const currentCaches = await caches.keys();
await Promise.all( await Promise.all(
currentCaches currentCaches
.filter( .filter(
(name) => (name) =>
name.startsWith(OFFLINE_CACHE_PREFIX) && name.startsWith(OFFLINE_CACHE_PREFIX) &&
![OFFLINE_META_CACHE, nextData, nextArtwork].includes(name) ![OFFLINE_META_CACHE, nextData, artworkCache].includes(name)
) )
.map((name) => caches.delete(name)) .map((name) => caches.delete(name))
); );
const failedArtwork = await cacheArtwork(
await caches.open(artworkCache),
Array.from(new Set(event.data.artworkUrls ?? [])),
isCurrent
);
if (!isCurrent())
throw new Error('Offline synchronization was superseded by an account change');
reply({ ok: true, failedArtwork }); reply({ ok: true, failedArtwork });
} catch (error) { } catch (error) {
await Promise.allSettled( if (!committed && nextData) await caches.delete(nextData).catch(() => undefined);
[nextData, nextArtwork].filter(Boolean).map((name) => caches.delete(name))
);
reply({ ok: false, error: error instanceof Error ? error.message : String(error) }); reply({ ok: false, error: error instanceof Error ? error.message : String(error) });
} }
}) })
@@ -203,15 +231,22 @@ self.addEventListener('fetch', (event) => {
event.respondWith( event.respondWith(
(async () => { (async () => {
const meta = await currentOfflineMeta(); const meta = await currentOfflineMeta();
if (meta?.artworkCache) { if (!meta?.artworkCache) return fetch(event.request);
const cached = await ( const cache = await caches.open(meta.artworkCache);
await caches.open(meta.artworkCache) const cached = await cache.match(event.request, { ignoreSearch: true });
).match(event.request, { if (cached) return cached;
ignoreSearch: true if (!isSpriteUrl(url)) return fetch(event.request);
// Cache-first with fill-on-miss: a sprite shown online is stored once and served from the
// cache from then on, so the bulk sync never has to download it again.
try {
const response = await fetch(url.href, {
mode: url.origin === self.location.origin ? 'same-origin' : 'cors'
}); });
if (cached) return cached; if (response.ok) event.waitUntil(cache.put(url.href, response.clone()));
return response;
} catch {
return fetch(event.request);
} }
return fetch(event.request);
})() })()
); );
}); });
@@ -0,0 +1,73 @@
-- Stable capability links for read-only Pokédex sharing.
ALTER TABLE pokedexes
ADD COLUMN "shareToken" UUID NOT NULL DEFAULT gen_random_uuid();
ALTER TABLE pokedexes
ADD CONSTRAINT pokedexes_share_token_key UNIQUE ("shareToken");
CREATE OR REPLACE FUNCTION prevent_pokedex_share_token_update()
RETURNS TRIGGER
LANGUAGE plpgsql
SET search_path = public, pg_temp
AS $$
BEGIN
IF NEW."shareToken" IS DISTINCT FROM OLD."shareToken" THEN
RAISE EXCEPTION 'shareToken is immutable';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER prevent_pokedex_share_token_update
BEFORE UPDATE OF "shareToken" ON pokedexes
FOR EACH ROW EXECUTE FUNCTION prevent_pokedex_share_token_update();
-- This is the only anonymous path into user-owned Pokédex data. Keep the returned
-- shape deliberately narrow: no owner identity, row IDs, timestamps, or personal notes.
CREATE OR REPLACE FUNCTION get_shared_pokedex(p_share_token UUID)
RETURNS JSONB
SECURITY DEFINER
STABLE
SET search_path = public, pg_temp
LANGUAGE sql
AS $$
SELECT jsonb_build_object(
'name', p.name,
'description', COALESCE(p.description, ''),
'isLivingDex', COALESCE(p."isLivingDex", false),
'isShinyDex', COALESCE(p."isShinyDex", false),
'isOriginDex', COALESCE(p."isOriginDex", false),
'isFormDex', COALESCE(p."isFormDex", false),
'gameScope', p."gameScope",
'dexScopes', COALESCE(
(
SELECT jsonb_agg(pds."dexId" ORDER BY pds."dexId")
FROM pokedex_dex_scopes pds
WHERE pds."pokedexId" = p.id
),
'[]'::jsonb
),
'catchStatuses', COALESCE(
(
SELECT jsonb_agg(
jsonb_build_object(
'pokemonId', cr."pokemonId"::text,
'caught', COALESCE(cr.caught, false),
'haveToEvolve', COALESCE(cr."haveToEvolve", false),
'inHome', COALESCE(cr."inHome", false),
'hasGigantamaxed', COALESCE(cr."hasGigantamaxed", false)
)
ORDER BY cr."pokemonId"
)
FROM catch_records cr
WHERE cr."pokedexId" = p.id
),
'[]'::jsonb
)
)
FROM pokedexes p
WHERE p."shareToken" = p_share_token;
$$;
REVOKE ALL ON FUNCTION get_shared_pokedex(UUID) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION get_shared_pokedex(UUID) TO anon, authenticated;
@@ -0,0 +1,19 @@
Feature: Share a Pokédex
As a trainer
I want to share my progress without granting edit access
So that friends can follow my collection safely
Background:
Given I am signed in
Scenario: Share a live read-only Pokédex
Given I have a Living Dex named "Public Journey"
When I mark the first Pokémon as caught
And I add the note "share-secret-note" to the first Pokémon
When I open the Pokédex share dialog
Then I receive an unguessable read-only link
When I visit the shared link while signed out
Then I can browse the shared Pokédex without editing it
And the shared page does not expose the private note
And the shared page advertises a social progress image
And the social progress image is a PNG
+3 -1
View File
@@ -12,6 +12,7 @@ export type ScenarioState = {
lastMessage: string | null; lastMessage: string | null;
caughtEntryLabel: string | null; caughtEntryLabel: string | null;
legacyServiceWorkerRequested: boolean; legacyServiceWorkerRequested: boolean;
shareUrl: string | null;
}; };
type Fixtures = { state: ScenarioState; providerMock: void }; type Fixtures = { state: ScenarioState; providerMock: void };
@@ -53,7 +54,8 @@ export const test = base.extend<Fixtures>({
lastResponseStatus: null, lastResponseStatus: null,
lastMessage: null, lastMessage: null,
caughtEntryLabel: null, caughtEntryLabel: null,
legacyServiceWorkerRequested: false legacyServiceWorkerRequested: false,
shareUrl: null
}); });
} }
}); });
+12 -9
View File
@@ -67,16 +67,19 @@ When('I go offline and reload the sign-in page', async ({ page }) => {
Given('my offline copy is synchronized', async ({ page, state }) => { Given('my offline copy is synchronized', async ({ page, state }) => {
await waitForServiceWorker(page); await waitForServiceWorker(page);
// A full Living Dex snapshot plus its artwork can take longer than the default poll window on CI.
await expect await expect
.poll(() => .poll(
page.evaluate(async (userId) => { () =>
const meta = await ( page.evaluate(async (userId) => {
await caches.open('livingdex-offline-meta-v1') const meta = await (
).match('/__offline/current'); await caches.open('livingdex-offline-meta-v1')
if (!meta) return false; ).match('/__offline/current');
const value = await meta.json(); if (!meta) return false;
return value.userId === userId; const value = await meta.json();
}, state.userId) return value.userId === userId;
}, state.userId),
{ timeout: 30_000 }
) )
.toBe(true); .toBe(true);
const serializedSnapshot = await page.evaluate(async () => { const serializedSnapshot = await page.evaluate(async () => {
+65
View File
@@ -0,0 +1,65 @@
import { createBdd } from 'playwright-bdd';
import { test, expect } from '../fixtures';
const { When, Then } = createBdd(test);
When('I open the Pokédex share dialog', async ({ page, state }) => {
const detailsDialog = page.getByRole('dialog').filter({ hasText: 'Notes:' });
if (await detailsDialog.count()) {
await detailsDialog.getByRole('button', { name: 'Close', exact: true }).click();
}
await page.getByRole('button', { name: 'Share', exact: true }).click();
const dialog = page.getByRole('dialog', { name: /Share Public Journey/ });
await expect(dialog).toBeVisible();
state.shareUrl = await dialog.getByLabel('Read-only link').inputValue();
});
Then('I receive an unguessable read-only link', async ({ state }) => {
expect(state.shareUrl).toMatch(
/^https?:\/\/[^/]+\/shared\/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
);
});
When('I visit the shared link while signed out', async ({ page, context, state }) => {
if (!state.shareUrl) throw new Error('A share URL is required');
await context.clearCookies();
await page.goto(state.shareUrl);
});
Then('I can browse the shared Pokédex without editing it', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Public Journey' })).toBeVisible();
await expect(page.getByText('Read-only shared Pokédex')).toBeVisible();
await expect(page.getByLabel('Choose box view layout density')).toBeVisible();
await expect(page.getByText('Filters:', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Open bulk actions menu' })).toHaveCount(0);
await expect(page.getByRole('button', { name: /Create Pokédex data/ })).toHaveCount(0);
await expect(page.getByText('Personal notes')).toHaveCount(0);
});
Then('the shared page does not expose the private note', async ({ page }) => {
await expect(page.getByText('share-secret-note')).toHaveCount(0);
});
Then('the shared page advertises a social progress image', async ({ page }) => {
const canonical = await page.locator('link[rel="canonical"]').getAttribute('href');
const image = await page.locator('meta[property="og:image"]').getAttribute('content');
expect(canonical).toBe(page.url());
expect(image).toBe(`${page.url()}/preview.png`);
await expect(page.locator('meta[name="twitter:card"]')).toHaveAttribute(
'content',
'summary_large_image'
);
await expect(page.locator('meta[name="robots"]')).toHaveAttribute('content', 'noindex, nofollow');
await expect(page.locator('meta[name="referrer"]')).toHaveAttribute('content', 'no-referrer');
});
Then('the social progress image is a PNG', async ({ page }) => {
const image = await page.locator('meta[property="og:image"]').getAttribute('content');
if (!image) throw new Error('Open Graph image URL is required');
const response = await page.request.get(image);
expect(response.status()).toBe(200);
expect(response.headers()['content-type']).toBe('image/png');
expect(response.headers()['cache-control']).toContain('max-age=300');
const body = await response.body();
expect([...body.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]);
});
@@ -0,0 +1,138 @@
import { createClient } from '@supabase/supabase-js';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { requireLoopbackUrl } from '../support/loopback';
const url = requireLoopbackUrl(
process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321',
'TEST_SUPABASE_URL'
);
const anonKey = process.env.TEST_SUPABASE_ANON_KEY;
const serviceKey = process.env.E2E_SERVICE_ROLE_KEY ?? process.env.SUPABASE_SERVICE_ROLE_KEY;
describe('read-only Pokédex sharing', () => {
const createdUserIds: string[] = [];
beforeAll(() => {
if (!anonKey || !serviceKey) {
throw new Error('Integration tests require TEST_SUPABASE_ANON_KEY and E2E_SERVICE_ROLE_KEY');
}
});
afterAll(async () => {
if (!serviceKey) return;
const admin = createClient(url, serviceKey);
await Promise.all(createdUserIds.map((id) => admin.auth.admin.deleteUser(id)));
});
it('generates a stable token and exposes only sanitized data through the RPC', async () => {
const admin = createClient(url, serviceKey!);
const email = `integration-sharing-${Date.now()}@example.test`;
const password = 'Integration123!';
const created = await admin.auth.admin.createUser({ email, password, email_confirm: true });
expect(created.error).toBeNull();
const userId = created.data.user!.id;
createdUserIds.push(userId);
const { data: dex, error: dexError } = await admin
.from('pokedexes')
.insert({
userId,
name: 'Shared & Safe',
description: 'Public description',
isLivingDex: true
})
.select('id, shareToken')
.single();
expect(dexError).toBeNull();
expect(dex!.shareToken).toMatch(/^[0-9a-f-]{36}$/i);
const { data: pokemon } = await admin
.from('pokemon')
.select('id')
.order('id')
.limit(1)
.single();
expect(
(
await admin.from('catch_records').insert({
userId,
pokedexId: dex!.id,
pokemonId: pokemon!.id,
caught: true,
inHome: true,
personalNotes: 'This must remain private'
})
).error
).toBeNull();
const anonymous = createClient(url, anonKey!);
const direct = await anonymous.from('pokedexes').select('*').eq('id', dex!.id);
expect(direct.error).toBeNull();
expect(direct.data).toEqual([]);
const directCatchRecords = await anonymous
.from('catch_records')
.select('*')
.eq('pokedexId', dex!.id);
expect(directCatchRecords.error).toBeNull();
expect(directCatchRecords.data).toEqual([]);
const result = await anonymous.rpc('get_shared_pokedex', {
p_share_token: dex!.shareToken
});
expect(result.error).toBeNull();
expect(result.data).toMatchObject({
name: 'Shared & Safe',
description: 'Public description',
catchStatuses: [
{
pokemonId: String(pokemon!.id),
caught: true,
inHome: true
}
]
});
const serialized = JSON.stringify(result.data);
expect(serialized).not.toContain(userId);
expect(serialized).not.toContain(dex!.shareToken);
expect(serialized).not.toContain('This must remain private');
expect(serialized).not.toContain('personalNotes');
const attemptedWrite = await anonymous
.from('catch_records')
.update({ caught: false })
.eq('pokedexId', dex!.id)
.eq('pokemonId', pokemon!.id)
.select();
expect(attemptedWrite.error).toBeNull();
expect(attemptedWrite.data).toEqual([]);
const unchanged = await admin
.from('catch_records')
.select('caught')
.eq('pokedexId', dex!.id)
.eq('pokemonId', pokemon!.id)
.single();
expect(unchanged.data?.caught).toBe(true);
const owner = createClient(url, anonKey!);
expect((await owner.auth.signInWithPassword({ email, password })).error).toBeNull();
const ownerDex = await owner.from('pokedexes').select('shareToken').eq('id', dex!.id).single();
expect(ownerDex.data?.shareToken).toBe(dex!.shareToken);
expect(
(await owner.from('pokedexes').update({ shareToken: crypto.randomUUID() }).eq('id', dex!.id))
.error
).not.toBeNull();
const missing = await anonymous.rpc('get_shared_pokedex', {
p_share_token: crypto.randomUUID()
});
expect(missing.error).toBeNull();
expect(missing.data).toBeNull();
expect((await admin.from('pokedexes').delete().eq('id', dex!.id)).error).toBeNull();
const deleted = await anonymous.rpc('get_shared_pokedex', {
p_share_token: dex!.shareToken
});
expect(deleted.error).toBeNull();
expect(deleted.data).toBeNull();
});
});
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import sharp from 'sharp';
import {
buildSharePreviewSvg,
escapeXml,
renderSharePreview,
SHARE_PREVIEW_HEIGHT,
SHARE_PREVIEW_WIDTH,
truncatePreviewText
} from '$lib/services/SharePreviewService';
import type { SharedPokedexData } from '$lib/models/SharedPokedex';
const shared: SharedPokedexData = {
name: 'Johto & <Friends>',
description: 'A shared collection',
isLivingDex: true,
isShinyDex: false,
isOriginDex: false,
isFormDex: false,
gameScope: null,
dexScopes: [],
combinedData: [],
total: 100,
caught: 42,
completionPercentage: 42
};
describe('share preview rendering', () => {
it('escapes XML and truncates normalized user text', () => {
expect(escapeXml(`<tag attr="x">Tom & Jerry's</tag>`)).toBe(
'&lt;tag attr=&quot;x&quot;&gt;Tom &amp; Jerry&apos;s&lt;/tag&gt;'
);
expect(truncatePreviewText(' lots of\nspace ', 20)).toBe('lots of space');
expect(truncatePreviewText('abcdefghij', 6)).toBe('abcde…');
});
it('builds a branded progress card without raw user markup', () => {
const svg = buildSharePreviewSvg(shared);
expect(svg).toContain('Johto &amp; &lt;Friends&gt;');
expect(svg).not.toContain('Johto & <Friends>');
expect(svg).toContain('42 of 100 Pokémon caught');
expect(svg).toContain('42%');
});
it('lists every enabled dex badge and omits an empty description', () => {
const svg = buildSharePreviewSvg({
...shared,
description: '',
isShinyDex: true,
isOriginDex: true,
isFormDex: true,
gameScope: 'Scarlet'
});
for (const badge of ['Living', 'Shiny', 'Origin', 'Form', 'Scarlet']) {
expect(svg).toContain(`class="badge">${badge}</text>`);
}
expect(svg).not.toContain('All Games');
expect(svg).not.toContain('class="description"');
});
it('renders a valid 1200 by 630 PNG', async () => {
const png = await renderSharePreview(shared);
const metadata = await sharp(png).metadata();
expect(metadata.format).toBe('png');
expect(metadata.width).toBe(SHARE_PREVIEW_WIDTH);
expect(metadata.height).toBe(SHARE_PREVIEW_HEIGHT);
});
});
+126
View File
@@ -0,0 +1,126 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { PokedexEntry } from '$lib/models/PokedexEntry';
import type { SharedPokedexRpcData } from '$lib/models/SharedPokedex';
const { findAllCombinedData } = vi.hoisted(() => ({ findAllCombinedData: vi.fn() }));
vi.mock('$lib/repositories/CombinedDataRepository', () => ({
default: vi.fn().mockImplementation(() => ({ findAllCombinedData }))
}));
import {
calculateSharedProgress,
isShareToken,
loadSharedPokedex
} from '$lib/services/SharedPokedexService';
const TOKEN = '123e4567-e89b-42d3-a456-426614174000';
function supabaseWithRpc(result: { data: unknown; error: unknown }) {
const rpc = vi.fn().mockResolvedValue(result);
return { supabase: { rpc } as unknown as SupabaseClient, rpc };
}
describe('shared Pokédex helpers', () => {
it('accepts UUID capability tokens and rejects malformed route values', () => {
expect(isShareToken('123e4567-e89b-42d3-a456-426614174000')).toBe(true);
expect(isShareToken('not-a-token')).toBe(false);
expect(isShareToken('123e4567-e89b-12d3-a456-426614174000/extra')).toBe(false);
});
it('counts caught and needs-to-evolve entries as progress', () => {
const progress = calculateSharedProgress(
[
{
pokemonId: '1',
caught: true,
haveToEvolve: false,
inHome: false,
hasGigantamaxed: false
},
{
pokemonId: '2',
caught: false,
haveToEvolve: true,
inHome: false,
hasGigantamaxed: false
}
],
4
);
expect(progress).toEqual({ caught: 2, completionPercentage: 50 });
expect(calculateSharedProgress([], 0)).toEqual({ caught: 0, completionPercentage: 0 });
});
});
describe('loadSharedPokedex', () => {
beforeEach(() => {
findAllCombinedData.mockReset();
});
it('rejects malformed tokens without querying', async () => {
const { supabase, rpc } = supabaseWithRpc({ data: null, error: null });
expect(await loadSharedPokedex(supabase, 'not-a-token')).toBeNull();
expect(rpc).not.toHaveBeenCalled();
});
it('returns null when the token matches nothing or the RPC fails', async () => {
expect(
await loadSharedPokedex(supabaseWithRpc({ data: null, error: null }).supabase, TOKEN)
).toBeNull();
expect(
await loadSharedPokedex(
supabaseWithRpc({ data: null, error: { message: 'boom' } }).supabase,
TOKEN
)
).toBeNull();
expect(findAllCombinedData).not.toHaveBeenCalled();
});
it('maps shared catch statuses onto the scoped dex entries', async () => {
const rpcData: SharedPokedexRpcData = {
name: 'Kanto',
description: 'Gen 1',
isLivingDex: true,
isShinyDex: false,
isOriginDex: false,
isFormDex: true,
gameScope: null,
dexScopes: ['kanto'],
catchStatuses: [
{ pokemonId: '1', caught: true, haveToEvolve: false, inHome: true, hasGigantamaxed: false },
{
pokemonId: '99',
caught: true,
haveToEvolve: false,
inHome: false,
hasGigantamaxed: false
}
]
};
const entries = ['1', '2', '3', '4'].map((id) => ({
pokedexEntry: { _id: id } as PokedexEntry,
catchRecord: null
}));
findAllCombinedData.mockResolvedValue(entries);
const { supabase, rpc } = supabaseWithRpc({ data: rpcData, error: null });
const shared = await loadSharedPokedex(supabase, TOKEN);
expect(rpc).toHaveBeenCalledWith('get_shared_pokedex', { p_share_token: TOKEN });
expect(findAllCombinedData).toHaveBeenCalledWith('', true, '', '', ['kanto']);
expect(shared?.combinedData.map((entry) => entry.catchRecord?.pokemonId ?? null)).toEqual([
'1',
null,
null,
null
]);
// Statuses for Pokémon outside the dex scope must not inflate progress.
expect(shared).toMatchObject({
name: 'Kanto',
total: 4,
caught: 1,
completionPercentage: 25
});
});
});