feat(pokedex): add shareable read-only dex links

This commit is contained in:
Josh Creek
2026-09-14 14:25:09 +01:00
parent 951c9b2878
commit 7240376e00
16 changed files with 693 additions and 192 deletions
+4 -33
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",
@@ -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"
} }
+2 -2
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,7 +80,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"
}, },
"engines": { "engines": {
"node": ">=18.13.0" "node": ">=18.13.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,10 +101,25 @@
{/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"
> >
{#if readOnly}
<h3 class="text-lg font-semibold mb-2">Progress</h3>
<dl class="grid grid-cols-2 gap-x-4 gap-y-2">
<dt>Caught</dt>
<dd class="font-semibold">{sharedCatchStatus?.caught ? 'Yes' : 'No'}</dd>
<dt>Needs to evolve</dt>
<dd class="font-semibold">{sharedCatchStatus?.haveToEvolve ? 'Yes' : 'No'}</dd>
<dt>In HOME</dt>
<dd class="font-semibold">{sharedCatchStatus?.inHome ? 'Yes' : 'No'}</dd>
{#if pokedexEntry.canGigantamax && showForms}
<dt>Has Gigantamaxed</dt>
<dd class="font-semibold">{sharedCatchStatus?.hasGigantamaxed ? 'Yes' : 'No'}</dd>
{/if}
</dl>
{:else if catchRecord}
<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">
@@ -167,6 +188,7 @@
on:change={() => updateCatchRecord('notes-blur')} on:change={() => updateCatchRecord('notes-blur')}
></textarea> ></textarea>
</p> </p>
{/if}
</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,7 +358,7 @@
<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]"
@@ -434,7 +439,7 @@
</li> </li>
</ul> </ul>
{/if} {/if}
</div> </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,8 +588,10 @@
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>
{#if !readOnly}
<button class="btn" on:click={createCatchRecords}>Create Pokédex data</button> <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">
<h1>Loading Pokédex</h1> <h1>Loading Pokédex</h1>
+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
};
}
-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)
}
});
};
@@ -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;