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": {
"@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"
}
+2 -2
View File
@@ -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"
@@ -1,5 +1,6 @@
<script lang="ts">
import type { CatchRecord } from '$lib/models/CatchRecord';
import type { SharedCatchStatus } from '$lib/models/SharedPokedex';
import type { CatchInformationItem, PokedexEntry } from '$lib/models/PokedexEntry';
import PokemonSprite from '../PokemonSprite.svelte';
import { createEventDispatcher } from 'svelte';
@@ -11,9 +12,11 @@
export let showShiny: boolean;
export let userId: string | null = null;
export let pokedexId: string;
export let readOnly = false;
export let sharedCatchStatus: SharedCatchStatus | null = null;
// Create a default catch record if none exists
$: if (!catchRecord) {
$: if (!readOnly && !catchRecord) {
catchRecord = {
_id: '', // Empty string, not temp ID - will be created by server
userId: userId || '',
@@ -36,10 +39,12 @@
): value is CatchInformationItem => typeof value !== 'string';
function updateCatchRecord(source: UpdateCatchSource) {
if (readOnly) return;
dispatch('updateCatch', { pokedexEntry, catchRecord, source });
}
function onCaughtChange() {
if (readOnly) return;
if (!catchRecord) return;
// Mutually exclusive with "needs to evolve"
if (catchRecord.caught) {
@@ -49,6 +54,7 @@
}
function onNeedsToEvolveChange() {
if (readOnly) return;
if (!catchRecord) return;
// Mutually exclusive with "caught"
if (catchRecord.haveToEvolve) {
@@ -95,10 +101,25 @@
{/if}
</div>
{#if catchRecord}
{#if readOnly || catchRecord}
<div
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="form-control">
<label class="cursor-pointer label">
@@ -167,6 +188,7 @@
on:change={() => updateCatchRecord('notes-blur')}
></textarea>
</p>
{/if}
</div>
{/if}
@@ -2,12 +2,17 @@
import { onMount } from 'svelte';
import type { CatchRecord } from '$lib/models/CatchRecord';
import type { CombinedData } from '$lib/models/CombinedData';
import type { SharedCatchStatus, SharedCombinedData } from '$lib/models/SharedPokedex';
import { calculateBoxPlacement } from '$lib/utils/boxPlacement';
import PokemonSprite from '$lib/components/PokemonSprite.svelte';
import Tooltip from '$lib/components/Tooltip.svelte';
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 creatingRecords = false;
export let totalRecordsCreated = 0;
@@ -18,7 +23,7 @@
export let markBoxAsInHome: (boxNumber: number) => void = () => {};
export let markBoxAsNotInHome: (boxNumber: number) => void = () => {};
export let createCatchRecords = () => {};
export let onPokemonClick: (pokemon: CombinedData) => void = () => {};
export let onPokemonClick: (pokemon: DisplayData) => void = () => {};
let filterNotCaught = false;
let filterNeedsToEvolve = false;
@@ -36,7 +41,7 @@
return () => window.removeEventListener('click', close);
});
let filteredCombinedData: CombinedData[] = [];
let filteredCombinedData: DisplayData[] = [];
let filteredTotal = 0;
let overallTotal = 0;
let overallCaughtCount = 0;
@@ -47,7 +52,7 @@
let filtersActive = false;
let filtersKey = '';
function normalizedStatus(catchRecord: CatchRecord | null) {
function normalizedStatus(catchRecord: DisplayStatus) {
return {
caught: !!catchRecord?.caught,
needsToEvolve: !!catchRecord?.haveToEvolve,
@@ -55,7 +60,7 @@
};
}
function matchesFilters(catchRecord: CatchRecord | null) {
function matchesFilters(catchRecord: DisplayStatus) {
const status = normalizedStatus(catchRecord);
if (!filtersActive) return true;
@@ -148,7 +153,7 @@
boxViewLayout === 'comfortable' ? 1 : boxViewLayout === 'compact' ? 0.6 : 0.45;
$: 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.
if (catchRecord?.caught) {
// Match legend (green-600) while keeping sprites readable.
@@ -161,7 +166,7 @@
return '';
}
function statusLabel(catchRecord: CatchRecord | null) {
function statusLabel(catchRecord: DisplayStatus) {
const parts: string[] = [];
if (catchRecord?.caught) parts.push('Caught');
if (catchRecord?.haveToEvolve) parts.push('Needs to evolve');
@@ -169,7 +174,7 @@
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) {
return '';
} else {
@@ -353,7 +358,7 @@
<div class="mb-8">
<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>
<div class="relative">
{#if !readOnly}<div class="relative">
<button
type="button"
class="btn btn-sm btn-outline relative z-[210]"
@@ -434,7 +439,7 @@
</li>
</ul>
{/if}
</div>
</div>{/if}
</div>
<div class="grid grid-cols-6">
{#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
clicking this button.
</p>
{#if !readOnly}
<button class="btn" on:click={createCatchRecords}>Create Pokédex data</button>
{/if}
{/if}
{:else}
<div class="min-w-max mx-auto">
<h1>Loading Pokédex</h1>
+2
View File
@@ -1,5 +1,6 @@
export interface Pokedex {
_id: string;
shareToken: string;
userId: string;
name: string;
description: string;
@@ -13,6 +14,7 @@ export interface Pokedex {
export interface PokedexDB {
id: string;
shareToken: string;
userId: string;
name: 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 {
return {
_id: db.id,
shareToken: db.shareToken,
userId: db.userId,
name: db.name,
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"
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="%sveltekit.assets%/apple-touch-icon.png" /> -->
</svelte:head>
+8
View File
@@ -62,6 +62,14 @@
name="description"
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>
{#if isCheckingSession}
+93 -2
View File
@@ -17,6 +17,7 @@
import type { Pokedex } from '$lib/models/Pokedex';
import type { PageData } from './$types';
import { requestOfflineSync } from '$lib/stores/offlineSync';
import type { SharedCombinedData } from '$lib/models/SharedPokedex';
export let data: PageData;
@@ -49,6 +50,10 @@
let boxNumbers: number[] = [];
let showModal = false;
let selectedPokemon: CombinedData | null = null;
let showShareModal = false;
let shareUrl = '';
let shareFeedback = '';
let nativeShareSupported = false;
let catchWriteQueue: ReturnType<typeof createCatchRecordWriteQueue> | null = null;
let catchWriteQueueKey: string | null = null;
@@ -148,11 +153,47 @@
resetExportState();
});
function openPokemonModal(pokemon: CombinedData) {
selectedPokemon = pokemon;
function openPokemonModal(pokemon: CombinedData | SharedCombinedData) {
selectedPokemon = pokemon as CombinedData;
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() {
showModal = false;
selectedPokemon = null;
@@ -438,6 +479,7 @@
onMount(() => {
if (!browser) return;
nativeShareSupported = typeof navigator.share === 'function';
const flushKeepalive = () => {
if (!catchWriteQueue) return;
@@ -617,6 +659,20 @@
Save failed (will retry)
</div>
{/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">
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -667,4 +723,39 @@
/>
</PokedexModal>
{/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}
+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;