From 7240376e008f6dc12e2bd07c1e5551d838f5090c Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:25:09 +0100 Subject: [PATCH] feat(pokedex): add shareable read-only dex links --- package-lock.json | 37 +--- package.json | 4 +- .../pokedex/PokedexEntryCatchRecord.svelte | 138 +++++++------ .../pokedex/PokedexViewBoxes.svelte | 187 +++++++++--------- src/lib/models/Pokedex.ts | 2 + src/lib/models/SharedPokedex.ts | 36 ++++ src/lib/repositories/PokedexRepository.ts | 1 + src/lib/services/SharePreviewService.ts | 88 +++++++++ src/lib/services/SharedPokedexService.ts | 69 +++++++ src/routes/+layout.svelte | 7 - src/routes/+page.svelte | 8 + src/routes/pokedex/[id]/+page.svelte | 95 ++++++++- src/routes/shared/[token]/+page.server.ts | 19 ++ src/routes/shared/[token]/+page.svelte | 103 ++++++++++ .../shared/[token]/preview.png/+server.ts | 18 ++ .../20260914000000_add_pokedex_sharing.sql | 73 +++++++ 16 files changed, 693 insertions(+), 192 deletions(-) create mode 100644 src/lib/models/SharedPokedex.ts create mode 100644 src/lib/services/SharePreviewService.ts create mode 100644 src/lib/services/SharedPokedexService.ts create mode 100644 src/routes/shared/[token]/+page.server.ts create mode 100644 src/routes/shared/[token]/+page.svelte create mode 100644 src/routes/shared/[token]/preview.png/+server.ts create mode 100644 supabase/migrations/20260914000000_add_pokedex_sharing.sql diff --git a/package-lock.json b/package-lock.json index ff78751..3568ad1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "@supabase/ssr": "^0.1.0", "@supabase/supabase-js": "^2.42.0", - "nanoid": "^5.0.4" + "nanoid": "^5.0.4", + "sharp": "^0.33.4" }, "devDependencies": { "@playwright/test": "1.55.1", @@ -36,7 +37,6 @@ "postcss": "^8.4.38", "prettier": "^3.1.1", "prettier-plugin-svelte": "^3.1.2", - "sharp": "^0.33.4", "supabase": "2.72.7", "svelte": "^4.2.8", "svelte-check": "^3.6.2", @@ -2118,7 +2118,6 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2698,7 +2697,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2721,7 +2719,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2744,7 +2741,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2761,7 +2757,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2778,7 +2773,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2795,7 +2789,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2812,7 +2805,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2829,7 +2821,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2846,7 +2837,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2863,7 +2853,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2880,7 +2869,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2903,7 +2891,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2926,7 +2913,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2949,7 +2935,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2972,7 +2957,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2995,7 +2979,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3018,7 +3001,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -3038,7 +3020,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -3058,7 +3039,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -5315,7 +5295,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "dev": true, "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" @@ -5328,7 +5307,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -5339,14 +5317,12 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/color-string": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "dev": true, "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" @@ -5742,7 +5718,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -7140,8 +7115,7 @@ "node_modules/is-arrayish": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", - "dev": true + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" }, "node_modules/is-bigint": { "version": "1.0.4", @@ -9484,7 +9458,6 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -9547,7 +9520,6 @@ "version": "0.33.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", - "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -9703,7 +9675,6 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", - "dev": true, "dependencies": { "is-arrayish": "^0.3.1" } diff --git a/package.json b/package.json index de48747..8e0f65c 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,6 @@ "postcss": "^8.4.38", "prettier": "^3.1.1", "prettier-plugin-svelte": "^3.1.2", - "sharp": "^0.33.4", "supabase": "2.72.7", "svelte": "^4.2.8", "svelte-check": "^3.6.2", @@ -81,7 +80,8 @@ "dependencies": { "@supabase/ssr": "^0.1.0", "@supabase/supabase-js": "^2.42.0", - "nanoid": "^5.0.4" + "nanoid": "^5.0.4", + "sharp": "^0.33.4" }, "engines": { "node": ">=18.13.0" diff --git a/src/lib/components/pokedex/PokedexEntryCatchRecord.svelte b/src/lib/components/pokedex/PokedexEntryCatchRecord.svelte index ffc43d1..a991184 100644 --- a/src/lib/components/pokedex/PokedexEntryCatchRecord.svelte +++ b/src/lib/components/pokedex/PokedexEntryCatchRecord.svelte @@ -1,5 +1,6 @@ + + + {shared.name} - Living Dex Tracker + + + + + + + + + + + + + + + + + + +
+
+
+
+
+

{shared.name}

+ Read-only shared Pokédex +
+
+ {#each typeBadges as badge} + {badge} + {/each} + {shared.gameScope || 'All Games'} +
+ {#if shared.description}

{shared.description}

{/if} +

+ {shared.caught} of {shared.total} Pokémon caught · {shared.completionPercentage}% complete +

+
+
+
+ + +
+ +{#if showModal && selectedPokemon} + + + +{/if} diff --git a/src/routes/shared/[token]/preview.png/+server.ts b/src/routes/shared/[token]/preview.png/+server.ts new file mode 100644 index 0000000..d3d17d9 --- /dev/null +++ b/src/routes/shared/[token]/preview.png/+server.ts @@ -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) + } + }); +}; diff --git a/supabase/migrations/20260914000000_add_pokedex_sharing.sql b/supabase/migrations/20260914000000_add_pokedex_sharing.sql new file mode 100644 index 0000000..f850752 --- /dev/null +++ b/supabase/migrations/20260914000000_add_pokedex_sharing.sql @@ -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;