mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-11-09 19:35:56 +00:00
Merge pull request #95 from jcreek/feat/shareable-pokedex
This commit is contained in:
@@ -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
|
||||||
|
|||||||
Generated
+5
-34
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 || '',
|
||||||
|
|||||||
@@ -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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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`
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(
|
||||||
|
'<tag attr="x">Tom & Jerry's</tag>'
|
||||||
|
);
|
||||||
|
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 & <Friends>');
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user