mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-16 02:22:17 +00:00
Merge pull request #96 from jcreek/fix/offline-data-usage
Fix/offline data usage
This commit is contained in:
@@ -9,6 +9,7 @@ src/lib/helpers/pokeapi-pokemon.json
|
|||||||
static/sprites/pokemon.json
|
static/sprites/pokemon.json
|
||||||
src/lib/helpers/pokedex.json
|
src/lib/helpers/pokedex.json
|
||||||
src/lib/helpers/sprites.json
|
src/lib/helpers/sprites.json
|
||||||
|
static/sprites-small/manifest.json
|
||||||
|
|
||||||
# Machine-local editor and tool settings.
|
# Machine-local editor and tool settings.
|
||||||
**/*.local.json
|
**/*.local.json
|
||||||
|
|||||||
Generated
+67
-91
@@ -8,8 +8,8 @@
|
|||||||
"name": "livingdextracker",
|
"name": "livingdextracker",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/ssr": "^0.1.0",
|
"@supabase/ssr": "^0.12.7",
|
||||||
"@supabase/supabase-js": "^2.42.0",
|
"@supabase/supabase-js": "^2.116.0",
|
||||||
"nanoid": "^5.0.4",
|
"nanoid": "^5.0.4",
|
||||||
"sharp": "^0.33.4"
|
"sharp": "^0.33.4"
|
||||||
},
|
},
|
||||||
@@ -3728,103 +3728,120 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/auth-js": {
|
"node_modules/@supabase/auth-js": {
|
||||||
"version": "2.89.0",
|
"version": "2.116.0",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.89.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.116.0.tgz",
|
||||||
"integrity": "sha512-wiWZdz8WMad8LQdJMWYDZ2SJtZP5MwMqzQq3ehtW2ngiI3UTgbKiFrvMUUS3KADiVlk4LiGfODB2mrYx7w2f8w==",
|
"integrity": "sha512-Cmosty12gyKGK9N3bQb+lMmuAFev5nmUzaR1AsmZHqKOAGzqX1VQzmp49CNPwOx/pw0H9Qqk4rs9yhwTlKpfDg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "2.8.1"
|
"tslib": "2.8.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=22.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/functions-js": {
|
"node_modules/@supabase/functions-js": {
|
||||||
"version": "2.89.0",
|
"version": "2.116.0",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.89.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.116.0.tgz",
|
||||||
"integrity": "sha512-XEueaC5gMe5NufNYfBh9kPwJlP5M2f+Ogr8rvhmRDAZNHgY6mI35RCkYDijd92pMcNM7g8pUUJov93UGUnqfyw==",
|
"integrity": "sha512-E+VOc2QDcni/fySqkBFiZhnoB3SGydEdZgFI6/dEAGAHx6yEhB46TN9qb2wXs+E+RSzOBV0R6dasiSlw4xlZAA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "2.8.1"
|
"tslib": "2.8.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=22.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@supabase/phoenix": {
|
||||||
|
"version": "0.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz",
|
||||||
|
"integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@supabase/postgrest-js": {
|
"node_modules/@supabase/postgrest-js": {
|
||||||
"version": "2.89.0",
|
"version": "2.116.0",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.89.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.116.0.tgz",
|
||||||
"integrity": "sha512-/b0fKrxV9i7RNOEXMno/I1862RsYhuUo+Q6m6z3ar1f4ulTMXnDfv0y4YYxK2POcgrOXQOgKYQx1eArybyNvtg==",
|
"integrity": "sha512-kGpVZTDHxFTJS3tu+rU0iTAZ+4U0bcLVjxwCk8f3gRhjw3qdCZjTBlgYvc4kGH2XccmAzbkKwXL/mrNHMGSc+A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "2.8.1"
|
"tslib": "2.8.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=22.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/realtime-js": {
|
"node_modules/@supabase/realtime-js": {
|
||||||
"version": "2.89.0",
|
"version": "2.116.0",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.89.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.116.0.tgz",
|
||||||
"integrity": "sha512-aMOvfDb2a52u6PX6jrrjvACHXGV3zsOlWRzZsTIOAJa0hOVvRp01AwC1+nLTGUzxzezejrYeCX+KnnM1xHdl+w==",
|
"integrity": "sha512-MHAnlXxi2s6yiJsZsQMfs2B3RFxeVfQWxerqYhIMqcCQV/FuY3LIeouPEkXw/ah7wUWMLYwempF9MOCUScyddg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/phoenix": "^1.6.6",
|
"@supabase/phoenix": "0.4.5",
|
||||||
"@types/ws": "^8.18.1",
|
"tslib": "2.8.1"
|
||||||
"tslib": "2.8.1",
|
|
||||||
"ws": "^8.18.2"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=22.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/ssr": {
|
"node_modules/@supabase/ssr": {
|
||||||
"version": "0.1.0",
|
"version": "0.12.7",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.12.7.tgz",
|
||||||
"integrity": "sha512-bIVrkqjAK5G3KjkIMKYKtAOlCgRRplEWjrlyRyXSOYtgDieiOhk2ZyNAPsEOa1By9OZVxuX5eAW1fitdnuxayw==",
|
"integrity": "sha512-wiBtEie1KkRJi9RrZWY3R2imRhX1JY7qMyUCH2z9AUk15gQebNEplM+urbCKamdxaTJLXUU6LlpkJsaxhojCEg==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie": "^0.5.0",
|
"cookie": "^1.0.2"
|
||||||
"ramda": "^0.29.0"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@supabase/supabase-js": "^2.33.1"
|
"@supabase/supabase-js": "^2.114.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/ssr/node_modules/cookie": {
|
"node_modules/@supabase/ssr/node_modules/cookie": {
|
||||||
"version": "0.5.0",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==",
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/storage-js": {
|
"node_modules/@supabase/storage-js": {
|
||||||
"version": "2.89.0",
|
"version": "2.116.0",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.89.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.116.0.tgz",
|
||||||
"integrity": "sha512-6zKcXofk/M/4Eato7iqpRh+B+vnxeiTumCIP+Tz26xEqIiywzD9JxHq+udRrDuv6hXE+pmetvJd8n5wcf4MFRQ==",
|
"integrity": "sha512-6/3hR6vccBP6oGM5B6RfbwZcTCKmQOodd/ZWQdsw8yJsU5zO/a//oBL6yLnmgxcjnHSrelW8rsO7hL5DPybyUQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"iceberg-js": "^0.8.1",
|
"iceberg-js": "^0.8.1",
|
||||||
"tslib": "2.8.1"
|
"tslib": "2.8.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=22.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/supabase-js": {
|
"node_modules/@supabase/supabase-js": {
|
||||||
"version": "2.89.0",
|
"version": "2.116.0",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.89.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.116.0.tgz",
|
||||||
"integrity": "sha512-KlaRwSfFA0fD73PYVMHj5/iXFtQGCcX7PSx0FdQwYEEw9b2wqM7GxadY+5YwcmuEhalmjFB/YvqaoNVF+sWUlg==",
|
"integrity": "sha512-YyWmKXt2NspV9iO8FPnlswUFJIRnrLd3oTCb+3ZyYRuKZtBH0xCUDgnUqoyA0fGUxpM/UhfwDjYf/dht/9bp7g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/auth-js": "2.89.0",
|
"@supabase/auth-js": "2.116.0",
|
||||||
"@supabase/functions-js": "2.89.0",
|
"@supabase/functions-js": "2.116.0",
|
||||||
"@supabase/postgrest-js": "2.89.0",
|
"@supabase/postgrest-js": "2.116.0",
|
||||||
"@supabase/realtime-js": "2.89.0",
|
"@supabase/realtime-js": "2.116.0",
|
||||||
"@supabase/storage-js": "2.89.0"
|
"@supabase/storage-js": "2.116.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=22.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@opentelemetry/api": ">=1.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@opentelemetry/api": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@surma/rollup-plugin-off-main-thread": {
|
"node_modules/@surma/rollup-plugin-off-main-thread": {
|
||||||
@@ -4016,16 +4033,12 @@
|
|||||||
"version": "20.12.5",
|
"version": "20.12.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.5.tgz",
|
||||||
"integrity": "sha512-BD+BjQ9LS/D8ST9p5uqBxghlN+S42iuNxjsUGjeZobe/ciXzk2qb1B6IXc6AnRLS+yFJRpN2IPEHMzwspfDJNw==",
|
"integrity": "sha512-BD+BjQ9LS/D8ST9p5uqBxghlN+S42iuNxjsUGjeZobe/ciXzk2qb1B6IXc6AnRLS+yFJRpN2IPEHMzwspfDJNw==",
|
||||||
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~5.26.4"
|
"undici-types": "~5.26.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/phoenix": {
|
|
||||||
"version": "1.6.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
|
|
||||||
"integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/pug": {
|
"node_modules/@types/pug": {
|
||||||
"version": "2.0.10",
|
"version": "2.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.10.tgz",
|
||||||
@@ -4062,15 +4075,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/ws": {
|
|
||||||
"version": "8.18.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
|
||||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.1.1.tgz",
|
||||||
@@ -9020,15 +9024,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/ramda": {
|
|
||||||
"version": "0.29.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ramda/-/ramda-0.29.1.tgz",
|
|
||||||
"integrity": "sha512-OfxIeWzd4xdUNxlWhgFazxsA/nl3mS4/jGZI5n00uWOoSSFRhC1b6gl6xvmzUamgmqELraWp0J/qqVlXYPDPyA==",
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/ramda"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/randombytes": {
|
"node_modules/randombytes": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||||
@@ -10908,7 +10903,9 @@
|
|||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "5.26.5",
|
"version": "5.26.5",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
|
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||||
|
"dev": true,
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/unicode-canonical-property-names-ecmascript": {
|
"node_modules/unicode-canonical-property-names-ecmascript": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
@@ -12268,27 +12265,6 @@
|
|||||||
"node": "^20.17.0 || >=22.9.0"
|
"node": "^20.17.0 || >=22.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
|
||||||
"version": "8.18.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
|
||||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"bufferutil": "^4.0.1",
|
|
||||||
"utf-8-validate": ">=5.0.2"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"bufferutil": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"utf-8-validate": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/xmlbuilder": {
|
"node_modules/xmlbuilder": {
|
||||||
"version": "15.1.1",
|
"version": "15.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
||||||
|
|||||||
+3
-2
@@ -7,6 +7,7 @@
|
|||||||
"dev-generate": "GENERATE_SW=true npx tailwindcss -i ./static/input.css -o ./static/output.css && vite dev",
|
"dev-generate": "GENERATE_SW=true npx tailwindcss -i ./static/input.css -o ./static/output.css && vite dev",
|
||||||
"dev-generate-suppress-w": "GENERATE_SW=true SUPPRESS_WARNING=true npx tailwindcss -i ./static/input.css -o ./static/output.css && vite dev",
|
"dev-generate-suppress-w": "GENERATE_SW=true SUPPRESS_WARNING=true npx tailwindcss -i ./static/input.css -o ./static/output.css && vite dev",
|
||||||
"sprites:build": "node scripts/optimize-sprites.mjs",
|
"sprites:build": "node scripts/optimize-sprites.mjs",
|
||||||
|
"sprites:manifest": "node scripts/sprite-manifest.mjs",
|
||||||
"build-generate-sw": "npm run tailwind && GENERATE_SW=true vite build",
|
"build-generate-sw": "npm run tailwind && GENERATE_SW=true vite build",
|
||||||
"build-generate-sw-node": "npm run tailwind && NODE_ADAPTER=true GENERATE_SW=true vite build",
|
"build-generate-sw-node": "npm run tailwind && NODE_ADAPTER=true GENERATE_SW=true vite build",
|
||||||
"build": "npm run tailwind && vite build",
|
"build": "npm run tailwind && vite build",
|
||||||
@@ -78,8 +79,8 @@
|
|||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/ssr": "^0.1.0",
|
"@supabase/ssr": "^0.12.7",
|
||||||
"@supabase/supabase-js": "^2.42.0",
|
"@supabase/supabase-js": "^2.116.0",
|
||||||
"nanoid": "^5.0.4",
|
"nanoid": "^5.0.4",
|
||||||
"sharp": "^0.33.4"
|
"sharp": "^0.33.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { mkdir, readdir, rename } from 'node:fs/promises';
|
import { mkdir, readdir, rename } from 'node:fs/promises';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
|
import { writeSpriteManifest } from './sprite-manifest.mjs';
|
||||||
|
|
||||||
const inputDir = process.env.SPRITE_INPUT_DIR ?? path.join(process.cwd(), 'static', 'sprites');
|
const inputDir = process.env.SPRITE_INPUT_DIR ?? path.join(process.cwd(), 'static', 'sprites');
|
||||||
const outputDir =
|
const outputDir =
|
||||||
@@ -69,4 +70,5 @@ for (const [index, file] of files.entries()) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Sprite optimization complete.');
|
const manifest = await writeSpriteManifest(outputDir);
|
||||||
|
console.log(`Sprite optimization complete. Manifest lists ${manifest.files.length} sprites.`);
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Lists every sprite (all forms, shiny and female variants) with its size, so the offline worker can
|
||||||
|
// save the complete set on request and tell whether anything is still missing.
|
||||||
|
import path from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { readdir, stat, writeFile } from 'node:fs/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
export const SPRITE_MANIFEST_NAME = 'manifest.json';
|
||||||
|
|
||||||
|
async function walk(dir, files = []) {
|
||||||
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) await walk(fullPath, files);
|
||||||
|
else if (entry.isFile() && entry.name.endsWith('.webp')) files.push(fullPath);
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds the manifest for `<spritesDir>/home`, with paths relative to that folder. */
|
||||||
|
export async function buildSpriteManifest(spritesDir) {
|
||||||
|
const homeDir = path.join(spritesDir, 'home');
|
||||||
|
const files = await walk(homeDir);
|
||||||
|
const entries = await Promise.all(
|
||||||
|
files.map(async (file) => [
|
||||||
|
path.relative(homeDir, file).split(path.sep).join('/'),
|
||||||
|
(await stat(file)).size
|
||||||
|
])
|
||||||
|
);
|
||||||
|
entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
||||||
|
return { version: 1, files: entries };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeSpriteManifest(spritesDir) {
|
||||||
|
const manifest = await buildSpriteManifest(spritesDir);
|
||||||
|
await writeFile(path.join(spritesDir, SPRITE_MANIFEST_NAME), `${JSON.stringify(manifest)}\n`);
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||||
|
const spritesDir =
|
||||||
|
process.env.SPRITE_OUTPUT_DIR ?? path.join(process.cwd(), 'static', 'sprites-small');
|
||||||
|
const manifest = await writeSpriteManifest(spritesDir);
|
||||||
|
const bytes = manifest.files.reduce((total, [, size]) => total + size, 0);
|
||||||
|
console.log(
|
||||||
|
`Wrote ${manifest.files.length} sprites (${(bytes / 1048576).toFixed(1)} MB) to ${SPRITE_MANIFEST_NAME}`
|
||||||
|
);
|
||||||
|
}
|
||||||
+8
-11
@@ -1,23 +1,20 @@
|
|||||||
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
|
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
|
||||||
import { createServerClient } from '@supabase/ssr';
|
import { createServerClient } from '@supabase/ssr';
|
||||||
import type { Handle } from '@sveltejs/kit';
|
import type { Handle } from '@sveltejs/kit';
|
||||||
import type { CookieSerializeOptions } from 'cookie';
|
|
||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
||||||
cookies: {
|
cookies: {
|
||||||
get: (key: string) => event.cookies.get(key),
|
getAll: () => event.cookies.getAll(),
|
||||||
/**
|
/**
|
||||||
* Note: You have to add the `path` variable to the
|
* Note: You have to add the `path` variable to the set method due to sveltekit's cookie
|
||||||
* set and remove method due to sveltekit's cookie API
|
* API requiring this to be set, setting the path to '/' will replicate previous/standard
|
||||||
* requiring this to be set, setting the path to an empty string
|
* behaviour (https://kit.svelte.dev/docs/types#public-types-cookies)
|
||||||
* will replicate previous/standard behaviour (https://kit.svelte.dev/docs/types#public-types-cookies)
|
|
||||||
*/
|
*/
|
||||||
set: (key: string, value: string, options: CookieSerializeOptions) => {
|
setAll: (cookiesToSet) => {
|
||||||
event.cookies.set(key, value, { ...options, path: '/' });
|
cookiesToSet.forEach(({ name, value, options }) => {
|
||||||
},
|
event.cookies.set(name, value, { ...options, path: '/' });
|
||||||
remove: (key: string, options: CookieSerializeOptions) => {
|
});
|
||||||
event.cookies.delete(key, { ...options, path: '/' });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+128
-38
@@ -1,24 +1,46 @@
|
|||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
import { PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER } from '$env/static/public';
|
import { PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER } from '$env/static/public';
|
||||||
import type { OfflineSnapshot } from '$lib/models/OfflineSnapshot';
|
import type { OfflineSnapshot } from '$lib/models/OfflineSnapshot';
|
||||||
import { resolveSpriteUrl } from '$lib/utils/spriteUrl';
|
import { resolveSpriteUrl, spriteRoot } from '$lib/utils/spriteUrl';
|
||||||
|
|
||||||
export type OfflineSyncStatus = {
|
export type OfflineSyncStatus = {
|
||||||
state: 'idle' | 'syncing' | 'ready' | 'partial' | 'error';
|
state: 'idle' | 'syncing' | 'ready' | 'error';
|
||||||
generatedAt: string | null;
|
generatedAt: string | null;
|
||||||
message: string | null;
|
message: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ArtworkDownloadStatus = {
|
||||||
|
// unknown: not checked yet; missing: some sprites aren't saved; done: every sprite is saved.
|
||||||
|
state: 'unknown' | 'missing' | 'downloading' | 'done' | 'error';
|
||||||
|
missingBytes: number | null;
|
||||||
|
message: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export const offlineSyncStatus = writable<OfflineSyncStatus>({
|
export const offlineSyncStatus = writable<OfflineSyncStatus>({
|
||||||
state: 'idle',
|
state: 'idle',
|
||||||
generatedAt: null,
|
generatedAt: null,
|
||||||
message: null
|
message: null
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const artworkDownloadStatus = writable<ArtworkDownloadStatus>({
|
||||||
|
state: 'unknown',
|
||||||
|
missingBytes: null,
|
||||||
|
message: null
|
||||||
|
});
|
||||||
|
|
||||||
const SYNC_EVENT = 'livingdex:offline-sync';
|
const SYNC_EVENT = 'livingdex:offline-sync';
|
||||||
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';
|
||||||
|
// Must match OFFLINE_META_FORMAT in static/offline-worker.js. Older copies are always re-synced so
|
||||||
|
// the worker can migrate them (e.g. drop the full-size artwork cache).
|
||||||
|
const OFFLINE_META_FORMAT = 2;
|
||||||
|
// A page load reuses an offline copy this recent instead of downloading the whole collection again.
|
||||||
|
// Changes made in the app request a sync explicitly, so this only delays picking up edits made on
|
||||||
|
// another device.
|
||||||
|
const SNAPSHOT_FRESH_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
type OfflineMeta = { userId: string; generatedAt?: string; format?: number };
|
||||||
|
|
||||||
async function workerMessage(
|
async function workerMessage(
|
||||||
message: unknown,
|
message: unknown,
|
||||||
@@ -50,6 +72,14 @@ export function requestOfflineSync(): void {
|
|||||||
if (typeof window !== 'undefined') window.dispatchEvent(new Event(SYNC_EVENT));
|
if (typeof window !== 'undefined') window.dispatchEvent(new Event(SYNC_EVENT));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readOfflineMeta(): Promise<OfflineMeta | null> {
|
||||||
|
if (!('caches' in window)) return null;
|
||||||
|
if (!(await caches.keys()).includes(OFFLINE_META_CACHE)) return null;
|
||||||
|
const response = await (await caches.open(OFFLINE_META_CACHE)).match(OFFLINE_META_URL);
|
||||||
|
const meta = await response?.json().catch(() => null);
|
||||||
|
return typeof meta?.userId === 'string' ? meta : null;
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteOfflineCaches(): Promise<void> {
|
async function deleteOfflineCaches(): Promise<void> {
|
||||||
if (typeof window === 'undefined' || !('caches' in window)) return;
|
if (typeof window === 'undefined' || !('caches' in window)) return;
|
||||||
const names = await caches.keys();
|
const names = await caches.keys();
|
||||||
@@ -60,13 +90,9 @@ async function deleteOfflineCaches(): Promise<void> {
|
|||||||
|
|
||||||
export async function claimOfflineData(userId: string): Promise<void> {
|
export async function claimOfflineData(userId: string): Promise<void> {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
if ('caches' in window) {
|
if ('caches' in window && (await caches.keys()).includes(OFFLINE_META_CACHE)) {
|
||||||
const names = await caches.keys();
|
const meta = await readOfflineMeta();
|
||||||
if (names.includes(OFFLINE_META_CACHE)) {
|
if (meta?.userId !== userId) await deleteOfflineCaches();
|
||||||
const response = await (await caches.open(OFFLINE_META_CACHE)).match(OFFLINE_META_URL);
|
|
||||||
const meta = await response?.json().catch(() => null);
|
|
||||||
if (typeof meta?.userId !== 'string' || meta.userId !== userId) await deleteOfflineCaches();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if ('serviceWorker' in navigator && (await navigator.serviceWorker.getRegistration())?.active) {
|
if ('serviceWorker' in navigator && (await navigator.serviceWorker.getRegistration())?.active) {
|
||||||
await workerMessage({ type: 'CLAIM_OFFLINE_USER', userId }, 10_000, false);
|
await workerMessage({ type: 'CLAIM_OFFLINE_USER', userId }, 10_000, false);
|
||||||
@@ -79,9 +105,69 @@ export async function clearOfflineData(): Promise<void> {
|
|||||||
if ('serviceWorker' in navigator && (await navigator.serviceWorker.getRegistration())?.active) {
|
if ('serviceWorker' in navigator && (await navigator.serviceWorker.getRegistration())?.active) {
|
||||||
await workerMessage({ type: 'CLEAR_OFFLINE_DATA' }, 10_000, false);
|
await workerMessage({ type: 'CLEAR_OFFLINE_DATA' }, 10_000, false);
|
||||||
}
|
}
|
||||||
|
// Sprites are kept across sign-out (they aren't account data), so the artwork status stays valid.
|
||||||
offlineSyncStatus.set({ state: 'idle', generatedAt: null, message: null });
|
offlineSyncStatus.set({ state: 'idle', generatedAt: null, message: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function currentSpriteRoot(): string {
|
||||||
|
return spriteRoot(PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyArtworkResult(result: Record<string, unknown>): void {
|
||||||
|
const missing = Number(result.missing ?? 0);
|
||||||
|
const failed = Number(result.failedArtwork ?? 0);
|
||||||
|
if (failed > 0) {
|
||||||
|
artworkDownloadStatus.set({
|
||||||
|
state: 'error',
|
||||||
|
missingBytes: Number(result.missingBytes ?? 0),
|
||||||
|
message: `${failed} artwork files could not be saved`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
artworkDownloadStatus.set({
|
||||||
|
state: missing > 0 ? 'missing' : 'done',
|
||||||
|
missingBytes: Number(result.missingBytes ?? 0),
|
||||||
|
message: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checks whether every sprite (all forms, shiny and female) is already saved on this device. */
|
||||||
|
export async function checkArtworkStatus(): Promise<void> {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
applyArtworkResult(
|
||||||
|
await workerMessage({ type: 'ARTWORK_STATUS', spriteRoot: currentSpriteRoot() }, 30_000)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Leave the link hidden rather than offering a download that can't be checked.
|
||||||
|
console.error('Unable to check saved artwork', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Artwork is normally cached as it is viewed. This saves every sprite that exists - all forms,
|
||||||
|
* shiny and female variants, not just the saved dexes - skipping any that are already cached.
|
||||||
|
*/
|
||||||
|
export async function downloadAllArtwork(): Promise<void> {
|
||||||
|
if (typeof window === 'undefined' || !navigator.onLine) return;
|
||||||
|
artworkDownloadStatus.update((status) => ({ ...status, state: 'downloading', message: null }));
|
||||||
|
try {
|
||||||
|
// Generous timeout: the worker fetches each missing sprite with its own 15s limit.
|
||||||
|
applyArtworkResult(
|
||||||
|
await workerMessage(
|
||||||
|
{ type: 'CACHE_ALL_ARTWORK', spriteRoot: currentSpriteRoot() },
|
||||||
|
30 * 60 * 1000
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
artworkDownloadStatus.update((status) => ({
|
||||||
|
...status,
|
||||||
|
state: 'error',
|
||||||
|
message: error instanceof Error ? error.message : String(error)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function startOfflineSync(getUserId: () => string | null): () => void {
|
export function startOfflineSync(getUserId: () => string | null): () => void {
|
||||||
let timer: number | null = null;
|
let timer: number | null = null;
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
@@ -89,7 +175,7 @@ export function startOfflineSync(getUserId: () => string | null): () => void {
|
|||||||
let rerun = false;
|
let rerun = false;
|
||||||
let lastGeneratedAt: string | null = null;
|
let lastGeneratedAt: string | null = null;
|
||||||
|
|
||||||
const synchronize = async () => {
|
const synchronize = async (reuseFreshCopy: boolean) => {
|
||||||
if (stopped || !navigator.onLine) return;
|
if (stopped || !navigator.onLine) return;
|
||||||
if (running) {
|
if (running) {
|
||||||
rerun = true;
|
rerun = true;
|
||||||
@@ -98,9 +184,24 @@ export function startOfflineSync(getUserId: () => string | null): () => void {
|
|||||||
const userId = getUserId();
|
const userId = getUserId();
|
||||||
if (!userId || !('serviceWorker' in navigator)) return;
|
if (!userId || !('serviceWorker' in navigator)) return;
|
||||||
running = true;
|
running = true;
|
||||||
offlineSyncStatus.set({ state: 'syncing', generatedAt: null, message: null });
|
|
||||||
try {
|
try {
|
||||||
await claimOfflineData(userId);
|
await claimOfflineData(userId);
|
||||||
|
if (reuseFreshCopy) {
|
||||||
|
const meta = await readOfflineMeta();
|
||||||
|
const age = meta?.generatedAt ? Date.now() - Date.parse(meta.generatedAt) : Infinity;
|
||||||
|
if (
|
||||||
|
meta?.userId === userId &&
|
||||||
|
meta.format === OFFLINE_META_FORMAT &&
|
||||||
|
age >= 0 &&
|
||||||
|
age < SNAPSHOT_FRESH_MS
|
||||||
|
) {
|
||||||
|
lastGeneratedAt = meta.generatedAt ?? null;
|
||||||
|
offlineSyncStatus.set({ state: 'ready', generatedAt: lastGeneratedAt, message: null });
|
||||||
|
void checkArtworkStatus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offlineSyncStatus.set({ state: 'syncing', generatedAt: null, message: null });
|
||||||
const response = await fetch('/api/offline-snapshot', {
|
const response = await fetch('/api/offline-snapshot', {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: { Accept: 'application/json' }
|
headers: { Accept: 'application/json' }
|
||||||
@@ -109,32 +210,19 @@ export function startOfflineSync(getUserId: () => string | null): () => void {
|
|||||||
const snapshot = (await response.json()) as OfflineSnapshot;
|
const snapshot = (await response.json()) as OfflineSnapshot;
|
||||||
if (snapshot.userId !== userId) throw new Error('Snapshot owner did not match the session');
|
if (snapshot.userId !== userId) throw new Error('Snapshot owner did not match the session');
|
||||||
const useLocal = PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true';
|
const useLocal = PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true';
|
||||||
const artworkUrls = Array.from(
|
// The worker derives which artwork belongs to the collection from these URLs, and the offline
|
||||||
new Set(
|
// viewer renders them.
|
||||||
snapshot.pokedexes.flatMap(({ pokedex, entries }) =>
|
for (const { pokedex, entries } of snapshot.pokedexes) {
|
||||||
entries.map(({ pokedexEntry }) => {
|
for (const { pokedexEntry } of entries) {
|
||||||
const url = resolveSpriteUrl(pokedexEntry, pokedex.isShinyDex, useLocal);
|
(pokedexEntry as typeof pokedexEntry & { offlineSpriteUrl: string }).offlineSpriteUrl =
|
||||||
(
|
resolveSpriteUrl(pokedexEntry, pokedex.isShinyDex, useLocal);
|
||||||
pokedexEntry as typeof pokedexEntry & { offlineSpriteUrl: string }
|
}
|
||||||
).offlineSpriteUrl = url;
|
}
|
||||||
return url;
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
if (getUserId() !== userId) throw new Error('Session changed during offline synchronization');
|
if (getUserId() !== userId) throw new Error('Session changed during offline synchronization');
|
||||||
const result = await workerMessage({
|
await workerMessage({ type: 'SYNC_OFFLINE_SNAPSHOT', snapshot });
|
||||||
type: 'SYNC_OFFLINE_SNAPSHOT',
|
offlineSyncStatus.set({ state: 'ready', generatedAt: snapshot.generatedAt, message: null });
|
||||||
snapshot,
|
|
||||||
artworkUrls
|
|
||||||
});
|
|
||||||
const failed = Number(result.failedArtwork ?? 0);
|
|
||||||
offlineSyncStatus.set({
|
|
||||||
state: failed > 0 ? 'partial' : 'ready',
|
|
||||||
generatedAt: snapshot.generatedAt,
|
|
||||||
message: failed > 0 ? `${failed} artwork files could not be cached` : null
|
|
||||||
});
|
|
||||||
lastGeneratedAt = snapshot.generatedAt;
|
lastGeneratedAt = snapshot.generatedAt;
|
||||||
|
void checkArtworkStatus();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
offlineSyncStatus.set({
|
offlineSyncStatus.set({
|
||||||
state: 'error',
|
state: 'error',
|
||||||
@@ -150,19 +238,21 @@ export function startOfflineSync(getUserId: () => string | null): () => void {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const schedule = () => {
|
const scheduleSync = (reuseFreshCopy: boolean) => {
|
||||||
if (timer !== null) window.clearTimeout(timer);
|
if (timer !== null) window.clearTimeout(timer);
|
||||||
timer = window.setTimeout(() => {
|
timer = window.setTimeout(() => {
|
||||||
timer = null;
|
timer = null;
|
||||||
void synchronize();
|
void synchronize(reuseFreshCopy);
|
||||||
}, 1_000);
|
}, 1_000);
|
||||||
};
|
};
|
||||||
|
// Explicit requests (edits, Retry, a new sign-in) and reconnecting always fetch a new copy.
|
||||||
|
const schedule = () => scheduleSync(false);
|
||||||
|
|
||||||
// Best effort: ask the browser not to evict the offline artwork cache under storage pressure.
|
// Best effort: ask the browser not to evict the offline artwork cache under storage pressure.
|
||||||
void navigator.storage?.persist?.().catch(() => undefined);
|
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();
|
scheduleSync(true);
|
||||||
return () => {
|
return () => {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
if (timer !== null) window.clearTimeout(timer);
|
if (timer !== null) window.clearTimeout(timer);
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
/** Folder holding every sprite; paths in static/sprites-small/manifest.json are relative to it. */
|
||||||
|
export function spriteRoot(useLocalSprites: boolean): string {
|
||||||
|
return useLocalSprites
|
||||||
|
? '/sprites-small/home'
|
||||||
|
: 'https://raw.githubusercontent.com/jcreek/LivingDexTracker/master/static/sprites-small/home';
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveSpriteUrl(
|
export function resolveSpriteUrl(
|
||||||
entry: { pokedexNumber: number; form?: string; spriteKey?: string },
|
entry: { pokedexNumber: number; form?: string; spriteKey?: string },
|
||||||
shiny: boolean,
|
shiny: boolean,
|
||||||
@@ -34,9 +41,7 @@ export function resolveSpriteUrl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let root = useLocalSprites
|
let root = spriteRoot(useLocalSprites);
|
||||||
? '/sprites-small/home'
|
|
||||||
: 'https://raw.githubusercontent.com/jcreek/LivingDexTracker/master/static/sprites-small/home';
|
|
||||||
if (shiny) root += '/shiny';
|
if (shiny) root += '/shiny';
|
||||||
if (/^female\b/i.test(form)) root += '/female';
|
if (/^female\b/i.test(form)) root += '/female';
|
||||||
return `${root}/${key}.webp`;
|
return `${root}/${key}.webp`;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { LayoutServerLoad } from './$types';
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: LayoutServerLoad = async ({ locals: { safeGetSession } }) => {
|
export const load: LayoutServerLoad = async ({ locals: { safeGetSession }, cookies }) => {
|
||||||
const { session, user } = await safeGetSession();
|
const { session, user } = await safeGetSession();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
session,
|
session,
|
||||||
user
|
user,
|
||||||
|
// The universal layout load rebuilds a server-side client from these during SSR.
|
||||||
|
cookies: cookies.getAll()
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,8 +7,9 @@
|
|||||||
import SignOut from '$lib/components/SignOut.svelte';
|
import SignOut from '$lib/components/SignOut.svelte';
|
||||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||||
import {
|
import {
|
||||||
|
artworkDownloadStatus,
|
||||||
claimOfflineData,
|
claimOfflineData,
|
||||||
clearOfflineData,
|
downloadAllArtwork,
|
||||||
offlineSyncStatus,
|
offlineSyncStatus,
|
||||||
requestOfflineSync,
|
requestOfflineSync,
|
||||||
startOfflineSync
|
startOfflineSync
|
||||||
@@ -65,14 +66,21 @@
|
|||||||
const {
|
const {
|
||||||
data: { subscription }
|
data: { subscription }
|
||||||
} = supabase.auth.onAuthStateChange((event, session) => {
|
} = supabase.auth.onAuthStateChange((event, session) => {
|
||||||
|
const previousUserId = localUser?.id ?? null;
|
||||||
if (session) {
|
if (session) {
|
||||||
localUser = session.user;
|
localUser = session.user;
|
||||||
void claimOfflineData(session.user.id)
|
// SIGNED_IN also fires when a tab regains focus, so only a change of account fetches a new
|
||||||
.then(requestOfflineSync)
|
// offline copy. Page loads and token refreshes reuse the saved one while it is fresh.
|
||||||
.catch((error) => console.error('Unable to claim offline data', error));
|
if (event === 'SIGNED_IN' && session.user.id !== previousUserId) {
|
||||||
|
void claimOfflineData(session.user.id)
|
||||||
|
.then(requestOfflineSync)
|
||||||
|
.catch((error) => console.error('Unable to claim offline data', error));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Offline data is only cleared by the Sign Out button (or another account claiming it).
|
||||||
|
// An expired or rejected session must not throw away artwork that would then have to be
|
||||||
|
// downloaded again after signing back in.
|
||||||
localUser = null;
|
localUser = null;
|
||||||
if (event === 'SIGNED_OUT') void clearOfflineData();
|
|
||||||
}
|
}
|
||||||
user.set(localUser);
|
user.set(localUser);
|
||||||
});
|
});
|
||||||
@@ -90,6 +98,11 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function formatMegabytes(bytes: number) {
|
||||||
|
const megabytes = bytes / 1048576;
|
||||||
|
return megabytes < 1 ? '<1 MB' : `≈${Math.round(megabytes)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
async function getUser() {
|
async function getUser() {
|
||||||
const {
|
const {
|
||||||
data: { session }
|
data: { session }
|
||||||
@@ -214,10 +227,12 @@
|
|||||||
<span>Offline copy could not be refreshed: {$offlineSyncStatus.message}</span>
|
<span>Offline copy could not be refreshed: {$offlineSyncStatus.message}</span>
|
||||||
<button class="btn btn-sm" on:click={requestOfflineSync}>Retry</button>
|
<button class="btn btn-sm" on:click={requestOfflineSync}>Retry</button>
|
||||||
</div>
|
</div>
|
||||||
{:else if localUser && $offlineSyncStatus.state === 'partial'}
|
{:else if localUser && $artworkDownloadStatus.state === 'error'}
|
||||||
<div class="alert alert-warning rounded-none" role="status">
|
<div class="alert alert-warning rounded-none" role="status">
|
||||||
<span>Offline data is saved, but {$offlineSyncStatus.message}.</span>
|
<span
|
||||||
<button class="btn btn-sm" on:click={requestOfflineSync}>Retry</button>
|
>Offline data is saved, but artwork could not be saved: {$artworkDownloadStatus.message}.</span
|
||||||
|
>
|
||||||
|
<button class="btn btn-sm" on:click={downloadAllArtwork}>Retry</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if isOnline && localUser && $offlineSyncStatus.state === 'syncing'}
|
{#if isOnline && localUser && $offlineSyncStatus.state === 'syncing'}
|
||||||
@@ -225,6 +240,15 @@
|
|||||||
{:else if isOnline && localUser && $offlineSyncStatus.generatedAt}
|
{:else if isOnline && localUser && $offlineSyncStatus.generatedAt}
|
||||||
<p class="bg-base-200 px-4 py-1 text-center text-xs" role="status">
|
<p class="bg-base-200 px-4 py-1 text-center text-xs" role="status">
|
||||||
Offline copy updated {new Date($offlineSyncStatus.generatedAt).toLocaleString()}.
|
Offline copy updated {new Date($offlineSyncStatus.generatedAt).toLocaleString()}.
|
||||||
|
{#if $artworkDownloadStatus.state === 'downloading'}
|
||||||
|
Saving all artwork for offline…
|
||||||
|
{:else if $artworkDownloadStatus.state === 'missing'}
|
||||||
|
<button class="link" on:click={downloadAllArtwork}>
|
||||||
|
Save all artwork for offline{#if $artworkDownloadStatus.missingBytes}{' '}({formatMegabytes(
|
||||||
|
$artworkDownloadStatus.missingBytes
|
||||||
|
)}){/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
+16
-23
@@ -1,7 +1,6 @@
|
|||||||
import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public';
|
import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public';
|
||||||
import type { LayoutLoad } from './$types';
|
import type { LayoutLoad } from './$types';
|
||||||
import { createBrowserClient, isBrowser, parse, serialize } from '@supabase/ssr';
|
import { createBrowserClient, createServerClient, isBrowser } from '@supabase/ssr';
|
||||||
import type { CookieSerializeOptions } from 'cookie';
|
|
||||||
|
|
||||||
export const load: LayoutLoad = async ({ fetch, data, depends }) => {
|
export const load: LayoutLoad = async ({ fetch, data, depends }) => {
|
||||||
depends('supabase:auth');
|
depends('supabase:auth');
|
||||||
@@ -31,29 +30,23 @@ export const load: LayoutLoad = async ({ fetch, data, depends }) => {
|
|||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
const supabase = createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
// The browser client manages document.cookie itself, including removing stale session chunks.
|
||||||
global: {
|
const supabase = isBrowser()
|
||||||
fetch: authFetch
|
? createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
||||||
},
|
global: {
|
||||||
cookies: {
|
fetch: authFetch
|
||||||
get(key: string) {
|
|
||||||
if (!isBrowser()) {
|
|
||||||
return JSON.stringify(data.session);
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
const cookie = parse(document.cookie);
|
: createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
||||||
return cookie[key];
|
global: {
|
||||||
},
|
fetch
|
||||||
set(key: string, value: string, options: CookieSerializeOptions) {
|
},
|
||||||
if (isBrowser()) document.cookie = serialize(key, value, { ...options, path: '/' });
|
cookies: {
|
||||||
},
|
getAll() {
|
||||||
remove(key: string, options: CookieSerializeOptions) {
|
return data.cookies;
|
||||||
if (isBrowser()) {
|
}
|
||||||
document.cookie = serialize(key, '', { ...options, path: '/', maxAge: 0 });
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* It's fine to use `getSession` here, because on the client, `getSession` is
|
* It's fine to use `getSession` here, because on the client, `getSession` is
|
||||||
|
|||||||
+98
-45
@@ -1,6 +1,16 @@
|
|||||||
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';
|
||||||
|
// Must match OFFLINE_META_FORMAT in src/lib/stores/offlineSync.ts. Bumped when artwork moved to the
|
||||||
|
// shared sprite cache, so pages re-sync older copies instead of reusing them.
|
||||||
|
const OFFLINE_META_FORMAT = 2;
|
||||||
|
// Sprites never change at a given URL and aren't user data, so one cache serves every account and is
|
||||||
|
// kept forever: it deliberately sits outside OFFLINE_CACHE_PREFIX, which sign-out, account changes
|
||||||
|
// and sync cleanup all delete. Only bump the version if the files at existing URLs are replaced.
|
||||||
|
const SPRITE_CACHE = 'livingdex-sprites-v1';
|
||||||
|
// Every sprite file (all forms, shiny and female) with its size; generated by
|
||||||
|
// scripts/sprite-manifest.mjs. Kept in SPRITE_CACHE, so it changes exactly when the sprites do.
|
||||||
|
const SPRITE_MANIFEST_URL = '/sprites-small/manifest.json';
|
||||||
const ARTWORK_FETCH_TIMEOUT_MS = 15_000;
|
const ARTWORK_FETCH_TIMEOUT_MS = 15_000;
|
||||||
let offlineEpoch = 0;
|
let offlineEpoch = 0;
|
||||||
let offlineOperation = Promise.resolve();
|
let offlineOperation = Promise.resolve();
|
||||||
@@ -16,10 +26,50 @@ function dataCacheName(userId, generation) {
|
|||||||
return `${OFFLINE_CACHE_PREFIX}data-v1-${userId}-${generation}`;
|
return `${OFFLINE_CACHE_PREFIX}data-v1-${userId}-${generation}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sprites never change at a given URL, so each user keeps one artwork cache that is topped up
|
// The old per-user artwork caches hold full-size (or opaque, quota-padded) sprites.
|
||||||
// incrementally instead of being rebuilt on every sync.
|
function isObsoleteArtworkCache(name) {
|
||||||
function artworkCacheName(userId) {
|
return name.startsWith(`${OFFLINE_CACHE_PREFIX}art-`);
|
||||||
return `${OFFLINE_CACHE_PREFIX}art-v2-${userId}`;
|
}
|
||||||
|
|
||||||
|
async function deleteObsoleteArtworkCaches() {
|
||||||
|
await Promise.all(
|
||||||
|
(await caches.keys()).filter(isObsoleteArtworkCache).map((name) => caches.delete(name))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSpriteManifest(cache) {
|
||||||
|
let response = await cache.match(SPRITE_MANIFEST_URL);
|
||||||
|
if (!response) {
|
||||||
|
response = await fetch(SPRITE_MANIFEST_URL);
|
||||||
|
if (!response.ok) throw new Error(`Sprite list unavailable (HTTP ${response.status})`);
|
||||||
|
await cache.put(SPRITE_MANIFEST_URL, response.clone());
|
||||||
|
}
|
||||||
|
const manifest = await response.json();
|
||||||
|
if (manifest?.version !== 1 || !Array.isArray(manifest.files))
|
||||||
|
throw new Error('Unsupported sprite list');
|
||||||
|
return manifest.files;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maps every sprite in the manifest to its absolute URL under `root` (the page's sprite folder).
|
||||||
|
async function allSpriteSizes(cache, root) {
|
||||||
|
const sizes = new Map();
|
||||||
|
for (const [file, size] of await loadSpriteManifest(cache)) {
|
||||||
|
const url = new URL(`${root}/${file}`, self.location.origin);
|
||||||
|
if (!isSpriteUrl(url)) throw new Error('Invalid sprite location');
|
||||||
|
sizes.set(url.href, size);
|
||||||
|
}
|
||||||
|
return sizes;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function missingSprites(cache, root) {
|
||||||
|
const sizes = await allSpriteSizes(cache, root);
|
||||||
|
const cached = new Set((await cache.keys()).map((request) => request.url));
|
||||||
|
const missing = [...sizes.keys()].filter((url) => !cached.has(url));
|
||||||
|
return {
|
||||||
|
total: sizes.size,
|
||||||
|
missing,
|
||||||
|
missingBytes: missing.reduce((bytes, url) => bytes + sizes.get(url), 0)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Covers both the local `/sprites-small/...` folder and the GitHub-hosted copy of it.
|
// Covers both the local `/sprites-small/...` folder and the GitHub-hosted copy of it.
|
||||||
@@ -46,23 +96,13 @@ 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, isCurrent) {
|
async function fetchSprites(cache, missing) {
|
||||||
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, missing.length) }, async () => {
|
const workers = Array.from({ length: Math.min(6, missing.length) }, async () => {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const index = next++;
|
const index = next++;
|
||||||
if (index >= missing.length || !isCurrent()) return;
|
if (index >= missing.length) return;
|
||||||
const url = missing[index];
|
const url = missing[index];
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), ARTWORK_FETCH_TIMEOUT_MS);
|
const timeout = setTimeout(() => controller.abort(), ARTWORK_FETCH_TIMEOUT_MS);
|
||||||
@@ -120,6 +160,32 @@ self.addEventListener('message', (event) => {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Sprites aren't account data, so these work for whoever is signed in and survive sign-out.
|
||||||
|
if (event.data?.type === 'ARTWORK_STATUS' || event.data?.type === 'CACHE_ALL_ARTWORK') {
|
||||||
|
const download = event.data.type === 'CACHE_ALL_ARTWORK';
|
||||||
|
const root = String(event.data.spriteRoot ?? '');
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const cache = await caches.open(SPRITE_CACHE);
|
||||||
|
let status = await missingSprites(cache, root);
|
||||||
|
let failedArtwork = 0;
|
||||||
|
if (download && status.missing.length > 0) {
|
||||||
|
failedArtwork = await fetchSprites(cache, status.missing);
|
||||||
|
status = await missingSprites(cache, root);
|
||||||
|
}
|
||||||
|
reply({
|
||||||
|
ok: true,
|
||||||
|
total: status.total,
|
||||||
|
missing: status.missing.length,
|
||||||
|
missingBytes: status.missingBytes,
|
||||||
|
failedArtwork
|
||||||
|
});
|
||||||
|
})().catch((error) =>
|
||||||
|
reply({ ok: false, error: error instanceof Error ? error.message : String(error) })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
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;
|
||||||
@@ -140,13 +206,7 @@ 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
|
await deleteObsoleteArtworkCaches();
|
||||||
// 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()}`;
|
||||||
@@ -161,18 +221,15 @@ self.addEventListener('message', (event) => {
|
|||||||
if (!isCurrent())
|
if (!isCurrent())
|
||||||
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,
|
||||||
new Response(
|
new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
format: OFFLINE_META_FORMAT,
|
||||||
userId: snapshot.userId,
|
userId: snapshot.userId,
|
||||||
generatedAt: snapshot.generatedAt,
|
generatedAt: snapshot.generatedAt,
|
||||||
dataCache: nextData,
|
dataCache: nextData
|
||||||
artworkCache
|
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { 'Content-Type': 'application/json' }
|
||||||
@@ -192,19 +249,14 @@ self.addEventListener('message', (event) => {
|
|||||||
.filter(
|
.filter(
|
||||||
(name) =>
|
(name) =>
|
||||||
name.startsWith(OFFLINE_CACHE_PREFIX) &&
|
name.startsWith(OFFLINE_CACHE_PREFIX) &&
|
||||||
![OFFLINE_META_CACHE, nextData, artworkCache].includes(name)
|
![OFFLINE_META_CACHE, nextData].includes(name)
|
||||||
)
|
)
|
||||||
.map((name) => caches.delete(name))
|
.map((name) => caches.delete(name))
|
||||||
);
|
);
|
||||||
|
|
||||||
const failedArtwork = await cacheArtwork(
|
// Artwork is not downloaded here: the fetch handler caches sprites as they are viewed, and
|
||||||
await caches.open(artworkCache),
|
// CACHE_ALL_ARTWORK fetches the rest only when the user asks for it.
|
||||||
Array.from(new Set(event.data.artworkUrls ?? [])),
|
reply({ ok: true });
|
||||||
isCurrent
|
|
||||||
);
|
|
||||||
if (!isCurrent())
|
|
||||||
throw new Error('Offline synchronization was superseded by an account change');
|
|
||||||
reply({ ok: true, failedArtwork });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!committed && nextData) await caches.delete(nextData).catch(() => undefined);
|
if (!committed && nextData) await caches.delete(nextData).catch(() => undefined);
|
||||||
reply({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
reply({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
||||||
@@ -213,6 +265,10 @@ self.addEventListener('message', (event) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(deleteObsoleteArtworkCaches());
|
||||||
|
});
|
||||||
|
|
||||||
self.addEventListener('fetch', (event) => {
|
self.addEventListener('fetch', (event) => {
|
||||||
const url = new URL(event.request.url);
|
const url = new URL(event.request.url);
|
||||||
if (event.request.mode === 'navigate' && !['/offline', '/offline.html'].includes(url.pathname)) {
|
if (event.request.mode === 'navigate' && !['/offline', '/offline.html'].includes(url.pathname)) {
|
||||||
@@ -227,17 +283,14 @@ self.addEventListener('fetch', (event) => {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.request.destination !== 'image') return;
|
if (event.request.destination !== 'image' || !isSpriteUrl(url)) return;
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
(async () => {
|
(async () => {
|
||||||
const meta = await currentOfflineMeta();
|
// Cache-first with fill-on-miss for everyone, signed in or not: a sprite is downloaded once
|
||||||
if (!meta?.artworkCache) return fetch(event.request);
|
// and served from the cache from then on.
|
||||||
const cache = await caches.open(meta.artworkCache);
|
const cache = await caches.open(SPRITE_CACHE);
|
||||||
const cached = await cache.match(event.request, { ignoreSearch: true });
|
const cached = await cache.match(url.href, { ignoreSearch: true });
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
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 {
|
try {
|
||||||
const response = await fetch(url.href, {
|
const response = await fetch(url.href, {
|
||||||
mode: url.origin === self.location.origin ? 'same-origin' : 'cors'
|
mode: url.origin === self.location.origin ? 'same-origin' : 'cors'
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,35 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
// @ts-expect-error - plain ESM build script without type declarations
|
||||||
|
import { buildSpriteManifest } from '../../scripts/sprite-manifest.mjs';
|
||||||
|
import { resolveSpriteUrl, spriteRoot } from '$lib/utils/spriteUrl';
|
||||||
|
|
||||||
|
const spritesDir = path.join(process.cwd(), 'static', 'sprites-small');
|
||||||
|
|
||||||
|
describe('sprite manifest', () => {
|
||||||
|
const committed = JSON.parse(readFileSync(path.join(spritesDir, 'manifest.json'), 'utf8'));
|
||||||
|
|
||||||
|
it('lists exactly the sprite files on disk, with their sizes', async () => {
|
||||||
|
// "Save all artwork" downloads this list, so a stale manifest would miss or 404 sprites.
|
||||||
|
expect(committed).toEqual(await buildSpriteManifest(spritesDir));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('covers forms, shiny and female variants', () => {
|
||||||
|
const files: string[] = committed.files.map(([file]: [string, number]) => file);
|
||||||
|
expect(files.some((file) => file.startsWith('shiny/'))).toBe(true);
|
||||||
|
expect(files.some((file) => file.startsWith('female/'))).toBe(true);
|
||||||
|
expect(files.some((file) => file.startsWith('shiny/female/'))).toBe(true);
|
||||||
|
// Forms use either a "<number>-<form>" key or a 10000+ national-style number.
|
||||||
|
expect(files.some((file) => /^\d+-[a-z0-9-]+\.webp$/.test(file))).toBe(true);
|
||||||
|
expect(files.some((file) => /^10\d{3}\.webp$/.test(file))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the same folder that sprite URLs resolve into', () => {
|
||||||
|
for (const useLocal of [true, false]) {
|
||||||
|
expect(
|
||||||
|
resolveSpriteUrl({ pokedexNumber: 25, form: '', spriteKey: '25' }, true, useLocal)
|
||||||
|
).toBe(`${spriteRoot(useLocal)}/shiny/25.webp`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user