mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-17 11:02:06 +00:00
Compare commits
24 Commits
bfd74a2985
...
5c25a763c0
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c25a763c0 | |||
| 9ddca18f54 | |||
| 030571fd14 | |||
| 721c44dcd0 | |||
| b7d2db4959 | |||
| f382176804 | |||
| 3f8daea8d2 | |||
| c3a3d43883 | |||
| f2d8451c34 | |||
| ca7f9c0e48 | |||
| 27274171fe | |||
| 7240376e00 | |||
| 951c9b2878 | |||
| 2c5fc0d459 | |||
| 4b2f076e50 | |||
| 3ea194f87c | |||
| 86f1c21e4d | |||
| 4af33709a3 | |||
| 3a2c18bbeb | |||
| 92d6460765 | |||
| f9b7bbdf0a | |||
| de39dc78ea | |||
| 08c5e3271c | |||
| 676490b800 |
@@ -7,3 +7,16 @@ GOOGLE_OAUTH_CLIENT_ID="your-google-client-id"
|
|||||||
GOOGLE_OAUTH_CLIENT_SECRET="your-google-client-secret"
|
GOOGLE_OAUTH_CLIENT_SECRET="your-google-client-secret"
|
||||||
DROPBOX_OAUTH_CLIENT_ID="your-dropbox-client-id"
|
DROPBOX_OAUTH_CLIENT_ID="your-dropbox-client-id"
|
||||||
DROPBOX_OAUTH_CLIENT_SECRET="your-dropbox-client-secret"
|
DROPBOX_OAUTH_CLIENT_SECRET="your-dropbox-client-secret"
|
||||||
|
|
||||||
|
# Endpoint overrides for deterministic local provider tests. They are ignored unless
|
||||||
|
# ALLOW_PROVIDER_ENDPOINT_OVERRIDES is exactly "true", the local BDD stack variables are present,
|
||||||
|
# and every URL is loopback. These endpoints receive OAuth secrets, so never set this in a deployed
|
||||||
|
# environment. `npm run test:bdd` supplies the complete test context.
|
||||||
|
ALLOW_PROVIDER_ENDPOINT_OVERRIDES=""
|
||||||
|
GOOGLE_OAUTH_AUTHORIZE_URL=""
|
||||||
|
GOOGLE_OAUTH_TOKEN_URL=""
|
||||||
|
GOOGLE_DRIVE_API_URL=""
|
||||||
|
GOOGLE_DRIVE_UPLOAD_URL=""
|
||||||
|
DROPBOX_OAUTH_AUTHORIZE_URL=""
|
||||||
|
DROPBOX_OAUTH_TOKEN_URL=""
|
||||||
|
DROPBOX_UPLOAD_URL=""
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: tests-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# `$env/static/public` is resolved at build time, so every variable imported from it must be
|
||||||
|
# present for `vite build` and `svelte-check` to succeed on a clean checkout. These are the
|
||||||
|
# local-stack defaults already published in .env.local.example - never real credentials.
|
||||||
|
env:
|
||||||
|
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER: 'false'
|
||||||
|
PUBLIC_SUPABASE_URL: http://127.0.0.1:54321
|
||||||
|
PUBLIC_SUPABASE_ANON_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- name: Check committed whitespace
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||||
|
git diff --check "${{ github.event.pull_request.base.sha }}...HEAD"
|
||||||
|
else
|
||||||
|
git diff --check "HEAD^...HEAD"
|
||||||
|
fi
|
||||||
|
- run: npm run check
|
||||||
|
- run: npm run lint
|
||||||
|
- run: npm run test:fast
|
||||||
|
- name: Verify tests leave the checkout clean
|
||||||
|
run: test -z "$(git status --porcelain --untracked-files=all)"
|
||||||
|
- uses: actions/upload-artifact@v6
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: coverage
|
||||||
|
path: coverage/
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|
||||||
|
integration:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
# `supabase start` applies migrations and seeds; no separate reset is needed.
|
||||||
|
- run: npx supabase start
|
||||||
|
- run: npm run test:integration
|
||||||
|
- if: always()
|
||||||
|
run: npx supabase stop
|
||||||
|
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 25
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run test:build
|
||||||
|
|
||||||
|
bdd:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
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:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- uses: actions/cache@v5
|
||||||
|
with:
|
||||||
|
path: ~/.cache/ms-playwright
|
||||||
|
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
playwright-chromium-${{ runner.os }}-
|
||||||
|
- run: npx playwright install --with-deps chromium
|
||||||
|
- run: npx supabase start
|
||||||
|
- run: npm run test:bdd
|
||||||
|
- uses: actions/upload-artifact@v6
|
||||||
|
if: failure()
|
||||||
|
with:
|
||||||
|
name: playwright-failures
|
||||||
|
path: |
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
if-no-files-found: ignore
|
||||||
|
- if: always()
|
||||||
|
run: npx supabase stop
|
||||||
@@ -10,3 +10,7 @@ vite.config.js.timestamp-*
|
|||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
/static/output.css
|
/static/output.css
|
||||||
.netlify
|
.netlify
|
||||||
|
.features-gen
|
||||||
|
coverage
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
|||||||
@@ -2,3 +2,14 @@
|
|||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
package-lock.json
|
package-lock.json
|
||||||
yarn.lock
|
yarn.lock
|
||||||
|
|
||||||
|
# Generated data exports. Nobody hand-edits these, and reformatting them buries real data
|
||||||
|
# changes under tens of thousands of lines of churn.
|
||||||
|
src/lib/helpers/pokeapi-pokemon.json
|
||||||
|
static/sprites/pokemon.json
|
||||||
|
src/lib/helpers/pokedex.json
|
||||||
|
src/lib/helpers/sprites.json
|
||||||
|
static/sprites-small/manifest.json
|
||||||
|
|
||||||
|
# Machine-local editor and tool settings.
|
||||||
|
**/*.local.json
|
||||||
|
|||||||
@@ -31,6 +31,76 @@ To create a production version:
|
|||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
The test suite is split by responsibility so a failure points to the correct layer:
|
||||||
|
|
||||||
|
- `tests/unit` contains fast, isolated tests for utilities, repositories, and services.
|
||||||
|
- `tests/data` validates the tracked Pokémon, game, region, dex, and sprite reference files.
|
||||||
|
- `tests/integration` checks the migrated Supabase schema, views, constraints, RLS, and repositories.
|
||||||
|
- `tests/bdd/features` is the executable Gherkin specification for user-visible behaviour. Step
|
||||||
|
definitions and browser fixtures live beside it under `tests/bdd`.
|
||||||
|
- `tests/build` verifies generated service-worker and manifest artifacts after each supported build.
|
||||||
|
|
||||||
|
Run the offline suites while developing. `test:fast` includes the coverage run, so there is no need
|
||||||
|
to run both:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:fast
|
||||||
|
```
|
||||||
|
|
||||||
|
Coverage is measured across all of `src/lib` (excluding type-only models and the browser-only
|
||||||
|
store/action modules) with global thresholds set to the current baseline, so new untested code lowers
|
||||||
|
the number instead of being invisible to the gate. Ratchet the thresholds in `vitest.config.mts` up as
|
||||||
|
coverage grows, never down.
|
||||||
|
|
||||||
|
Database and BDD tests require Docker and the local Supabase stack. The wrappers read the local keys
|
||||||
|
from `supabase status` at run time, so no keys are hard-coded in the test files; the local stack's
|
||||||
|
well-known demo keys do appear in `.env.local.example`, and no real credentials are committed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run supabase:start
|
||||||
|
npm run supabase:reset
|
||||||
|
npm run test:integration
|
||||||
|
npm run test:bdd
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm test` runs the complete CI-equivalent sequence and fails with setup instructions when Supabase is
|
||||||
|
not available. Individual layers are available as `test:unit`, `test:data`, `test:integration`,
|
||||||
|
`test:build`, and `test:bdd`.
|
||||||
|
|
||||||
|
Gherkin describes outcomes in domain language. Keep selectors, API calls, test-user provisioning, and
|
||||||
|
provider mocks in step definitions or support fixtures. `@product-review` marks a rule that should be
|
||||||
|
reviewed with product stakeholders, but does not skip it. Missing or ambiguous steps fail generation.
|
||||||
|
|
||||||
|
Google Drive and Dropbox scenarios use a local provider server (`scripts/mock-provider-server.mjs`)
|
||||||
|
and never contact real provider accounts. The endpoint overrides that point at it are refused unless
|
||||||
|
`ALLOW_PROVIDER_ENDPOINT_OVERRIDES=true`, the local test-stack/service-role variables are present,
|
||||||
|
and the override is a loopback URL. These endpoints receive the OAuth client secret and refresh
|
||||||
|
token, so they must not be redirectable in a deployed environment. `npm run test:bdd` supplies the
|
||||||
|
complete test context.
|
||||||
|
|
||||||
|
The mock's recorded requests, refresh counter and fail-uploads switch are reset before every scenario
|
||||||
|
by an auto fixture in `tests/bdd/fixtures.ts`. That reset is also why the suite runs with a single
|
||||||
|
worker: the mock is one shared process, so parallel scenarios would reset each other's state. A global
|
||||||
|
teardown deletes the users each run creates, so repeated local runs do not need a database reset.
|
||||||
|
|
||||||
|
Chromium is the only configured browser project. Playwright traces and screenshots are retained on
|
||||||
|
failure under `test-results`.
|
||||||
|
|
||||||
|
Password-reset scenarios follow recovery links generated by the local Supabase stack and verify both
|
||||||
|
the rejected old password and accepted replacement password. The application waits for Supabase to
|
||||||
|
confirm the recovery session before enabling the replacement form.
|
||||||
|
|
||||||
|
After sign-in, the application automatically stores a versioned, per-user read-only copy of every
|
||||||
|
Pokédex and its referenced artwork. Offline navigation opens a static viewer; all mutation and
|
||||||
|
authentication controls remain unavailable until connectivity returns. A successful sign-out removes
|
||||||
|
the user-specific snapshot and artwork caches from the device.
|
||||||
|
|
||||||
|
The current National Dex maximum is deliberately asserted as 1025. When adding a new generation,
|
||||||
|
update that expectation together with Pokémon data, the corresponding game/dex files, database seed,
|
||||||
|
and sprites. Data tests print the exact conflicting identities or broken references.
|
||||||
|
|
||||||
You can preview the production build with `npm run preview`.
|
You can preview the production build with `npm run preview`.
|
||||||
|
|
||||||
## Sprites
|
## Sprites
|
||||||
|
|||||||
+17
-4
@@ -1,7 +1,20 @@
|
|||||||
import process from 'node:process'
|
import process from 'node:process';
|
||||||
import AdapterNode from '@sveltejs/adapter-node';
|
import AdapterNode from '@sveltejs/adapter-node';
|
||||||
import AdpaterStatic from '@sveltejs/adapter-static';
|
import AdapterNetlify from '@sveltejs/adapter-netlify';
|
||||||
|
|
||||||
export const nodeAdapter = process.env.NODE_ADAPTER === 'true'
|
export const nodeAdapter = process.env.NODE_ADAPTER === 'true';
|
||||||
|
|
||||||
export const adapter = nodeAdapter ? AdapterNode() : AdpaterStatic()
|
// Netlify is the deployment target; the node adapter exists so the service worker
|
||||||
|
// build tests can check the `build/client` layout a Node server produces.
|
||||||
|
export const adapter = nodeAdapter
|
||||||
|
? AdapterNode()
|
||||||
|
: AdapterNetlify({
|
||||||
|
// if true, will create a Netlify Edge Function rather
|
||||||
|
// than using standard Node-based functions
|
||||||
|
edge: false,
|
||||||
|
|
||||||
|
// if true, will split your app into multiple functions
|
||||||
|
// instead of creating a single one for the entire app.
|
||||||
|
// if `edge` is true, this option cannot be used
|
||||||
|
split: false
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
|
|
||||||
test('Test offline and trailing slashes', async ({ browser }) => {
|
|
||||||
// test offline + trailing slashes routes
|
|
||||||
const context = await browser.newContext();
|
|
||||||
const offlinePage = await context.newPage();
|
|
||||||
await offlinePage.goto('/');
|
|
||||||
const offlineSwURL = await offlinePage.evaluate(async () => {
|
|
||||||
const registration = await Promise.race([
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
navigator.serviceWorker.ready,
|
|
||||||
new Promise((_, reject) =>
|
|
||||||
setTimeout(() => reject(new Error('Service worker registration failed: time out')), 10000)
|
|
||||||
)
|
|
||||||
]);
|
|
||||||
// @ts-expect-error registration is of type unknown
|
|
||||||
return registration.active?.scriptURL;
|
|
||||||
});
|
|
||||||
const offlineSwName = 'sw.js';
|
|
||||||
expect(offlineSwURL).toBe(`http://localhost:4173/${offlineSwName}`);
|
|
||||||
await context.setOffline(true);
|
|
||||||
const aboutAnchor = offlinePage.getByRole('link', { name: 'About' });
|
|
||||||
expect(await aboutAnchor.getAttribute('href')).toBe('/about');
|
|
||||||
await aboutAnchor.click({ noWaitAfter: false });
|
|
||||||
const url = await offlinePage.evaluate(async () => {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
||||||
return location.href;
|
|
||||||
});
|
|
||||||
expect(url).toBe('http://localhost:4173/about');
|
|
||||||
expect(offlinePage.locator('li[aria-current="page"] a').getByText('About')).toBeTruthy();
|
|
||||||
await offlinePage.reload({ waitUntil: 'load' });
|
|
||||||
expect(offlinePage.url()).toBe('http://localhost:4173/about');
|
|
||||||
expect(offlinePage.locator('li[aria-current="page"] a').getByText('About')).toBeTruthy();
|
|
||||||
// Dispose context once it's no longer needed.
|
|
||||||
await context.close();
|
|
||||||
});
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
|
|
||||||
test('The service worker is registered and cache storage is present', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
|
|
||||||
const swURL = await page.evaluate(async () => {
|
|
||||||
const registration = await Promise.race([
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
navigator.serviceWorker.ready,
|
|
||||||
new Promise((_, reject) =>
|
|
||||||
setTimeout(() => reject(new Error('Service worker registration failed: time out')), 10000)
|
|
||||||
)
|
|
||||||
]);
|
|
||||||
// @ts-expect-error registration is of type unknown
|
|
||||||
return registration.active?.scriptURL;
|
|
||||||
});
|
|
||||||
const swName = 'sw.js';
|
|
||||||
expect(swURL).toBe(`http://localhost:4173/${swName}`);
|
|
||||||
|
|
||||||
const cacheContents = await page.evaluate(async () => {
|
|
||||||
const cacheState: Record<string, Array<string>> = {};
|
|
||||||
for (const cacheName of await caches.keys()) {
|
|
||||||
const cache = await caches.open(cacheName);
|
|
||||||
cacheState[cacheName] = (await cache.keys()).map((req) => req.url);
|
|
||||||
}
|
|
||||||
return cacheState;
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(Object.keys(cacheContents).length).toEqual(1);
|
|
||||||
|
|
||||||
const key = 'workbox-precache-v2-http://localhost:4173/';
|
|
||||||
|
|
||||||
expect(Object.keys(cacheContents)[0]).toEqual(key);
|
|
||||||
|
|
||||||
const urls = cacheContents[key].map((url) => url.slice('http://localhost:4173/'.length));
|
|
||||||
|
|
||||||
/*
|
|
||||||
'http://localhost:4173/about?__WB_REVISION__=38251751d310c9b683a1426c22c135a2',
|
|
||||||
'http://localhost:4173/?__WB_REVISION__=073370aa3804305a787b01180cd6b8aa',
|
|
||||||
'http://localhost:4173/manifest.webmanifest?__WB_REVISION__=27df2fa4f35d014b42361148a2207da3'
|
|
||||||
*/
|
|
||||||
expect(urls.some((url) => url.startsWith('manifest.webmanifest?__WB_REVISION__='))).toEqual(true);
|
|
||||||
expect(urls.some((url) => url.startsWith('?__WB_REVISION__='))).toEqual(true);
|
|
||||||
expect(urls.some((url) => url.startsWith('about?__WB_REVISION__='))).toEqual(true);
|
|
||||||
// dontCacheBustURLsMatching: any asset in _app/immutable folder shouldn't have a revision (?__WB_REVISION__=)
|
|
||||||
expect(urls.some((url) => url.startsWith('_app/immutable/') && url.endsWith('.css'))).toEqual(
|
|
||||||
true
|
|
||||||
);
|
|
||||||
expect(urls.some((url) => url.startsWith('_app/immutable/') && url.endsWith('.js'))).toEqual(
|
|
||||||
true
|
|
||||||
);
|
|
||||||
expect(urls.some((url) => url.includes('_app/version.json?__WB_REVISION__='))).toEqual(true);
|
|
||||||
});
|
|
||||||
@@ -59,7 +59,7 @@ dexNumber,pokemon,form,notes
|
|||||||
58,Pyroar,,
|
58,Pyroar,,
|
||||||
59,Psyduck,,
|
59,Psyduck,,
|
||||||
60,Golduck,,
|
60,Golduck,,
|
||||||
61,Farfetch'd,,
|
61,Farfetch’d,,
|
||||||
62,Riolu,,
|
62,Riolu,,
|
||||||
63,Lucario,,
|
63,Lucario,,
|
||||||
64,Ralts,,
|
64,Ralts,,
|
||||||
|
|||||||
|
Generated
+610
-146
File diff suppressed because it is too large
Load Diff
+30
-16
@@ -7,11 +7,13 @@
|
|||||||
"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",
|
||||||
"build-generate-sw": "GENERATE_SW=true vite build",
|
"sprites:manifest": "node scripts/sprite-manifest.mjs",
|
||||||
"build-generate-sw-node": "NODE_ADAPTER=true GENERATE_SW=true vite build",
|
"build-generate-sw": "npm run tailwind && GENERATE_SW=true vite build",
|
||||||
"build": "npx tailwindcss -i ./static/input.css -o ./static/output.css && vite build",
|
"build-generate-sw-node": "npm run tailwind && NODE_ADAPTER=true GENERATE_SW=true vite build",
|
||||||
"build-inject-manifest-node": "NODE_ADAPTER=true vite build",
|
"build": "npm run tailwind && vite build",
|
||||||
"build-self-destroying": "SELF_DESTROYING_SW=true vite build",
|
"build-inject-manifest": "npm run tailwind && vite build",
|
||||||
|
"build-inject-manifest-node": "npm run tailwind && NODE_ADAPTER=true vite build",
|
||||||
|
"build-self-destroying": "npm run tailwind && SELF_DESTROYING_SW=true vite build",
|
||||||
"preview": "vite preview --port=4173",
|
"preview": "vite preview --port=4173",
|
||||||
"preview-node": "PORT=4173 node build",
|
"preview-node": "PORT=4173 node build",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
@@ -20,11 +22,21 @@
|
|||||||
"lint-fix": "npm run lint --fix",
|
"lint-fix": "npm run lint --fix",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"tailwind": "npx tailwindcss -i ./static/input.css -o ./static/output.css",
|
"tailwind": "npx tailwindcss -i ./static/input.css -o ./static/output.css",
|
||||||
"test-generate-sw": "npm run build-generate-sw && GENERATE_SW=true vitest run && GENERATE_SW=true playwright test",
|
"test:unit": "vitest run tests/unit",
|
||||||
"test-generate-sw-node": "npm run build-generate-sw-node && NODE_ADAPTER=true GENERATE_SW=true vitest run && NODE_ADAPTER=true GENERATE_SW=true playwright test",
|
"test:data": "vitest run tests/data",
|
||||||
"test-inject-manifest": "npm run build-inject-manifest && vitest run && playwright test",
|
"test:coverage": "vitest run tests/unit --coverage",
|
||||||
"test-inject-manifest-node": "npm run build-inject-manifest-node && NODE_ADAPTER=true vitest run && NODE_ADAPTER=true playwright test",
|
"test:fast": "npm run test:coverage && npm run test:data",
|
||||||
"test": "npm run test-generate-sw && npm run test-generate-sw-node && npm run test-inject-manifest && npm run test-inject-manifest-node",
|
"test:integration": "node scripts/run-with-local-supabase.mjs vitest run --config vitest.integration.config.mts",
|
||||||
|
"test:bdd:generate": "bddgen",
|
||||||
|
"test:bdd:inner": "npm run build-inject-manifest && npm run test:bdd:generate && playwright test --project=chromium",
|
||||||
|
"test:bdd": "ALLOW_PROVIDER_ENDPOINT_OVERRIDES=true GOOGLE_OAUTH_CLIENT_ID=mock-google GOOGLE_OAUTH_CLIENT_SECRET=mock-google-secret DROPBOX_OAUTH_CLIENT_ID=mock-dropbox DROPBOX_OAUTH_CLIENT_SECRET=mock-dropbox-secret GOOGLE_OAUTH_AUTHORIZE_URL=http://127.0.0.1:4199/google/authorize GOOGLE_OAUTH_TOKEN_URL=http://127.0.0.1:4199/google/token GOOGLE_DRIVE_API_URL=http://127.0.0.1:4199/google/drive GOOGLE_DRIVE_UPLOAD_URL=http://127.0.0.1:4199/google/upload DROPBOX_OAUTH_AUTHORIZE_URL=http://127.0.0.1:4199/dropbox/authorize DROPBOX_OAUTH_TOKEN_URL=http://127.0.0.1:4199/dropbox/token DROPBOX_UPLOAD_URL=http://127.0.0.1:4199/dropbox/files/upload node scripts/run-with-local-supabase.mjs npm run test:bdd:inner",
|
||||||
|
"test:build:generate-static": "npm run build-generate-sw && GENERATE_SW=true vitest run --config vitest.build.config.mts",
|
||||||
|
"test:build:generate-node": "npm run build-generate-sw-node && NODE_ADAPTER=true GENERATE_SW=true vitest run --config vitest.build.config.mts",
|
||||||
|
"test:build:inject-static": "npm run build-inject-manifest && vitest run --config vitest.build.config.mts",
|
||||||
|
"test:build:inject-node": "npm run build-inject-manifest-node && NODE_ADAPTER=true vitest run --config vitest.build.config.mts",
|
||||||
|
"test:build": "npm run test:build:generate-static && npm run test:build:generate-node && npm run test:build:inject-static && npm run test:build:inject-node",
|
||||||
|
"test:ci": "npm run test:fast && npm run test:integration && npm run test:build && npm run test:bdd",
|
||||||
|
"test": "npm run test:ci",
|
||||||
"supabase:start": "supabase start",
|
"supabase:start": "supabase start",
|
||||||
"supabase:stop": "supabase stop",
|
"supabase:stop": "supabase stop",
|
||||||
"supabase:reset": "supabase db reset",
|
"supabase:reset": "supabase db reset",
|
||||||
@@ -34,7 +46,7 @@
|
|||||||
"dev:supabase": "supabase start && npm run dev"
|
"dev:supabase": "supabase start && npm run dev"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.37.1",
|
"@playwright/test": "1.55.1",
|
||||||
"@sveltejs/adapter-auto": "^3.0.0",
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
"@sveltejs/adapter-netlify": "^4.1.0",
|
"@sveltejs/adapter-netlify": "^4.1.0",
|
||||||
"@sveltejs/adapter-node": "^2.0.0",
|
"@sveltejs/adapter-node": "^2.0.0",
|
||||||
@@ -47,15 +59,16 @@
|
|||||||
"@typescript-eslint/parser": "^7.0.0",
|
"@typescript-eslint/parser": "^7.0.0",
|
||||||
"@vite-pwa/assets-generator": "^0.2.4",
|
"@vite-pwa/assets-generator": "^0.2.4",
|
||||||
"@vite-pwa/sveltekit": "^0.4.0",
|
"@vite-pwa/sveltekit": "^0.4.0",
|
||||||
|
"@vitest/coverage-v8": "^1.6.1",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"daisyui": "^4.10.1",
|
"daisyui": "^4.10.1",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-svelte": "^2.35.1",
|
"eslint-plugin-svelte": "^2.35.1",
|
||||||
|
"playwright-bdd": "^8.5.1",
|
||||||
"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",
|
||||||
@@ -66,11 +79,12 @@
|
|||||||
},
|
},
|
||||||
"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"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.13.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+90
-73
@@ -1,10 +1,18 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test'
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
import { defineBddConfig } from 'playwright-bdd';
|
||||||
|
|
||||||
const url = 'http://localhost:4173'
|
const url = 'http://localhost:4173';
|
||||||
|
|
||||||
|
const testDir = defineBddConfig({
|
||||||
|
features: 'tests/bdd/features/**/*.feature',
|
||||||
|
steps: ['tests/bdd/steps/**/*.ts', 'tests/bdd/fixtures.ts'],
|
||||||
|
outputDir: '.features-gen',
|
||||||
|
missingSteps: 'fail-on-gen'
|
||||||
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { nodeAdapter } from './adapter.mjs'
|
import { nodeAdapter } from './adapter.mjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read environment variables from file.
|
* Read environment variables from file.
|
||||||
@@ -16,81 +24,90 @@ import { nodeAdapter } from './adapter.mjs'
|
|||||||
* See https://playwright.dev/docs/test-configuration.
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './client-test',
|
testDir,
|
||||||
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
|
globalTeardown: './tests/bdd/globalTeardown.ts',
|
||||||
outputDir: 'test-results/',
|
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
|
||||||
timeout: 5 * 1000,
|
outputDir: 'test-results/',
|
||||||
expect: {
|
timeout: 90 * 1000,
|
||||||
/**
|
expect: {
|
||||||
* Maximum time expect() should wait for the condition to be met.
|
/**
|
||||||
* For example in `await expect(locator).toHaveText();`
|
* Maximum time expect() should wait for the condition to be met.
|
||||||
*/
|
* For example in `await expect(locator).toHaveText();`
|
||||||
timeout: 1000,
|
*/
|
||||||
},
|
timeout: 10 * 1000
|
||||||
/* Run tests in files in parallel */
|
},
|
||||||
fullyParallel: true,
|
/* Run tests in files in parallel */
|
||||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
fullyParallel: false,
|
||||||
forbidOnly: !!process.env.CI,
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
/* Retry on CI only */
|
forbidOnly: !!process.env.CI,
|
||||||
retries: 0,
|
/* Retry on CI only */
|
||||||
/* Opt out of parallel tests on CI. */
|
retries: 0,
|
||||||
workers: process.env.CI ? 1 : undefined,
|
/* Opt out of parallel tests on CI. */
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
workers: 1,
|
||||||
reporter: 'line',
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
reporter: [['line'], ['./tests/support/no-skips-reporter.ts']],
|
||||||
use: {
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
|
use: {
|
||||||
actionTimeout: 0,
|
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
|
||||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
actionTimeout: 0,
|
||||||
baseURL: url,
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
//offline: true,
|
baseURL: url,
|
||||||
|
//offline: true,
|
||||||
|
|
||||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
trace: 'on-first-retry',
|
trace: 'retain-on-failure',
|
||||||
},
|
screenshot: 'only-on-failure'
|
||||||
|
},
|
||||||
|
|
||||||
/* Configure projects for major browsers */
|
/* Configure projects for major browsers */
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'chromium',
|
name: 'chromium',
|
||||||
use: { ...devices['Desktop Chrome'] },
|
use: { ...devices['Desktop Chrome'] }
|
||||||
},
|
}
|
||||||
|
|
||||||
// {
|
// {
|
||||||
// name: 'firefox',
|
// name: 'firefox',
|
||||||
// use: { ...devices['Desktop Firefox'] },
|
// use: { ...devices['Desktop Firefox'] },
|
||||||
// },
|
// },
|
||||||
|
|
||||||
// {
|
// {
|
||||||
// name: 'webkit',
|
// name: 'webkit',
|
||||||
// use: { ...devices['Desktop Safari'] },
|
// use: { ...devices['Desktop Safari'] },
|
||||||
// },
|
// },
|
||||||
|
|
||||||
/* Test against mobile viewports. */
|
/* Test against mobile viewports. */
|
||||||
// {
|
// {
|
||||||
// name: 'Mobile Chrome',
|
// name: 'Mobile Chrome',
|
||||||
// use: { ...devices['Pixel 5'] },
|
// use: { ...devices['Pixel 5'] },
|
||||||
// },
|
// },
|
||||||
// {
|
// {
|
||||||
// name: 'Mobile Safari',
|
// name: 'Mobile Safari',
|
||||||
// use: { ...devices['iPhone 12'] },
|
// use: { ...devices['iPhone 12'] },
|
||||||
// },
|
// },
|
||||||
|
|
||||||
/* Test against branded browsers. */
|
/* Test against branded browsers. */
|
||||||
// {
|
// {
|
||||||
// name: 'Microsoft Edge',
|
// name: 'Microsoft Edge',
|
||||||
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||||
// },
|
// },
|
||||||
// {
|
// {
|
||||||
// name: 'Google Chrome',
|
// name: 'Google Chrome',
|
||||||
// use: { ..devices['Desktop Chrome'], channel: 'chrome' },
|
// use: { ..devices['Desktop Chrome'], channel: 'chrome' },
|
||||||
// },
|
// },
|
||||||
],
|
],
|
||||||
|
|
||||||
/* Run your local dev server before starting the tests */
|
/* Run your local dev server before starting the tests */
|
||||||
webServer: {
|
webServer: [
|
||||||
command: nodeAdapter ? 'pnpm run preview-node' : 'pnpm run preview',
|
{
|
||||||
url,
|
command: 'node scripts/mock-provider-server.mjs',
|
||||||
reuseExistingServer: !process.env.CI,
|
url: 'http://127.0.0.1:4199/__mock/state',
|
||||||
},
|
reuseExistingServer: !process.env.CI
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: nodeAdapter ? 'npm run preview-node' : 'npm run preview',
|
||||||
|
url,
|
||||||
|
reuseExistingServer: !process.env.CI
|
||||||
|
}
|
||||||
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
-7717
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {}
|
||||||
},
|
}
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
import process from 'node:process'
|
import process from 'node:process';
|
||||||
|
|
||||||
export const generateSW = process.env.GENERATE_SW === 'true'
|
export const generateSW = process.env.GENERATE_SW === 'true';
|
||||||
|
|||||||
+133
-119
@@ -17,167 +17,181 @@ const __dirname = path.dirname(__filename);
|
|||||||
* Parse CSV file into array of objects
|
* Parse CSV file into array of objects
|
||||||
*/
|
*/
|
||||||
function parseCSV(filePath) {
|
function parseCSV(filePath) {
|
||||||
const content = fs.readFileSync(filePath, 'utf-8');
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
const lines = content.split('\n').filter(line => line.trim());
|
const lines = content.split('\n').filter((line) => line.trim());
|
||||||
const headers = lines[0].split(',');
|
const headers = lines[0].split(',');
|
||||||
|
|
||||||
return lines.slice(1).map(line => {
|
return lines.slice(1).map((line) => {
|
||||||
const values = parseCSVLine(line);
|
const values = parseCSVLine(line);
|
||||||
const obj = {};
|
const obj = {};
|
||||||
headers.forEach((header, i) => {
|
headers.forEach((header, i) => {
|
||||||
obj[header] = values[i] || null;
|
obj[header] = values[i] || null;
|
||||||
});
|
});
|
||||||
return obj;
|
return obj;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a single CSV line, handling quoted values
|
* Parse a single CSV line, handling quoted values
|
||||||
*/
|
*/
|
||||||
function parseCSVLine(line) {
|
function parseCSVLine(line) {
|
||||||
const values = [];
|
const values = [];
|
||||||
let current = '';
|
let current = '';
|
||||||
let inQuotes = false;
|
let inQuotes = false;
|
||||||
|
|
||||||
for (let i = 0; i < line.length; i++) {
|
for (let i = 0; i < line.length; i++) {
|
||||||
const char = line[i];
|
const char = line[i];
|
||||||
|
|
||||||
if (char === '"') {
|
if (char === '"') {
|
||||||
inQuotes = !inQuotes;
|
inQuotes = !inQuotes;
|
||||||
} else if (char === ',' && !inQuotes) {
|
} else if (char === ',' && !inQuotes) {
|
||||||
values.push(current);
|
values.push(current);
|
||||||
current = '';
|
current = '';
|
||||||
} else {
|
} else {
|
||||||
current += char;
|
current += char;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
values.push(current);
|
values.push(current);
|
||||||
|
|
||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate migration SQL from CSV data
|
* Generate migration SQL from CSV data
|
||||||
*/
|
*/
|
||||||
function generateMigration(region) {
|
function generateMigration(region) {
|
||||||
console.log(`\nGenerating migration for ${region}...`);
|
console.log(`\nGenerating migration for ${region}...`);
|
||||||
|
|
||||||
// Determine generation number from region
|
// Determine generation number from region
|
||||||
const regionToGen = {
|
const regionToGen = {
|
||||||
'Kanto': 1, 'Johto': 2, 'Hoenn': 3, 'Sinnoh': 4,
|
Kanto: 1,
|
||||||
'Unova': 5, 'Kalos': 6, 'Alola': 7, 'Galar': 8,
|
Johto: 2,
|
||||||
'Hisui': 8, 'Paldea': 9
|
Hoenn: 3,
|
||||||
};
|
Sinnoh: 4,
|
||||||
const gen = regionToGen[region] || 1;
|
Unova: 5,
|
||||||
|
Kalos: 6,
|
||||||
|
Alola: 7,
|
||||||
|
Galar: 8,
|
||||||
|
Hisui: 8,
|
||||||
|
Paldea: 9
|
||||||
|
};
|
||||||
|
const gen = regionToGen[region] || 1;
|
||||||
|
|
||||||
// 1. Load CSV files
|
// 1. Load CSV files
|
||||||
const pokemonPath = path.join(__dirname, '..', 'data', 'pokemon', `gen${gen}-${region.toLowerCase()}.csv`);
|
const pokemonPath = path.join(
|
||||||
const gamesPath = path.join(__dirname, '..', 'data', 'pokemon', 'games.csv');
|
__dirname,
|
||||||
|
'..',
|
||||||
|
'data',
|
||||||
|
'pokemon',
|
||||||
|
`gen${gen}-${region.toLowerCase()}.csv`
|
||||||
|
);
|
||||||
|
const gamesPath = path.join(__dirname, '..', 'data', 'pokemon', 'games.csv');
|
||||||
|
|
||||||
if (!fs.existsSync(pokemonPath)) {
|
if (!fs.existsSync(pokemonPath)) {
|
||||||
console.error(`Error: Pokemon CSV not found at ${pokemonPath}`);
|
console.error(`Error: Pokemon CSV not found at ${pokemonPath}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pokemon = parseCSV(pokemonPath);
|
const pokemon = parseCSV(pokemonPath);
|
||||||
const games = parseCSV(gamesPath);
|
const games = parseCSV(gamesPath);
|
||||||
|
|
||||||
// 2. Filter for this region
|
// 2. Filter for this region
|
||||||
const regionGames = games.filter(g => g.region === region);
|
const regionGames = games.filter((g) => g.region === region);
|
||||||
|
|
||||||
if (regionGames.length === 0) {
|
if (regionGames.length === 0) {
|
||||||
console.error(`Error: No games found for region ${region} in games.csv`);
|
console.error(`Error: No games found for region ${region} in games.csv`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Generate SQL
|
// 3. Generate SQL
|
||||||
let sql = `-- Seed ${region} region Pokémon (Gen ${gen})\n`;
|
let sql = `-- Seed ${region} region Pokémon (Gen ${gen})\n`;
|
||||||
sql += `-- Auto-generated from CSV files\n\n`;
|
sql += `-- Auto-generated from CSV files\n\n`;
|
||||||
|
|
||||||
// Region-game mappings
|
// Region-game mappings
|
||||||
sql += `-- Insert ${region} region-game mappings\n`;
|
sql += `-- Insert ${region} region-game mappings\n`;
|
||||||
sql += `INSERT INTO region_game_mappings (region, game) VALUES\n`;
|
sql += `INSERT INTO region_game_mappings (region, game) VALUES\n`;
|
||||||
sql += regionGames.map(g => ` ('${region}', '${g.displayName}')`).join(',\n');
|
sql += regionGames.map((g) => ` ('${region}', '${g.displayName}')`).join(',\n');
|
||||||
sql += `\nON CONFLICT (region, game) DO NOTHING;\n\n`;
|
sql += `\nON CONFLICT (region, game) DO NOTHING;\n\n`;
|
||||||
|
|
||||||
// Pokemon entries (without regional dex number)
|
// Pokemon entries (without regional dex number)
|
||||||
sql += `-- Insert ${region} Pokémon entries\n`;
|
sql += `-- Insert ${region} Pokémon entries\n`;
|
||||||
sql += `INSERT INTO pokedex_entries (\n`;
|
sql += `INSERT INTO pokedex_entries (\n`;
|
||||||
sql += ` "pokedexNumber",\n`;
|
sql += ` "pokedexNumber",\n`;
|
||||||
sql += ` pokemon,\n`;
|
sql += ` pokemon,\n`;
|
||||||
sql += ` form,\n`;
|
sql += ` form,\n`;
|
||||||
sql += ` "canGigantamax",\n`;
|
sql += ` "canGigantamax",\n`;
|
||||||
sql += ` "regionToCatchIn",\n`;
|
sql += ` "regionToCatchIn",\n`;
|
||||||
sql += ` "gamesToCatchIn"\n`;
|
sql += ` "gamesToCatchIn"\n`;
|
||||||
sql += `) VALUES\n`;
|
sql += `) VALUES\n`;
|
||||||
|
|
||||||
const rows = pokemon.map(p => {
|
const rows = pokemon.map((p) => {
|
||||||
const form = p.form ? `'${p.form}'` : 'NULL';
|
const form = p.form ? `'${p.form}'` : 'NULL';
|
||||||
// Use regionalDexGames for the database (regional dex availability)
|
// Use regionalDexGames for the database (regional dex availability)
|
||||||
// originGames column is for future origin dex feature
|
// originGames column is for future origin dex feature
|
||||||
const gamesField = p.regionalDexGames || p.games; // Fallback to old 'games' column for compatibility
|
const gamesField = p.regionalDexGames || p.games; // Fallback to old 'games' column for compatibility
|
||||||
const gamesList = gamesField.split('|');
|
const gamesList = gamesField.split('|');
|
||||||
const gamesArray = `ARRAY[${gamesList.map(g => `'${g}'`).join(', ')}]`;
|
const gamesArray = `ARRAY[${gamesList.map((g) => `'${g}'`).join(', ')}]`;
|
||||||
|
|
||||||
return `(${p.pokedexNumber}, '${p.name}', ${form}, false, '${region}', ${gamesArray})`;
|
return `(${p.pokedexNumber}, '${p.name}', ${form}, false, '${region}', ${gamesArray})`;
|
||||||
});
|
});
|
||||||
|
|
||||||
sql += rows.join(',\n');
|
sql += rows.join(',\n');
|
||||||
sql += '\nON CONFLICT ON CONSTRAINT unique_pokemon_form DO NOTHING;\n\n';
|
sql += '\nON CONFLICT ON CONSTRAINT unique_pokemon_form DO NOTHING;\n\n';
|
||||||
|
|
||||||
// Regional dex numbers (separate table)
|
// Regional dex numbers (separate table)
|
||||||
sql += `-- Insert ${region} regional dex numbers\n`;
|
sql += `-- Insert ${region} regional dex numbers\n`;
|
||||||
|
|
||||||
const dexRows = pokemon
|
const dexRows = pokemon
|
||||||
.filter(p => p.regionalNumber) // Only entries with regional dex numbers
|
.filter((p) => p.regionalNumber) // Only entries with regional dex numbers
|
||||||
.map(p => {
|
.map((p) => {
|
||||||
const formCondition = p.form
|
const formCondition = p.form ? `form = '${p.form}'` : `form IS NULL`;
|
||||||
? `form = '${p.form}'`
|
|
||||||
: `form IS NULL`;
|
|
||||||
|
|
||||||
return ` ((SELECT id FROM pokedex_entries WHERE "pokedexNumber" = ${p.pokedexNumber} AND ${formCondition}), '${region}', ${p.regionalNumber})`;
|
return ` ((SELECT id FROM pokedex_entries WHERE "pokedexNumber" = ${p.pokedexNumber} AND ${formCondition}), '${region}', ${p.regionalNumber})`;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dexRows.length > 0) {
|
if (dexRows.length > 0) {
|
||||||
sql += `INSERT INTO regional_dex_numbers (\n`;
|
sql += `INSERT INTO regional_dex_numbers (\n`;
|
||||||
sql += ` pokedex_entry_id,\n`;
|
sql += ` pokedex_entry_id,\n`;
|
||||||
sql += ` region,\n`;
|
sql += ` region,\n`;
|
||||||
sql += ` dex_number\n`;
|
sql += ` dex_number\n`;
|
||||||
sql += `) VALUES\n`;
|
sql += `) VALUES\n`;
|
||||||
sql += dexRows.join(',\n');
|
sql += dexRows.join(',\n');
|
||||||
sql += ';\n\n';
|
sql += ';\n\n';
|
||||||
} else {
|
} else {
|
||||||
sql += '-- No regional dex numbers for this region\n\n';
|
sql += '-- No regional dex numbers for this region\n\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add metadata
|
// Add metadata
|
||||||
sql += `-- Add metadata\n`;
|
sql += `-- Add metadata\n`;
|
||||||
sql += `INSERT INTO metadata (key, value) VALUES\n`;
|
sql += `INSERT INTO metadata (key, value) VALUES\n`;
|
||||||
sql += ` ('${region.toLowerCase()}_seeded', 'true'),\n`;
|
sql += ` ('${region.toLowerCase()}_seeded', 'true'),\n`;
|
||||||
sql += ` ('${region.toLowerCase()}_seed_date', NOW()::TEXT),\n`;
|
sql += ` ('${region.toLowerCase()}_seed_date', NOW()::TEXT),\n`;
|
||||||
sql += ` ('${region.toLowerCase()}_pokemon_count', '${pokemon.filter(p => !p.form).length}');\n`;
|
sql += ` ('${region.toLowerCase()}_pokemon_count', '${pokemon.filter((p) => !p.form).length}');\n`;
|
||||||
|
|
||||||
// 4. Write file
|
// 4. Write file
|
||||||
const timestamp = new Date().toISOString().replace(/[-:T.]/g, '').slice(0, 14);
|
const timestamp = new Date()
|
||||||
const filename = `${timestamp}_seed_${region.toLowerCase()}.sql`;
|
.toISOString()
|
||||||
const outputPath = path.join(__dirname, '..', 'supabase', 'migrations', filename);
|
.replace(/[-:T.]/g, '')
|
||||||
|
.slice(0, 14);
|
||||||
|
const filename = `${timestamp}_seed_${region.toLowerCase()}.sql`;
|
||||||
|
const outputPath = path.join(__dirname, '..', 'supabase', 'migrations', filename);
|
||||||
|
|
||||||
fs.writeFileSync(outputPath, sql);
|
fs.writeFileSync(outputPath, sql);
|
||||||
|
|
||||||
console.log(`✓ Generated ${filename}`);
|
console.log(`✓ Generated ${filename}`);
|
||||||
console.log(` - ${pokemon.length} Pokemon entries`);
|
console.log(` - ${pokemon.length} Pokemon entries`);
|
||||||
console.log(` - ${dexRows.length} regional dex numbers`);
|
console.log(` - ${dexRows.length} regional dex numbers`);
|
||||||
console.log(` - ${regionGames.length} games\n`);
|
console.log(` - ${regionGames.length} games\n`);
|
||||||
|
|
||||||
return filename;
|
return filename;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run
|
// Run
|
||||||
const region = process.argv[2];
|
const region = process.argv[2];
|
||||||
if (!region) {
|
if (!region) {
|
||||||
console.error('Usage: node csv-to-migration.js <Region>');
|
console.error('Usage: node csv-to-migration.js <Region>');
|
||||||
console.error('Example: node csv-to-migration.js Kanto');
|
console.error('Example: node csv-to-migration.js Kanto');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
generateMigration(region);
|
generateMigration(region);
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
|
||||||
|
const port = Number(process.env.MOCK_PROVIDER_PORT ?? 4199);
|
||||||
|
const state = { requests: [], failUploads: false, refreshes: 0 };
|
||||||
|
|
||||||
|
function send(response, status, body, headers = {}) {
|
||||||
|
response.writeHead(status, { 'Content-Type': 'application/json', ...headers });
|
||||||
|
response.end(typeof body === 'string' ? body : JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = createServer(async (request, response) => {
|
||||||
|
const url = new URL(request.url ?? '/', `http://127.0.0.1:${port}`);
|
||||||
|
let body = '';
|
||||||
|
for await (const chunk of request) body += chunk;
|
||||||
|
|
||||||
|
// Control-plane calls (including Playwright's webServer readiness polling of /__mock/state)
|
||||||
|
// must not show up as provider traffic the assertions then reason about.
|
||||||
|
if (!url.pathname.startsWith('/__mock/')) {
|
||||||
|
state.requests.push({ method: request.method, path: url.pathname, query: url.search, body });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/__mock/state') return send(response, 200, state);
|
||||||
|
if (url.pathname === '/__mock/reset') {
|
||||||
|
state.requests = [];
|
||||||
|
state.failUploads = false;
|
||||||
|
state.refreshes = 0;
|
||||||
|
return send(response, 200, { ok: true });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/__mock/fail-uploads') {
|
||||||
|
state.failUploads = true;
|
||||||
|
return send(response, 200, { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.endsWith('/authorize')) {
|
||||||
|
const redirectUri = url.searchParams.get('redirect_uri');
|
||||||
|
const oauthState = url.searchParams.get('state');
|
||||||
|
if (!redirectUri || !oauthState) return send(response, 400, { error: 'missing redirect data' });
|
||||||
|
const callback = new URL(redirectUri);
|
||||||
|
callback.searchParams.set('code', 'mock-authorization-code');
|
||||||
|
callback.searchParams.set('state', oauthState);
|
||||||
|
response.writeHead(302, { Location: callback.toString() });
|
||||||
|
return response.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.endsWith('/token')) {
|
||||||
|
if (body.includes('grant_type=refresh_token')) state.refreshes++;
|
||||||
|
return send(response, 200, {
|
||||||
|
access_token: 'mock-access-token',
|
||||||
|
refresh_token: 'mock-refresh-token',
|
||||||
|
expires_in: 3600,
|
||||||
|
scope: 'files.content.write drive.file'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.includes('/upload') || url.pathname.endsWith('/files/upload')) {
|
||||||
|
if (state.failUploads) return send(response, 503, { error: 'mock upload failure' });
|
||||||
|
return send(response, 200, { id: 'mock-file-id', name: 'pokedex.csv' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.endsWith('/drive/files')) {
|
||||||
|
if (request.method === 'GET') return send(response, 200, { files: [{ id: 'mock-folder-id' }] });
|
||||||
|
return send(response, 200, { id: 'mock-folder-id' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return send(response, 404, { error: `No mock route for ${url.pathname}` });
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, '127.0.0.1', () => {
|
||||||
|
console.log(`Mock provider server listening on http://127.0.0.1:${port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const signal of ['SIGTERM', 'SIGINT']) {
|
||||||
|
process.on(signal, () => server.close(() => process.exit(0)));
|
||||||
|
}
|
||||||
@@ -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,122 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
|
||||||
|
const [, , command, ...args] = process.argv;
|
||||||
|
if (!command) {
|
||||||
|
console.error('Usage: node scripts/run-with-local-supabase.mjs <command> [...args]');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SETUP_HINT = 'Run "npm run supabase:start" followed by "npm run supabase:reset".';
|
||||||
|
// npx is a shell script on Windows, where spawn needs a shell to find it.
|
||||||
|
const useShell = process.platform === 'win32';
|
||||||
|
|
||||||
|
function fail(message, detail) {
|
||||||
|
console.error(message);
|
||||||
|
if (detail) console.error(String(detail).trim());
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = spawnSync('npx', ['supabase', 'status', '--output', 'json'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
shell: useShell
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status.error) {
|
||||||
|
fail(
|
||||||
|
'Unable to run "npx supabase status" - is the supabase CLI installed?',
|
||||||
|
status.error.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.status !== 0) {
|
||||||
|
const stderr = status.stderr ?? '';
|
||||||
|
// Distinguish a stopped stack from a genuinely broken CLI invocation, so the hint is only
|
||||||
|
// printed when it is actually the advice the reader needs.
|
||||||
|
if (/not running|supabase start/i.test(stderr)) {
|
||||||
|
fail(`Local Supabase is required but is not running.\n${SETUP_HINT}`, stderr);
|
||||||
|
}
|
||||||
|
fail(`"supabase status" failed with exit code ${status.status}.`, stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
let values;
|
||||||
|
try {
|
||||||
|
values = JSON.parse(status.stdout);
|
||||||
|
} catch (error) {
|
||||||
|
fail('Unable to parse "supabase status --output json".', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiUrl = values.API_URL ?? values.api_url ?? 'http://127.0.0.1:54321';
|
||||||
|
const anonKey = values.ANON_KEY ?? values.PUBLISHABLE_KEY ?? values.anon_key;
|
||||||
|
const serviceRoleKey = values.SERVICE_ROLE_KEY ?? values.SECRET_KEY ?? values.service_role_key;
|
||||||
|
|
||||||
|
if (!anonKey || !serviceRoleKey) {
|
||||||
|
fail(`Supabase status did not return an anonymous and service-role key.\n${SETUP_HINT}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoopbackUrl(value) {
|
||||||
|
try {
|
||||||
|
return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(value).hostname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoopbackUrl(apiUrl)) {
|
||||||
|
fail(`Refusing to run local-stack tests against non-loopback Supabase URL: ${apiUrl}`);
|
||||||
|
}
|
||||||
|
const providerUrlVariables = [
|
||||||
|
'MOCK_PROVIDER_URL',
|
||||||
|
'GOOGLE_OAUTH_AUTHORIZE_URL',
|
||||||
|
'GOOGLE_OAUTH_TOKEN_URL',
|
||||||
|
'GOOGLE_DRIVE_API_URL',
|
||||||
|
'GOOGLE_DRIVE_UPLOAD_URL',
|
||||||
|
'DROPBOX_OAUTH_AUTHORIZE_URL',
|
||||||
|
'DROPBOX_OAUTH_TOKEN_URL',
|
||||||
|
'DROPBOX_UPLOAD_URL'
|
||||||
|
];
|
||||||
|
for (const name of providerUrlVariables) {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (value && !isLoopbackUrl(value)) {
|
||||||
|
fail(`Refusing to run provider tests with non-loopback ${name}: ${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A running-but-unseeded database is the most common broken state, and it surfaces downstream as
|
||||||
|
// a confusing assertion failure. Check it here instead.
|
||||||
|
const probe = await fetch(`${apiUrl}/rest/v1/pokedex_entries?select=id&limit=1`, {
|
||||||
|
headers: { apikey: anonKey, Authorization: `Bearer ${anonKey}` }
|
||||||
|
}).catch((error) => {
|
||||||
|
fail(`Unable to reach the local Supabase REST API at ${apiUrl}.\n${SETUP_HINT}`, error);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!probe.ok) {
|
||||||
|
fail(
|
||||||
|
`The local Supabase database has no readable pokedex_entries (HTTP ${probe.status}).\n${SETUP_HINT}`,
|
||||||
|
await probe.text()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (((await probe.json()) ?? []).length === 0) {
|
||||||
|
fail(`The local Supabase database is empty - migrations or seeds have not run.\n${SETUP_HINT}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = spawnSync(command, args, {
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: useShell,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
// Never allow an exported production value to redirect a local integration run.
|
||||||
|
PUBLIC_SUPABASE_URL: apiUrl,
|
||||||
|
PUBLIC_SUPABASE_ANON_KEY: anonKey,
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
|
||||||
|
TEST_SUPABASE_URL: apiUrl,
|
||||||
|
TEST_SUPABASE_ANON_KEY: anonKey,
|
||||||
|
E2E_SERVICE_ROLE_KEY: serviceRoleKey
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (child.error) fail(`Unable to run "${command}".`, child.error.message);
|
||||||
|
// A signalled child reports status === null; exiting 0 there would hide the failure.
|
||||||
|
process.exit(child.signal ? 1 : child.status ?? 1);
|
||||||
@@ -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
-10
@@ -5,18 +5,16 @@ import type { Handle } from '@sveltejs/kit';
|
|||||||
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) => 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, value, options) => {
|
setAll: (cookiesToSet) => {
|
||||||
event.cookies.set(key, value, { ...options, path: '/' });
|
cookiesToSet.forEach(({ name, value, options }) => {
|
||||||
},
|
event.cookies.set(name, value, { ...options, path: '/' });
|
||||||
remove: (key, options) => {
|
});
|
||||||
event.cookies.delete(key, { ...options, path: '/' });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
currentPage = Math.max(currentPage - 1, 1);
|
currentPage = Math.max(currentPage - 1, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setItemsPerPage(event: any) {
|
function setItemsPerPage(event: Event) {
|
||||||
itemsPerPage = parseInt(event.target.value, 10);
|
itemsPerPage = parseInt((event.target as HTMLSelectElement).value, 10);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER } from '$env/static/public';
|
import { PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER } from '$env/static/public';
|
||||||
import { inView } from '$lib/actions/inView';
|
import { inView } from '$lib/actions/inView';
|
||||||
|
import { resolveSpriteUrl } from '$lib/utils/spriteUrl';
|
||||||
|
|
||||||
export let pokemonName: string;
|
export let pokemonName: string;
|
||||||
export let pokedexNumber: string | number;
|
export let pokedexNumber: string | number;
|
||||||
@@ -12,46 +13,7 @@
|
|||||||
let imagePath = null as string | null;
|
let imagePath = null as string | null;
|
||||||
let isInView = false;
|
let isInView = false;
|
||||||
|
|
||||||
function isFemaleForm(value?: string) {
|
|
||||||
return /^female\b/i.test((value ?? '').trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildFallbackKey() {
|
|
||||||
const strippedPokedexNumber = pokedexNumber.toString().replace(/^0+/, '') || '0';
|
|
||||||
if (!form) return strippedPokedexNumber;
|
|
||||||
|
|
||||||
let formValue = form.trim();
|
|
||||||
formValue = formValue.replace(/^female[-\s]*/i, '');
|
|
||||||
formValue = formValue
|
|
||||||
.replace(/\s*\(.*?\)/g, '')
|
|
||||||
.replace(/\s*\[.*?\]/g, '')
|
|
||||||
.trim();
|
|
||||||
if (!formValue || formValue.toLowerCase() === 'male') return strippedPokedexNumber;
|
|
||||||
|
|
||||||
formValue = formValue
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/%/g, '')
|
|
||||||
.replace(/\balolan\b/g, 'alola')
|
|
||||||
.replace(/\bgalarian\b/g, 'galar')
|
|
||||||
.replace(/\bhisuian\b/g, 'hisui')
|
|
||||||
.replace(/\bpaldean\b/g, 'paldea')
|
|
||||||
.replace(/\bform(e)?$/, '')
|
|
||||||
.replace(/\bability$/, '')
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.replace(/(^-|-$)/g, '')
|
|
||||||
.replace(/2/g, 'two')
|
|
||||||
.replace(/3/g, 'three')
|
|
||||||
.replace(/4/g, 'four');
|
|
||||||
|
|
||||||
return formValue ? `${strippedPokedexNumber}-${formValue}` : strippedPokedexNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
const rootFolderBase =
|
|
||||||
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true'
|
|
||||||
? '/sprites-small/home'
|
|
||||||
: 'https://raw.githubusercontent.com/jcreek/LivingDexTracker/master/static/sprites-small/home';
|
|
||||||
const resolvedSpriteKey = spriteKey?.trim() || buildFallbackKey();
|
|
||||||
if (!spriteKey?.trim()) {
|
if (!spriteKey?.trim()) {
|
||||||
console.warn('Missing sprite key for pokemon entry', {
|
console.warn('Missing sprite key for pokemon entry', {
|
||||||
pokemonName,
|
pokemonName,
|
||||||
@@ -60,14 +22,11 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let rootFolder = rootFolderBase;
|
imagePath = resolveSpriteUrl(
|
||||||
if (shiny) {
|
{ pokedexNumber: Number(pokedexNumber), form, spriteKey },
|
||||||
rootFolder += '/shiny';
|
!!shiny,
|
||||||
}
|
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true'
|
||||||
if (isFemaleForm(form)) {
|
);
|
||||||
rootFolder += '/female';
|
|
||||||
}
|
|
||||||
imagePath = `${rootFolder}/${resolvedSpriteKey}.webp`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if (loadingStrategy !== 'inView') {
|
$: if (loadingStrategy !== 'inView') {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@
|
|||||||
let errorMessage = '';
|
let errorMessage = '';
|
||||||
|
|
||||||
// Access the supabase client from the layout data
|
// Access the supabase client from the layout data
|
||||||
export let supabase: any;
|
export let supabase: SupabaseClient;
|
||||||
|
|
||||||
async function signInWithEmail() {
|
async function signInWithEmail() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
|
|||||||
@@ -1,20 +1,46 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { clearOfflineData } from '$lib/stores/offlineSync';
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
let errorMessage = '';
|
||||||
|
let isSigningOut = false;
|
||||||
|
|
||||||
function emitSignedOutEvent() {
|
function emitSignedOutEvent() {
|
||||||
dispatch('signedOut', {});
|
dispatch('signedOut', {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Access the supabase client from the layout data
|
// Access the supabase client from the layout data
|
||||||
export let supabase: any;
|
export let supabase: SupabaseClient;
|
||||||
|
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
// TODO use the error from the response
|
errorMessage = '';
|
||||||
const { error } = await supabase.auth.signOut().then(() => {
|
isSigningOut = true;
|
||||||
|
try {
|
||||||
|
const { error } = await supabase.auth.signOut();
|
||||||
|
if (error) {
|
||||||
|
errorMessage = `Sign out failed: ${error.message || 'Please try again.'}`;
|
||||||
|
dispatch('signOutFailed', { message: errorMessage });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await clearOfflineData();
|
||||||
|
} catch (cacheError) {
|
||||||
|
console.error('Signed out, but failed to clear offline data', cacheError);
|
||||||
|
}
|
||||||
emitSignedOutEvent();
|
emitSignedOutEvent();
|
||||||
});
|
await goto('/', { invalidateAll: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Sign out failed', error);
|
||||||
|
errorMessage = 'Sign out failed. Please try again.';
|
||||||
|
dispatch('signOutFailed', { message: errorMessage });
|
||||||
|
} finally {
|
||||||
|
isSigningOut = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button on:click={signOut}>Sign Out</button>
|
<button on:click={signOut} disabled={isSigningOut}>
|
||||||
|
{isSigningOut ? 'Signing Out…' : 'Sign Out'}
|
||||||
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
@@ -6,17 +7,16 @@
|
|||||||
let password = '';
|
let password = '';
|
||||||
|
|
||||||
// Access the supabase client from the layout data
|
// Access the supabase client from the layout data
|
||||||
export let supabase: any;
|
export let supabase: SupabaseClient;
|
||||||
|
|
||||||
async function signUpNewUser() {
|
async function signUpNewUser() {
|
||||||
try {
|
try {
|
||||||
|
// `redirectTo` is not a signUp option - it was silently ignored, so the confirmation
|
||||||
|
// link has always used Supabase's configured site URL. Sending the user to /welcome
|
||||||
|
// would need `emailRedirectTo` with an absolute, allow-listed URL.
|
||||||
const { data, error } = await supabase.auth.signUp({
|
const { data, error } = await supabase.auth.signUp({
|
||||||
email: email,
|
email: email,
|
||||||
password: password,
|
password: password
|
||||||
options: {
|
|
||||||
// Redirect URL after successful sign-up
|
|
||||||
redirectTo: '/welcome'
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -56,7 +62,6 @@
|
|||||||
}
|
}
|
||||||
updateCatchRecord('toggle');
|
updateCatchRecord('toggle');
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -96,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}
|
||||||
|
|
||||||
|
|||||||
@@ -59,10 +59,8 @@
|
|||||||
// Validation
|
// Validation
|
||||||
$: hasAtLeastOneType =
|
$: hasAtLeastOneType =
|
||||||
pokedex.isLivingDex || pokedex.isShinyDex || pokedex.isOriginDex || pokedex.isFormDex;
|
pokedex.isLivingDex || pokedex.isShinyDex || pokedex.isOriginDex || pokedex.isFormDex;
|
||||||
$: hasDexScope =
|
$: hasDexScope = !pokedex.gameScope || (pokedex.dexScopes && pokedex.dexScopes.length > 0);
|
||||||
!pokedex.gameScope || (pokedex.dexScopes && pokedex.dexScopes.length > 0);
|
$: canSubmit = pokedex.name && pokedex.name.trim() !== '' && hasAtLeastOneType && hasDexScope;
|
||||||
$: canSubmit =
|
|
||||||
pokedex.name && pokedex.name.trim() !== '' && hasAtLeastOneType && hasDexScope;
|
|
||||||
|
|
||||||
$: if (pokedex.gameScope !== lastGameScope) {
|
$: if (pokedex.gameScope !== lastGameScope) {
|
||||||
const shouldResetDexes = mode === 'create' || hasSeenGameScope;
|
const shouldResetDexes = mode === 'create' || hasSeenGameScope;
|
||||||
@@ -162,16 +160,14 @@
|
|||||||
<option value={null}>All Games</option>
|
<option value={null}>All Games</option>
|
||||||
{#if loadingDexes}
|
{#if loadingDexes}
|
||||||
<option disabled>Loading games...</option>
|
<option disabled>Loading games...</option>
|
||||||
|
{:else if gameList.length > 0}
|
||||||
|
{#each gameList as game}
|
||||||
|
<option value={game.displayName}>{game.displayName}</option>
|
||||||
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
{#if gameList.length > 0}
|
{#each gameOrder.length > 0 ? gameOrder : Object.keys(gameDexes) as game}
|
||||||
{#each gameList as game}
|
<option value={game}>{game}</option>
|
||||||
<option value={game.displayName}>{game.displayName}</option>
|
{/each}
|
||||||
{/each}
|
|
||||||
{:else}
|
|
||||||
{#each gameOrder.length > 0 ? gameOrder : Object.keys(gameDexes) as game}
|
|
||||||
<option value={game}>{game}</option>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -181,7 +177,9 @@
|
|||||||
<fieldset class="w-full">
|
<fieldset class="w-full">
|
||||||
<legend class="label">
|
<legend class="label">
|
||||||
<span class="label-text">Dex Scope</span>
|
<span class="label-text">Dex Scope</span>
|
||||||
<span class="label-text-alt text-error">{!hasDexScope ? 'Select at least one dex' : ''}</span>
|
<span class="label-text-alt text-error"
|
||||||
|
>{!hasDexScope ? 'Select at least one dex' : ''}</span
|
||||||
|
>
|
||||||
</legend>
|
</legend>
|
||||||
{#if availableDexes.length === 0}
|
{#if availableDexes.length === 0}
|
||||||
<p class="text-sm text-error">No dexes found for this game.</p>
|
<p class="text-sm text-error">No dexes found for this game.</p>
|
||||||
|
|||||||
@@ -2,23 +2,28 @@
|
|||||||
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;
|
||||||
export let failedToLoad = false;
|
export let failedToLoad = false;
|
||||||
export let markBoxAsNotCaught = (boxNumber: number) => {};
|
export let markBoxAsNotCaught: (boxNumber: number) => void = () => {};
|
||||||
export let markBoxAsCaught = (boxNumber: number) => {};
|
export let markBoxAsCaught: (boxNumber: number) => void = () => {};
|
||||||
export let markBoxAsNeedsToEvolve = (boxNumber: number) => {};
|
export let markBoxAsNeedsToEvolve: (boxNumber: number) => void = () => {};
|
||||||
export let markBoxAsInHome = (boxNumber: number) => {};
|
export let markBoxAsInHome: (boxNumber: number) => void = () => {};
|
||||||
export let markBoxAsNotInHome = (boxNumber: number) => {};
|
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">
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { CombinedData } from './CombinedData';
|
||||||
|
import type { Pokedex } from './Pokedex';
|
||||||
|
|
||||||
|
export const OFFLINE_SNAPSHOT_VERSION = 1;
|
||||||
|
|
||||||
|
export type OfflinePokedexSnapshot = {
|
||||||
|
pokedex: Pokedex;
|
||||||
|
entries: CombinedData[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OfflineSnapshot = {
|
||||||
|
version: typeof OFFLINE_SNAPSHOT_VERSION;
|
||||||
|
generatedAt: string;
|
||||||
|
userId: string;
|
||||||
|
pokedexes: OfflinePokedexSnapshot[];
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -126,7 +126,7 @@ class CombinedDataRepository {
|
|||||||
let start = 0;
|
let start = 0;
|
||||||
const maxRows = CombinedDataRepository.MAX_ROWS_PER_REQUEST;
|
const maxRows = CombinedDataRepository.MAX_ROWS_PER_REQUEST;
|
||||||
|
|
||||||
while (true) {
|
for (;;) {
|
||||||
const end = start + maxRows - 1;
|
const end = start + maxRows - 1;
|
||||||
let query = this.supabase.from('pokedex_entries').select('*').not('form', 'is', null);
|
let query = this.supabase.from('pokedex_entries').select('*').not('form', 'is', null);
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@ class CombinedDataRepository {
|
|||||||
let start = 0;
|
let start = 0;
|
||||||
const maxRows = CombinedDataRepository.MAX_ROWS_PER_REQUEST;
|
const maxRows = CombinedDataRepository.MAX_ROWS_PER_REQUEST;
|
||||||
|
|
||||||
while (true) {
|
for (;;) {
|
||||||
const end = start + maxRows - 1;
|
const end = start + maxRows - 1;
|
||||||
const { data, error } = await this.buildDexEntriesQuery(dexScopes, enableForms, region).range(
|
const { data, error } = await this.buildDexEntriesQuery(dexScopes, enableForms, region).range(
|
||||||
start,
|
start,
|
||||||
@@ -312,7 +312,7 @@ class CombinedDataRepository {
|
|||||||
let start = 0;
|
let start = 0;
|
||||||
const maxRows = CombinedDataRepository.MAX_ROWS_PER_REQUEST;
|
const maxRows = CombinedDataRepository.MAX_ROWS_PER_REQUEST;
|
||||||
|
|
||||||
while (true) {
|
for (;;) {
|
||||||
const end = start + maxRows - 1;
|
const end = start + maxRows - 1;
|
||||||
const { data, error } = await this.buildEntriesQuery(enableForms, region, game).range(
|
const { data, error } = await this.buildEntriesQuery(enableForms, region, game).range(
|
||||||
start,
|
start,
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ import type { SupabaseClient } from '@supabase/supabase-js';
|
|||||||
class PokedexEntryRepository {
|
class PokedexEntryRepository {
|
||||||
constructor(private supabase: SupabaseClient) {}
|
constructor(private supabase: SupabaseClient) {}
|
||||||
|
|
||||||
private parseCatchInformation(
|
private parseCatchInformation(values: string[] | null): Array<string | CatchInformationItem> {
|
||||||
values: string[] | null
|
|
||||||
): Array<string | CatchInformationItem> {
|
|
||||||
if (!values) return [];
|
if (!values) return [];
|
||||||
return values.map((value) => {
|
return values.map((value) => {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import type {
|
|||||||
PokedexExportIntegrationDB
|
PokedexExportIntegrationDB
|
||||||
} from '$lib/models/PokedexExportIntegration';
|
} from '$lib/models/PokedexExportIntegration';
|
||||||
|
|
||||||
|
// `from()` returns a table builder; only `select()` yields the filter builder that `eq`/`is`
|
||||||
|
// live on. Typing the scope helper with the table builder made `data` untyped downstream.
|
||||||
|
type IntegrationQuery = ReturnType<ReturnType<SupabaseClient['from']>['select']>;
|
||||||
|
|
||||||
class PokedexExportIntegrationRepository {
|
class PokedexExportIntegrationRepository {
|
||||||
constructor(
|
constructor(
|
||||||
private supabase: SupabaseClient,
|
private supabase: SupabaseClient,
|
||||||
@@ -34,7 +38,7 @@ class PokedexExportIntegrationRepository {
|
|||||||
return this.supabase.from('pokedex_export_integrations').select('*').eq('userId', this.userId);
|
return this.supabase.from('pokedex_export_integrations').select('*').eq('userId', this.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private addPokedexScope(query: ReturnType<SupabaseClient['from']>) {
|
private addPokedexScope(query: IntegrationQuery): IntegrationQuery {
|
||||||
if (this.pokedexId) {
|
if (this.pokedexId) {
|
||||||
return query.eq('pokedexId', this.pokedexId);
|
return query.eq('pokedexId', this.pokedexId);
|
||||||
}
|
}
|
||||||
@@ -49,7 +53,8 @@ class PokedexExportIntegrationRepository {
|
|||||||
throw new Error(`Failed to load export integrations: ${error.message}`);
|
throw new Error(`Failed to load export integrations: ${error.message}`);
|
||||||
}
|
}
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
return data.map((row) => this.transform(row));
|
// PostgREST rows are untyped without generated database types.
|
||||||
|
return (data as PokedexExportIntegrationDB[]).map((row) => this.transform(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
async listAll(): Promise<PokedexExportIntegration[]> {
|
async listAll(): Promise<PokedexExportIntegration[]> {
|
||||||
@@ -60,12 +65,12 @@ class PokedexExportIntegrationRepository {
|
|||||||
throw new Error(`Failed to load export integrations: ${error.message}`);
|
throw new Error(`Failed to load export integrations: ${error.message}`);
|
||||||
}
|
}
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
return data.map((row) => this.transform(row));
|
// PostgREST rows are untyped without generated database types.
|
||||||
|
return (data as PokedexExportIntegrationDB[]).map((row) => this.transform(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsert(
|
async upsert(
|
||||||
data: Partial<PokedexExportIntegrationDB> &
|
data: Partial<PokedexExportIntegrationDB> & Pick<PokedexExportIntegrationDB, 'provider'>
|
||||||
Pick<PokedexExportIntegrationDB, 'provider'>
|
|
||||||
): Promise<PokedexExportIntegration> {
|
): Promise<PokedexExportIntegration> {
|
||||||
const payload: Partial<PokedexExportIntegrationDB> = {
|
const payload: Partial<PokedexExportIntegrationDB> = {
|
||||||
userId: this.userId,
|
userId: this.userId,
|
||||||
@@ -105,7 +110,8 @@ class PokedexExportIntegrationRepository {
|
|||||||
throw new Error(`Failed to load export integrations: ${error.message}`);
|
throw new Error(`Failed to load export integrations: ${error.message}`);
|
||||||
}
|
}
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
return data.map((row) => this.transform(row));
|
// PostgREST rows are untyped without generated database types.
|
||||||
|
return (data as PokedexExportIntegrationDB[]).map((row) => this.transform(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateTokens(
|
async updateTokens(
|
||||||
|
|||||||
@@ -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 || '',
|
||||||
|
|||||||
@@ -84,9 +84,7 @@ export async function setPokedexDexScopes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listGameDexes(
|
export async function listGameDexes(supabase: SupabaseClient): Promise<{
|
||||||
supabase: SupabaseClient
|
|
||||||
): Promise<{
|
|
||||||
gameDexes: Record<string, GameDexRow[]>;
|
gameDexes: Record<string, GameDexRow[]>;
|
||||||
gameOrder: string[];
|
gameOrder: string[];
|
||||||
games: { displayName: string; releaseYear: number }[];
|
games: { displayName: string; releaseYear: number }[];
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { CombinedData } from '$lib/models/CombinedData';
|
||||||
|
|
||||||
|
export function csvEscape(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
const str = String(value);
|
||||||
|
if (/[",\n\r]/.test(str)) return `"${str.replace(/"/g, '""')}"`;
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeFileName(name: string, fallback: string): string {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
const safe = trimmed.replace(/[\\/:*?"<>|]+/g, '-');
|
||||||
|
if (!safe) return fallback;
|
||||||
|
return safe.endsWith('.csv') ? safe : `${safe}.csv`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCsv(combinedData: CombinedData[]): string {
|
||||||
|
const headers = [
|
||||||
|
'pokemonId',
|
||||||
|
'pokedexNumber',
|
||||||
|
'pokemon',
|
||||||
|
'form',
|
||||||
|
'caught',
|
||||||
|
'haveToEvolve',
|
||||||
|
'inHome',
|
||||||
|
'personalNotes'
|
||||||
|
];
|
||||||
|
const lines = [headers.map(csvEscape).join(',')];
|
||||||
|
|
||||||
|
for (const row of combinedData) {
|
||||||
|
const entry = row.pokedexEntry;
|
||||||
|
const catchRecord = row.catchRecord ?? {
|
||||||
|
caught: false,
|
||||||
|
haveToEvolve: false,
|
||||||
|
inHome: false,
|
||||||
|
personalNotes: ''
|
||||||
|
};
|
||||||
|
lines.push(
|
||||||
|
[
|
||||||
|
entry._id,
|
||||||
|
entry.pokedexNumber,
|
||||||
|
entry.pokemon,
|
||||||
|
entry.form || '',
|
||||||
|
catchRecord.caught,
|
||||||
|
catchRecord.haveToEvolve,
|
||||||
|
catchRecord.inHome,
|
||||||
|
catchRecord.personalNotes || ''
|
||||||
|
]
|
||||||
|
.map(csvEscape)
|
||||||
|
.join(',')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\r\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRefreshToken(expiresAt: string | null): boolean {
|
||||||
|
if (!expiresAt) return false;
|
||||||
|
const expiry = new Date(expiresAt).getTime();
|
||||||
|
if (!Number.isFinite(expiry)) return false;
|
||||||
|
return expiry - Date.now() < 60_000;
|
||||||
|
}
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
import type { CombinedData } from '$lib/models/CombinedData';
|
|
||||||
import type { Pokedex } from '$lib/models/Pokedex';
|
import type { Pokedex } from '$lib/models/Pokedex';
|
||||||
import type { ExportProvider, PokedexExportIntegration } from '$lib/models/PokedexExportIntegration';
|
import type {
|
||||||
|
ExportProvider,
|
||||||
|
PokedexExportIntegration
|
||||||
|
} from '$lib/models/PokedexExportIntegration';
|
||||||
import PokedexRepository from '$lib/repositories/PokedexRepository';
|
import PokedexRepository from '$lib/repositories/PokedexRepository';
|
||||||
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
|
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
|
||||||
import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportIntegrationRepository';
|
import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportIntegrationRepository';
|
||||||
import { resolveDexScopes } from '$lib/services/PokedexDexScopeService';
|
import { resolveDexScopes } from '$lib/services/PokedexDexScopeService';
|
||||||
import { getEnv } from '$lib/utils/env';
|
import { getEnv } from '$lib/utils/env';
|
||||||
|
import { getProviderEndpoints } from '$lib/services/providerEndpoints';
|
||||||
|
import {
|
||||||
|
buildCsv,
|
||||||
|
sanitizeFileName,
|
||||||
|
shouldRefreshToken
|
||||||
|
} from '$lib/services/PokedexExportFormatting';
|
||||||
|
|
||||||
type ExportFailure = {
|
type ExportFailure = {
|
||||||
integrationId: string;
|
integrationId: string;
|
||||||
@@ -21,71 +29,6 @@ export type PokedexExportResult = {
|
|||||||
failed: ExportFailure[];
|
failed: ExportFailure[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function csvEscape(value: unknown): string {
|
|
||||||
if (value === null || value === undefined) return '';
|
|
||||||
const str = String(value);
|
|
||||||
if (/[",\n\r]/.test(str)) {
|
|
||||||
return `"${str.replace(/"/g, '""')}"`;
|
|
||||||
}
|
|
||||||
return str;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeFileName(name: string, fallback: string): string {
|
|
||||||
const trimmed = name.trim();
|
|
||||||
const safe = trimmed.replace(/[\\/:*?"<>|]+/g, '-');
|
|
||||||
if (!safe) return fallback;
|
|
||||||
return safe.endsWith('.csv') ? safe : `${safe}.csv`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCsv(pokedex: Pokedex, combinedData: CombinedData[]): string {
|
|
||||||
const headers = [
|
|
||||||
'pokemonId',
|
|
||||||
'pokedexNumber',
|
|
||||||
'pokemon',
|
|
||||||
'form',
|
|
||||||
'caught',
|
|
||||||
'haveToEvolve',
|
|
||||||
'inHome',
|
|
||||||
'personalNotes'
|
|
||||||
];
|
|
||||||
|
|
||||||
const lines = [headers.map(csvEscape).join(',')];
|
|
||||||
|
|
||||||
for (const row of combinedData) {
|
|
||||||
const entry = row.pokedexEntry;
|
|
||||||
const catchRecord = row.catchRecord ?? {
|
|
||||||
caught: false,
|
|
||||||
haveToEvolve: false,
|
|
||||||
inHome: false,
|
|
||||||
hasGigantamaxed: false,
|
|
||||||
personalNotes: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
const values = [
|
|
||||||
entry._id,
|
|
||||||
entry.pokedexNumber,
|
|
||||||
entry.pokemon,
|
|
||||||
entry.form || '',
|
|
||||||
catchRecord.caught,
|
|
||||||
catchRecord.haveToEvolve,
|
|
||||||
catchRecord.inHome,
|
|
||||||
catchRecord.personalNotes || ''
|
|
||||||
];
|
|
||||||
|
|
||||||
lines.push(values.map(csvEscape).join(','));
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join('\r\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldRefreshToken(expiresAt: string | null): boolean {
|
|
||||||
if (!expiresAt) return false;
|
|
||||||
const expiry = new Date(expiresAt).getTime();
|
|
||||||
if (!Number.isFinite(expiry)) return false;
|
|
||||||
// Refresh if within 60 seconds of expiry.
|
|
||||||
return expiry - Date.now() < 60_000;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshGoogleToken(
|
async function refreshGoogleToken(
|
||||||
integration: PokedexExportIntegration,
|
integration: PokedexExportIntegration,
|
||||||
repo: PokedexExportIntegrationRepository
|
repo: PokedexExportIntegrationRepository
|
||||||
@@ -108,7 +51,7 @@ async function refreshGoogleToken(
|
|||||||
grant_type: 'refresh_token'
|
grant_type: 'refresh_token'
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch('https://oauth2.googleapis.com/token', {
|
const response = await fetch(getProviderEndpoints().google.token, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: params.toString()
|
body: params.toString()
|
||||||
@@ -162,7 +105,7 @@ async function refreshDropboxToken(
|
|||||||
grant_type: 'refresh_token'
|
grant_type: 'refresh_token'
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch('https://api.dropbox.com/oauth2/token', {
|
const response = await fetch(getProviderEndpoints().dropbox.token, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: params.toString()
|
body: params.toString()
|
||||||
@@ -217,7 +160,10 @@ type GoogleDriveMetadata = {
|
|||||||
files?: Record<string, string>;
|
files?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getGoogleFileId(metadata: Record<string, unknown> | null, pokedexId: string): string | null {
|
function getGoogleFileId(
|
||||||
|
metadata: Record<string, unknown> | null,
|
||||||
|
pokedexId: string
|
||||||
|
): string | null {
|
||||||
const data = metadata as GoogleDriveMetadata | null;
|
const data = metadata as GoogleDriveMetadata | null;
|
||||||
const fileId = data?.files?.[pokedexId];
|
const fileId = data?.files?.[pokedexId];
|
||||||
return typeof fileId === 'string' && fileId ? fileId : null;
|
return typeof fileId === 'string' && fileId ? fileId : null;
|
||||||
@@ -262,7 +208,7 @@ async function uploadToGoogleDrive(
|
|||||||
if (!folderId) {
|
if (!folderId) {
|
||||||
try {
|
try {
|
||||||
const folderResponse = await fetch(
|
const folderResponse = await fetch(
|
||||||
'https://www.googleapis.com/drive/v3/files?' +
|
`${getProviderEndpoints().google.driveApi}/files?` +
|
||||||
new URLSearchParams({
|
new URLSearchParams({
|
||||||
q: "name='Living Dex Tracker' and mimeType='application/vnd.google-apps.folder' and trashed=false",
|
q: "name='Living Dex Tracker' and mimeType='application/vnd.google-apps.folder' and trashed=false",
|
||||||
fields: 'files(id,name)',
|
fields: 'files(id,name)',
|
||||||
@@ -286,7 +232,7 @@ async function uploadToGoogleDrive(
|
|||||||
|
|
||||||
if (!folderId) {
|
if (!folderId) {
|
||||||
try {
|
try {
|
||||||
const createResponse = await fetch('https://www.googleapis.com/drive/v3/files', {
|
const createResponse = await fetch(`${getProviderEndpoints().google.driveApi}/files`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${refreshed.accessToken}`,
|
Authorization: `Bearer ${refreshed.accessToken}`,
|
||||||
@@ -332,12 +278,11 @@ async function uploadToGoogleDrive(
|
|||||||
].join('\r\n');
|
].join('\r\n');
|
||||||
|
|
||||||
const url = currentFileId
|
const url = currentFileId
|
||||||
? `https://www.googleapis.com/upload/drive/v3/files/${currentFileId}?uploadType=multipart`
|
? `${getProviderEndpoints().google.driveUpload}/files/${currentFileId}?uploadType=multipart`
|
||||||
: 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart';
|
: `${getProviderEndpoints().google.driveUpload}/files?uploadType=multipart`;
|
||||||
const method = currentFileId ? 'PATCH' : 'POST';
|
const method = currentFileId ? 'PATCH' : 'POST';
|
||||||
const uploadUrl = currentFileId && folderId
|
const uploadUrl =
|
||||||
? `${url}&addParents=${encodeURIComponent(folderId)}`
|
currentFileId && folderId ? `${url}&addParents=${encodeURIComponent(folderId)}` : url;
|
||||||
: url;
|
|
||||||
|
|
||||||
const response = await fetch(uploadUrl, {
|
const response = await fetch(uploadUrl, {
|
||||||
method,
|
method,
|
||||||
@@ -392,7 +337,7 @@ async function uploadToDropbox(
|
|||||||
targetPath = `${targetPath}/${fileName}`;
|
targetPath = `${targetPath}/${fileName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch('https://content.dropboxapi.com/2/files/upload', {
|
const response = await fetch(getProviderEndpoints().dropbox.upload, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${refreshed.accessToken}`,
|
Authorization: `Bearer ${refreshed.accessToken}`,
|
||||||
@@ -453,7 +398,7 @@ export async function exportPokedexIfConfigured(
|
|||||||
pokedex.gameScope || '',
|
pokedex.gameScope || '',
|
||||||
dexScopes
|
dexScopes
|
||||||
);
|
);
|
||||||
const csv = buildCsv(pokedex, combinedData);
|
const csv = buildCsv(combinedData);
|
||||||
|
|
||||||
const failures: ExportFailure[] = [];
|
const failures: ExportFailure[] = [];
|
||||||
let successes = 0;
|
let successes = 0;
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { getEnv } from '$lib/utils/env';
|
||||||
|
|
||||||
|
export type ProviderEndpoints = {
|
||||||
|
google: { authorize: string; token: string; driveApi: string; driveUpload: string };
|
||||||
|
dropbox: { authorize: string; token: string; upload: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULTS: ProviderEndpoints = {
|
||||||
|
google: {
|
||||||
|
authorize: 'https://accounts.google.com/o/oauth2/v2/auth',
|
||||||
|
token: 'https://oauth2.googleapis.com/token',
|
||||||
|
driveApi: 'https://www.googleapis.com/drive/v3',
|
||||||
|
driveUpload: 'https://www.googleapis.com/upload/drive/v3'
|
||||||
|
},
|
||||||
|
dropbox: {
|
||||||
|
authorize: 'https://www.dropbox.com/oauth2/authorize',
|
||||||
|
token: 'https://api.dropbox.com/oauth2/token',
|
||||||
|
upload: 'https://content.dropboxapi.com/2/files/upload'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* These endpoints receive the OAuth client secret and the user's refresh token, so an override
|
||||||
|
* is only ever a local test seam - never a deployment knob. The guards require the explicit
|
||||||
|
* override flag, the BDD service-role context, and a loopback test stack. Each override must also
|
||||||
|
* be loopback, so a single injected variable cannot redirect credentials to an arbitrary host.
|
||||||
|
* Values are read at runtime from `$env/dynamic/private`.
|
||||||
|
*/
|
||||||
|
function isLocalOverride(value: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return (
|
||||||
|
(url.protocol === 'http:' || url.protocol === 'https:') && LOOPBACK_HOSTS.has(url.hostname)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveProviderEndpoints(
|
||||||
|
env: Record<string, string | undefined>
|
||||||
|
): ProviderEndpoints {
|
||||||
|
const overridesAllowed =
|
||||||
|
env.ALLOW_PROVIDER_ENDPOINT_OVERRIDES === 'true' &&
|
||||||
|
!!env.E2E_SERVICE_ROLE_KEY &&
|
||||||
|
!!env.TEST_SUPABASE_URL &&
|
||||||
|
isLocalOverride(env.TEST_SUPABASE_URL);
|
||||||
|
const pick = (override: string | undefined, fallback: string) =>
|
||||||
|
overridesAllowed && override && isLocalOverride(override) ? override : fallback;
|
||||||
|
|
||||||
|
return {
|
||||||
|
google: {
|
||||||
|
authorize: pick(env.GOOGLE_OAUTH_AUTHORIZE_URL, DEFAULTS.google.authorize),
|
||||||
|
token: pick(env.GOOGLE_OAUTH_TOKEN_URL, DEFAULTS.google.token),
|
||||||
|
driveApi: pick(env.GOOGLE_DRIVE_API_URL, DEFAULTS.google.driveApi),
|
||||||
|
driveUpload: pick(env.GOOGLE_DRIVE_UPLOAD_URL, DEFAULTS.google.driveUpload)
|
||||||
|
},
|
||||||
|
dropbox: {
|
||||||
|
authorize: pick(env.DROPBOX_OAUTH_AUTHORIZE_URL, DEFAULTS.dropbox.authorize),
|
||||||
|
token: pick(env.DROPBOX_OAUTH_TOKEN_URL, DEFAULTS.dropbox.token),
|
||||||
|
upload: pick(env.DROPBOX_UPLOAD_URL, DEFAULTS.dropbox.upload)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProviderEndpoints(): ProviderEndpoints {
|
||||||
|
return resolveProviderEndpoints(getEnv());
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PROVIDER_ENDPOINT_DEFAULTS = DEFAULTS;
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import { PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER } from '$env/static/public';
|
||||||
|
import type { OfflineSnapshot } from '$lib/models/OfflineSnapshot';
|
||||||
|
import { resolveSpriteUrl, spriteRoot } from '$lib/utils/spriteUrl';
|
||||||
|
|
||||||
|
export type OfflineSyncStatus = {
|
||||||
|
state: 'idle' | 'syncing' | 'ready' | 'error';
|
||||||
|
generatedAt: 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>({
|
||||||
|
state: 'idle',
|
||||||
|
generatedAt: null,
|
||||||
|
message: null
|
||||||
|
});
|
||||||
|
|
||||||
|
export const artworkDownloadStatus = writable<ArtworkDownloadStatus>({
|
||||||
|
state: 'unknown',
|
||||||
|
missingBytes: null,
|
||||||
|
message: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const SYNC_EVENT = 'livingdex:offline-sync';
|
||||||
|
const OFFLINE_CACHE_PREFIX = 'livingdex-offline-';
|
||||||
|
const OFFLINE_META_CACHE = `${OFFLINE_CACHE_PREFIX}meta-v1`;
|
||||||
|
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(
|
||||||
|
message: unknown,
|
||||||
|
timeoutMs = 120_000,
|
||||||
|
waitForReady = true
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
if (!('serviceWorker' in navigator)) throw new Error('Service workers are unavailable');
|
||||||
|
const registration = waitForReady
|
||||||
|
? await navigator.serviceWorker.ready
|
||||||
|
: await navigator.serviceWorker.getRegistration();
|
||||||
|
if (!registration?.active) throw new Error('Offline worker is not active');
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const channel = new MessageChannel();
|
||||||
|
const timeout = window.setTimeout(
|
||||||
|
() => reject(new Error('Offline worker timed out')),
|
||||||
|
timeoutMs
|
||||||
|
);
|
||||||
|
channel.port1.onmessage = (event) => {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
const result = event.data as Record<string, unknown>;
|
||||||
|
if (result?.ok) resolve(result);
|
||||||
|
else reject(new Error(String(result?.error ?? 'Offline worker failed')));
|
||||||
|
};
|
||||||
|
registration.active?.postMessage(message, [channel.port2]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestOfflineSync(): void {
|
||||||
|
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> {
|
||||||
|
if (typeof window === 'undefined' || !('caches' in window)) return;
|
||||||
|
const names = await caches.keys();
|
||||||
|
await Promise.all(
|
||||||
|
names.filter((name) => name.startsWith(OFFLINE_CACHE_PREFIX)).map((name) => caches.delete(name))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function claimOfflineData(userId: string): Promise<void> {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if ('caches' in window && (await caches.keys()).includes(OFFLINE_META_CACHE)) {
|
||||||
|
const meta = await readOfflineMeta();
|
||||||
|
if (meta?.userId !== userId) await deleteOfflineCaches();
|
||||||
|
}
|
||||||
|
if ('serviceWorker' in navigator && (await navigator.serviceWorker.getRegistration())?.active) {
|
||||||
|
await workerMessage({ type: 'CLAIM_OFFLINE_USER', userId }, 10_000, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearOfflineData(): Promise<void> {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
await deleteOfflineCaches();
|
||||||
|
if ('serviceWorker' in navigator && (await navigator.serviceWorker.getRegistration())?.active) {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
let timer: number | null = null;
|
||||||
|
let stopped = false;
|
||||||
|
let running = false;
|
||||||
|
let rerun = false;
|
||||||
|
let lastGeneratedAt: string | null = null;
|
||||||
|
|
||||||
|
const synchronize = async (reuseFreshCopy: boolean) => {
|
||||||
|
if (stopped || !navigator.onLine) return;
|
||||||
|
if (running) {
|
||||||
|
rerun = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userId = getUserId();
|
||||||
|
if (!userId || !('serviceWorker' in navigator)) return;
|
||||||
|
running = true;
|
||||||
|
try {
|
||||||
|
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', {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { Accept: 'application/json' }
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`Snapshot request failed (${response.status})`);
|
||||||
|
const snapshot = (await response.json()) as OfflineSnapshot;
|
||||||
|
if (snapshot.userId !== userId) throw new Error('Snapshot owner did not match the session');
|
||||||
|
const useLocal = PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true';
|
||||||
|
// The worker derives which artwork belongs to the collection from these URLs, and the offline
|
||||||
|
// viewer renders them.
|
||||||
|
for (const { pokedex, entries } of snapshot.pokedexes) {
|
||||||
|
for (const { pokedexEntry } of entries) {
|
||||||
|
(pokedexEntry as typeof pokedexEntry & { offlineSpriteUrl: string }).offlineSpriteUrl =
|
||||||
|
resolveSpriteUrl(pokedexEntry, pokedex.isShinyDex, useLocal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (getUserId() !== userId) throw new Error('Session changed during offline synchronization');
|
||||||
|
await workerMessage({ type: 'SYNC_OFFLINE_SNAPSHOT', snapshot });
|
||||||
|
offlineSyncStatus.set({ state: 'ready', generatedAt: snapshot.generatedAt, message: null });
|
||||||
|
lastGeneratedAt = snapshot.generatedAt;
|
||||||
|
void checkArtworkStatus();
|
||||||
|
} catch (error) {
|
||||||
|
offlineSyncStatus.set({
|
||||||
|
state: 'error',
|
||||||
|
generatedAt: lastGeneratedAt,
|
||||||
|
message: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
running = false;
|
||||||
|
if (rerun && !stopped) {
|
||||||
|
rerun = false;
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleSync = (reuseFreshCopy: boolean) => {
|
||||||
|
if (timer !== null) window.clearTimeout(timer);
|
||||||
|
timer = window.setTimeout(() => {
|
||||||
|
timer = null;
|
||||||
|
void synchronize(reuseFreshCopy);
|
||||||
|
}, 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.
|
||||||
|
void navigator.storage?.persist?.().catch(() => undefined);
|
||||||
|
window.addEventListener(SYNC_EVENT, schedule);
|
||||||
|
window.addEventListener('online', schedule);
|
||||||
|
scheduleSync(true);
|
||||||
|
return () => {
|
||||||
|
stopped = true;
|
||||||
|
if (timer !== null) window.clearTimeout(timer);
|
||||||
|
window.removeEventListener(SYNC_EVENT, schedule);
|
||||||
|
window.removeEventListener('online', schedule);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,11 +7,11 @@ import { error } from '@sveltejs/kit';
|
|||||||
*/
|
*/
|
||||||
export async function requireAuth(event: RequestEvent): Promise<string> {
|
export async function requireAuth(event: RequestEvent): Promise<string> {
|
||||||
const { session, user } = await event.locals.safeGetSession();
|
const { session, user } = await event.locals.safeGetSession();
|
||||||
|
|
||||||
if (!session || !user) {
|
if (!session || !user) {
|
||||||
throw error(401, 'Authentication required');
|
throw error(401, 'Authentication required');
|
||||||
}
|
}
|
||||||
|
|
||||||
return user.id;
|
return user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,9 +21,9 @@ export async function requireAuth(event: RequestEvent): Promise<string> {
|
|||||||
*/
|
*/
|
||||||
export async function getOptionalUserId(event: RequestEvent): Promise<string | null> {
|
export async function getOptionalUserId(event: RequestEvent): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const { session, user } = await event.locals.safeGetSession();
|
const { user } = await event.locals.safeGetSession();
|
||||||
return user?.id || null;
|
return user?.id || null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export function calculateBoxPlacement(index: number): {
|
|||||||
} {
|
} {
|
||||||
const POKEMON_PER_BOX = 30;
|
const POKEMON_PER_BOX = 30;
|
||||||
const COLUMNS_PER_BOX = 6;
|
const COLUMNS_PER_BOX = 6;
|
||||||
const ROWS_PER_BOX = 5;
|
|
||||||
|
|
||||||
// Calculate which box this Pokémon belongs to (1-indexed)
|
// Calculate which box this Pokémon belongs to (1-indexed)
|
||||||
const box = Math.floor(index / POKEMON_PER_BOX) + 1;
|
const box = Math.floor(index / POKEMON_PER_BOX) + 1;
|
||||||
|
|||||||
@@ -24,62 +24,62 @@ type RegionalDexKey =
|
|||||||
|
|
||||||
const gameToRegionalDexMap: Record<string, RegionalDexKey> = {
|
const gameToRegionalDexMap: Record<string, RegionalDexKey> = {
|
||||||
// Kanto region
|
// Kanto region
|
||||||
'Red': 'kanto',
|
Red: 'kanto',
|
||||||
'Blue': 'kanto',
|
Blue: 'kanto',
|
||||||
'Yellow': 'kanto',
|
Yellow: 'kanto',
|
||||||
'FireRed': 'kanto',
|
FireRed: 'kanto',
|
||||||
'LeafGreen': 'kanto',
|
LeafGreen: 'kanto',
|
||||||
'LG: Pikachu': 'kanto',
|
'LG: Pikachu': 'kanto',
|
||||||
'LG: Eevee': 'kanto',
|
'LG: Eevee': 'kanto',
|
||||||
|
|
||||||
// Johto region
|
// Johto region
|
||||||
'Gold': 'johto',
|
Gold: 'johto',
|
||||||
'Silver': 'johto',
|
Silver: 'johto',
|
||||||
'Crystal': 'johto',
|
Crystal: 'johto',
|
||||||
'HeartGold': 'johto',
|
HeartGold: 'johto',
|
||||||
'SoulSilver': 'johto',
|
SoulSilver: 'johto',
|
||||||
|
|
||||||
// Hoenn region
|
// Hoenn region
|
||||||
'Ruby': 'hoenn',
|
Ruby: 'hoenn',
|
||||||
'Sapphire': 'hoenn',
|
Sapphire: 'hoenn',
|
||||||
'Emerald': 'hoenn',
|
Emerald: 'hoenn',
|
||||||
'OmegaRuby': 'hoenn',
|
OmegaRuby: 'hoenn',
|
||||||
'AlphaSapphire': 'hoenn',
|
AlphaSapphire: 'hoenn',
|
||||||
|
|
||||||
// Sinnoh region
|
// Sinnoh region
|
||||||
'Diamond': 'sinnoh',
|
Diamond: 'sinnoh',
|
||||||
'Pearl': 'sinnoh',
|
Pearl: 'sinnoh',
|
||||||
'Platinum': 'sinnoh',
|
Platinum: 'sinnoh',
|
||||||
'BrilliantDiamond': 'sinnoh',
|
BrilliantDiamond: 'sinnoh',
|
||||||
'ShiningPearl': 'sinnoh',
|
ShiningPearl: 'sinnoh',
|
||||||
|
|
||||||
// Unova region
|
// Unova region
|
||||||
'Black': 'unova_bw',
|
Black: 'unova_bw',
|
||||||
'White': 'unova_bw',
|
White: 'unova_bw',
|
||||||
'Black2': 'unova_b2w2',
|
Black2: 'unova_b2w2',
|
||||||
'White2': 'unova_b2w2',
|
White2: 'unova_b2w2',
|
||||||
|
|
||||||
// Kalos region - Note: All XY use all three sub-dexes
|
// Kalos region - Note: All XY use all three sub-dexes
|
||||||
// We default to Central for simplicity
|
// We default to Central for simplicity
|
||||||
'X': 'kalos_central',
|
X: 'kalos_central',
|
||||||
'Y': 'kalos_central',
|
Y: 'kalos_central',
|
||||||
|
|
||||||
// Alola region
|
// Alola region
|
||||||
'Sun': 'alola_sm',
|
Sun: 'alola_sm',
|
||||||
'Moon': 'alola_sm',
|
Moon: 'alola_sm',
|
||||||
'UltraSun': 'alola_usum',
|
UltraSun: 'alola_usum',
|
||||||
'UltraMoon': 'alola_usum',
|
UltraMoon: 'alola_usum',
|
||||||
|
|
||||||
// Galar region
|
// Galar region
|
||||||
'Sword': 'galar',
|
Sword: 'galar',
|
||||||
'Shield': 'galar',
|
Shield: 'galar',
|
||||||
|
|
||||||
// Hisui region
|
// Hisui region
|
||||||
'LegendsArceus': 'hisui',
|
LegendsArceus: 'hisui',
|
||||||
|
|
||||||
// Paldea region
|
// Paldea region
|
||||||
'Scarlet': 'paldea',
|
Scarlet: 'paldea',
|
||||||
'Violet': 'paldea'
|
Violet: 'paldea'
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,22 +119,22 @@ export function getRegionalDexFieldName(gameName: string): string | undefined {
|
|||||||
|
|
||||||
// Map regional key to actual PokedexEntry field name (camelCase)
|
// Map regional key to actual PokedexEntry field name (camelCase)
|
||||||
const fieldMap: Record<string, string> = {
|
const fieldMap: Record<string, string> = {
|
||||||
'kanto': 'kantoDexNumber',
|
kanto: 'kantoDexNumber',
|
||||||
'johto': 'johtoDexNumber',
|
johto: 'johtoDexNumber',
|
||||||
'hoenn': 'hoennDexNumber',
|
hoenn: 'hoennDexNumber',
|
||||||
'sinnoh': 'sinnohDexNumber',
|
sinnoh: 'sinnohDexNumber',
|
||||||
'unova_bw': 'unovaBwDexNumber',
|
unova_bw: 'unovaBwDexNumber',
|
||||||
'unova_b2w2': 'unovaB2w2DexNumber',
|
unova_b2w2: 'unovaB2w2DexNumber',
|
||||||
'kalos_central': 'kalosCentralDexNumber',
|
kalos_central: 'kalosCentralDexNumber',
|
||||||
'kalos_coastal': 'kalosCoastalDexNumber',
|
kalos_coastal: 'kalosCoastalDexNumber',
|
||||||
'kalos_mountain': 'kalosMountainDexNumber',
|
kalos_mountain: 'kalosMountainDexNumber',
|
||||||
'alola_sm': 'alolaSmDexNumber',
|
alola_sm: 'alolaSmDexNumber',
|
||||||
'alola_usum': 'alolaUsumDexNumber',
|
alola_usum: 'alolaUsumDexNumber',
|
||||||
'galar': 'galarDexNumber',
|
galar: 'galarDexNumber',
|
||||||
'galar_isle_of_armor': 'galarIsleOfArmorDexNumber',
|
galar_isle_of_armor: 'galarIsleOfArmorDexNumber',
|
||||||
'galar_crown_tundra': 'galarCrownTundraDexNumber',
|
galar_crown_tundra: 'galarCrownTundraDexNumber',
|
||||||
'hisui': 'hisuiDexNumber',
|
hisui: 'hisuiDexNumber',
|
||||||
'paldea': 'paldeaDexNumber'
|
paldea: 'paldeaDexNumber'
|
||||||
};
|
};
|
||||||
|
|
||||||
return fieldMap[regionalKey];
|
return fieldMap[regionalKey];
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/** 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(
|
||||||
|
entry: { pokedexNumber: number; form?: string; spriteKey?: string },
|
||||||
|
shiny: boolean,
|
||||||
|
useLocalSprites: boolean
|
||||||
|
): string {
|
||||||
|
const form = entry.form?.trim() ?? '';
|
||||||
|
const strippedNumber = String(entry.pokedexNumber).replace(/^0+/, '') || '0';
|
||||||
|
let key = entry.spriteKey?.trim();
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
let formKey = form
|
||||||
|
.replace(/^female[-\s]*/i, '')
|
||||||
|
.replace(/\s*\(.*?\)/g, '')
|
||||||
|
.replace(/\s*\[.*?\]/g, '')
|
||||||
|
.trim();
|
||||||
|
if (!formKey || formKey.toLowerCase() === 'male') {
|
||||||
|
key = strippedNumber;
|
||||||
|
} else {
|
||||||
|
formKey = formKey
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/%/g, '')
|
||||||
|
.replace(/\balolan\b/g, 'alola')
|
||||||
|
.replace(/\bgalarian\b/g, 'galar')
|
||||||
|
.replace(/\bhisuian\b/g, 'hisui')
|
||||||
|
.replace(/\bpaldean\b/g, 'paldea')
|
||||||
|
.replace(/\bform(e)?$/, '')
|
||||||
|
.replace(/\bability$/, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/(^-|-$)/g, '')
|
||||||
|
.replace(/2/g, 'two')
|
||||||
|
.replace(/3/g, 'three')
|
||||||
|
.replace(/4/g, 'four');
|
||||||
|
key = formKey ? `${strippedNumber}-${formKey}` : strippedNumber;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let root = spriteRoot(useLocalSprites);
|
||||||
|
if (shiny) root += '/shiny';
|
||||||
|
if (/^female\b/i.test(form)) root += '/female';
|
||||||
|
return `${root}/${key}.webp`;
|
||||||
|
}
|
||||||
+5
-35
@@ -2,26 +2,18 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
/// <reference no-default-lib="true"/>
|
/// <reference no-default-lib="true"/>
|
||||||
/// <reference lib="esnext" />
|
/// <reference lib="esnext" />
|
||||||
import {
|
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching';
|
||||||
cleanupOutdatedCaches,
|
|
||||||
// createHandlerBoundToURL,
|
|
||||||
precacheAndRoute,
|
|
||||||
precache
|
|
||||||
} from 'workbox-precaching';
|
|
||||||
import { registerRoute } from 'workbox-routing';
|
|
||||||
import { CacheFirst } from 'workbox-strategies';
|
|
||||||
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
|
|
||||||
import { ExpirationPlugin } from 'workbox-expiration';
|
|
||||||
|
|
||||||
declare let self: ServiceWorkerGlobalScope;
|
declare let self: ServiceWorkerGlobalScope;
|
||||||
|
|
||||||
|
// Kept as a static script so the same snapshot, artwork and navigation behavior can be imported
|
||||||
|
// by Workbox's generateSW output too.
|
||||||
|
self.importScripts('/offline-worker.js');
|
||||||
|
|
||||||
self.addEventListener('message', (event) => {
|
self.addEventListener('message', (event) => {
|
||||||
if (event.data && event.data.type === 'SKIP_WAITING') self.skipWaiting();
|
if (event.data && event.data.type === 'SKIP_WAITING') self.skipWaiting();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add root route to precache manifest
|
|
||||||
precache([{ url: '/', revision: null }]);
|
|
||||||
|
|
||||||
// self.__WB_MANIFEST is default injection point
|
// self.__WB_MANIFEST is default injection point
|
||||||
// Handle the case where __WB_MANIFEST might be undefined in development
|
// Handle the case where __WB_MANIFEST might be undefined in development
|
||||||
const manifest = self.__WB_MANIFEST || [];
|
const manifest = self.__WB_MANIFEST || [];
|
||||||
@@ -29,26 +21,4 @@ if (Array.isArray(manifest)) {
|
|||||||
precacheAndRoute(manifest);
|
precacheAndRoute(manifest);
|
||||||
}
|
}
|
||||||
|
|
||||||
// clean old assets
|
|
||||||
cleanupOutdatedCaches();
|
cleanupOutdatedCaches();
|
||||||
|
|
||||||
registerRoute(
|
|
||||||
({ request }) => request.destination === 'image',
|
|
||||||
new CacheFirst({
|
|
||||||
cacheName: 'image-cache',
|
|
||||||
plugins: [
|
|
||||||
new CacheableResponsePlugin({ statuses: [0, 200] }),
|
|
||||||
new ExpirationPlugin({
|
|
||||||
maxEntries: 3000,
|
|
||||||
maxAgeSeconds: 60 * 60 * 24 * 30,
|
|
||||||
purgeOnQuotaError: true
|
|
||||||
})
|
|
||||||
]
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// let allowlist: undefined | RegExp[];
|
|
||||||
// if (import.meta.env.DEV) allowlist = [/^\/$/];
|
|
||||||
|
|
||||||
// // to allow work offline
|
|
||||||
// registerRoute(new NavigationRoute(createHandlerBoundToURL('/'), { allowlist }));
|
|
||||||
|
|||||||
@@ -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()
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+116
-29
@@ -6,6 +6,14 @@
|
|||||||
import SignIn from '$lib/components/SignIn.svelte';
|
import SignIn from '$lib/components/SignIn.svelte';
|
||||||
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 {
|
||||||
|
artworkDownloadStatus,
|
||||||
|
claimOfflineData,
|
||||||
|
downloadAllArtwork,
|
||||||
|
offlineSyncStatus,
|
||||||
|
requestOfflineSync,
|
||||||
|
startOfflineSync
|
||||||
|
} from '$lib/stores/offlineSync';
|
||||||
|
|
||||||
import { pwaInfo } from 'virtual:pwa-info';
|
import { pwaInfo } from 'virtual:pwa-info';
|
||||||
import { pwaAssetsHead } from 'virtual:pwa-assets/head';
|
import { pwaAssetsHead } from 'virtual:pwa-assets/head';
|
||||||
@@ -24,48 +32,77 @@
|
|||||||
onDestroy(unsubscribe);
|
onDestroy(unsubscribe);
|
||||||
|
|
||||||
let authSubscription: { unsubscribe: () => void } | null = null;
|
let authSubscription: { unsubscribe: () => void } | null = null;
|
||||||
|
let stopOfflineSync: (() => void) | null = null;
|
||||||
|
let isOnline = true;
|
||||||
|
let signOutError = '';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void getUser();
|
isOnline = navigator.onLine;
|
||||||
|
const updateOnlineState = () => {
|
||||||
|
isOnline = navigator.onLine;
|
||||||
|
document.documentElement.classList.toggle('offline-readonly', !isOnline);
|
||||||
|
};
|
||||||
|
const blockOfflineMutation = (event: Event) => {
|
||||||
|
if (navigator.onLine) return;
|
||||||
|
const target = event.target instanceof Element ? event.target : null;
|
||||||
|
if (!target?.closest('button, input, textarea, select, form')) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
};
|
||||||
|
window.addEventListener('online', updateOnlineState);
|
||||||
|
window.addEventListener('offline', updateOnlineState);
|
||||||
|
for (const name of ['click', 'submit', 'input', 'change', 'keydown']) {
|
||||||
|
window.addEventListener(name, blockOfflineMutation, true);
|
||||||
|
}
|
||||||
|
updateOnlineState();
|
||||||
|
void getUser()
|
||||||
|
.then(async () => {
|
||||||
|
if (localUser) await claimOfflineData(localUser.id);
|
||||||
|
stopOfflineSync = startOfflineSync(() => localUser?.id ?? null);
|
||||||
|
})
|
||||||
|
.catch((error) => console.error('Unable to claim offline data', error));
|
||||||
|
|
||||||
// Listen for auth state changes to keep the user store in sync
|
// Listen for auth state changes to keep the user store in sync
|
||||||
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;
|
||||||
|
// SIGNED_IN also fires when a tab regains focus, so only a change of account fetches a new
|
||||||
|
// offline copy. Page loads and token refreshes reuse the saved one while it is fresh.
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
user.set(localUser);
|
user.set(localUser);
|
||||||
});
|
});
|
||||||
authSubscription = subscription;
|
authSubscription = subscription;
|
||||||
|
|
||||||
if (pwaInfo) {
|
|
||||||
void (async () => {
|
|
||||||
const { registerSW } = await import('virtual:pwa-register');
|
|
||||||
registerSW({
|
|
||||||
immediate: true,
|
|
||||||
onRegistered(r) {
|
|
||||||
// uncomment following code if you want check for updates
|
|
||||||
// r && setInterval(() => {
|
|
||||||
// console.log('Checking for sw update')
|
|
||||||
// r.update()
|
|
||||||
// }, 20000 /* 20s for testing purposes */)
|
|
||||||
console.log(`SW Registered: ${r}`);
|
|
||||||
},
|
|
||||||
onRegisterError(error) {
|
|
||||||
console.log('SW registration error', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
authSubscription?.unsubscribe();
|
authSubscription?.unsubscribe();
|
||||||
|
stopOfflineSync?.();
|
||||||
|
window.removeEventListener('online', updateOnlineState);
|
||||||
|
window.removeEventListener('offline', updateOnlineState);
|
||||||
|
for (const name of ['click', 'submit', 'input', 'change', 'keydown']) {
|
||||||
|
window.removeEventListener(name, blockOfflineMutation, true);
|
||||||
|
}
|
||||||
|
document.documentElement.classList.remove('offline-readonly');
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 }
|
||||||
@@ -99,13 +136,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>
|
||||||
@@ -162,7 +192,16 @@
|
|||||||
<li>
|
<li>
|
||||||
<a href="/backup-settings"> Backup Settings </a>
|
<a href="/backup-settings"> Backup Settings </a>
|
||||||
</li>
|
</li>
|
||||||
<li><SignOut {supabase} on:signedOut={getUser} /></li>
|
<li>
|
||||||
|
<SignOut
|
||||||
|
{supabase}
|
||||||
|
on:signedOut={() => {
|
||||||
|
signOutError = '';
|
||||||
|
void getUser();
|
||||||
|
}}
|
||||||
|
on:signOutFailed={(event) => (signOutError = event.detail.message)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
{:else}
|
{:else}
|
||||||
<li><SignIn {supabase} on:signedIn={getUser} /></li>
|
<li><SignIn {supabase} on:signedIn={getUser} /></li>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -174,6 +213,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
{#if signOutError}
|
||||||
|
<div class="alert alert-error rounded-none" role="alert">
|
||||||
|
<span>{signOutError}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if !isOnline}
|
||||||
|
<div class="alert rounded-none" role="status">
|
||||||
|
<span>Offline read-only mode: saved data remains available, but changes are disabled.</span>
|
||||||
|
</div>
|
||||||
|
{:else if localUser && $offlineSyncStatus.state === 'error'}
|
||||||
|
<div class="alert alert-warning rounded-none" role="status">
|
||||||
|
<span>Offline copy could not be refreshed: {$offlineSyncStatus.message}</span>
|
||||||
|
<button class="btn btn-sm" on:click={requestOfflineSync}>Retry</button>
|
||||||
|
</div>
|
||||||
|
{:else if localUser && $artworkDownloadStatus.state === 'error'}
|
||||||
|
<div class="alert alert-warning rounded-none" role="status">
|
||||||
|
<span
|
||||||
|
>Offline data is saved, but artwork could not be saved: {$artworkDownloadStatus.message}.</span
|
||||||
|
>
|
||||||
|
<button class="btn btn-sm" on:click={downloadAllArtwork}>Retry</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if isOnline && localUser && $offlineSyncStatus.state === 'syncing'}
|
||||||
|
<p class="bg-base-200 px-4 py-1 text-center text-xs" role="status">Updating offline copy…</p>
|
||||||
|
{:else if isOnline && localUser && $offlineSyncStatus.generatedAt}
|
||||||
|
<p class="bg-base-200 px-4 py-1 text-center text-xs" role="status">
|
||||||
|
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>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<main class="flex-grow">
|
<main class="flex-grow">
|
||||||
<slot />
|
<slot />
|
||||||
@@ -223,3 +300,13 @@
|
|||||||
<ReloadPrompt />
|
<ReloadPrompt />
|
||||||
{/await}
|
{/await}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(.offline-readonly button),
|
||||||
|
:global(.offline-readonly input),
|
||||||
|
:global(.offline-readonly textarea),
|
||||||
|
:global(.offline-readonly select) {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
+47
-16
@@ -1,25 +1,52 @@
|
|||||||
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 } from '@supabase/ssr';
|
import { createBrowserClient, createServerClient, isBrowser } from '@supabase/ssr';
|
||||||
|
|
||||||
export const load: LayoutLoad = async ({ fetch, data, depends }) => {
|
export const load: LayoutLoad = async ({ fetch, data, depends }) => {
|
||||||
depends('supabase:auth');
|
depends('supabase:auth');
|
||||||
|
let recoveryExchangeSucceeded = false;
|
||||||
const supabase = createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
let hashRecoveryCallback = false;
|
||||||
global: {
|
let codeRecoveryCallback = false;
|
||||||
fetch
|
if (isBrowser() && window.location.pathname === '/reset-password') {
|
||||||
},
|
const hash = new URLSearchParams(window.location.hash.slice(1));
|
||||||
cookies: {
|
hashRecoveryCallback =
|
||||||
get(key) {
|
hash.get('type') === 'recovery' && hash.has('access_token') && hash.has('refresh_token');
|
||||||
if (!isBrowser()) {
|
codeRecoveryCallback = new URL(window.location.href).searchParams.has('code');
|
||||||
return JSON.stringify(data.session);
|
}
|
||||||
}
|
const authFetch: typeof fetch = async (input, init) => {
|
||||||
|
const response = await fetch(input, init);
|
||||||
const cookie = parse(document.cookie);
|
if (codeRecoveryCallback && response.ok) {
|
||||||
return cookie[key];
|
const requestUrl = new URL(
|
||||||
|
typeof input === 'string' || input instanceof URL ? input : input.url,
|
||||||
|
window.location.origin
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
requestUrl.pathname.endsWith('/auth/v1/token') &&
|
||||||
|
requestUrl.searchParams.get('grant_type') === 'pkce'
|
||||||
|
) {
|
||||||
|
recoveryExchangeSucceeded = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The browser client manages document.cookie itself, including removing stale session chunks.
|
||||||
|
const supabase = isBrowser()
|
||||||
|
? createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
||||||
|
global: {
|
||||||
|
fetch: authFetch
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
||||||
|
global: {
|
||||||
|
fetch
|
||||||
|
},
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return data.cookies;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
@@ -30,5 +57,9 @@ export const load: LayoutLoad = async ({ fetch, data, depends }) => {
|
|||||||
data: { session }
|
data: { session }
|
||||||
} = await supabase.auth.getSession();
|
} = await supabase.auth.getSession();
|
||||||
|
|
||||||
return { supabase, session };
|
return {
|
||||||
|
supabase,
|
||||||
|
session,
|
||||||
|
recoveryIntent: !!session && (hashRecoveryCallback || recoveryExchangeSucceeded)
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+10
-9
@@ -1,7 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { user } from '$lib/stores/user.js';
|
|
||||||
import { type User } from '@supabase/auth-js';
|
|
||||||
import SignUp from '$lib/components/SignUp.svelte';
|
import SignUp from '$lib/components/SignUp.svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
@@ -9,12 +7,7 @@
|
|||||||
let { supabase, stats } = data;
|
let { supabase, stats } = data;
|
||||||
$: ({ supabase, stats } = data);
|
$: ({ supabase, stats } = data);
|
||||||
|
|
||||||
let localUser: User | null;
|
// Redirection is decided from the live session below, so the user store is not needed here.
|
||||||
const unsubscribe = user.subscribe((value) => {
|
|
||||||
localUser = value;
|
|
||||||
});
|
|
||||||
onDestroy(unsubscribe);
|
|
||||||
|
|
||||||
let isCheckingSession = true;
|
let isCheckingSession = true;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -69,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}
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ export const PUT = async (event: RequestEvent) => {
|
|||||||
.eq('userId', userId)
|
.eq('userId', userId)
|
||||||
.is('pokedexId', null)
|
.is('pokedexId', null)
|
||||||
.eq('provider', provider)
|
.eq('provider', provider)
|
||||||
.select('id, provider, enabled, fileName, folderId, path, metadata, lastExportedAt, lastError')
|
.select(
|
||||||
|
'id, provider, enabled, fileName, folderId, path, metadata, lastExportedAt, lastError'
|
||||||
|
)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportI
|
|||||||
import { requireAuth } from '$lib/utils/auth';
|
import { requireAuth } from '$lib/utils/auth';
|
||||||
import { clearOAuthStateCookie, readOAuthStateCookie } from '$lib/utils/oauthState';
|
import { clearOAuthStateCookie, readOAuthStateCookie } from '$lib/utils/oauthState';
|
||||||
import { getEnv } from '$lib/utils/env';
|
import { getEnv } from '$lib/utils/env';
|
||||||
|
import { getProviderEndpoints } from '$lib/services/providerEndpoints';
|
||||||
|
|
||||||
export const GET = async (event: RequestEvent) => {
|
export const GET = async (event: RequestEvent) => {
|
||||||
try {
|
try {
|
||||||
@@ -56,7 +57,7 @@ export const GET = async (event: RequestEvent) => {
|
|||||||
grant_type: 'authorization_code'
|
grant_type: 'authorization_code'
|
||||||
});
|
});
|
||||||
|
|
||||||
const tokenResponse = await fetch('https://api.dropbox.com/oauth2/token', {
|
const tokenResponse = await fetch(getProviderEndpoints().dropbox.token, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: tokenParams.toString()
|
body: tokenParams.toString()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import PokedexRepository from '$lib/repositories/PokedexRepository';
|
|||||||
import { requireAuth } from '$lib/utils/auth';
|
import { requireAuth } from '$lib/utils/auth';
|
||||||
import { createOAuthState, setOAuthStateCookie } from '$lib/utils/oauthState';
|
import { createOAuthState, setOAuthStateCookie } from '$lib/utils/oauthState';
|
||||||
import { getEnv } from '$lib/utils/env';
|
import { getEnv } from '$lib/utils/env';
|
||||||
|
import { getProviderEndpoints } from '$lib/services/providerEndpoints';
|
||||||
|
|
||||||
export const GET = async (event: RequestEvent) => {
|
export const GET = async (event: RequestEvent) => {
|
||||||
const userId = await requireAuth(event);
|
const userId = await requireAuth(event);
|
||||||
@@ -60,5 +61,5 @@ export const GET = async (event: RequestEvent) => {
|
|||||||
scope: 'files.content.write'
|
scope: 'files.content.write'
|
||||||
});
|
});
|
||||||
|
|
||||||
throw redirect(302, `https://www.dropbox.com/oauth2/authorize?${params.toString()}`);
|
throw redirect(302, `${getProviderEndpoints().dropbox.authorize}?${params.toString()}`);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportI
|
|||||||
import { requireAuth } from '$lib/utils/auth';
|
import { requireAuth } from '$lib/utils/auth';
|
||||||
import { clearOAuthStateCookie, readOAuthStateCookie } from '$lib/utils/oauthState';
|
import { clearOAuthStateCookie, readOAuthStateCookie } from '$lib/utils/oauthState';
|
||||||
import { getEnv } from '$lib/utils/env';
|
import { getEnv } from '$lib/utils/env';
|
||||||
|
import { getProviderEndpoints } from '$lib/services/providerEndpoints';
|
||||||
|
|
||||||
export const GET = async (event: RequestEvent) => {
|
export const GET = async (event: RequestEvent) => {
|
||||||
try {
|
try {
|
||||||
@@ -57,7 +58,7 @@ export const GET = async (event: RequestEvent) => {
|
|||||||
grant_type: 'authorization_code'
|
grant_type: 'authorization_code'
|
||||||
});
|
});
|
||||||
|
|
||||||
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
|
const tokenResponse = await fetch(getProviderEndpoints().google.token, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: tokenParams.toString()
|
body: tokenParams.toString()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import PokedexRepository from '$lib/repositories/PokedexRepository';
|
|||||||
import { requireAuth } from '$lib/utils/auth';
|
import { requireAuth } from '$lib/utils/auth';
|
||||||
import { createOAuthState, setOAuthStateCookie } from '$lib/utils/oauthState';
|
import { createOAuthState, setOAuthStateCookie } from '$lib/utils/oauthState';
|
||||||
import { getEnv } from '$lib/utils/env';
|
import { getEnv } from '$lib/utils/env';
|
||||||
|
import { getProviderEndpoints } from '$lib/services/providerEndpoints';
|
||||||
|
|
||||||
export const GET = async (event: RequestEvent) => {
|
export const GET = async (event: RequestEvent) => {
|
||||||
const userId = await requireAuth(event);
|
const userId = await requireAuth(event);
|
||||||
@@ -65,5 +66,5 @@ export const GET = async (event: RequestEvent) => {
|
|||||||
state
|
state
|
||||||
});
|
});
|
||||||
|
|
||||||
throw redirect(302, `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`);
|
throw redirect(302, `${getProviderEndpoints().google.authorize}?${params.toString()}`);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { json, type RequestEvent } from '@sveltejs/kit';
|
||||||
|
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
|
||||||
|
import PokedexRepository from '$lib/repositories/PokedexRepository';
|
||||||
|
import { resolveDexScopes } from '$lib/services/PokedexDexScopeService';
|
||||||
|
import { OFFLINE_SNAPSHOT_VERSION, type OfflineSnapshot } from '$lib/models/OfflineSnapshot';
|
||||||
|
import { requireAuth } from '$lib/utils/auth';
|
||||||
|
|
||||||
|
export const GET = async (event: RequestEvent) => {
|
||||||
|
try {
|
||||||
|
const userId = await requireAuth(event);
|
||||||
|
const pokedexes = await new PokedexRepository(event.locals.supabase, userId).findAll();
|
||||||
|
const snapshots = await Promise.all(
|
||||||
|
pokedexes.map(async (pokedex) => {
|
||||||
|
const dexScopes = await resolveDexScopes(event.locals.supabase, pokedex);
|
||||||
|
const entries = await new CombinedDataRepository(
|
||||||
|
event.locals.supabase,
|
||||||
|
userId,
|
||||||
|
pokedex._id
|
||||||
|
).findAllCombinedData(userId, pokedex.isFormDex, '', pokedex.gameScope ?? '', dexScopes);
|
||||||
|
return { pokedex: { ...pokedex, dexScopes }, entries };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshot: OfflineSnapshot = {
|
||||||
|
version: OFFLINE_SNAPSHOT_VERSION,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
userId,
|
||||||
|
pokedexes: snapshots
|
||||||
|
};
|
||||||
|
|
||||||
|
return json(snapshot, {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'private, no-store',
|
||||||
|
Vary: 'Cookie'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Unable to build offline snapshot:', error);
|
||||||
|
if (error && typeof error === 'object' && 'status' in error) throw error;
|
||||||
|
return json({ error: 'Unable to build offline snapshot' }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -31,9 +31,7 @@ export const GET = async (event: RequestEvent) => {
|
|||||||
|
|
||||||
const repo = new CatchRecordRepository(event.locals.supabase, userId, pokedexId);
|
const repo = new CatchRecordRepository(event.locals.supabase, userId, pokedexId);
|
||||||
const catchData = await repo.findAll();
|
const catchData = await repo.findAll();
|
||||||
const sortedData = catchData.sort(
|
const sortedData = catchData.sort((a, b) => Number(a.pokemonId) - Number(b.pokemonId));
|
||||||
(a, b) => Number(a.pokemonId) - Number(b.pokemonId)
|
|
||||||
);
|
|
||||||
return json(sortedData);
|
return json(sortedData);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -159,10 +157,7 @@ export const POST = async (event: RequestEvent) => {
|
|||||||
try {
|
try {
|
||||||
await exportPokedexIfConfigured(event.locals.supabase, userId, pokedexId);
|
await exportPokedexIfConfigured(event.locals.supabase, userId, pokedexId);
|
||||||
} catch (exportError) {
|
} catch (exportError) {
|
||||||
console.error(
|
console.error('Failed to export pokedex after per-record catch updates:', exportError);
|
||||||
'Failed to export pokedex after per-record catch updates:',
|
|
||||||
exportError
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return json(insertedRecords);
|
return json(insertedRecords);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import type { Pokedex } from '$lib/models/Pokedex';
|
import type { Pokedex } from '$lib/models/Pokedex';
|
||||||
import PokedexCard from '$lib/components/pokedex/PokedexCard.svelte';
|
import PokedexCard from '$lib/components/pokedex/PokedexCard.svelte';
|
||||||
import PokedexForm from '$lib/components/pokedex/PokedexForm.svelte';
|
import PokedexForm from '$lib/components/pokedex/PokedexForm.svelte';
|
||||||
|
import { requestOfflineSync } from '$lib/stores/offlineSync';
|
||||||
|
|
||||||
export let data;
|
export let data;
|
||||||
let { pokedexes } = data;
|
let { pokedexes } = data;
|
||||||
@@ -93,6 +94,7 @@
|
|||||||
|
|
||||||
closeModal();
|
closeModal();
|
||||||
await loadPokedexes();
|
await loadPokedexes();
|
||||||
|
requestOfflineSync();
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
// Create new pokédex
|
// Create new pokédex
|
||||||
@@ -114,6 +116,7 @@
|
|||||||
// Refresh local state BEFORE deciding whether this is the user's first pokédex.
|
// Refresh local state BEFORE deciding whether this is the user's first pokédex.
|
||||||
closeModal();
|
closeModal();
|
||||||
await loadPokedexes();
|
await loadPokedexes();
|
||||||
|
requestOfflineSync();
|
||||||
|
|
||||||
// If the user's total pokédex count is now 1, this newly created one is their first.
|
// If the user's total pokédex count is now 1, this newly created one is their first.
|
||||||
if (pokedexes.length === 1) {
|
if (pokedexes.length === 1) {
|
||||||
@@ -149,6 +152,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
await loadPokedexes();
|
await loadPokedexes();
|
||||||
|
requestOfflineSync();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting pokédex:', error);
|
console.error('Error deleting pokédex:', error);
|
||||||
alert('An error occurred');
|
alert('An error occurred');
|
||||||
@@ -204,11 +208,7 @@
|
|||||||
</h3>
|
</h3>
|
||||||
<PokedexForm bind:pokedex={formData} {mode} onSubmit={handleSubmit} onCancel={closeModal} />
|
<PokedexForm bind:pokedex={formData} {mode} onSubmit={handleSubmit} onCancel={closeModal} />
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button type="button" class="modal-backdrop" aria-label="Close modal" on:click={closeModal}
|
||||||
type="button"
|
|
||||||
class="modal-backdrop"
|
|
||||||
aria-label="Close modal"
|
|
||||||
on:click={closeModal}
|
|
||||||
></button>
|
></button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
import PokedexEntryCatchRecord from '$lib/components/pokedex/PokedexEntryCatchRecord.svelte';
|
import PokedexEntryCatchRecord from '$lib/components/pokedex/PokedexEntryCatchRecord.svelte';
|
||||||
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 type { SharedCombinedData } from '$lib/models/SharedPokedex';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
@@ -38,7 +39,10 @@
|
|||||||
// Box view requires the full dataset for correct box numbering/placement.
|
// Box view requires the full dataset for correct box numbering/placement.
|
||||||
// If/when a paginated list view is introduced, this can be lowered and paired with UI controls.
|
// If/when a paginated list view is introduced, this can be lowered and paired with UI controls.
|
||||||
let itemsPerPage = 9999 as number;
|
let itemsPerPage = 9999 as number;
|
||||||
let totalPages = 0 as number;
|
type CatchUpdateEvent = CustomEvent<{
|
||||||
|
catchRecord: CatchRecord;
|
||||||
|
source: 'toggle' | 'notes' | 'notes-blur';
|
||||||
|
}>;
|
||||||
let creatingRecords = false;
|
let creatingRecords = false;
|
||||||
let totalRecordsCreated = 0;
|
let totalRecordsCreated = 0;
|
||||||
let failedToLoad = false;
|
let failedToLoad = false;
|
||||||
@@ -46,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;
|
||||||
@@ -57,6 +65,7 @@
|
|||||||
lastFlushAttemptAt: null,
|
lastFlushAttemptAt: null,
|
||||||
lastSuccessfulFlushAt: null
|
lastSuccessfulFlushAt: null
|
||||||
};
|
};
|
||||||
|
let lastOfflineSyncFlush: number | null = null;
|
||||||
let exportAfterFlush = false;
|
let exportAfterFlush = false;
|
||||||
let exportInFlight = false;
|
let exportInFlight = false;
|
||||||
let exportTimer: ReturnType<typeof setTimeout> | null = null;
|
let exportTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -128,7 +137,6 @@
|
|||||||
}, 250);
|
}, 250);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Derive from pokedex config
|
// Derive from pokedex config
|
||||||
$: showOrigins = !!pokedex?.isOriginDex;
|
$: showOrigins = !!pokedex?.isOriginDex;
|
||||||
$: showShiny = !!pokedex?.isShinyDex;
|
$: showShiny = !!pokedex?.isShinyDex;
|
||||||
@@ -145,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;
|
||||||
@@ -178,6 +222,15 @@
|
|||||||
|
|
||||||
catchWriteQueueUnsubscribe = catchWriteQueue.getStatus.subscribe((s) => {
|
catchWriteQueueUnsubscribe = catchWriteQueue.getStatus.subscribe((s) => {
|
||||||
catchWriteStatus = s;
|
catchWriteStatus = s;
|
||||||
|
if (
|
||||||
|
s.lastSuccessfulFlushAt &&
|
||||||
|
s.lastSuccessfulFlushAt !== lastOfflineSyncFlush &&
|
||||||
|
s.pending === 0 &&
|
||||||
|
s.inFlight === 0
|
||||||
|
) {
|
||||||
|
lastOfflineSyncFlush = s.lastSuccessfulFlushAt;
|
||||||
|
requestOfflineSync();
|
||||||
|
}
|
||||||
if (s.pending > 0 || s.inFlight > 0) {
|
if (s.pending > 0 || s.inFlight > 0) {
|
||||||
if (exportTimer) {
|
if (exportTimer) {
|
||||||
clearTimeout(exportTimer);
|
clearTimeout(exportTimer);
|
||||||
@@ -190,8 +243,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function applyOptimisticCatchRecordUpdate(next: CatchRecord) {
|
function applyOptimisticCatchRecordUpdate(next: CatchRecord) {
|
||||||
if (!combinedData) return;
|
if (!combinedData) return;
|
||||||
const idx = combinedData.findIndex((cd) => cd.pokedexEntry._id === next.pokemonId);
|
const idx = combinedData.findIndex((cd) => cd.pokedexEntry._id === next.pokemonId);
|
||||||
@@ -212,7 +263,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleModalCatchUpdate(event: any) {
|
async function handleModalCatchUpdate(event: CatchUpdateEvent) {
|
||||||
await updateACatch(event);
|
await updateACatch(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,20 +294,16 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
combinedData = fetchedData.combinedData;
|
combinedData = fetchedData.combinedData;
|
||||||
totalPages = fetchedData.totalPages || 0;
|
|
||||||
// Always extract box numbers for box view
|
// Always extract box numbers for box view
|
||||||
if (combinedData) {
|
if (combinedData) {
|
||||||
boxNumbers = calculateBoxNumbers(combinedData.length);
|
boxNumbers = calculateBoxNumbers(combinedData.length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateACatch(event: any) {
|
async function updateACatch(event: CatchUpdateEvent) {
|
||||||
if (!pokedexId) return;
|
if (!pokedexId) return;
|
||||||
ensureCatchWriteQueue();
|
ensureCatchWriteQueue();
|
||||||
const { catchRecord, source } = event.detail as {
|
const { catchRecord, source } = event.detail;
|
||||||
catchRecord: CatchRecord;
|
|
||||||
source: 'toggle' | 'notes' | 'notes-blur';
|
|
||||||
};
|
|
||||||
// Enforce mutual exclusivity (should be impossible to have both true).
|
// Enforce mutual exclusivity (should be impossible to have both true).
|
||||||
const sanitizedCatchRecord: CatchRecord = { ...catchRecord };
|
const sanitizedCatchRecord: CatchRecord = { ...catchRecord };
|
||||||
if (sanitizedCatchRecord.caught) {
|
if (sanitizedCatchRecord.caught) {
|
||||||
@@ -432,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;
|
||||||
@@ -464,7 +512,6 @@
|
|||||||
window.clearInterval(reconcileInterval);
|
window.clearInterval(reconcileInterval);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -599,7 +646,6 @@
|
|||||||
{#if pokedex.description}
|
{#if pokedex.description}
|
||||||
<p class="text-sm text-base-content/70 mt-3">{pokedex.description}</p>
|
<p class="text-sm text-base-content/70 mt-3">{pokedex.description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right side: Actions -->
|
<!-- Right side: Actions -->
|
||||||
@@ -613,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"
|
||||||
@@ -637,6 +697,7 @@
|
|||||||
bind:combinedData
|
bind:combinedData
|
||||||
bind:boxNumbers
|
bind:boxNumbers
|
||||||
bind:creatingRecords
|
bind:creatingRecords
|
||||||
|
{totalRecordsCreated}
|
||||||
bind:failedToLoad
|
bind:failedToLoad
|
||||||
{markBoxAsNotCaught}
|
{markBoxAsNotCaught}
|
||||||
{markBoxAsCaught}
|
{markBoxAsCaught}
|
||||||
@@ -662,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}
|
||||||
|
|||||||
@@ -1,42 +1,74 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { user } from '$lib/stores/user.js';
|
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import type { User } from '@supabase/auth-js';
|
|
||||||
|
|
||||||
export let data;
|
export let data;
|
||||||
let { supabase } = data;
|
let { supabase } = data;
|
||||||
$: ({ supabase } = data);
|
$: ({ supabase } = data);
|
||||||
|
|
||||||
let localUser: User | null = null;
|
|
||||||
const unsubscribe = user.subscribe((value) => {
|
|
||||||
localUser = value;
|
|
||||||
});
|
|
||||||
onDestroy(unsubscribe);
|
|
||||||
|
|
||||||
let showAnimation = false;
|
let showAnimation = false;
|
||||||
let hasCheckedAuth = false;
|
let authReady = false;
|
||||||
|
let authChecking = true;
|
||||||
|
let authError = '';
|
||||||
|
const recoveryMarkerKey = 'livingdex:password-recovery';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Start animation
|
const animationTimer = setTimeout(() => {
|
||||||
setTimeout(() => {
|
|
||||||
showAnimation = true;
|
showAnimation = true;
|
||||||
}, 100);
|
}, 100);
|
||||||
|
let settled = false;
|
||||||
// Check authentication after a short delay to allow Supabase to process the token
|
const settle = (hasSession: boolean) => {
|
||||||
setTimeout(() => {
|
if (settled && !hasSession) return;
|
||||||
hasCheckedAuth = true;
|
settled = hasSession;
|
||||||
if (!localUser) {
|
authReady = hasSession;
|
||||||
goto('/signin');
|
authChecking = false;
|
||||||
|
authError = hasSession
|
||||||
|
? ''
|
||||||
|
: 'This password-reset link is invalid or expired. Request a new link and try again.';
|
||||||
|
};
|
||||||
|
const {
|
||||||
|
data: { subscription }
|
||||||
|
} = supabase.auth.onAuthStateChange((event, session) => {
|
||||||
|
if (event === 'PASSWORD_RECOVERY' && session) {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
recoveryMarkerKey,
|
||||||
|
JSON.stringify({ userId: session.user.id, expiresAt: Date.now() + 30 * 60_000 })
|
||||||
|
);
|
||||||
|
settle(true);
|
||||||
}
|
}
|
||||||
}, 500);
|
});
|
||||||
|
void supabase.auth.getSession().then(({ data: { session }, error }) => {
|
||||||
|
if (error) {
|
||||||
|
authChecking = false;
|
||||||
|
authError = error.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let marker: { userId?: string; expiresAt?: number } | null = null;
|
||||||
|
try {
|
||||||
|
marker = JSON.parse(sessionStorage.getItem(recoveryMarkerKey) ?? 'null');
|
||||||
|
} catch {
|
||||||
|
sessionStorage.removeItem(recoveryMarkerKey);
|
||||||
|
}
|
||||||
|
const suppliedRecoveryIntent = data.recoveryIntent === true;
|
||||||
|
const isRecoverySession =
|
||||||
|
!!session &&
|
||||||
|
(suppliedRecoveryIntent ||
|
||||||
|
(marker?.userId === session.user.id && Number(marker.expiresAt) > Date.now()));
|
||||||
|
if (session && suppliedRecoveryIntent) {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
recoveryMarkerKey,
|
||||||
|
JSON.stringify({ userId: session.user.id, expiresAt: Date.now() + 30 * 60_000 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isRecoverySession) sessionStorage.removeItem(recoveryMarkerKey);
|
||||||
|
settle(isRecoverySession);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
clearTimeout(animationTimer);
|
||||||
|
subscription.unsubscribe();
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reactive: redirect if user becomes null after initial check
|
|
||||||
$: if (hasCheckedAuth && !localUser) {
|
|
||||||
goto('/signin');
|
|
||||||
}
|
|
||||||
|
|
||||||
let password = '';
|
let password = '';
|
||||||
let confirmPassword = '';
|
let confirmPassword = '';
|
||||||
let isLoading = false;
|
let isLoading = false;
|
||||||
@@ -44,6 +76,7 @@
|
|||||||
let successMessage = '';
|
let successMessage = '';
|
||||||
|
|
||||||
async function updatePassword() {
|
async function updatePassword() {
|
||||||
|
if (!authReady) return;
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
errorMessage = '';
|
errorMessage = '';
|
||||||
successMessage = '';
|
successMessage = '';
|
||||||
@@ -74,11 +107,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
successMessage = 'Password updated successfully! Redirecting to sign in...';
|
successMessage = 'Password updated successfully! Redirecting to sign in...';
|
||||||
|
sessionStorage.removeItem(recoveryMarkerKey);
|
||||||
// Redirect to sign in after a short delay
|
setTimeout(() => void finishPasswordReset(), 1_000);
|
||||||
setTimeout(() => {
|
|
||||||
goto('/signin');
|
|
||||||
}, 2000);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Update password error:', err);
|
console.error('Update password error:', err);
|
||||||
errorMessage = 'An unexpected error occurred. Please try again.';
|
errorMessage = 'An unexpected error occurred. Please try again.';
|
||||||
@@ -87,6 +117,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function finishPasswordReset() {
|
||||||
|
try {
|
||||||
|
const { error } = await supabase.auth.signOut();
|
||||||
|
if (error) {
|
||||||
|
errorMessage = `Password updated, but sign out failed: ${error.message}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await goto('/signin');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Sign out after password reset failed:', error);
|
||||||
|
errorMessage = 'Password updated, but sign out failed. Please try again.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleKeyPress(event: KeyboardEvent) {
|
function handleKeyPress(event: KeyboardEvent) {
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
updatePassword();
|
updatePassword();
|
||||||
@@ -132,6 +176,11 @@
|
|||||||
<!-- Reset Password Card -->
|
<!-- Reset Password Card -->
|
||||||
<div class="card bg-base-200 shadow-xl {showAnimation ? 'animate-slide-up' : ''}">
|
<div class="card bg-base-200 shadow-xl {showAnimation ? 'animate-slide-up' : ''}">
|
||||||
<div class="card-body p-6 md:p-8">
|
<div class="card-body p-6 md:p-8">
|
||||||
|
{#if authChecking}
|
||||||
|
<div class="alert"><span>Validating your password-reset link…</span></div>
|
||||||
|
{:else if authError}
|
||||||
|
<div class="alert alert-error" role="alert"><span>{authError}</span></div>
|
||||||
|
{/if}
|
||||||
<!-- Error Message -->
|
<!-- Error Message -->
|
||||||
{#if errorMessage}
|
{#if errorMessage}
|
||||||
<div class="alert alert-error text-sm">
|
<div class="alert alert-error text-sm">
|
||||||
@@ -185,7 +234,7 @@
|
|||||||
class="input input-bordered w-full pl-10"
|
class="input input-bordered w-full pl-10"
|
||||||
bind:value={password}
|
bind:value={password}
|
||||||
on:keypress={handleKeyPress}
|
on:keypress={handleKeyPress}
|
||||||
disabled={isLoading}
|
disabled={isLoading || !authReady}
|
||||||
/>
|
/>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -215,7 +264,7 @@
|
|||||||
class="input input-bordered w-full pl-10"
|
class="input input-bordered w-full pl-10"
|
||||||
bind:value={confirmPassword}
|
bind:value={confirmPassword}
|
||||||
on:keypress={handleKeyPress}
|
on:keypress={handleKeyPress}
|
||||||
disabled={isLoading}
|
disabled={isLoading || !authReady}
|
||||||
/>
|
/>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -237,7 +286,7 @@
|
|||||||
<button
|
<button
|
||||||
class="btn btn-primary w-full"
|
class="btn btn-primary w-full"
|
||||||
on:click={updatePassword}
|
on:click={updatePassword}
|
||||||
disabled={isLoading || !password || !confirmPassword}
|
disabled={isLoading || !authReady || !password || !confirmPassword}
|
||||||
>
|
>
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
const META_CACHE = 'livingdex-offline-meta-v1';
|
||||||
|
const META_URL = '/__offline/current';
|
||||||
|
|
||||||
|
function element(tag, className, text) {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
if (className) node.className = className;
|
||||||
|
if (text !== undefined) node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusText(record) {
|
||||||
|
if (!record) return 'Not caught';
|
||||||
|
const values = [];
|
||||||
|
if (record.caught) values.push('Caught');
|
||||||
|
if (record.haveToEvolve) values.push('Needs evolution');
|
||||||
|
if (record.inHome) values.push('In HOME');
|
||||||
|
return values.join(' · ') || 'Not caught';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSnapshot(snapshot) {
|
||||||
|
const content = document.querySelector('#offline-content');
|
||||||
|
for (const { pokedex, entries } of snapshot.pokedexes) {
|
||||||
|
const section = element('section', 'card bg-base-100 mb-6 shadow');
|
||||||
|
const body = element('div', 'card-body');
|
||||||
|
body.append(element('h2', 'card-title text-2xl', pokedex.name));
|
||||||
|
body.append(element('p', 'text-sm opacity-70', `${entries.length} entries`));
|
||||||
|
const grid = element(
|
||||||
|
'div',
|
||||||
|
'grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-5 lg:grid-cols-6'
|
||||||
|
);
|
||||||
|
for (const { pokedexEntry: entry, catchRecord } of entries) {
|
||||||
|
const card = element('article', 'rounded border border-base-300 p-2');
|
||||||
|
const image = element('img', 'mx-auto h-20 w-20 object-contain');
|
||||||
|
image.alt = `${entry.pokemon}${entry.form ? ` — ${entry.form}` : ''}`;
|
||||||
|
image.src = entry.offlineSpriteUrl ?? '/placeholder-bulb.png';
|
||||||
|
image.addEventListener(
|
||||||
|
'error',
|
||||||
|
() => {
|
||||||
|
image.src = '/placeholder-bulb.png';
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
card.append(image);
|
||||||
|
card.append(element('h3', 'font-semibold', `#${entry.pokedexNumber} ${entry.pokemon}`));
|
||||||
|
if (entry.form) card.append(element('p', 'text-xs opacity-70', entry.form));
|
||||||
|
card.append(element('p', 'text-sm', statusText(catchRecord)));
|
||||||
|
if (catchRecord?.personalNotes)
|
||||||
|
card.append(element('p', 'mt-1 whitespace-pre-wrap text-xs', catchRecord.personalNotes));
|
||||||
|
grid.append(card);
|
||||||
|
}
|
||||||
|
body.append(grid);
|
||||||
|
section.append(body);
|
||||||
|
content.append(section);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const status = document.querySelector('#offline-status');
|
||||||
|
try {
|
||||||
|
const meta = await (await (await caches.open(META_CACHE)).match(META_URL))?.json();
|
||||||
|
if (!meta?.userId) throw new Error('No collection has been synchronized on this device.');
|
||||||
|
if (!meta.dataCache) throw new Error('The saved collection metadata is incomplete.');
|
||||||
|
const dataCache = await caches.open(meta.dataCache);
|
||||||
|
const response = await dataCache.match(
|
||||||
|
`/__offline/snapshot/${encodeURIComponent(meta.userId)}`
|
||||||
|
);
|
||||||
|
if (!response)
|
||||||
|
throw new Error('The saved collection is incomplete. Reconnect and synchronize again.');
|
||||||
|
const snapshot = await response.json();
|
||||||
|
if (snapshot.version !== 1 || snapshot.userId !== meta.userId)
|
||||||
|
throw new Error('The saved collection is incompatible with this app version.');
|
||||||
|
status.textContent = `Saved ${new Date(snapshot.generatedAt).toLocaleString()}.`;
|
||||||
|
renderSnapshot(snapshot);
|
||||||
|
} catch (error) {
|
||||||
|
status.className = 'alert alert-warning';
|
||||||
|
status.textContent = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load();
|
||||||
|
|
||||||
|
navigator.serviceWorker?.addEventListener('message', (event) => {
|
||||||
|
if (event.data?.type !== 'OFFLINE_DATA_CLEARED') return;
|
||||||
|
const content = document.querySelector('#offline-content');
|
||||||
|
if (content) content.replaceChildren();
|
||||||
|
const status = document.querySelector('#offline-status');
|
||||||
|
if (status) {
|
||||||
|
status.className = 'alert alert-warning';
|
||||||
|
status.textContent =
|
||||||
|
'The saved collection was removed because the account changed or signed out.';
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
const OFFLINE_CACHE_PREFIX = 'livingdex-offline-';
|
||||||
|
const OFFLINE_META_CACHE = `${OFFLINE_CACHE_PREFIX}meta-v1`;
|
||||||
|
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;
|
||||||
|
let offlineEpoch = 0;
|
||||||
|
let offlineOperation = Promise.resolve();
|
||||||
|
let claimedUserId = null;
|
||||||
|
|
||||||
|
function queueOfflineOperation(operation) {
|
||||||
|
const result = offlineOperation.then(operation, operation);
|
||||||
|
offlineOperation = result.catch(() => undefined);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataCacheName(userId, generation) {
|
||||||
|
return `${OFFLINE_CACHE_PREFIX}data-v1-${userId}-${generation}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The old per-user artwork caches hold full-size (or opaque, quota-padded) sprites.
|
||||||
|
function isObsoleteArtworkCache(name) {
|
||||||
|
return name.startsWith(`${OFFLINE_CACHE_PREFIX}art-`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
function isSpriteUrl(url) {
|
||||||
|
return /\/sprites(-small)?\//.test(url.pathname) && url.pathname.endsWith('.webp');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function currentOfflineMeta() {
|
||||||
|
const response = await (await caches.open(OFFLINE_META_CACHE)).match(OFFLINE_META_URL);
|
||||||
|
if (!response) return null;
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
return typeof data?.userId === 'string' ? data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearOfflineData() {
|
||||||
|
const names = await caches.keys();
|
||||||
|
await Promise.all(
|
||||||
|
names.filter((name) => name.startsWith(OFFLINE_CACHE_PREFIX)).map((name) => caches.delete(name))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notifyOfflineDataCleared() {
|
||||||
|
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||||
|
for (const client of windows) client.postMessage({ type: 'OFFLINE_DATA_CLEARED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSprites(cache, missing) {
|
||||||
|
let next = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const workers = Array.from({ length: Math.min(6, missing.length) }, async () => {
|
||||||
|
for (;;) {
|
||||||
|
const index = next++;
|
||||||
|
if (index >= missing.length) return;
|
||||||
|
const url = missing[index];
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), ARTWORK_FETCH_TIMEOUT_MS);
|
||||||
|
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, {
|
||||||
|
mode: url.startsWith(self.location.origin) ? 'same-origin' : 'cors',
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
await cache.put(url, response);
|
||||||
|
} catch {
|
||||||
|
failed++;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(workers);
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
const reply = (value) => event.ports[0]?.postMessage(value);
|
||||||
|
if (event.data?.type === 'CLEAR_OFFLINE_DATA') {
|
||||||
|
claimedUserId = null;
|
||||||
|
offlineEpoch++;
|
||||||
|
event.waitUntil(
|
||||||
|
queueOfflineOperation(async () => {
|
||||||
|
await clearOfflineData();
|
||||||
|
await notifyOfflineDataCleared();
|
||||||
|
})
|
||||||
|
.then(() => reply({ ok: true }))
|
||||||
|
.catch((error) => reply({ ok: false, error: String(error) }))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.data?.type === 'CLAIM_OFFLINE_USER') {
|
||||||
|
if (typeof event.data.userId !== 'string') {
|
||||||
|
reply({ ok: false, error: 'Invalid offline cache owner' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
claimedUserId = event.data.userId;
|
||||||
|
offlineEpoch++;
|
||||||
|
event.waitUntil(
|
||||||
|
queueOfflineOperation(async () => {
|
||||||
|
const meta = await currentOfflineMeta();
|
||||||
|
if (meta?.userId && meta.userId !== event.data.userId) {
|
||||||
|
await clearOfflineData();
|
||||||
|
await notifyOfflineDataCleared();
|
||||||
|
}
|
||||||
|
reply({ ok: true });
|
||||||
|
}).catch((error) => reply({ ok: false, error: String(error) }))
|
||||||
|
);
|
||||||
|
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;
|
||||||
|
const syncEpoch = offlineEpoch;
|
||||||
|
const syncUserId = claimedUserId;
|
||||||
|
const isCurrent = () => syncEpoch === offlineEpoch;
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
queueOfflineOperation(async () => {
|
||||||
|
let nextData;
|
||||||
|
let committed = false;
|
||||||
|
try {
|
||||||
|
const snapshot = event.data.snapshot;
|
||||||
|
if (!snapshot || snapshot.version !== 1 || typeof snapshot.userId !== 'string') {
|
||||||
|
throw new Error('Unsupported offline snapshot');
|
||||||
|
}
|
||||||
|
if (!syncUserId || snapshot.userId !== syncUserId) {
|
||||||
|
throw new Error('Offline snapshot owner did not match the claimed account');
|
||||||
|
}
|
||||||
|
const previousMeta = await currentOfflineMeta();
|
||||||
|
if (previousMeta?.userId && previousMeta.userId !== snapshot.userId)
|
||||||
|
await clearOfflineData();
|
||||||
|
await deleteObsoleteArtworkCaches();
|
||||||
|
|
||||||
|
const timestamp = String(snapshot.generatedAt).replace(/[^0-9]/g, '');
|
||||||
|
const generation = `${timestamp}-${crypto.randomUUID()}`;
|
||||||
|
nextData = dataCacheName(snapshot.userId, generation);
|
||||||
|
const dataCache = await caches.open(nextData);
|
||||||
|
await dataCache.put(
|
||||||
|
`/__offline/snapshot/${encodeURIComponent(snapshot.userId)}`,
|
||||||
|
new Response(JSON.stringify(snapshot), {
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (!isCurrent())
|
||||||
|
throw new Error('Offline synchronization was superseded by an account change');
|
||||||
|
|
||||||
|
const metaCache = await caches.open(OFFLINE_META_CACHE);
|
||||||
|
await metaCache.put(
|
||||||
|
OFFLINE_META_URL,
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
format: OFFLINE_META_FORMAT,
|
||||||
|
userId: snapshot.userId,
|
||||||
|
generatedAt: snapshot.generatedAt,
|
||||||
|
dataCache: nextData
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (!isCurrent()) {
|
||||||
|
const current = await currentOfflineMeta();
|
||||||
|
if (current?.dataCache === nextData) await metaCache.delete(OFFLINE_META_URL);
|
||||||
|
throw new Error('Offline synchronization was superseded by an account change');
|
||||||
|
}
|
||||||
|
committed = true;
|
||||||
|
|
||||||
|
const currentCaches = await caches.keys();
|
||||||
|
await Promise.all(
|
||||||
|
currentCaches
|
||||||
|
.filter(
|
||||||
|
(name) =>
|
||||||
|
name.startsWith(OFFLINE_CACHE_PREFIX) &&
|
||||||
|
![OFFLINE_META_CACHE, nextData].includes(name)
|
||||||
|
)
|
||||||
|
.map((name) => caches.delete(name))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Artwork is not downloaded here: the fetch handler caches sprites as they are viewed, and
|
||||||
|
// CACHE_ALL_ARTWORK fetches the rest only when the user asks for it.
|
||||||
|
reply({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (!committed && nextData) await caches.delete(nextData).catch(() => undefined);
|
||||||
|
reply({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(deleteObsoleteArtworkCaches());
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
if (event.request.mode === 'navigate' && !['/offline', '/offline.html'].includes(url.pathname)) {
|
||||||
|
const fallback = async () =>
|
||||||
|
(await caches.match('/offline', { ignoreSearch: true })) ??
|
||||||
|
(await caches.match('/offline.html', { ignoreSearch: true })) ??
|
||||||
|
Response.error();
|
||||||
|
event.respondWith(
|
||||||
|
self.navigator.onLine
|
||||||
|
? fetch(new Request(event.request, { cache: 'no-store' })).catch(fallback)
|
||||||
|
: fallback()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.request.destination !== 'image' || !isSpriteUrl(url)) return;
|
||||||
|
event.respondWith(
|
||||||
|
(async () => {
|
||||||
|
// Cache-first with fill-on-miss for everyone, signed in or not: a sprite is downloaded once
|
||||||
|
// and served from the cache from then on.
|
||||||
|
const cache = await caches.open(SPRITE_CACHE);
|
||||||
|
const cached = await cache.match(url.href, { ignoreSearch: true });
|
||||||
|
if (cached) return cached;
|
||||||
|
try {
|
||||||
|
const response = await fetch(url.href, {
|
||||||
|
mode: url.origin === self.location.origin ? 'same-origin' : 'cors'
|
||||||
|
});
|
||||||
|
if (response.ok) event.waitUntil(cache.put(url.href, response.clone()));
|
||||||
|
return response;
|
||||||
|
} catch {
|
||||||
|
return fetch(event.request);
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#f00000" />
|
||||||
|
<title>Living Dex Tracker — Offline</title>
|
||||||
|
<link rel="stylesheet" href="/output.css" />
|
||||||
|
<script type="module" src="/offline-viewer.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-base-200 text-base-content">
|
||||||
|
<header class="navbar bg-primary text-primary-content">
|
||||||
|
<div class="mx-auto w-full max-w-screen-2xl px-4 text-xl font-semibold">
|
||||||
|
Living Dex Tracker
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="mx-auto max-w-screen-2xl p-4">
|
||||||
|
<div class="alert mb-4" role="status">
|
||||||
|
<span
|
||||||
|
>You are offline. This is a read-only copy; changes, exports, and account actions are
|
||||||
|
disabled.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<p id="offline-status" class="mb-4">Loading the saved collection…</p>
|
||||||
|
<div id="offline-content"></div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because one or more lines are too long
@@ -119,7 +119,8 @@ enabled = true
|
|||||||
# in emails.
|
# in emails.
|
||||||
site_url = "http://localhost:5173"
|
site_url = "http://localhost:5173"
|
||||||
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
|
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
|
||||||
additional_redirect_urls = ["http://localhost:5173"]
|
# 4173 is the preview server the BDD suite runs against; recovery links redirect there.
|
||||||
|
additional_redirect_urls = ["http://localhost:5173", "http://localhost:4173"]
|
||||||
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
|
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
|
||||||
jwt_expiry = 3600
|
jwt_expiry = 3600
|
||||||
# If disabled, the refresh token will never expire.
|
# If disabled, the refresh token will never expire.
|
||||||
@@ -149,7 +150,8 @@ anonymous_users = 30
|
|||||||
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
|
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
|
||||||
token_refresh = 150
|
token_refresh = 150
|
||||||
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
|
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
|
||||||
sign_in_sign_ups = 30
|
# Raised for the BDD suite: every scenario provisions and signs in its own user from one IP.
|
||||||
|
sign_in_sign_ups = 300
|
||||||
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
|
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
|
||||||
token_verifications = 30
|
token_verifications = 30
|
||||||
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
|
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
|
||||||
|
|||||||
@@ -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;
|
||||||
+6
-12
@@ -1,7 +1,7 @@
|
|||||||
import adapter from '@sveltejs/adapter-netlify';
|
|
||||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
// you don't need to do this if you're using generateSW strategy in your app
|
// you don't need to do this if you're using generateSW strategy in your app
|
||||||
import { generateSW } from './pwa.mjs';
|
import { generateSW } from './pwa.mjs';
|
||||||
|
import { adapter } from './adapter.mjs';
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
@@ -10,18 +10,12 @@ const config = {
|
|||||||
preprocess: vitePreprocess(),
|
preprocess: vitePreprocess(),
|
||||||
|
|
||||||
kit: {
|
kit: {
|
||||||
adapter: adapter({
|
// Netlify by default, or the node adapter when NODE_ADAPTER=true. See adapter.mjs.
|
||||||
// if true, will create a Netlify Edge Function rather
|
adapter,
|
||||||
// than using standard Node-based functions
|
|
||||||
edge: false,
|
|
||||||
|
|
||||||
// if true, will split your app into multiple functions
|
|
||||||
// instead of creating a single one for the entire app.
|
|
||||||
// if `edge` is true, this option cannot be used
|
|
||||||
split: false
|
|
||||||
}),
|
|
||||||
serviceWorker: {
|
serviceWorker: {
|
||||||
register: true
|
// VitePWA owns registration. Registering here as well requests SvelteKit's default
|
||||||
|
// /service-worker.js even though the inject-manifest output is /prompt-sw.js.
|
||||||
|
register: false
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
// you don't need to do this if you're using generateSW strategy in your app
|
// you don't need to do this if you're using generateSW strategy in your app
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import { existsSync, readFileSync } from 'node:fs'
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
import { generateSW } from '../pwa.mjs'
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
import { nodeAdapter } from '../adapter.mjs'
|
|
||||||
|
|
||||||
describe(`test-build: ${nodeAdapter ? 'node' : 'static'} adapter`, () => {
|
|
||||||
it(`service worker is generated: ${generateSW ? 'sw.js' : 'prompt-sw.js'}`, () => {
|
|
||||||
const swName = `./build/${nodeAdapter ? 'client/': ''}${generateSW ? 'sw.js' : 'prompt-sw.js'}`
|
|
||||||
expect(existsSync(swName), `${swName} doesn't exist`).toBeTruthy()
|
|
||||||
const webManifest = `./build/${nodeAdapter ? 'client/': ''}manifest.webmanifest`
|
|
||||||
expect(existsSync(webManifest), `${webManifest} doesn't exist`).toBeTruthy()
|
|
||||||
const swContent = readFileSync(swName, 'utf-8')
|
|
||||||
let match: RegExpMatchArray | null
|
|
||||||
if (generateSW) {
|
|
||||||
match = swContent.match(/define\(\['\.\/(workbox-\w+)'/)
|
|
||||||
expect(match && match.length === 2, `workbox-***.js entry not found in ${swName}`).toBeTruthy()
|
|
||||||
const workboxName = `./build/${nodeAdapter ? 'client/': ''}${match?.[1]}.js`
|
|
||||||
expect(existsSync(workboxName),`${workboxName} doesn't exist`).toBeTruthy()
|
|
||||||
}
|
|
||||||
match = swContent.match(/"url":\s*"manifest\.webmanifest"/)
|
|
||||||
expect(match && match.length === 1, 'missing manifest.webmanifest in sw precache manifest').toBeTruthy()
|
|
||||||
match = swContent.match(/"url":\s*"\/"/)
|
|
||||||
expect(match && match.length === 1, 'missing entry point route (/) in sw precache manifest').toBeTruthy()
|
|
||||||
match = swContent.match(/"url":\s*"about"/)
|
|
||||||
expect(match && match.length === 1,'missing about route (/about) in sw precache manifest').toBeTruthy()
|
|
||||||
if (nodeAdapter) {
|
|
||||||
match = swContent.match(/"url":\s*"server\//)
|
|
||||||
expect(match === null, 'found server/ entries in sw precache manifest').toBeTruthy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { createCatchRecordWriteQueue } from '../src/lib/utils/catchRecordWriteQueue';
|
|
||||||
import type { CatchRecord } from '../src/lib/models/CatchRecord';
|
|
||||||
|
|
||||||
function mkRecord(overrides: Partial<CatchRecord> = {}): CatchRecord {
|
|
||||||
return {
|
|
||||||
_id: '',
|
|
||||||
userId: 'u1',
|
|
||||||
pokedexId: 'p1',
|
|
||||||
pokemonId: '25',
|
|
||||||
haveToEvolve: false,
|
|
||||||
caught: false,
|
|
||||||
inHome: false,
|
|
||||||
hasGigantamaxed: false,
|
|
||||||
personalNotes: '',
|
|
||||||
...overrides
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('createCatchRecordWriteQueue()', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('coalesces multiple updates for the same key and flushes only the latest state', async () => {
|
|
||||||
const fetchFn = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
||||||
return new Response(init?.body as string, { status: 200 });
|
|
||||||
});
|
|
||||||
|
|
||||||
const queue = createCatchRecordWriteQueue({
|
|
||||||
endpointUrl: '/api/pokedexes/p1/catch-records',
|
|
||||||
fetchFn,
|
|
||||||
batchSize: 50,
|
|
||||||
concurrency: 1
|
|
||||||
});
|
|
||||||
|
|
||||||
queue.enqueue(mkRecord({ caught: true }));
|
|
||||||
queue.enqueue(mkRecord({ caught: false, haveToEvolve: true }));
|
|
||||||
|
|
||||||
await queue.flushNow();
|
|
||||||
|
|
||||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
|
||||||
const body = JSON.parse(String(fetchFn.mock.calls[0]?.[1]?.body));
|
|
||||||
expect(body).toHaveLength(1);
|
|
||||||
expect(body[0].haveToEvolve).toBe(true);
|
|
||||||
expect(body[0].caught).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('debounces notes updates before flushing', async () => {
|
|
||||||
const fetchFn = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
||||||
return new Response(init?.body as string, { status: 200 });
|
|
||||||
});
|
|
||||||
|
|
||||||
const queue = createCatchRecordWriteQueue({
|
|
||||||
endpointUrl: '/api/pokedexes/p1/catch-records',
|
|
||||||
fetchFn,
|
|
||||||
batchSize: 50,
|
|
||||||
concurrency: 1
|
|
||||||
});
|
|
||||||
|
|
||||||
queue.enqueue(mkRecord({ personalNotes: 'a' }), { debounceMs: 500 });
|
|
||||||
await queue.flushNow();
|
|
||||||
expect(fetchFn).toHaveBeenCalledTimes(0);
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(499);
|
|
||||||
await queue.flushNow();
|
|
||||||
expect(fetchFn).toHaveBeenCalledTimes(0);
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(1);
|
|
||||||
await queue.flushNow();
|
|
||||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
Feature: Account access
|
||||||
|
As a trainer
|
||||||
|
I want secure access to my account
|
||||||
|
So that only I can update my collection
|
||||||
|
|
||||||
|
Scenario: Register a new account
|
||||||
|
Given I am a new visitor
|
||||||
|
When I register with valid account details
|
||||||
|
Then I am told to confirm my email
|
||||||
|
And a confirmation email is captured locally
|
||||||
|
|
||||||
|
Scenario: Sign in with valid credentials
|
||||||
|
Given I have a confirmed account
|
||||||
|
When I sign in with my credentials
|
||||||
|
Then I arrive at my Pokédex list
|
||||||
|
|
||||||
|
Scenario: Reject invalid credentials
|
||||||
|
Given I have a confirmed account
|
||||||
|
When I sign in with an incorrect password
|
||||||
|
Then I see a sign-in error
|
||||||
|
|
||||||
|
Scenario: Redirect an authenticated visitor
|
||||||
|
Given I am signed in
|
||||||
|
When I visit the public home page
|
||||||
|
Then I arrive at my Pokédex list
|
||||||
|
|
||||||
|
Scenario: Sign out
|
||||||
|
Given I am signed in
|
||||||
|
And my offline copy is synchronized
|
||||||
|
When I sign out
|
||||||
|
Then I return to the public home page
|
||||||
|
And my offline copy is removed
|
||||||
|
|
||||||
|
Scenario: Keep the session when sign out fails
|
||||||
|
Given I am signed in
|
||||||
|
And my offline copy is synchronized
|
||||||
|
When the sign-out request fails
|
||||||
|
Then I remain signed in with an error
|
||||||
|
And my offline copy remains
|
||||||
|
|
||||||
|
Scenario: Request a password reset
|
||||||
|
Given I have a confirmed account
|
||||||
|
When I request a password reset
|
||||||
|
Then a password reset email is captured locally
|
||||||
|
|
||||||
|
Scenario: Reject a normal session on the recovery page
|
||||||
|
Given I am signed in
|
||||||
|
When I visit the password recovery page directly
|
||||||
|
Then the replacement password form is unavailable
|
||||||
|
|
||||||
|
@product-review
|
||||||
|
Scenario: Reject mismatched replacement passwords
|
||||||
|
Given I follow a valid password reset link
|
||||||
|
When I enter two different replacement passwords
|
||||||
|
Then I am told that the passwords do not match
|
||||||
|
|
||||||
|
Scenario: Complete a password reset
|
||||||
|
Given I follow a valid password reset link
|
||||||
|
When I enter a valid replacement password
|
||||||
|
Then I am told that my password was updated
|
||||||
|
And only the replacement password signs me in
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
Feature: Backup and export
|
||||||
|
As a trainer
|
||||||
|
I want changes exported to my connected storage
|
||||||
|
So that I retain a portable backup
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given I am signed in
|
||||||
|
|
||||||
|
Scenario: Show disconnected backup providers
|
||||||
|
When I visit backup settings
|
||||||
|
Then Google Drive and Dropbox are shown as not connected
|
||||||
|
|
||||||
|
Scenario Outline: Connect a backup provider
|
||||||
|
When I connect the mocked "<provider>" provider
|
||||||
|
Then "<provider>" is shown as connected
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| provider |
|
||||||
|
| Google Drive |
|
||||||
|
| Dropbox |
|
||||||
|
|
||||||
|
Scenario: Reject an invalid OAuth state
|
||||||
|
When a mocked OAuth callback has an invalid state
|
||||||
|
Then the backup connection is rejected
|
||||||
|
|
||||||
|
Scenario: Export escaped catch data without losing the update
|
||||||
|
Given Google Drive is connected to the mocked provider
|
||||||
|
And I have a Living Dex named "Quoted, Dex"
|
||||||
|
When I save a catch note containing a comma and quote
|
||||||
|
Then the mocked provider receives a valid escaped CSV
|
||||||
|
And the note survives a reload
|
||||||
|
|
||||||
|
Scenario: Refresh an expired provider token
|
||||||
|
Given Dropbox is connected with an expired token
|
||||||
|
When an export is requested
|
||||||
|
Then the token is refreshed before the mocked upload
|
||||||
|
|
||||||
|
Scenario: Record provider failure without losing progress
|
||||||
|
Given Google Drive is connected to a failing mocked provider
|
||||||
|
When I update collection progress
|
||||||
|
Then the catch remains marked caught
|
||||||
|
And the provider failure is shown in backup settings
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
Feature: Pokédex composition
|
||||||
|
As a trainer
|
||||||
|
I want the correct Pokémon in each configured dex
|
||||||
|
So that completion totals are trustworthy
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given I am signed in
|
||||||
|
|
||||||
|
Scenario: Build a national Living Dex from canonical forms
|
||||||
|
Given I have a Living Dex named "National"
|
||||||
|
When I inspect its entries without forms
|
||||||
|
Then it contains 1025 unique species
|
||||||
|
And named default forms are represented once
|
||||||
|
|
||||||
|
Scenario: Include all supported forms
|
||||||
|
Given I have a Form Dex named "Forms"
|
||||||
|
When I inspect its entries with forms
|
||||||
|
Then every entry identity is unique
|
||||||
|
And Basculin has 3 forms
|
||||||
|
And Alcremie has 63 forms
|
||||||
|
And Unown has 28 forms
|
||||||
|
|
||||||
|
Scenario: Render shiny artwork
|
||||||
|
Given I have a Shiny Dex named "Shinies"
|
||||||
|
When I view the Pokédex
|
||||||
|
Then its Pokémon use shiny sprites
|
||||||
|
|
||||||
|
Scenario: Respect game and dex scope
|
||||||
|
Given I have a Form Dex named "Black Forms" scoped to game "Black" and dex "Unova"
|
||||||
|
When I inspect its entries with forms
|
||||||
|
Then Rotom includes its named default form without duplicate forms
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
Feature: Pokédex lifecycle
|
||||||
|
As a trainer
|
||||||
|
I want to configure and manage Pokédexes
|
||||||
|
So that each collection matches my goal
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given I am signed in
|
||||||
|
|
||||||
|
Scenario: See the empty state
|
||||||
|
Given I have no Pokédexes
|
||||||
|
When I visit my Pokédex list
|
||||||
|
Then I see the empty Pokédex message
|
||||||
|
|
||||||
|
Scenario: Validate a new Pokédex
|
||||||
|
When I open the new Pokédex form
|
||||||
|
Then I cannot create a Pokédex without a name and type
|
||||||
|
|
||||||
|
Scenario Outline: Create each supported Pokédex type
|
||||||
|
When I create a Pokédex named "<name>" of type "<type>"
|
||||||
|
Then the Pokédex "<name>" is available to view
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| name | type |
|
||||||
|
| Living | Living Dex |
|
||||||
|
| Shiny | Shiny Dex |
|
||||||
|
| Origin | Origin Dex |
|
||||||
|
| Every Form | Form Dex |
|
||||||
|
|
||||||
|
Scenario: Create a game and dex scoped Pokédex
|
||||||
|
When I create a Living Dex named "Black Regional" scoped to game "Black" and dex "Unova"
|
||||||
|
Then the Pokédex "Black Regional" is available to view
|
||||||
|
|
||||||
|
Scenario: Reject a duplicate name
|
||||||
|
Given I have a Living Dex named "My Collection"
|
||||||
|
When I try to create another Living Dex named "My Collection"
|
||||||
|
Then I am told that the Pokédex name is already used
|
||||||
|
|
||||||
|
Scenario: Edit a Pokédex
|
||||||
|
Given I have a Living Dex named "Before Editing"
|
||||||
|
When I rename it to "After Editing" and enable forms
|
||||||
|
Then the Pokédex "After Editing" is available to view
|
||||||
|
|
||||||
|
Scenario: Cancel deleting a Pokédex
|
||||||
|
Given I have a Living Dex named "Keep Me"
|
||||||
|
When I cancel deleting "Keep Me"
|
||||||
|
Then the Pokédex "Keep Me" is available to view
|
||||||
|
|
||||||
|
Scenario: Delete a Pokédex
|
||||||
|
Given I have a Living Dex named "Delete Me"
|
||||||
|
When I confirm deleting "Delete Me"
|
||||||
|
Then the Pokédex "Delete Me" is no longer listed
|
||||||
|
|
||||||
|
Scenario: Keep another user's Pokédex private
|
||||||
|
Given another trainer has a Pokédex
|
||||||
|
When I request the other trainer's Pokédex
|
||||||
|
Then the Pokédex is not disclosed
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
Feature: Progress tracking
|
||||||
|
As a trainer
|
||||||
|
I want to record collection state
|
||||||
|
So that my Pokédex shows what remains
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given I am signed in
|
||||||
|
And I have a Living Dex named "Progress"
|
||||||
|
And I view the Pokédex
|
||||||
|
|
||||||
|
Scenario: Mark a Pokémon caught
|
||||||
|
When I mark the first Pokémon as caught
|
||||||
|
Then the first Pokémon is shown as caught after reloading
|
||||||
|
|
||||||
|
Scenario: Keep caught and needs-to-evolve mutually exclusive
|
||||||
|
When I mark the first Pokémon as caught
|
||||||
|
And I mark the first Pokémon as needing evolution
|
||||||
|
Then the first Pokémon needs evolution and is not marked caught
|
||||||
|
|
||||||
|
Scenario: Record HOME state and notes
|
||||||
|
When I mark the first Pokémon as in HOME
|
||||||
|
And I add the note "Caught, traded, and checked" to the first Pokémon
|
||||||
|
Then its HOME state and note persist after reloading
|
||||||
|
|
||||||
|
Scenario: Update an entire box
|
||||||
|
When I mark box 1 as caught
|
||||||
|
Then box 1 contains 30 caught Pokémon
|
||||||
|
|
||||||
|
Scenario: Filter collection progress
|
||||||
|
When I mark the first Pokémon as caught
|
||||||
|
And I filter to Pokémon that are not caught
|
||||||
|
Then the caught Pokémon is filtered out
|
||||||
|
|
||||||
|
Scenario: Remember box layout density
|
||||||
|
When I select the "Compact" box layout
|
||||||
|
And I reload the Pokédex
|
||||||
|
Then the "Compact" box layout remains selected
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
Feature: Offline-friendly application
|
||||||
|
As a trainer
|
||||||
|
I want the installed site to survive network loss
|
||||||
|
So that I can consult my collection anywhere
|
||||||
|
|
||||||
|
Scenario: Register the service worker
|
||||||
|
When I open the built application
|
||||||
|
Then a service worker controls the page
|
||||||
|
And the application shell is precached
|
||||||
|
And no legacy service worker is requested
|
||||||
|
|
||||||
|
Scenario: Reload the home page while offline
|
||||||
|
Given I have opened the built application online
|
||||||
|
When I go offline and reload the home page
|
||||||
|
Then the read-only offline viewer is available
|
||||||
|
|
||||||
|
Scenario: Reload a nested route while offline
|
||||||
|
Given I have opened the built application online
|
||||||
|
When I go offline and reload the sign-in page
|
||||||
|
Then the read-only offline viewer is available
|
||||||
|
|
||||||
|
Scenario: Read a synchronized collection offline
|
||||||
|
Given I am signed in
|
||||||
|
And I have a Living Dex named "Offline Collection"
|
||||||
|
And my offline copy is synchronized
|
||||||
|
When I go offline and reload the current Pokédex
|
||||||
|
Then the offline copy contains "Offline Collection"
|
||||||
|
|
||||||
|
Scenario: Restore network access
|
||||||
|
Given I have opened the built application online
|
||||||
|
When I go offline and then return online
|
||||||
|
Then the application remains available
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { test as base } from 'playwright-bdd';
|
||||||
|
|
||||||
|
export type ScenarioState = {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
replacementPassword: string;
|
||||||
|
userId: string | null;
|
||||||
|
pokedexId: string | null;
|
||||||
|
pokedexName: string | null;
|
||||||
|
entries: Array<{ pokemon: string; form: string | null; num: number; id: string }>;
|
||||||
|
lastResponseStatus: number | null;
|
||||||
|
lastMessage: string | null;
|
||||||
|
caughtEntryLabel: string | null;
|
||||||
|
legacyServiceWorkerRequested: boolean;
|
||||||
|
shareUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Fixtures = { state: ScenarioState; providerMock: void };
|
||||||
|
|
||||||
|
const MOCK_URL = process.env.MOCK_PROVIDER_URL ?? 'http://127.0.0.1:4199';
|
||||||
|
|
||||||
|
export const test = base.extend<Fixtures>({
|
||||||
|
/**
|
||||||
|
* The mock provider keeps recorded requests, the refresh counter and the fail-uploads switch
|
||||||
|
* in one process-wide object. Without a reset per scenario, assertions are satisfied by
|
||||||
|
* whatever ran before them - and the failing-upload scenario would poison every later one.
|
||||||
|
*/
|
||||||
|
providerMock: [
|
||||||
|
// eslint-disable-next-line no-empty-pattern
|
||||||
|
async ({}, use) => {
|
||||||
|
const response = await fetch(`${MOCK_URL}/__mock/reset`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Unable to reset the mock provider at ${MOCK_URL}: ${response.status}`);
|
||||||
|
}
|
||||||
|
await use();
|
||||||
|
},
|
||||||
|
{ auto: true }
|
||||||
|
],
|
||||||
|
// Playwright fixture callbacks require the dependency object even when this fixture has none.
|
||||||
|
// eslint-disable-next-line no-empty-pattern
|
||||||
|
state: async ({}, use, testInfo) => {
|
||||||
|
const slug = testInfo.testId
|
||||||
|
.replace(/[^a-z0-9]/gi, '')
|
||||||
|
.slice(-18)
|
||||||
|
.toLowerCase();
|
||||||
|
await use({
|
||||||
|
email: `bdd-${slug}-${Date.now()}@example.test`,
|
||||||
|
password: 'BddPassword123!',
|
||||||
|
replacementPassword: 'BddReplacement456!',
|
||||||
|
userId: null,
|
||||||
|
pokedexId: null,
|
||||||
|
pokedexName: null,
|
||||||
|
entries: [],
|
||||||
|
lastResponseStatus: null,
|
||||||
|
lastMessage: null,
|
||||||
|
caughtEntryLabel: null,
|
||||||
|
legacyServiceWorkerRequested: false,
|
||||||
|
shareUrl: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { expect } from '@playwright/test';
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { requireLoopbackUrl } from '../support/loopback';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the users each BDD run provisions, so repeated local runs do not need a full
|
||||||
|
* `supabase db reset` to stay clean. Pokédexes, catch records and integrations follow via the
|
||||||
|
* schema's cascades. Only the suite's own synthetic addresses are touched.
|
||||||
|
*/
|
||||||
|
const SUPABASE_URL = requireLoopbackUrl(
|
||||||
|
process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321',
|
||||||
|
'TEST_SUPABASE_URL'
|
||||||
|
);
|
||||||
|
const OWNED_EMAIL = /^(bdd|other|integration)-.*@example\.test$/;
|
||||||
|
|
||||||
|
type AdminUser = { id: string; email?: string };
|
||||||
|
|
||||||
|
async function adminRequest(path: string, init: RequestInit = {}) {
|
||||||
|
const key = process.env.E2E_SERVICE_ROLE_KEY;
|
||||||
|
if (!key) return null;
|
||||||
|
return fetch(`${SUPABASE_URL}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
apikey: key,
|
||||||
|
Authorization: `Bearer ${key}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(init.headers ?? {})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function globalTeardown() {
|
||||||
|
const listed = await adminRequest('/auth/v1/admin/users?per_page=1000');
|
||||||
|
if (!listed) {
|
||||||
|
console.warn('Skipping BDD teardown: E2E_SERVICE_ROLE_KEY is not set.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!listed.ok) {
|
||||||
|
console.warn(`Skipping BDD teardown: unable to list users (${listed.status}).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { users = [] } = (await listed.json()) as { users?: AdminUser[] };
|
||||||
|
const disposable = users.filter((user) => user.email && OWNED_EMAIL.test(user.email));
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
for (const user of disposable) {
|
||||||
|
const deleted = await adminRequest(`/auth/v1/admin/users/${user.id}`, { method: 'DELETE' });
|
||||||
|
if (!deleted?.ok) failures++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`BDD teardown removed ${disposable.length - failures} of ${disposable.length} test users.`
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { createBdd } from 'playwright-bdd';
|
||||||
|
import { test, expect } from '../fixtures';
|
||||||
|
import { createConfirmedUser, signIn } from '../support/app';
|
||||||
|
|
||||||
|
const { Given, When, Then } = createBdd(test);
|
||||||
|
const MAILPIT_URL = process.env.TEST_MAILPIT_URL ?? 'http://127.0.0.1:54324';
|
||||||
|
|
||||||
|
async function mailCountFor(email: string, subject: string): Promise<number> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${MAILPIT_URL}/api/v1/search?query=${encodeURIComponent(`to:${email} subject:${subject}`)}`
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error(`MailPit is unavailable: ${response.status}`);
|
||||||
|
const body = (await response.json()) as { total?: number; messages?: unknown[] };
|
||||||
|
return body.total ?? body.messages?.length ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recoveryLinkFromMail(email: string): Promise<string> {
|
||||||
|
for (let attempt = 0; attempt < 50; attempt++) {
|
||||||
|
const search = await fetch(
|
||||||
|
`${MAILPIT_URL}/api/v1/search?query=${encodeURIComponent(`to:${email} subject:Reset`)}`
|
||||||
|
);
|
||||||
|
if (search.ok) {
|
||||||
|
const result = (await search.json()) as { messages?: Array<{ ID?: string; Id?: string }> };
|
||||||
|
const id = result.messages?.[0]?.ID ?? result.messages?.[0]?.Id;
|
||||||
|
if (id) {
|
||||||
|
const response = await fetch(`${MAILPIT_URL}/api/v1/message/${id}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const message = JSON.stringify(await response.json());
|
||||||
|
const match = message.match(/https?:\/\/[^"'<>\s]+\/auth\/v1\/verify[^"'<>\s]+/);
|
||||||
|
if (match) return match[0].replaceAll('&', '&').replaceAll('\\u0026', '&');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
}
|
||||||
|
throw new Error('No password recovery link arrived in MailPit');
|
||||||
|
}
|
||||||
|
|
||||||
|
Given('I am a new visitor', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
Given('I have a confirmed account', async ({ state }) => {
|
||||||
|
await createConfirmedUser(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
Given('I am signed in', async ({ page, state }) => {
|
||||||
|
await createConfirmedUser(state);
|
||||||
|
await signIn(page, state);
|
||||||
|
await expect(page).toHaveURL(/\/my-pokedexes$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
Given('I follow a valid password reset link', async ({ page, state }) => {
|
||||||
|
await createConfirmedUser(state);
|
||||||
|
await page.goto('/forgot-password');
|
||||||
|
await page.getByLabel('Email').fill(state.email);
|
||||||
|
await page.getByRole('button', { name: 'Send Reset Link' }).click();
|
||||||
|
await expect(page.getByText('Check your email for the password reset link')).toBeVisible();
|
||||||
|
const actionLink = await recoveryLinkFromMail(state.email);
|
||||||
|
await page.goto(actionLink);
|
||||||
|
await expect(page).toHaveURL(/\/reset-password/);
|
||||||
|
await expect(page.getByLabel('New Password')).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I register with valid account details', async ({ page, state }) => {
|
||||||
|
await page.getByLabel('Email').fill(state.email);
|
||||||
|
await page.getByLabel('Password').fill(state.password);
|
||||||
|
await page.getByRole('button', { name: 'Sign Up', exact: true }).first().click();
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I sign in with my credentials', async ({ page, state }) => {
|
||||||
|
await signIn(page, state);
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I sign in with an incorrect password', async ({ page, state }) => {
|
||||||
|
await signIn(page, state, `${state.password}-incorrect`);
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I visit the public home page', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I sign out', async ({ page }) => {
|
||||||
|
await page.getByRole('button', { name: 'usericon' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Sign Out', exact: true }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
When('the sign-out request fails', async ({ page }) => {
|
||||||
|
await page.route('**/auth/v1/logout*', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 503,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: '{"message":"unavailable"}'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.getByRole('button', { name: 'usericon' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Sign Out', exact: true }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I request a password reset', async ({ page, state }) => {
|
||||||
|
await page.goto('/forgot-password');
|
||||||
|
await page.getByLabel('Email').fill(state.email);
|
||||||
|
await page.getByRole('button', { name: 'Send Reset Link' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I visit the password recovery page directly', async ({ page }) => {
|
||||||
|
await page.goto('/reset-password?code=arbitrary-code');
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I enter two different replacement passwords', async ({ page, state }) => {
|
||||||
|
await page.getByLabel('New Password').fill(state.replacementPassword);
|
||||||
|
await page.getByLabel('Confirm Password').fill(`${state.replacementPassword}-different`);
|
||||||
|
await page.getByRole('button', { name: 'Update Password' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I enter a valid replacement password', async ({ page, state }) => {
|
||||||
|
await page.getByLabel('New Password').fill(state.replacementPassword);
|
||||||
|
await page.getByLabel('Confirm Password').fill(state.replacementPassword);
|
||||||
|
await page.getByRole('button', { name: 'Update Password' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I am told to confirm my email', async ({ page }) => {
|
||||||
|
await expect(page).toHaveURL(/\/welcome$/);
|
||||||
|
await expect(page.getByText('Check Your Email', { exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('a confirmation email is captured locally', async ({ state }) => {
|
||||||
|
await expect.poll(() => mailCountFor(state.email, 'Confirm')).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I arrive at my Pokédex list', async ({ page }) => {
|
||||||
|
await expect(page).toHaveURL(/\/my-pokedexes$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I see a sign-in error', async ({ page }) => {
|
||||||
|
await expect(page.locator('.alert-error')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I return to the public home page', async ({ page }) => {
|
||||||
|
await expect(page).toHaveURL(/\/$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I remain signed in with an error', async ({ page }) => {
|
||||||
|
await expect(page).toHaveURL(/\/my-pokedexes$/);
|
||||||
|
await expect(page.locator('.alert-error.rounded-none')).toContainText('Sign out failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('a password reset email is captured locally', async ({ page, state }) => {
|
||||||
|
await expect(page.getByText('Check your email for the password reset link')).toBeVisible();
|
||||||
|
await expect.poll(() => mailCountFor(state.email, 'Reset')).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the replacement password form is unavailable', async ({ page }) => {
|
||||||
|
await expect(page.getByLabel('New Password')).toBeDisabled();
|
||||||
|
await expect(page.getByText(/invalid or expired/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I am told that the passwords do not match', async ({ page }) => {
|
||||||
|
await expect(page.getByText('Passwords do not match')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I am told that my password was updated', async ({ page }) => {
|
||||||
|
await expect(page.getByText(/Password updated successfully/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('only the replacement password signs me in', async ({ page, state }) => {
|
||||||
|
await expect(page).toHaveURL(/\/signin$/, { timeout: 10_000 });
|
||||||
|
await signIn(page, state, state.password);
|
||||||
|
await expect(page.locator('.alert-error')).toBeVisible();
|
||||||
|
await page.getByLabel('Password').fill(state.replacementPassword);
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/my-pokedexes$/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { createBdd } from 'playwright-bdd';
|
||||||
|
import { test, expect } from '../fixtures';
|
||||||
|
import { createDexThroughUi, firstPokemon, openFirstPokemon } from '../support/app';
|
||||||
|
import { requireLoopbackUrl } from '../../support/loopback';
|
||||||
|
|
||||||
|
const { Given, When, Then } = createBdd(test);
|
||||||
|
const MOCK_URL = process.env.MOCK_PROVIDER_URL ?? 'http://127.0.0.1:4199';
|
||||||
|
const SUPABASE_URL = requireLoopbackUrl(
|
||||||
|
process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321',
|
||||||
|
'TEST_SUPABASE_URL'
|
||||||
|
);
|
||||||
|
|
||||||
|
type Provider = 'google_drive' | 'dropbox';
|
||||||
|
|
||||||
|
async function seedIntegration(
|
||||||
|
state: import('../fixtures').ScenarioState,
|
||||||
|
provider: Provider,
|
||||||
|
overrides: Record<string, unknown> = {}
|
||||||
|
) {
|
||||||
|
const key = process.env.E2E_SERVICE_ROLE_KEY;
|
||||||
|
if (!key || !state.userId)
|
||||||
|
throw new Error('A confirmed user and E2E_SERVICE_ROLE_KEY are required');
|
||||||
|
const response = await fetch(`${SUPABASE_URL}/rest/v1/pokedex_export_integrations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
apikey: key,
|
||||||
|
Authorization: `Bearer ${key}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'resolution=merge-duplicates,return=representation'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
userId: state.userId,
|
||||||
|
pokedexId: null,
|
||||||
|
provider,
|
||||||
|
enabled: true,
|
||||||
|
accessToken: 'mock-access-token',
|
||||||
|
refreshToken: 'mock-refresh-token',
|
||||||
|
accessTokenExpiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
||||||
|
...overrides
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureExportDex(
|
||||||
|
page: import('@playwright/test').Page,
|
||||||
|
state: import('../fixtures').ScenarioState
|
||||||
|
) {
|
||||||
|
if (!state.pokedexId) {
|
||||||
|
await createDexThroughUi(page, state, {
|
||||||
|
name: state.pokedexName ?? `Export ${Date.now()}`,
|
||||||
|
type: 'Living Dex'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Given('Google Drive is connected to the mocked provider', async ({ state }) => {
|
||||||
|
await seedIntegration(state, 'google_drive');
|
||||||
|
});
|
||||||
|
|
||||||
|
Given('Dropbox is connected with an expired token', async ({ page, state }) => {
|
||||||
|
await seedIntegration(state, 'dropbox', {
|
||||||
|
accessTokenExpiresAt: new Date(Date.now() - 60_000).toISOString()
|
||||||
|
});
|
||||||
|
await ensureExportDex(page, state);
|
||||||
|
});
|
||||||
|
|
||||||
|
Given('Google Drive is connected to a failing mocked provider', async ({ page, state }) => {
|
||||||
|
await seedIntegration(state, 'google_drive');
|
||||||
|
await fetch(`${MOCK_URL}/__mock/fail-uploads`);
|
||||||
|
await ensureExportDex(page, state);
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I visit backup settings', async ({ page }) => {
|
||||||
|
await page.goto('/backup-settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I connect the mocked {string} provider', async ({ page }, provider: string) => {
|
||||||
|
await page.goto('/backup-settings');
|
||||||
|
const card = page.locator('.card, .border').filter({ hasText: provider }).last();
|
||||||
|
await card.getByRole('button', { name: 'Connect' }).click();
|
||||||
|
await page.waitForURL(/\/backup-settings\?export=.*-connected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
When('a mocked OAuth callback has an invalid state', async ({ page, state }) => {
|
||||||
|
const response = await page.request.get(
|
||||||
|
'/api/integrations/google-drive/callback?code=mock&state=invalid-state'
|
||||||
|
);
|
||||||
|
state.lastResponseStatus = response.status();
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I save a catch note containing a comma and quote', async ({ page, state }) => {
|
||||||
|
await ensureExportDex(page, state);
|
||||||
|
await page.goto(`/pokedex/${state.pokedexId}`);
|
||||||
|
await openFirstPokemon(page);
|
||||||
|
await page.getByRole('dialog').getByLabel('Notes:').fill('A comma, and a "quote"');
|
||||||
|
await page.getByRole('dialog').getByLabel('Notes:').blur();
|
||||||
|
await expect(page.getByText(/Saving…/)).toHaveCount(0, { timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
When('an export is requested', async ({ page, state }) => {
|
||||||
|
const response = await page.request.post(`/api/pokedexes/${state.pokedexId}/export`);
|
||||||
|
state.lastResponseStatus = response.status();
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I update collection progress', async ({ page, state }) => {
|
||||||
|
await page.goto(`/pokedex/${state.pokedexId}`);
|
||||||
|
await openFirstPokemon(page);
|
||||||
|
await page
|
||||||
|
.getByRole('dialog')
|
||||||
|
.getByText('Caught:', { exact: true })
|
||||||
|
.locator('..')
|
||||||
|
.getByRole('checkbox')
|
||||||
|
.check();
|
||||||
|
await expect(page.getByText(/Saving…/)).toHaveCount(0, { timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('Google Drive and Dropbox are shown as not connected', async ({ page }) => {
|
||||||
|
await expect(page.getByText('Not Connected', { exact: true })).toHaveCount(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('{string} is shown as connected', async ({ page }, provider: string) => {
|
||||||
|
const card = page.locator('.border').filter({ hasText: provider });
|
||||||
|
await expect(card.getByText('Connected', { exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the backup connection is rejected', async ({ state }) => {
|
||||||
|
expect(state.lastResponseStatus).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function mockState() {
|
||||||
|
const response = await fetch(`${MOCK_URL}/__mock/state`);
|
||||||
|
if (!response.ok) throw new Error(`Mock provider is unavailable: ${response.status}`);
|
||||||
|
return (await response.json()) as {
|
||||||
|
refreshes: number;
|
||||||
|
requests: Array<{ method: string; path: string; body: string }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Then('the mocked provider receives a valid escaped CSV', async () => {
|
||||||
|
const { requests } = await mockState();
|
||||||
|
const upload = requests.find((request) => request.path.includes('upload'));
|
||||||
|
expect(upload, 'no upload reached the mock provider').toBeDefined();
|
||||||
|
expect(upload!.body).toContain('"A comma, and a ""quote"""');
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the note survives a reload', async ({ page, state }) => {
|
||||||
|
await page.goto(`/pokedex/${state.pokedexId}`);
|
||||||
|
await expect(firstPokemon(page)).toBeVisible();
|
||||||
|
await openFirstPokemon(page);
|
||||||
|
await expect(page.getByRole('dialog').getByLabel('Notes:')).toHaveValue('A comma, and a "quote"');
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the catch remains marked caught', async ({ page, state }) => {
|
||||||
|
await page.goto(`/pokedex/${state.pokedexId}`);
|
||||||
|
await expect(firstPokemon(page)).toBeVisible();
|
||||||
|
await openFirstPokemon(page);
|
||||||
|
const caught = page
|
||||||
|
.getByRole('dialog')
|
||||||
|
.getByText('Caught:', { exact: true })
|
||||||
|
.locator('..')
|
||||||
|
.getByRole('checkbox');
|
||||||
|
await expect(caught).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the token is refreshed before the mocked upload', async ({ state }) => {
|
||||||
|
expect(state.lastResponseStatus).toBe(200);
|
||||||
|
const { refreshes, requests } = await mockState();
|
||||||
|
// Exactly one refresh, and it has to come before the upload it was needed for - a global
|
||||||
|
// ">= 1" would be satisfied by any earlier scenario's traffic.
|
||||||
|
expect(refreshes).toBe(1);
|
||||||
|
const refreshIndex = requests.findIndex(
|
||||||
|
(request) =>
|
||||||
|
request.path.endsWith('/token') && request.body.includes('grant_type=refresh_token')
|
||||||
|
);
|
||||||
|
const uploadIndex = requests.findIndex((request) => request.path.includes('upload'));
|
||||||
|
expect(refreshIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(uploadIndex).toBeGreaterThan(refreshIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the provider failure is shown in backup settings', async ({ page }) => {
|
||||||
|
await page.goto('/backup-settings');
|
||||||
|
await expect(page.getByText(/mock upload failure/)).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { createBdd } from 'playwright-bdd';
|
||||||
|
import { test, expect } from '../fixtures';
|
||||||
|
import { loadEntries } from '../support/app';
|
||||||
|
|
||||||
|
const { When, Then } = createBdd(test);
|
||||||
|
|
||||||
|
When('I inspect its entries without forms', async ({ page, state }) => {
|
||||||
|
await loadEntries(page, state, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I inspect its entries with forms', async ({ page, state }) => {
|
||||||
|
await loadEntries(page, state, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I view the Pokédex', async ({ page, state }) => {
|
||||||
|
if (!state.pokedexId) throw new Error('A Pokédex must exist before it can be viewed');
|
||||||
|
await page.goto(`/pokedex/${state.pokedexId}`);
|
||||||
|
await expect(page.getByRole('button', { name: /^View details for / }).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('it contains {int} unique species', async ({ state }, count: number) => {
|
||||||
|
expect(state.entries).toHaveLength(count);
|
||||||
|
expect(new Set(state.entries.map((entry) => entry.pokemon)).size).toBe(count);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('named default forms are represented once', async ({ state }) => {
|
||||||
|
for (const [pokemon, form] of Object.entries({
|
||||||
|
Basculin: 'Red-striped',
|
||||||
|
Tornadus: 'Incarnate Form',
|
||||||
|
Oricorio: 'Baile (Red)',
|
||||||
|
Zygarde: '50%',
|
||||||
|
Gimmighoul: 'Box Form',
|
||||||
|
Rotom: 'Lightbulb',
|
||||||
|
Unown: 'A'
|
||||||
|
})) {
|
||||||
|
expect(state.entries.filter((entry) => entry.pokemon === pokemon)).toEqual([
|
||||||
|
expect.objectContaining({ form })
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('every entry identity is unique', async ({ state }) => {
|
||||||
|
const identities = state.entries.map((entry) => `${entry.pokemon}|${entry.form ?? ''}`);
|
||||||
|
expect(new Set(identities).size).toBe(identities.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('{word} has {int} forms', async ({ state }, pokemon: string, count: number) => {
|
||||||
|
expect(state.entries.filter((entry) => entry.pokemon === pokemon)).toHaveLength(count);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('its Pokémon use shiny sprites', async ({ page }) => {
|
||||||
|
const first = page.locator('img[src*="/shiny/"]').first();
|
||||||
|
for (let attempts = 0; attempts < 10 && (await first.count()) === 0; attempts++) {
|
||||||
|
await page.mouse.wheel(0, 2500);
|
||||||
|
}
|
||||||
|
await expect(first).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('Rotom includes its named default form without duplicate forms', async ({ state }) => {
|
||||||
|
const forms = state.entries
|
||||||
|
.filter((entry) => entry.pokemon === 'Rotom')
|
||||||
|
.map((entry) => entry.form ?? '');
|
||||||
|
expect(forms).toContain('Lightbulb');
|
||||||
|
expect(new Set(forms).size).toBe(forms.length);
|
||||||
|
});
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { createBdd } from 'playwright-bdd';
|
||||||
|
import { test, expect } from '../fixtures';
|
||||||
|
import { createDexThroughUi, deleteAllPokedexes } from '../support/app';
|
||||||
|
import { requireLoopbackUrl } from '../../support/loopback';
|
||||||
|
|
||||||
|
const { Given, When, Then } = createBdd(test);
|
||||||
|
|
||||||
|
async function dexCard(page: Parameters<typeof createDexThroughUi>[0], name: string) {
|
||||||
|
await page.goto('/my-pokedexes');
|
||||||
|
const card = page.locator('.card').filter({ hasText: name }).first();
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Establishes the precondition rather than asserting it - a fresh user happens to be empty,
|
||||||
|
// which would make an assertion here pass without testing anything.
|
||||||
|
Given('I have no Pokédexes', async ({ state }) => {
|
||||||
|
await deleteAllPokedexes(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
Given('I have a Living Dex named {string}', async ({ page, state }, name: string) => {
|
||||||
|
await createDexThroughUi(page, state, { name, type: 'Living Dex' });
|
||||||
|
});
|
||||||
|
|
||||||
|
Given('I have a Form Dex named {string}', async ({ page, state }, name: string) => {
|
||||||
|
await createDexThroughUi(page, state, { name, type: 'Form Dex' });
|
||||||
|
});
|
||||||
|
|
||||||
|
Given(
|
||||||
|
'I have a Form Dex named {string} scoped to game {string} and dex {string}',
|
||||||
|
async ({ page, state }, name: string, game: string, dex: string) => {
|
||||||
|
await createDexThroughUi(page, state, { name, type: 'Form Dex', game, dex });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Given('I have a Shiny Dex named {string}', async ({ page, state }, name: string) => {
|
||||||
|
await createDexThroughUi(page, state, { name, type: 'Shiny Dex' });
|
||||||
|
});
|
||||||
|
|
||||||
|
Given('another trainer has a Pokédex', async ({ state }) => {
|
||||||
|
const url = requireLoopbackUrl(
|
||||||
|
process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321',
|
||||||
|
'TEST_SUPABASE_URL'
|
||||||
|
);
|
||||||
|
const key = process.env.E2E_SERVICE_ROLE_KEY;
|
||||||
|
if (!key) throw new Error('E2E_SERVICE_ROLE_KEY is required');
|
||||||
|
const headers = {
|
||||||
|
apikey: key,
|
||||||
|
Authorization: `Bearer ${key}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
const userResponse = await fetch(`${url}/auth/v1/admin/users`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: `other-${Date.now()}@example.test`,
|
||||||
|
password: 'OtherPassword123!',
|
||||||
|
email_confirm: true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const other = (await userResponse.json()) as { id: string };
|
||||||
|
const dexResponse = await fetch(`${url}/rest/v1/pokedexes`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...headers, Prefer: 'return=representation' },
|
||||||
|
body: JSON.stringify({ userId: other.id, name: 'Private', isLivingDex: true })
|
||||||
|
});
|
||||||
|
if (!dexResponse.ok) throw new Error(await dexResponse.text());
|
||||||
|
const [dex] = (await dexResponse.json()) as Array<{ id: string }>;
|
||||||
|
state.pokedexId = dex.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I visit my Pokédex list', async ({ page }) => {
|
||||||
|
await page.goto('/my-pokedexes');
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I open the new Pokédex form', async ({ page }) => {
|
||||||
|
await page.goto('/my-pokedexes');
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: /Create (New|Your First) Pokédex/ })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I create a Pokédex named {string} of type {string}', async ({ page, state }, name, type) => {
|
||||||
|
await createDexThroughUi(page, state, { name, type });
|
||||||
|
});
|
||||||
|
|
||||||
|
When(
|
||||||
|
'I create a Living Dex named {string} scoped to game {string} and dex {string}',
|
||||||
|
async ({ page, state }, name: string, game: string, dex: string) => {
|
||||||
|
await createDexThroughUi(page, state, { name, type: 'Living Dex', game, dex });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
When('I try to create another Living Dex named {string}', async ({ page, state }, name: string) => {
|
||||||
|
await page.goto('/my-pokedexes');
|
||||||
|
await page.getByRole('button', { name: 'Create New Pokédex', exact: true }).click();
|
||||||
|
const modal = page.locator('.modal-open');
|
||||||
|
await modal.getByLabel('Name').fill(name);
|
||||||
|
await modal.getByText('Living Dex', { exact: false }).locator('..').getByRole('checkbox').check();
|
||||||
|
page.once('dialog', async (dialog) => {
|
||||||
|
state.lastMessage = dialog.message();
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
await modal.getByRole('button', { name: 'Create', exact: true }).click();
|
||||||
|
await expect.poll(() => state.lastMessage).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I rename it to {string} and enable forms', async ({ page }, name: string) => {
|
||||||
|
const card = await dexCard(page, 'Before Editing');
|
||||||
|
await card.getByRole('button', { name: 'Edit' }).click();
|
||||||
|
const modal = page.locator('.modal-open');
|
||||||
|
await modal.getByLabel('Name').fill(name);
|
||||||
|
await modal.getByText('Form Dex', { exact: false }).locator('..').getByRole('checkbox').check();
|
||||||
|
const response = page.waitForResponse(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.url().includes('/api/pokedexes/') && candidate.request().method() === 'PUT'
|
||||||
|
);
|
||||||
|
await modal.getByRole('button', { name: 'Save', exact: true }).click();
|
||||||
|
expect((await response).ok()).toBe(true);
|
||||||
|
await expect(modal).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I cancel deleting {string}', async ({ page }, name: string) => {
|
||||||
|
const card = await dexCard(page, name);
|
||||||
|
page.once('dialog', (dialog) => dialog.dismiss());
|
||||||
|
await card.getByRole('button', { name: 'Delete' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I confirm deleting {string}', async ({ page }, name: string) => {
|
||||||
|
const card = await dexCard(page, name);
|
||||||
|
page.once('dialog', (dialog) => dialog.accept());
|
||||||
|
const response = page.waitForResponse(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.url().includes('/api/pokedexes/') && candidate.request().method() === 'DELETE'
|
||||||
|
);
|
||||||
|
await card.getByRole('button', { name: 'Delete' }).click();
|
||||||
|
expect((await response).ok()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I request the other trainer's Pokédex", async ({ page, state }) => {
|
||||||
|
const response = await page.request.get(`/api/pokedexes/${state.pokedexId}`);
|
||||||
|
state.lastResponseStatus = response.status();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I see the empty Pokédex message', async ({ page }) => {
|
||||||
|
await expect(page.getByText("You haven't created any pokédexes yet!")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I cannot create a Pokédex without a name and type', async ({ page }) => {
|
||||||
|
await expect(page.getByRole('button', { name: 'Create', exact: true })).toBeDisabled();
|
||||||
|
await expect(page.getByText('At least one type required')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the Pokédex {string} is available to view', async ({ page }, name: string) => {
|
||||||
|
await expect((await dexCard(page, name)).getByRole('button', { name: 'View' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I am told that the Pokédex name is already used', async ({ state }) => {
|
||||||
|
expect(state.lastMessage).toMatch(/already have a Pokédex named/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the Pokédex {string} is no longer listed', async ({ page }, name: string) => {
|
||||||
|
await page.goto('/my-pokedexes');
|
||||||
|
await expect(page.locator('.card').filter({ hasText: name })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the Pokédex is not disclosed', async ({ state }) => {
|
||||||
|
expect(state.lastResponseStatus).toBe(404);
|
||||||
|
});
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { createBdd } from 'playwright-bdd';
|
||||||
|
import { test, expect } from '../fixtures';
|
||||||
|
import { firstPokemon, openFirstPokemon } from '../support/app';
|
||||||
|
|
||||||
|
const { When, Then } = createBdd(test);
|
||||||
|
|
||||||
|
async function ensurePokemonModal(page: Parameters<typeof firstPokemon>[0]) {
|
||||||
|
if ((await page.getByRole('dialog').count()) === 0) await openFirstPokemon(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistCatchChange(
|
||||||
|
page: Parameters<typeof firstPokemon>[0],
|
||||||
|
action: () => Promise<void>
|
||||||
|
) {
|
||||||
|
const persistence = page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
new URL(response.url()).pathname.endsWith('/catch-records')
|
||||||
|
);
|
||||||
|
await action();
|
||||||
|
const response = await persistence;
|
||||||
|
expect(response.ok(), `catch-record persistence failed: ${response.status()}`).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settleAndReload(page: Parameters<typeof firstPokemon>[0]) {
|
||||||
|
await expect(page.getByText(/Saving…/)).toHaveCount(0, { timeout: 15_000 });
|
||||||
|
await page.reload({ waitUntil: 'networkidle' });
|
||||||
|
await expect(firstPokemon(page)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
When('I mark the first Pokémon as caught', async ({ page }) => {
|
||||||
|
await ensurePokemonModal(page);
|
||||||
|
const checkbox = page
|
||||||
|
.getByRole('dialog')
|
||||||
|
.getByText('Caught:', { exact: true })
|
||||||
|
.locator('..')
|
||||||
|
.getByRole('checkbox');
|
||||||
|
await persistCatchChange(page, () => checkbox.check());
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I mark the first Pokémon as needing evolution', async ({ page }) => {
|
||||||
|
await ensurePokemonModal(page);
|
||||||
|
const checkbox = page
|
||||||
|
.getByRole('dialog')
|
||||||
|
.getByText('Needs to evolve:', { exact: true })
|
||||||
|
.locator('..')
|
||||||
|
.getByRole('checkbox');
|
||||||
|
await persistCatchChange(page, () => checkbox.check());
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I mark the first Pokémon as in HOME', async ({ page }) => {
|
||||||
|
await ensurePokemonModal(page);
|
||||||
|
const checkbox = page
|
||||||
|
.getByRole('dialog')
|
||||||
|
.getByText('In Home:', { exact: true })
|
||||||
|
.locator('..')
|
||||||
|
.getByRole('checkbox');
|
||||||
|
await persistCatchChange(page, () => checkbox.check());
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I add the note {string} to the first Pokémon', async ({ page }, note: string) => {
|
||||||
|
await ensurePokemonModal(page);
|
||||||
|
await persistCatchChange(page, async () => {
|
||||||
|
await page.getByRole('dialog').getByLabel('Notes:').fill(note);
|
||||||
|
await page.getByRole('dialog').getByLabel('Notes:').blur();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function boxContainer(page: Parameters<typeof firstPokemon>[0], box: number) {
|
||||||
|
return page
|
||||||
|
.getByRole('heading', { name: `Box ${box}`, exact: true })
|
||||||
|
.locator('..')
|
||||||
|
.locator('..');
|
||||||
|
}
|
||||||
|
|
||||||
|
When('I mark box {int} as caught', async ({ page }, box: number) => {
|
||||||
|
const container = boxContainer(page, box);
|
||||||
|
await container.getByRole('button', { name: 'Open bulk actions menu' }).click();
|
||||||
|
await persistCatchChange(page, () =>
|
||||||
|
container.getByRole('button', { name: 'Mark box as Caught' }).click()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I filter to Pokémon that are not caught', async ({ page, state }) => {
|
||||||
|
if ((await page.getByRole('dialog').count()) > 0) {
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click();
|
||||||
|
}
|
||||||
|
// Remember which entry was caught, so the assertion can name it rather than trusting
|
||||||
|
// whichever entry happens to be first after filtering.
|
||||||
|
state.caughtEntryLabel = await firstPokemon(page).getAttribute('aria-label');
|
||||||
|
expect(state.caughtEntryLabel).toMatch(/Status: Caught/);
|
||||||
|
await page.getByText('Not caught', { exact: true }).locator('..').getByRole('checkbox').check();
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I select the {string} box layout', async ({ page }, layout: string) => {
|
||||||
|
await page.getByLabel('Choose box view layout density').selectOption(layout.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I reload the Pokédex', async ({ page }) => {
|
||||||
|
await settleAndReload(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the first Pokémon is shown as caught after reloading', async ({ page }) => {
|
||||||
|
await settleAndReload(page);
|
||||||
|
await expect(firstPokemon(page)).toHaveAttribute('aria-label', /Status: Caught/);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the first Pokémon needs evolution and is not marked caught', async ({ page }) => {
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click();
|
||||||
|
await settleAndReload(page);
|
||||||
|
await expect(firstPokemon(page)).toHaveAttribute('aria-label', /Needs to evolve/);
|
||||||
|
await expect(firstPokemon(page)).not.toHaveAttribute('aria-label', /Status: Caught(?:,|$)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('its HOME state and note persist after reloading', async ({ page }) => {
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click();
|
||||||
|
await settleAndReload(page);
|
||||||
|
await expect(firstPokemon(page)).toHaveAttribute('aria-label', /In HOME/);
|
||||||
|
await openFirstPokemon(page);
|
||||||
|
await expect(page.getByRole('dialog').getByLabel('Notes:')).toHaveValue(
|
||||||
|
'Caught, traded, and checked'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('box {int} contains {int} caught Pokémon', async ({ page }, box: number, count: number) => {
|
||||||
|
await settleAndReload(page);
|
||||||
|
const entries = boxContainer(page, box).getByRole('button', { name: /^View details for / });
|
||||||
|
await expect(entries).toHaveCount(count);
|
||||||
|
const labels = await entries.evaluateAll((elements) =>
|
||||||
|
elements.map((element) => element.getAttribute('aria-label') ?? '')
|
||||||
|
);
|
||||||
|
expect(labels.filter((label) => /Status: Caught/.test(label))).toHaveLength(count);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the caught Pokémon is filtered out', async ({ page, state }) => {
|
||||||
|
const label = state.caughtEntryLabel;
|
||||||
|
if (!label) throw new Error('No caught entry was recorded before filtering');
|
||||||
|
// That specific entry is excluded...
|
||||||
|
await expect(page.getByRole('button', { name: label, exact: true })).toHaveAttribute(
|
||||||
|
'aria-disabled',
|
||||||
|
'true'
|
||||||
|
);
|
||||||
|
// ...and the filter did not simply exclude everything.
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByRole('button', { name: /^View details for / })
|
||||||
|
.and(page.locator('[aria-disabled="false"]'))
|
||||||
|
.first()
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the {string} box layout remains selected', async ({ page }, layout: string) => {
|
||||||
|
await expect(page.getByLabel('Choose box view layout density')).toHaveValue(layout.toLowerCase());
|
||||||
|
});
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { createBdd } from 'playwright-bdd';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { test, expect } from '../fixtures';
|
||||||
|
|
||||||
|
const { Given, When, Then } = createBdd(test);
|
||||||
|
|
||||||
|
async function waitForServiceWorker(page: Page) {
|
||||||
|
return page.evaluate(async () => {
|
||||||
|
if (!('serviceWorker' in navigator)) throw new Error('Service workers are not supported');
|
||||||
|
const registration = await Promise.race([
|
||||||
|
navigator.serviceWorker.ready,
|
||||||
|
new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('Service worker registration timed out')), 15_000)
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
return registration.active?.scriptURL ?? null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cacheContents(page: Page) {
|
||||||
|
return page.evaluate(async () => {
|
||||||
|
const contents: Record<string, string[]> = {};
|
||||||
|
for (const name of await caches.keys()) {
|
||||||
|
const cache = await caches.open(name);
|
||||||
|
contents[name] = (await cache.keys()).map((request) => request.url);
|
||||||
|
}
|
||||||
|
return contents;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordLegacyWorkerRequest(page: Page, state: { legacyServiceWorkerRequested: boolean }) {
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (new URL(request.url()).pathname === '/service-worker.js') {
|
||||||
|
state.legacyServiceWorkerRequested = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
When('I open the built application', async ({ page, state }) => {
|
||||||
|
recordLegacyWorkerRequest(page, state);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForServiceWorker(page);
|
||||||
|
if (!(await page.evaluate(() => !!navigator.serviceWorker.controller))) {
|
||||||
|
await page.reload();
|
||||||
|
await waitForServiceWorker(page);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Given('I have opened the built application online', async ({ page, state }) => {
|
||||||
|
recordLegacyWorkerRequest(page, state);
|
||||||
|
await page.context().setOffline(false);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForServiceWorker(page);
|
||||||
|
await page.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I go offline and reload the home page', async ({ page }) => {
|
||||||
|
await page.context().setOffline(true);
|
||||||
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I go offline and reload the sign-in page', async ({ page }) => {
|
||||||
|
await page.goto('/signin');
|
||||||
|
await page.context().setOffline(true);
|
||||||
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||||
|
});
|
||||||
|
|
||||||
|
Given('my offline copy is synchronized', async ({ page, state }) => {
|
||||||
|
await waitForServiceWorker(page);
|
||||||
|
// A full Living Dex snapshot plus its artwork can take longer than the default poll window on CI.
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() =>
|
||||||
|
page.evaluate(async (userId) => {
|
||||||
|
const meta = await (
|
||||||
|
await caches.open('livingdex-offline-meta-v1')
|
||||||
|
).match('/__offline/current');
|
||||||
|
if (!meta) return false;
|
||||||
|
const value = await meta.json();
|
||||||
|
return value.userId === userId;
|
||||||
|
}, state.userId),
|
||||||
|
{ timeout: 30_000 }
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
const serializedSnapshot = await page.evaluate(async () => {
|
||||||
|
const metaResponse = await (
|
||||||
|
await caches.open('livingdex-offline-meta-v1')
|
||||||
|
).match('/__offline/current');
|
||||||
|
if (!metaResponse) throw new Error('Offline snapshot metadata was not cached');
|
||||||
|
const meta = await metaResponse.json();
|
||||||
|
const snapshotResponse = await (
|
||||||
|
await caches.open(meta.dataCache)
|
||||||
|
).match(`/__offline/snapshot/${encodeURIComponent(meta.userId)}`);
|
||||||
|
if (!snapshotResponse) throw new Error('Offline snapshot payload was not cached');
|
||||||
|
return snapshotResponse.text();
|
||||||
|
});
|
||||||
|
expect(serializedSnapshot).not.toMatch(/access_token|refresh_token/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I go offline and reload the current Pokédex', async ({ page, state }) => {
|
||||||
|
await page.context().setOffline(true);
|
||||||
|
await page.goto(`/pokedex/${state.pokedexId}/offline`, {
|
||||||
|
waitUntil: 'domcontentloaded'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I go offline and then return online', async ({ page }) => {
|
||||||
|
await page.context().setOffline(true);
|
||||||
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||||
|
await page.context().setOffline(false);
|
||||||
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('a service worker controls the page', async ({ page }) => {
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => navigator.serviceWorker.controller?.scriptURL ?? null))
|
||||||
|
.toMatch(/\/(?:sw|prompt-sw)\.js$/);
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() =>
|
||||||
|
navigator.serviceWorker.getRegistrations().then((items) => items.length)
|
||||||
|
)
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('no legacy service worker is requested', async ({ state }) => {
|
||||||
|
expect(state.legacyServiceWorkerRequested).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the deleted client-test/sw.spec.ts assertions. The precise entries differ by build
|
||||||
|
* strategy, so this checks the contract that matters: one workbox precache, the app shell and
|
||||||
|
* web manifest are in it, and `_app/immutable` assets are cached WITHOUT a revision query -
|
||||||
|
* that last one is the `dontCacheBustURLsMatching` behaviour, and it silently regresses.
|
||||||
|
*/
|
||||||
|
Then('the application shell is precached', async ({ page }) => {
|
||||||
|
const contents = await cacheContents(page);
|
||||||
|
const names = Object.keys(contents).filter((name) => name.startsWith('workbox-precache'));
|
||||||
|
expect(names).toHaveLength(1);
|
||||||
|
|
||||||
|
const origin = new URL(page.url()).origin;
|
||||||
|
const urls = contents[names[0]].map((url) => url.slice(`${origin}/`.length));
|
||||||
|
|
||||||
|
expect(urls, 'personalized SSR root must not be precached').not.toContain('');
|
||||||
|
expect(
|
||||||
|
urls.some((url) => /^offline(?:\.html)?(?:\?__WB_REVISION__=|$)/.test(url)),
|
||||||
|
'offline viewer is not precached'
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
urls.some((url) => url.startsWith('manifest.webmanifest?__WB_REVISION__=')),
|
||||||
|
'revisioned manifest.webmanifest is not precached'
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
urls.some((url) => url.startsWith('_app/version.json?__WB_REVISION__=')),
|
||||||
|
'revisioned _app/version.json is not precached'
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const immutable = urls.filter((url) => url.startsWith('_app/immutable/'));
|
||||||
|
expect(immutable.some((url) => url.endsWith('.css'))).toBe(true);
|
||||||
|
expect(immutable.some((url) => url.endsWith('.js'))).toBe(true);
|
||||||
|
expect(
|
||||||
|
immutable.filter((url) => url.includes('__WB_REVISION__')),
|
||||||
|
'immutable assets must not be cache-busted'
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the application remains available', async ({ page }) => {
|
||||||
|
await expect(page.getByRole('heading', { name: /Start Your Pokédex Journey/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the read-only offline viewer is available', async ({ page }) => {
|
||||||
|
await expect(page.getByText(/offline.*read-only/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the offline copy contains {string}', async ({ page }, name: string) => {
|
||||||
|
await expect(page.getByRole('heading', { name })).toBeVisible();
|
||||||
|
await expect(page.getByText(/read-only copy/i)).toBeVisible();
|
||||||
|
await expect(page.locator('button, input, textarea, select')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('my offline copy is removed', async ({ page }) => {
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(async () => {
|
||||||
|
const names = await caches.keys();
|
||||||
|
return names.some((name) => name.startsWith('livingdex-offline-'));
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('my offline copy remains', async ({ page, state }) => {
|
||||||
|
const owner = await page.evaluate(async () => {
|
||||||
|
const meta = await (await caches.open('livingdex-offline-meta-v1')).match('/__offline/current');
|
||||||
|
return meta ? (await meta.json()).userId : null;
|
||||||
|
});
|
||||||
|
expect(owner).toBe(state.userId);
|
||||||
|
});
|
||||||
@@ -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,130 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import type { ScenarioState } from '../fixtures';
|
||||||
|
import { requireLoopbackUrl } from '../../support/loopback';
|
||||||
|
|
||||||
|
const SUPABASE_URL = requireLoopbackUrl(
|
||||||
|
process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321',
|
||||||
|
'TEST_SUPABASE_URL'
|
||||||
|
);
|
||||||
|
const SERVICE_ROLE_KEY = process.env.E2E_SERVICE_ROLE_KEY;
|
||||||
|
|
||||||
|
function requireServiceRoleKey(): string {
|
||||||
|
if (!SERVICE_ROLE_KEY) {
|
||||||
|
throw new Error('E2E_SERVICE_ROLE_KEY is required. Run BDD through "npm run test:bdd".');
|
||||||
|
}
|
||||||
|
return SERVICE_ROLE_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createConfirmedUser(state: ScenarioState): Promise<void> {
|
||||||
|
if (state.userId) return;
|
||||||
|
const key = requireServiceRoleKey();
|
||||||
|
const response = await fetch(`${SUPABASE_URL}/auth/v1/admin/users`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
apikey: key,
|
||||||
|
Authorization: `Bearer ${key}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: state.email,
|
||||||
|
password: state.password,
|
||||||
|
email_confirm: true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`Unable to create BDD user: ${response.status} ${await response.text()}`);
|
||||||
|
const body = (await response.json()) as { id: string };
|
||||||
|
state.userId = body.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAllPokedexes(state: ScenarioState): Promise<void> {
|
||||||
|
const key = requireServiceRoleKey();
|
||||||
|
if (!state.userId) throw new Error('A confirmed user is required before clearing Pokédexes');
|
||||||
|
const response = await fetch(
|
||||||
|
`${SUPABASE_URL}/rest/v1/pokedexes?userId=eq.${encodeURIComponent(state.userId)}`,
|
||||||
|
{ method: 'DELETE', headers: { apikey: key, Authorization: `Bearer ${key}` } }
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error(`Unable to clear Pokédexes: ${await response.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signIn(page: Page, state: ScenarioState, password = state.password) {
|
||||||
|
await page.goto('/signin');
|
||||||
|
await page.getByLabel('Email').fill(state.email);
|
||||||
|
await page.getByLabel('Password').fill(password);
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDexThroughUi(
|
||||||
|
page: Page,
|
||||||
|
state: ScenarioState,
|
||||||
|
options: { name: string; type: string; game?: string; dex?: string }
|
||||||
|
) {
|
||||||
|
await page.goto('/my-pokedexes');
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: /Create (New|Your First) Pokédex/ })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await page.getByLabel('Name').fill(options.name);
|
||||||
|
await page.getByText(options.type, { exact: false }).locator('..').getByRole('checkbox').check();
|
||||||
|
if (options.game) {
|
||||||
|
await page.getByLabel('Game Scope').selectOption({ label: options.game });
|
||||||
|
if (options.dex) {
|
||||||
|
const checkbox = page
|
||||||
|
.getByText(options.dex, { exact: false })
|
||||||
|
.locator('..')
|
||||||
|
.getByRole('checkbox');
|
||||||
|
if (!(await checkbox.isChecked())) await checkbox.check();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.locator('.modal-open').getByRole('button', { name: 'Create', exact: true }).click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
state.pokedexName = options.name;
|
||||||
|
if (page.url().includes('/pokedex/')) {
|
||||||
|
state.pokedexId = page.url().split('/pokedex/')[1].split(/[?#]/)[0];
|
||||||
|
} else {
|
||||||
|
const card = page.locator('.card').filter({ hasText: options.name }).first();
|
||||||
|
await card.getByRole('button', { name: 'View', exact: true }).click();
|
||||||
|
await page.waitForURL('**/pokedex/**');
|
||||||
|
state.pokedexId = page.url().split('/pokedex/')[1].split(/[?#]/)[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadEntries(page: Page, state: ScenarioState, forms: boolean) {
|
||||||
|
if (!state.pokedexId) throw new Error('A Pokédex must be created before loading entries');
|
||||||
|
const result = await page.evaluate(
|
||||||
|
async ([id, enableForms]) => {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/pokedexes/${id}/combined-data?page=1&limit=9999&enableForms=${enableForms}`
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error(await response.text());
|
||||||
|
const body = await response.json();
|
||||||
|
return body.combinedData.map(
|
||||||
|
(row: {
|
||||||
|
pokedexEntry: {
|
||||||
|
_id: string;
|
||||||
|
pokemon: string;
|
||||||
|
form: string | null;
|
||||||
|
pokedexNumber: number;
|
||||||
|
};
|
||||||
|
}) => ({
|
||||||
|
id: row.pokedexEntry._id,
|
||||||
|
pokemon: row.pokedexEntry.pokemon,
|
||||||
|
form: row.pokedexEntry.form,
|
||||||
|
num: row.pokedexEntry.pokedexNumber
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[state.pokedexId, String(forms)]
|
||||||
|
);
|
||||||
|
state.entries = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function firstPokemon(page: Page) {
|
||||||
|
return page.getByRole('button', { name: /^View details for / }).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openFirstPokemon(page: Page) {
|
||||||
|
const pokemon = firstPokemon(page);
|
||||||
|
await pokemon.click();
|
||||||
|
await page.locator('.modal-open, [role="dialog"]').first().waitFor({ state: 'visible' });
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
import { generateSW } from '../../pwa.mjs';
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
import { nodeAdapter } from '../../adapter.mjs';
|
||||||
|
|
||||||
|
describe(`test-build: ${nodeAdapter ? 'node' : 'static'} adapter`, () => {
|
||||||
|
it(`service worker is generated: ${generateSW ? 'sw.js' : 'prompt-sw.js'}`, () => {
|
||||||
|
const swName = `./build/${nodeAdapter ? 'client/' : ''}${generateSW ? 'sw.js' : 'prompt-sw.js'}`;
|
||||||
|
expect(existsSync(swName), `${swName} doesn't exist`).toBeTruthy();
|
||||||
|
const webManifest = `./build/${nodeAdapter ? 'client/' : ''}manifest.webmanifest`;
|
||||||
|
expect(existsSync(webManifest), `${webManifest} doesn't exist`).toBeTruthy();
|
||||||
|
const swContent = readFileSync(swName, 'utf-8');
|
||||||
|
let match: RegExpMatchArray | null;
|
||||||
|
if (generateSW) {
|
||||||
|
match = swContent.match(/define\(\['\.\/(workbox-\w+)'/);
|
||||||
|
expect(
|
||||||
|
match && match.length === 2,
|
||||||
|
`workbox-***.js entry not found in ${swName}`
|
||||||
|
).toBeTruthy();
|
||||||
|
const workboxName = `./build/${nodeAdapter ? 'client/' : ''}${match?.[1]}.js`;
|
||||||
|
expect(existsSync(workboxName), `${workboxName} doesn't exist`).toBeTruthy();
|
||||||
|
}
|
||||||
|
match = swContent.match(/"url":\s*"manifest\.webmanifest"/);
|
||||||
|
expect(
|
||||||
|
match && match.length === 1,
|
||||||
|
'missing manifest.webmanifest in sw precache manifest'
|
||||||
|
).toBeTruthy();
|
||||||
|
match = swContent.match(/"?url"?:\s*"\/?offline(?:\.html)?"/);
|
||||||
|
expect(
|
||||||
|
match && match.length === 1,
|
||||||
|
'missing credential-free offline entry point in sw precache manifest'
|
||||||
|
).toBeTruthy();
|
||||||
|
const outputRoot = `./build/${nodeAdapter ? 'client/' : ''}`;
|
||||||
|
expect(existsSync(`${outputRoot}offline.html`)).toBe(true);
|
||||||
|
expect(existsSync(`${outputRoot}offline-worker.js`)).toBe(true);
|
||||||
|
expect(existsSync(`${outputRoot}service-worker.js`)).toBe(false);
|
||||||
|
expect(swContent).not.toMatch(/"?url"?:\s*"\/"/);
|
||||||
|
if (nodeAdapter) {
|
||||||
|
match = swContent.match(/"url":\s*"server\//);
|
||||||
|
expect(match === null, 'found server/ entries in sw precache manifest').toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { normalizedIdentity, readRepoCsv } from '../support/csv';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Correctness checks for the Pokemon reference data itself - not the code that queries it.
|
||||||
|
*
|
||||||
|
* Everything here is offline and unconditional: it cross-checks `data/csvs/pokemon.csv`
|
||||||
|
* against `static/sprites/pokeapi-pokemon.csv`, a tracked export of PokeAPI's `pokemon`
|
||||||
|
* table (`id` matches this project's spriteKey convention, `species_id` is the national dex
|
||||||
|
* number, `is_default` is PokeAPI's canonical default-variety flag).
|
||||||
|
*
|
||||||
|
* `defaultForm.integration.test.ts` asserts the CSV still mirrors the database, so these
|
||||||
|
* checks cannot quietly drift away from what the app actually serves. The canonical named
|
||||||
|
* forms are test input rather than being parsed from the fix migration, which lets this
|
||||||
|
* suite load on the pre-fix commit and fail only on observable data differences.
|
||||||
|
*/
|
||||||
|
type ApiRow = { id: string; identifier: string; species_id: string; is_default: string };
|
||||||
|
|
||||||
|
const EXPECTED_NATIONAL_DEX_MAX = 1025;
|
||||||
|
const pokemon = readRepoCsv('data/csvs/pokemon.csv');
|
||||||
|
const apiRows = readRepoCsv('static/sprites/pokeapi-pokemon.csv') as unknown as ApiRow[];
|
||||||
|
const api = new Map(apiRows.map((r) => [r.id, r]));
|
||||||
|
|
||||||
|
const canonicalNamedDefaults = {
|
||||||
|
Alcremie: 'Vanilla Strawberry',
|
||||||
|
Basculin: 'Red-striped',
|
||||||
|
Beautifly: 'male',
|
||||||
|
Burmy: 'Leaf Cloak',
|
||||||
|
Deerling: 'Spring',
|
||||||
|
Dudunsparce: '2-Segment',
|
||||||
|
Enamorus: 'Incarnate Form',
|
||||||
|
Flabébé: 'Red',
|
||||||
|
Floette: 'Red',
|
||||||
|
Florges: 'Red',
|
||||||
|
Gastrodon: 'West Sea',
|
||||||
|
Gimmighoul: 'Box Form',
|
||||||
|
Gourgeist: 'Medium',
|
||||||
|
Gulpin: 'male',
|
||||||
|
Hoopa: 'Confined',
|
||||||
|
Landorus: 'Incarnate Form',
|
||||||
|
Lycanroc: 'Midday',
|
||||||
|
Maushold: 'Family of 4',
|
||||||
|
Minior: 'Red',
|
||||||
|
Oricorio: 'Baile (Red)',
|
||||||
|
Poltchageist: 'Phony',
|
||||||
|
Polteageist: 'Phony',
|
||||||
|
Pumpkaboo: 'Medium',
|
||||||
|
Sawsbuck: 'Spring',
|
||||||
|
Shaymin: 'Normal Form',
|
||||||
|
Shellos: 'West Sea',
|
||||||
|
Sinistcha: 'Phony',
|
||||||
|
Sinistea: 'Phony',
|
||||||
|
Squawkabilly: 'Green',
|
||||||
|
Swalot: 'male',
|
||||||
|
Tatsugiri: 'Curly',
|
||||||
|
Thundurus: 'Incarnate Form',
|
||||||
|
Tornadus: 'Incarnate Form',
|
||||||
|
Toxtricity: 'Amped',
|
||||||
|
Unown: 'A',
|
||||||
|
Urshifu: 'Single',
|
||||||
|
Vivillon: 'Meadow (France-Alsace) [Not Ultra Sun compatible]',
|
||||||
|
Wormadam: 'Leaf Cloak',
|
||||||
|
Zygarde: '50%',
|
||||||
|
Rotom: 'Lightbulb'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const declaredDefaults = Object.entries(canonicalNamedDefaults).map(([pokemon, form]) => ({
|
||||||
|
pokemon,
|
||||||
|
form
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PokeAPI's default variety for Minior is `minior-red-meteor` (the shielded Meteor Form),
|
||||||
|
* which this dataset doesn't track because it isn't separately catchable - a caught Minior
|
||||||
|
* always resolves to a core colour. Red is the conventional stand-in.
|
||||||
|
*/
|
||||||
|
const DEFAULT_FORM_EXCEPTIONS = new Set(['Minior']);
|
||||||
|
|
||||||
|
describe('pokemon.csv structure', () => {
|
||||||
|
it('covers every national dex number from 1 through the declared maximum', () => {
|
||||||
|
const numbers = [...new Set(pokemon.map((row) => Number(row.pokedexNumber)))].sort(
|
||||||
|
(a, b) => a - b
|
||||||
|
);
|
||||||
|
expect(numbers).toEqual(
|
||||||
|
Array.from({ length: EXPECTED_NATIONAL_DEX_MAX }, (_, index) => index + 1)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps each number to one species and each species to one number', () => {
|
||||||
|
const speciesByNumber = new Map<number, Set<string>>();
|
||||||
|
const numbersBySpecies = new Map<string, Set<number>>();
|
||||||
|
for (const row of pokemon) {
|
||||||
|
const number = Number(row.pokedexNumber);
|
||||||
|
if (!speciesByNumber.has(number)) speciesByNumber.set(number, new Set());
|
||||||
|
if (!numbersBySpecies.has(row.pokemon)) numbersBySpecies.set(row.pokemon, new Set());
|
||||||
|
speciesByNumber.get(number)?.add(row.pokemon);
|
||||||
|
numbersBySpecies.get(row.pokemon)?.add(number);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect([...speciesByNumber].filter(([, species]) => species.size !== 1)).toEqual([]);
|
||||||
|
expect([...numbersBySpecies].filter(([, numbers]) => numbers.size !== 1)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no duplicate species/form rows', () => {
|
||||||
|
const seen = new Map<string, number>();
|
||||||
|
for (const r of pokemon) {
|
||||||
|
const key = normalizedIdentity(r.pokemon, r.form);
|
||||||
|
seen.set(key, (seen.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
expect([...seen.entries()].filter(([, n]) => n > 1)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no duplicate national-number/form rows', () => {
|
||||||
|
const seen = new Map<string, number>();
|
||||||
|
for (const row of pokemon) {
|
||||||
|
const key = normalizedIdentity(row.pokedexNumber, row.form);
|
||||||
|
seen.set(key, (seen.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
expect([...seen.entries()].filter(([, count]) => count > 1)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has valid required fields on every row', () => {
|
||||||
|
const invalid = pokemon.filter(
|
||||||
|
(row) =>
|
||||||
|
!Number.isInteger(Number(row.pokedexNumber)) ||
|
||||||
|
Number(row.pokedexNumber) <= 0 ||
|
||||||
|
!row.pokemon ||
|
||||||
|
!row.originRegionToCatchIn ||
|
||||||
|
!row.originGamesToCatchIn
|
||||||
|
);
|
||||||
|
expect(invalid).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives every row a sprite key', () => {
|
||||||
|
expect(pokemon.filter((r) => !r.spriteKey)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pokemon.csv agrees with the PokeAPI reference export', () => {
|
||||||
|
it('matches PokeAPI on the national dex number behind every numeric sprite key', () => {
|
||||||
|
// Wyrdeer was seeded at 999 with sprite 999 - Gimmighoul's - so it sorted into the
|
||||||
|
// wrong dex slot and rendered the wrong artwork. This is the check that catches that.
|
||||||
|
const mismatches = pokemon
|
||||||
|
.filter((r) => /^\d+$/.test(r.spriteKey) && api.has(r.spriteKey))
|
||||||
|
.filter((r) => api.get(r.spriteKey)!.species_id !== r.pokedexNumber)
|
||||||
|
.map((r) => `${r.pokemon} ${r.form} sprite=${r.spriteKey} dex=${r.pokedexNumber}`);
|
||||||
|
|
||||||
|
expect(mismatches).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves every numeric sprite key to a real PokeAPI entry', () => {
|
||||||
|
const unresolved = pokemon
|
||||||
|
.filter((r) => /^\d+$/.test(r.spriteKey) && !api.has(r.spriteKey))
|
||||||
|
.map((r) => `${r.pokemon} ${r.form} -> ${r.spriteKey}`);
|
||||||
|
|
||||||
|
expect(unresolved).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('declared default forms', () => {
|
||||||
|
const bySpeciesForm = new Map(pokemon.map((r) => [`${r.pokemon}|${r.form}`, r]));
|
||||||
|
|
||||||
|
it('names a row that actually exists', () => {
|
||||||
|
// Mirrors the migration's own assertion, but fails in CI rather than only on deploy.
|
||||||
|
const missing = declaredDefaults
|
||||||
|
.filter((d) => !bySpeciesForm.has(`${d.pokemon}|${d.form}`))
|
||||||
|
.map((d) => `${d.pokemon} '${d.form}'`);
|
||||||
|
|
||||||
|
expect(missing).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks the form PokeAPI marks as the default variety', () => {
|
||||||
|
// Independently validates the judgement calls - Zygarde 50% over 10%, Basculin
|
||||||
|
// Red-striped, Toxtricity Amped, Urshifu Single Strike and the rest.
|
||||||
|
const disagreements = declaredDefaults
|
||||||
|
.filter((d) => !DEFAULT_FORM_EXCEPTIONS.has(d.pokemon))
|
||||||
|
.map((d) => ({ d, row: bySpeciesForm.get(`${d.pokemon}|${d.form}`) }))
|
||||||
|
.filter(({ row }) => row && /^\d+$/.test(row.spriteKey) && api.has(row.spriteKey))
|
||||||
|
.filter(({ row }) => api.get(row!.spriteKey)!.is_default !== '1')
|
||||||
|
.map(
|
||||||
|
({ d, row }) =>
|
||||||
|
`${d.pokemon} '${d.form}' -> ${api.get(row!.spriteKey)!.identifier} (is_default=0)`
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(disagreements).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks a meaningful number of picks rather than silently resolving none', () => {
|
||||||
|
// Guards the test above: form-suffixed sprite keys (666-meadow, 201-a, ...) have no
|
||||||
|
// PokeAPI equivalent, so if the join broke entirely this would still pass vacuously.
|
||||||
|
const verifiable = declaredDefaults
|
||||||
|
.filter((d) => !DEFAULT_FORM_EXCEPTIONS.has(d.pokemon))
|
||||||
|
.map((d) => bySpeciesForm.get(`${d.pokemon}|${d.form}`))
|
||||||
|
.filter((row) => row && /^\d+$/.test(row.spriteKey) && api.has(row.spriteKey));
|
||||||
|
|
||||||
|
expect(verifiable.length).toBeGreaterThanOrEqual(28);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('default-form coverage', () => {
|
||||||
|
it('declares a default for every species whose forms are all named', () => {
|
||||||
|
// The original bug: Basculin's forms are all named, so nothing matched `form IS NULL`
|
||||||
|
// and it disappeared from every non-form dex. Any future species added the same way
|
||||||
|
// must be given an explicit default here.
|
||||||
|
const bySpecies = new Map<string, Record<string, string>[]>();
|
||||||
|
for (const r of pokemon) {
|
||||||
|
bySpecies.set(r.pokemon, [...(bySpecies.get(r.pokemon) ?? []), r]);
|
||||||
|
}
|
||||||
|
const declared = new Set(declaredDefaults.map((d) => d.pokemon));
|
||||||
|
|
||||||
|
const undeclared = [...bySpecies.entries()]
|
||||||
|
.filter(([species, rows]) => rows.every((r) => r.form !== '') && !declared.has(species))
|
||||||
|
.map(([species, rows]) => `${species} (forms: ${rows.map((r) => r.form).join(', ')})`);
|
||||||
|
|
||||||
|
expect(undeclared).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('declares at most one default per species', () => {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const d of declaredDefaults) counts.set(d.pokemon, (counts.get(d.pokemon) ?? 0) + 1);
|
||||||
|
expect([...counts.entries()].filter(([, n]) => n > 1)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { normalizedIdentity, readRepoCsv } from '../support/csv';
|
||||||
|
|
||||||
|
const pokemon = readRepoCsv('data/csvs/pokemon.csv');
|
||||||
|
const games = readRepoCsv('data/csvs/games.csv');
|
||||||
|
const regions = readRepoCsv('data/csvs/regions.csv');
|
||||||
|
const dexes = readRepoCsv('data/csvs/game-dexes.csv');
|
||||||
|
|
||||||
|
function duplicates(values: string[]) {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
|
||||||
|
return [...counts].filter(([, count]) => count > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('reference-data relationships', () => {
|
||||||
|
it('uses unique, valid region and game identities', () => {
|
||||||
|
expect(duplicates(regions.map((row) => normalizedIdentity(row.region)))).toEqual([]);
|
||||||
|
expect(duplicates(games.map((row) => normalizedIdentity(row.game)))).toEqual([]);
|
||||||
|
const knownRegions = new Set(regions.map((row) => row.region));
|
||||||
|
expect(games.filter((row) => !knownRegions.has(row.region))).toEqual([]);
|
||||||
|
expect(
|
||||||
|
games.filter(
|
||||||
|
(row) => !Number.isInteger(Number(row.releaseYear)) || Number(row.releaseYear) < 1996
|
||||||
|
)
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses unique dex ids and references known games and tracked files', () => {
|
||||||
|
expect(duplicates(dexes.map((row) => normalizedIdentity(row.dexId)))).toEqual([]);
|
||||||
|
const knownGames = new Set(games.map((row) => row.game));
|
||||||
|
expect(dexes.filter((row) => !knownGames.has(row.gameDisplayName))).toEqual([]);
|
||||||
|
expect(dexes.filter((row) => !existsSync(resolve('data/csvs', row.file)))).toEqual([]);
|
||||||
|
const knownDexes = new Set(dexes.map((row) => row.dexId));
|
||||||
|
expect(dexes.filter((row) => row.parentDexId && !knownDexes.has(row.parentDexId))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps Pokémon origin references valid', () => {
|
||||||
|
const knownRegions = new Set(regions.map((row) => row.region));
|
||||||
|
const knownGames = new Set(games.map((row) => row.game));
|
||||||
|
expect(pokemon.filter((row) => !knownRegions.has(row.originRegionToCatchIn))).toEqual([]);
|
||||||
|
const unknownGames = pokemon.flatMap((row) =>
|
||||||
|
row.originGamesToCatchIn
|
||||||
|
.split('/')
|
||||||
|
.map((game) => game.trim())
|
||||||
|
.filter((game) => game && !knownGames.has(game))
|
||||||
|
.map((game) => `${row.pokemon}: ${game}`)
|
||||||
|
);
|
||||||
|
expect(unknownGames).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves every native-dex member to a unique Pokémon identity', () => {
|
||||||
|
const identities = new Set(pokemon.map((row) => normalizedIdentity(row.pokemon, row.form)));
|
||||||
|
const species = new Set(pokemon.map((row) => normalizedIdentity(row.pokemon)));
|
||||||
|
const failures: string[] = [];
|
||||||
|
for (const file of new Set(dexes.map((row) => row.file))) {
|
||||||
|
const rows = readRepoCsv(`data/csvs/${file}`);
|
||||||
|
const memberIdentities = rows.map((row) => normalizedIdentity(row.pokemon, row.form));
|
||||||
|
for (const duplicate of duplicates(memberIdentities))
|
||||||
|
failures.push(`${file}: duplicate ${duplicate[0]}`);
|
||||||
|
for (const row of rows) {
|
||||||
|
const resolves = row.form
|
||||||
|
? identities.has(normalizedIdentity(row.pokemon, row.form))
|
||||||
|
: species.has(normalizedIdentity(row.pokemon));
|
||||||
|
if (!resolves) {
|
||||||
|
failures.push(`${file}: unknown ${row.pokemon}|${row.form}`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
row.dexNumber &&
|
||||||
|
(!Number.isInteger(Number(row.dexNumber)) || Number(row.dexNumber) < 0)
|
||||||
|
) {
|
||||||
|
failures.push(`${file}: invalid dex number ${row.dexNumber}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(failures).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has a tracked normal sprite for every declared sprite key', () => {
|
||||||
|
const missing = pokemon
|
||||||
|
.filter((row) => {
|
||||||
|
const folder = /^female\b/i.test(row.form) ? 'female' : '';
|
||||||
|
return !existsSync(resolve('static/sprites-small/home', folder, `${row.spriteKey}.webp`));
|
||||||
|
})
|
||||||
|
.map((row) => `${row.pokemon}|${row.form} -> ${row.spriteKey}`);
|
||||||
|
expect(missing).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
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('database integrity and ownership', () => {
|
||||||
|
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('enforces catch-record uniqueness and cascades records when a Pokédex is deleted', async () => {
|
||||||
|
const admin = createClient(url, serviceKey!);
|
||||||
|
const email = `integration-cascade-${Date.now()}@example.test`;
|
||||||
|
const { data: created, error: userError } = await admin.auth.admin.createUser({
|
||||||
|
email,
|
||||||
|
password: 'Integration123!',
|
||||||
|
email_confirm: true
|
||||||
|
});
|
||||||
|
expect(userError).toBeNull();
|
||||||
|
const userId = created.user!.id;
|
||||||
|
createdUserIds.push(userId);
|
||||||
|
const { data: dex, error: dexError } = await admin
|
||||||
|
.from('pokedexes')
|
||||||
|
.insert({ userId, name: 'Cascade', isLivingDex: true })
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
expect(dexError).toBeNull();
|
||||||
|
const { data: pokemon } = await admin
|
||||||
|
.from('pokemon')
|
||||||
|
.select('id')
|
||||||
|
.order('id')
|
||||||
|
.limit(1)
|
||||||
|
.single();
|
||||||
|
const record = { userId, pokedexId: dex!.id, pokemonId: pokemon!.id, caught: true };
|
||||||
|
expect((await admin.from('catch_records').insert(record)).error).toBeNull();
|
||||||
|
expect((await admin.from('catch_records').insert(record)).error?.code).toBe('23505');
|
||||||
|
|
||||||
|
expect((await admin.from('pokedexes').delete().eq('id', dex!.id)).error).toBeNull();
|
||||||
|
const { count } = await admin
|
||||||
|
.from('catch_records')
|
||||||
|
.select('*', { count: 'exact', head: true })
|
||||||
|
.eq('pokedexId', dex!.id);
|
||||||
|
expect(count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not disclose another user's Pokédex through row-level security", async () => {
|
||||||
|
const admin = createClient(url, serviceKey!);
|
||||||
|
const stamp = Date.now();
|
||||||
|
const password = 'Integration123!';
|
||||||
|
const firstEmail = `integration-owner-${stamp}@example.test`;
|
||||||
|
const secondEmail = `integration-other-${stamp}@example.test`;
|
||||||
|
const first = await admin.auth.admin.createUser({
|
||||||
|
email: firstEmail,
|
||||||
|
password,
|
||||||
|
email_confirm: true
|
||||||
|
});
|
||||||
|
const second = await admin.auth.admin.createUser({
|
||||||
|
email: secondEmail,
|
||||||
|
password,
|
||||||
|
email_confirm: true
|
||||||
|
});
|
||||||
|
expect(first.error).toBeNull();
|
||||||
|
expect(second.error).toBeNull();
|
||||||
|
createdUserIds.push(first.data.user!.id, second.data.user!.id);
|
||||||
|
const { data: dex } = await admin
|
||||||
|
.from('pokedexes')
|
||||||
|
.insert({ userId: first.data.user!.id, name: 'Owner only', isLivingDex: true })
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
const other = createClient(url, anonKey!);
|
||||||
|
expect(
|
||||||
|
(await other.auth.signInWithPassword({ email: secondEmail, password })).error
|
||||||
|
).toBeNull();
|
||||||
|
const { data, error } = await other.from('pokedexes').select('id').eq('id', dex!.id);
|
||||||
|
expect(error).toBeNull();
|
||||||
|
expect(data).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps mapping membership unique for each Pokédex', async () => {
|
||||||
|
const admin = createClient(url, serviceKey!);
|
||||||
|
const email = `integration-mapping-${Date.now()}@example.test`;
|
||||||
|
const created = await admin.auth.admin.createUser({
|
||||||
|
email,
|
||||||
|
password: 'Integration123!',
|
||||||
|
email_confirm: true
|
||||||
|
});
|
||||||
|
createdUserIds.push(created.data.user!.id);
|
||||||
|
const { data: dex } = await admin
|
||||||
|
.from('pokedexes')
|
||||||
|
.insert({ userId: created.data.user!.id, name: 'Unique mapping', isLivingDex: true })
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
const { data: pokemon } = await admin
|
||||||
|
.from('pokemon')
|
||||||
|
.select('id')
|
||||||
|
.order('id')
|
||||||
|
.limit(1)
|
||||||
|
.single();
|
||||||
|
const mapping = { pokedexId: dex!.id, pokemonId: pokemon!.id };
|
||||||
|
expect((await admin.from('pokedex_pokemon_mapping').insert(mapping)).error).toBeNull();
|
||||||
|
expect((await admin.from('pokedex_pokemon_mapping').insert(mapping)).error?.code).toBe('23505');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects mapping rows that reference missing records', async () => {
|
||||||
|
const admin = createClient(url, serviceKey!);
|
||||||
|
const { error } = await admin.from('pokedex_pokemon_mapping').insert({
|
||||||
|
pokedexId: '00000000-0000-0000-0000-000000000000',
|
||||||
|
pokemonId: -1
|
||||||
|
});
|
||||||
|
expect(error?.code).toBe('23503');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { describe, it, expect, beforeAll } from 'vitest';
|
||||||
|
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
|
||||||
|
import { readRepoCsv } from '../support/csv';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Black-box data and repository regressions. These tests deliberately use only the schema
|
||||||
|
* available before the fix: on master they load normally and fail on behavior, while the
|
||||||
|
* fix branch makes the same assertions pass without test-only schema knowledge.
|
||||||
|
*/
|
||||||
|
const SUPABASE_URL = process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321';
|
||||||
|
const SETUP_HINT =
|
||||||
|
'Run integration tests through "npm run test:integration", which reads the local keys from "supabase status".';
|
||||||
|
|
||||||
|
// No baked-in key: a fallback would silently point a misconfigured run at the wrong stack
|
||||||
|
// instead of failing with the instruction above.
|
||||||
|
function requireAnonKey(): string {
|
||||||
|
const key = process.env.TEST_SUPABASE_ANON_KEY;
|
||||||
|
if (!key) throw new Error(`Integration tests require TEST_SUPABASE_ANON_KEY. ${SETUP_HINT}`);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireSupabase() {
|
||||||
|
const key = requireAnonKey();
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${SUPABASE_URL}/rest/v1/pokedex_entries?select=id&limit=1`, {
|
||||||
|
headers: { apikey: key, Authorization: `Bearer ${key}` },
|
||||||
|
signal: AbortSignal.timeout(2000)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Local Supabase is required for integration tests at ${SUPABASE_URL}. Run "npm run supabase:start" and "npm run supabase:reset" first. ${String(error)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
id: number;
|
||||||
|
pokedexNumber: number;
|
||||||
|
pokemon: string;
|
||||||
|
form: string | null;
|
||||||
|
spriteKey: string | null;
|
||||||
|
isDefaultForm: boolean;
|
||||||
|
notes: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('pokedex behavior regressions', () => {
|
||||||
|
let supabase: SupabaseClient;
|
||||||
|
let rows: Row[];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await requireSupabase();
|
||||||
|
supabase = createClient(SUPABASE_URL, requireAnonKey());
|
||||||
|
const all: Row[] = [];
|
||||||
|
for (let from = 0; ; from += 1000) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('pokedex_entries')
|
||||||
|
.select('id, pokedexNumber, pokemon, form, spriteKey, isDefaultForm, notes')
|
||||||
|
.order('id', { ascending: true })
|
||||||
|
.range(from, from + 999);
|
||||||
|
if (error) throw new Error(error.message);
|
||||||
|
if (!data?.length) break;
|
||||||
|
all.push(...(data as Row[]));
|
||||||
|
if (data.length < 1000) break;
|
||||||
|
}
|
||||||
|
rows = all;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns one canonical representative for species whose forms are all named', async () => {
|
||||||
|
const repo = new CombinedDataRepository(supabase, null, null);
|
||||||
|
const entries = (await repo.findAllCombinedData('', false)).map((item) => item.pokedexEntry);
|
||||||
|
|
||||||
|
for (const [species, form] of Object.entries({
|
||||||
|
Basculin: 'Red-striped',
|
||||||
|
Tornadus: 'Incarnate Form',
|
||||||
|
Oricorio: 'Baile (Red)',
|
||||||
|
Zygarde: '50%',
|
||||||
|
Gimmighoul: 'Box Form',
|
||||||
|
Rotom: 'Lightbulb'
|
||||||
|
})) {
|
||||||
|
const found = entries.filter((entry) => entry.pokemon === species);
|
||||||
|
expect(found, `${species} should appear exactly once`).toHaveLength(1);
|
||||||
|
expect(found[0].form).toBe(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(entries.filter((entry) => entry.pokemon === 'Beautifly').map((e) => e.form)).toEqual([
|
||||||
|
'male'
|
||||||
|
]);
|
||||||
|
expect(entries.filter((entry) => entry.pokemon === 'Unown').map((e) => e.form)).toEqual(['A']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns every alternate form exactly once when forms are enabled', async () => {
|
||||||
|
const repo = new CombinedDataRepository(supabase, null, null);
|
||||||
|
const entries = (await repo.findAllCombinedData('', true)).map((item) => item.pokedexEntry);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
entries
|
||||||
|
.filter((entry) => entry.pokemon === 'Basculin')
|
||||||
|
.map((entry) => entry.form)
|
||||||
|
.sort()
|
||||||
|
).toEqual(['Blue-striped', 'Red-striped', 'White-striped']);
|
||||||
|
expect(entries.filter((entry) => entry.pokemon === 'Alcremie')).toHaveLength(63);
|
||||||
|
expect(entries.filter((entry) => entry.pokemon === 'Unown')).toHaveLength(28);
|
||||||
|
expect(new Set(entries.map((entry) => entry._id)).size).toBe(entries.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps named default forms in a game-scoped form dex without duplicates', async () => {
|
||||||
|
const repo = new CombinedDataRepository(supabase, null, null);
|
||||||
|
const rotom = (await repo.findAllCombinedData('', true, '', 'Black', ['black-unova']))
|
||||||
|
.map((item) => item.pokedexEntry)
|
||||||
|
.filter((entry) => entry.pokemon === 'Rotom');
|
||||||
|
|
||||||
|
expect(rotom.map((entry) => entry.form)).toContain('Lightbulb');
|
||||||
|
expect(rotom).toHaveLength(6);
|
||||||
|
expect(new Set(rotom.map((entry) => entry._id)).size).toBe(rotom.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the correct national dex numbers and sprite for corrected rows', () => {
|
||||||
|
const wyrdeer = rows.find((row) => row.pokemon === 'Wyrdeer');
|
||||||
|
expect(wyrdeer).toMatchObject({ pokedexNumber: 899, spriteKey: '899' });
|
||||||
|
expect(rows.find((row) => row.pokemon === 'Gimmighoul')?.pokedexNumber).toBe(999);
|
||||||
|
expect(
|
||||||
|
rows.find((row) => row.pokemon === 'Ursaluna' && row.form === 'Bloodmoon')?.pokedexNumber
|
||||||
|
).toBe(901);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns every expected entry despite the PostgREST row cap', async () => {
|
||||||
|
const { calculateExpectedEntries } = await import('$lib/services/PokedexMappingService');
|
||||||
|
const baseDex = {
|
||||||
|
id: 'test',
|
||||||
|
name: 'test',
|
||||||
|
isLivingDex: true,
|
||||||
|
isShinyDex: false,
|
||||||
|
isOriginDex: false,
|
||||||
|
isFormDex: false,
|
||||||
|
gameScope: null,
|
||||||
|
dexScopes: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseIds = await calculateExpectedEntries(supabase, baseDex as never);
|
||||||
|
const formIds = await calculateExpectedEntries(supabase, {
|
||||||
|
...baseDex,
|
||||||
|
isFormDex: true
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(baseIds).toHaveLength(1025);
|
||||||
|
expect(new Set(baseIds).size).toBe(baseIds.length);
|
||||||
|
expect(formIds).toHaveLength(rows.length);
|
||||||
|
expect(new Set(formIds).size).toBe(formIds.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the tracked CSV synchronized with the database', () => {
|
||||||
|
const fromCsv = new Map(
|
||||||
|
readRepoCsv('data/csvs/pokemon.csv').map((row) => [
|
||||||
|
`${row.pokemon}|${row.form}`,
|
||||||
|
row.pokedexNumber
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const fromDb = new Map(
|
||||||
|
rows.map((row) => [`${row.pokemon}|${row.form ?? ''}`, String(row.pokedexNumber)])
|
||||||
|
);
|
||||||
|
|
||||||
|
expect({
|
||||||
|
onlyInCsv: [...fromCsv.keys()].filter((key) => !fromDb.has(key)),
|
||||||
|
onlyInDb: [...fromDb.keys()].filter((key) => !fromCsv.has(key)),
|
||||||
|
differing: [...fromCsv.entries()]
|
||||||
|
.filter(([key, value]) => fromDb.has(key) && fromDb.get(key) !== value)
|
||||||
|
.map(([key, value]) => `${key}: csv ${value} vs db ${fromDb.get(key)}`)
|
||||||
|
}).toEqual({ onlyInCsv: [], onlyInDb: [], differing: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('projects exactly one default form per species through the public view', () => {
|
||||||
|
const bySpecies = new Map<string, Row[]>();
|
||||||
|
for (const row of rows)
|
||||||
|
bySpecies.set(row.pokemon, [...(bySpecies.get(row.pokemon) ?? []), row]);
|
||||||
|
const invalid = [...bySpecies]
|
||||||
|
.filter(([, entries]) => entries.filter((entry) => entry.isDefaultForm).length !== 1)
|
||||||
|
.map(([species, entries]) => ({
|
||||||
|
species,
|
||||||
|
defaults: entries.filter((entry) => entry.isDefaultForm).map((entry) => entry.form)
|
||||||
|
}));
|
||||||
|
expect(invalid).toEqual([]);
|
||||||
|
expect(rows[0]).toHaveProperty('notes');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
export type CsvRow = Record<string, string>;
|
||||||
|
|
||||||
|
export function parseCsv(text: string): CsvRow[] {
|
||||||
|
const rows: string[][] = [];
|
||||||
|
let row: string[] = [];
|
||||||
|
let cell = '';
|
||||||
|
let quoted = false;
|
||||||
|
|
||||||
|
for (let index = 0; index < text.length; index++) {
|
||||||
|
const char = text[index];
|
||||||
|
if (char === '"') {
|
||||||
|
if (quoted && text[index + 1] === '"') {
|
||||||
|
cell += '"';
|
||||||
|
index++;
|
||||||
|
} else {
|
||||||
|
quoted = !quoted;
|
||||||
|
}
|
||||||
|
} else if (char === ',' && !quoted) {
|
||||||
|
row.push(cell);
|
||||||
|
cell = '';
|
||||||
|
} else if ((char === '\n' || char === '\r') && !quoted) {
|
||||||
|
if (char === '\r' && text[index + 1] === '\n') index++;
|
||||||
|
row.push(cell);
|
||||||
|
if (row.some((value) => value.length > 0)) rows.push(row);
|
||||||
|
row = [];
|
||||||
|
cell = '';
|
||||||
|
} else {
|
||||||
|
cell += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quoted) throw new Error('Malformed CSV: unclosed quoted field');
|
||||||
|
if (cell.length > 0 || row.length > 0) {
|
||||||
|
row.push(cell);
|
||||||
|
if (row.some((value) => value.length > 0)) rows.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [headers, ...records] = rows;
|
||||||
|
if (!headers) return [];
|
||||||
|
return records.map((record, rowIndex) => {
|
||||||
|
if (record.length !== headers.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Malformed CSV row ${rowIndex + 2}: expected ${headers.length} columns, received ${record.length}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Object.fromEntries(
|
||||||
|
headers.map((header, index) => [header.trim(), record[index].trim()])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readRepoCsv(relativePath: string): CsvRow[] {
|
||||||
|
return parseCsv(readFileSync(resolve(process.cwd(), relativePath), 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typographic and straight apostrophes are the same identity here (Farfetch'd, Sirfetch'd), so
|
||||||
|
* the comparison absorbs the difference rather than forcing every data file to agree on one
|
||||||
|
* code point.
|
||||||
|
*/
|
||||||
|
export function normalizedIdentity(...parts: Array<string | null | undefined>): string {
|
||||||
|
return parts
|
||||||
|
.map((part) => (part ?? '').trim().replace(/[‘’ʼ]/g, "'").toLocaleLowerCase('en-GB'))
|
||||||
|
.join('|');
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Stands in for `$env/dynamic/private` so modules that reach for runtime env can be unit
|
||||||
|
* tested. Vitest maps the virtual module here; see vitest.config.mts.
|
||||||
|
*/
|
||||||
|
export const env: Record<string, string | undefined> = process.env;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);
|
||||||
|
|
||||||
|
export function isLoopbackUrl(value: string): boolean {
|
||||||
|
try {
|
||||||
|
return LOOPBACK_HOSTS.has(new URL(value).hostname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireLoopbackUrl(value: string, label: string): string {
|
||||||
|
if (!isLoopbackUrl(value))
|
||||||
|
throw new Error(`Refusing to send credentials to non-loopback ${label}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user