mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-16 02:22:17 +00:00
perf: fix slow page loads and gate performance in CI
- Ship one hashed Tailwind stylesheet instead of two (one render-blocking) - Compress responses in-app (brotli/gzip, streaming-safe) and precompress the node build so local and CI measurements match production - Validate the Supabase session once per request - Render the homepage immediately and stream public stats - Stream a Pokedex's entries with the page instead of fetching after hydration, running the rows and count queries in parallel - Shrink the avatar and offline placeholder images, fix layout shift, contrast, link names and missing meta descriptions - Add Lighthouse CI (mobile + desktop) with score and metric budgets, bundle-size budgets in the build tests, and signed-in speed scenarios
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -18,7 +18,6 @@
|
||||
})();
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
<link rel="stylesheet" href="%sveltekit.assets%/output.css" />
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
|
||||
+21
-13
@@ -1,6 +1,7 @@
|
||||
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
|
||||
import { createServerClient } from '@supabase/ssr';
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { compressResponse } from '$lib/server/compression';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
||||
@@ -24,24 +25,31 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
* doesn't validate the JWT, this function validates the JWT by first calling
|
||||
* `getUser` and aborts early if the JWT signature is invalid.
|
||||
*/
|
||||
event.locals.safeGetSession = async () => {
|
||||
const {
|
||||
data: { user },
|
||||
error
|
||||
} = await event.locals.supabase.auth.getUser();
|
||||
if (error) {
|
||||
return { session: null, user: null };
|
||||
}
|
||||
// getUser is a network round trip to Supabase Auth. Layout and page loads (and API routes) all
|
||||
// ask for the session, so validate once per request and share the result.
|
||||
let sessionPromise: ReturnType<App.Locals['safeGetSession']> | null = null;
|
||||
event.locals.safeGetSession = () => {
|
||||
sessionPromise ??= (async () => {
|
||||
const {
|
||||
data: { user },
|
||||
error
|
||||
} = await event.locals.supabase.auth.getUser();
|
||||
if (error) {
|
||||
return { session: null, user: null };
|
||||
}
|
||||
|
||||
const {
|
||||
data: { session }
|
||||
} = await event.locals.supabase.auth.getSession();
|
||||
return { session, user };
|
||||
const {
|
||||
data: { session }
|
||||
} = await event.locals.supabase.auth.getSession();
|
||||
return { session, user };
|
||||
})();
|
||||
return sessionPromise;
|
||||
};
|
||||
|
||||
return resolve(event, {
|
||||
const response = await resolve(event, {
|
||||
filterSerializedResponseHeaders(name) {
|
||||
return name === 'content-range';
|
||||
}
|
||||
});
|
||||
return compressResponse(event.request, response);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Readable } from 'node:stream';
|
||||
import type { ReadableStream as NodeReadableStream } from 'node:stream/web';
|
||||
import { constants, createBrotliCompress, createGzip } from 'node:zlib';
|
||||
|
||||
export type Encoding = 'br' | 'gzip';
|
||||
|
||||
// Text responses the app renders or returns from API routes. Images, fonts and other binary
|
||||
// content is already compressed, so re-compressing it only costs CPU.
|
||||
const COMPRESSIBLE = /^(text\/|application\/(json|javascript|xml|manifest\+json)|image\/svg\+xml)/i;
|
||||
|
||||
/** Picks the best encoding the client accepts, preferring brotli. Honours `q=0` refusals. */
|
||||
export function pickEncoding(acceptEncoding: string | null): Encoding | null {
|
||||
if (!acceptEncoding) return null;
|
||||
const accepted = new Map<string, number>();
|
||||
for (const part of acceptEncoding.split(',')) {
|
||||
const [name, ...params] = part.trim().toLowerCase().split(';');
|
||||
const q = params.map((p) => p.trim()).find((p) => p.startsWith('q='));
|
||||
accepted.set(name, q ? Number(q.slice(2)) : 1);
|
||||
}
|
||||
const allows = (name: Encoding) => (accepted.get(name) ?? accepted.get('*') ?? 0) > 0;
|
||||
if (allows('br')) return 'br';
|
||||
if (allows('gzip')) return 'gzip';
|
||||
return null;
|
||||
}
|
||||
|
||||
// adapter-netlify's Lambda handler serialises text responses with `response.text()`, which would
|
||||
// corrupt a compressed body. Netlify compresses function responses itself, so skip it there.
|
||||
// Everywhere else (the Node build, local preview, CI) the app compresses its own responses.
|
||||
const serialisesBodiesAsText = () => Boolean(process.env.AWS_LAMBDA_FUNCTION_NAME);
|
||||
|
||||
function shouldCompress(request: Request, response: Response): boolean {
|
||||
if (serialisesBodiesAsText()) return false;
|
||||
if (!response.body || request.method === 'HEAD') return false;
|
||||
if (response.status < 200 || response.status === 204 || response.status === 304) return false;
|
||||
if (response.headers.has('content-encoding')) return false;
|
||||
return COMPRESSIBLE.test(response.headers.get('content-type') ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresses a rendered page or API response so its transfer size doesn't depend on the host.
|
||||
* Every chunk is flushed as soon as it is written, so SvelteKit's streamed load data still reaches
|
||||
* the browser progressively instead of waiting for the whole body.
|
||||
*/
|
||||
export function compressResponse(request: Request, response: Response): Response {
|
||||
if (!shouldCompress(request, response)) return response;
|
||||
const encoding = pickEncoding(request.headers.get('accept-encoding'));
|
||||
|
||||
const headers = new Headers(response.headers);
|
||||
// Caches must key on the request encoding even when this response isn't compressed.
|
||||
headers.append('vary', 'Accept-Encoding');
|
||||
if (!encoding) return new Response(response.body, { status: response.status, headers });
|
||||
|
||||
const compressor =
|
||||
encoding === 'br'
|
||||
? createBrotliCompress({
|
||||
flush: constants.BROTLI_OPERATION_FLUSH,
|
||||
// Quality 11 is for build-time precompression; 5 is fast enough per request.
|
||||
params: { [constants.BROTLI_PARAM_QUALITY]: 5 }
|
||||
})
|
||||
: createGzip({ flush: constants.Z_SYNC_FLUSH, level: 6 });
|
||||
|
||||
const source = Readable.fromWeb(response.body as unknown as NodeReadableStream);
|
||||
source.on('error', (error) => compressor.destroy(error));
|
||||
source.pipe(compressor);
|
||||
|
||||
headers.set('content-encoding', encoding);
|
||||
headers.delete('content-length');
|
||||
return new Response(Readable.toWeb(compressor) as unknown as ReadableStream, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
|
||||
import { resolveDexScopes } from '$lib/services/PokedexDexScopeService';
|
||||
import type { Pokedex } from '$lib/models/Pokedex';
|
||||
|
||||
export type CombinedDataQuery = {
|
||||
page: number;
|
||||
limit: number;
|
||||
enableForms: boolean;
|
||||
region?: string;
|
||||
game?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads one page of a Pokédex's entries joined with the owner's catch records. Shared by the
|
||||
* combined-data API and the Pokédex page's server load so both return exactly the same data.
|
||||
* The caller must already have checked that `userId` owns `pokedex`.
|
||||
*/
|
||||
export async function loadCombinedDataPage(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
pokedex: Pokedex,
|
||||
{ page, limit, enableForms, region = '', game = '' }: CombinedDataQuery
|
||||
) {
|
||||
// Use the pokédex's gameScope as the default filter if no manual game filter is set.
|
||||
const effectiveGame = game || pokedex.gameScope || '';
|
||||
const dexScopes = await resolveDexScopes(supabase, pokedex);
|
||||
const repo = new CombinedDataRepository(supabase, userId, pokedex._id);
|
||||
|
||||
// The rows and the count are independent queries, so run them together.
|
||||
const [combinedData, totalCount] = await Promise.all([
|
||||
repo.findCombinedData(userId, page, limit, enableForms, region, effectiveGame, dexScopes),
|
||||
repo.countCombinedData(enableForms, region, effectiveGame, dexScopes)
|
||||
]);
|
||||
|
||||
return {
|
||||
combinedData,
|
||||
totalPages: Math.ceil(totalCount / limit),
|
||||
currentPage: page,
|
||||
totalCount
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import 'tailwindcss/tailwind.css';
|
||||
// The only app stylesheet: Vite bundles, minifies and content-hashes it so it is cached for good.
|
||||
// static/output.css is built separately for the credential-free offline.html page only.
|
||||
import '../app.css';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { user } from '$lib/stores/user.js';
|
||||
import { type User } from '@supabase/auth-js';
|
||||
@@ -180,7 +182,7 @@
|
||||
{#if localUser}
|
||||
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar">
|
||||
<div class="w-10 rounded-full">
|
||||
<img alt="usericon" src="/OIG5.jpg" />
|
||||
<img alt="Account menu" src="/avatar.webp" width="40" height="40" />
|
||||
</div>
|
||||
</div>
|
||||
<ul
|
||||
@@ -247,8 +249,14 @@
|
||||
|
||||
<footer class="footer items-center p-4 bg-neutral text-neutral-content bottom-0">
|
||||
<aside class="items-center grid-flow-col">
|
||||
<a href="https://github.com/jcreek/LivingDexTracker" target="_blank">
|
||||
<a
|
||||
href="https://github.com/jcreek/LivingDexTracker"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Living Dex Tracker on GitHub"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width="36"
|
||||
height="36"
|
||||
fill-rule="evenodd"
|
||||
@@ -269,8 +277,13 @@
|
||||
</p>
|
||||
</aside>
|
||||
<nav class="grid-flow-col gap-4 md:place-self-center md:justify-self-end">
|
||||
<a href="https://discord.gg/SQcJkaXDye" target="_blank"
|
||||
<a
|
||||
href="https://discord.gg/SQcJkaXDye"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Living Dex Tracker Discord community"
|
||||
><svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export type PublicStats = {
|
||||
pokemonCaught: number;
|
||||
users: number;
|
||||
livingDexesCompleted: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Server-side load function for the homepage
|
||||
*
|
||||
* Fetches public statistics from the database and passes them to the page component.
|
||||
* Stats are cached for 24 hours to improve performance.
|
||||
* Signed-in users go straight to their Pokédexes. For everyone else the page renders at once and
|
||||
* the public statistics stream in afterwards, so a slow stats query never delays the first paint.
|
||||
*/
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
// Fetch stats from database
|
||||
const statsResponse = await fetch('/api/stats');
|
||||
const statsData = await statsResponse.json();
|
||||
export const load: PageServerLoad = async ({ fetch, locals }) => {
|
||||
const { user } = await locals.safeGetSession();
|
||||
if (user) {
|
||||
throw redirect(303, '/my-pokedexes');
|
||||
}
|
||||
|
||||
return {
|
||||
stats: statsData.error ? null : statsData
|
||||
};
|
||||
const stats: Promise<PublicStats | null> = fetch('/api/stats')
|
||||
.then((response) => response.json())
|
||||
.then((statsData) => (statsData.error ? null : statsData))
|
||||
.catch(() => null);
|
||||
|
||||
return { stats };
|
||||
};
|
||||
|
||||
+302
-338
@@ -1,35 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import SignUp from '$lib/components/SignUp.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
// Signed-in visitors are redirected by the server load, so the page renders straight away.
|
||||
export let data;
|
||||
let { supabase, stats } = data;
|
||||
$: ({ supabase, stats } = data);
|
||||
|
||||
// Redirection is decided from the live session below, so the user store is not needed here.
|
||||
let isCheckingSession = true;
|
||||
|
||||
onMount(() => {
|
||||
checkSessionAndRedirect();
|
||||
});
|
||||
|
||||
async function checkSessionAndRedirect() {
|
||||
try {
|
||||
const {
|
||||
data: { session }
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (session) {
|
||||
await goto('/my-pokedexes');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking session:', error);
|
||||
} finally {
|
||||
isCheckingSession = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSignedUp() {
|
||||
await goto('/welcome');
|
||||
}
|
||||
@@ -44,16 +21,6 @@
|
||||
}
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
// Get formatted stats or fallback to 0
|
||||
let pokemonCaught = '0';
|
||||
let users = '0';
|
||||
let livingDexesCompleted = '0';
|
||||
$: {
|
||||
pokemonCaught = formatNumber(stats?.pokemonCaught ?? 0);
|
||||
users = formatNumber(stats?.users ?? 0);
|
||||
livingDexesCompleted = formatNumber(stats?.livingDexesCompleted ?? 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -72,322 +39,319 @@
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
{#if isCheckingSession}
|
||||
<!-- Loading placeholder while checking session -->
|
||||
<div class="hero bg-base-100 my-36">
|
||||
<div class="hero-content flex-col">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
<!-- Hero Section -->
|
||||
<div class="hero bg-base-100 my-36">
|
||||
<div class="hero-content flex-col lg:flex-row-reverse">
|
||||
<div class="text-center lg:text-left">
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<div class="badge badge-primary badge-lg gap-1">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Free
|
||||
</div>
|
||||
<div class="badge badge-secondary badge-lg gap-1">
|
||||
<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="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
|
||||
/>
|
||||
</svg>
|
||||
Open Source
|
||||
</div>
|
||||
<a href="/offline-guide" class="badge badge-accent badge-lg gap-1 hover:opacity-80">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Offline-friendly
|
||||
</a>
|
||||
</div>
|
||||
<h1 class="text-5xl font-bold mb-6">Start Your Pokédex Journey!</h1>
|
||||
<p class="text-xl mb-6 text-base-content/80">
|
||||
Track your progress towards a complete Living Pokédex — one of every Pokémon, actively
|
||||
maintained across your boxes. Join thousands of trainers worldwide.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-3 justify-center lg:justify-start">
|
||||
<a
|
||||
href="https://discord.gg/2ytj4pkUPY"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-primary btn-lg"
|
||||
aria-label="Join the Living Dex Tracker Discord community"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
|
||||
/>
|
||||
</svg>
|
||||
Join Discord
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/jcreek/LivingDexTracker"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-outline btn-lg"
|
||||
aria-label="View the Living Dex Tracker project on GitHub"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
|
||||
/>
|
||||
</svg>
|
||||
Contribute on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card shrink-0 w-full max-w-sm shadow-2xl bg-neutral">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl mb-4">Get Started Free</h2>
|
||||
<SignUp {supabase} on:signedUp={handleSignedUp} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Hero Section -->
|
||||
<div class="hero bg-base-100 my-36">
|
||||
<div class="hero-content flex-col lg:flex-row-reverse">
|
||||
<div class="text-center lg:text-left">
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<div class="badge badge-primary badge-lg gap-1">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Free
|
||||
</div>
|
||||
<div class="badge badge-secondary badge-lg gap-1">
|
||||
<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="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
|
||||
/>
|
||||
</svg>
|
||||
Open Source
|
||||
</div>
|
||||
<a href="/offline-guide" class="badge badge-accent badge-lg gap-1 hover:opacity-80">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Offline-friendly
|
||||
</a>
|
||||
</div>
|
||||
<h1 class="text-5xl font-bold mb-6">Start Your Pokédex Journey!</h1>
|
||||
<p class="text-xl mb-6 text-base-content/80">
|
||||
Track your progress towards a complete Living Pokédex — one of every Pokémon, actively
|
||||
maintained across your boxes. Join thousands of trainers worldwide.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-3 justify-center lg:justify-start">
|
||||
<a
|
||||
href="https://discord.gg/2ytj4pkUPY"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-primary btn-lg"
|
||||
aria-label="Join the Living Dex Tracker Discord community"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
|
||||
/>
|
||||
</svg>
|
||||
Join Discord
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/jcreek/LivingDexTracker"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-outline btn-lg"
|
||||
aria-label="View the Living Dex Tracker project on GitHub"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
|
||||
/>
|
||||
</svg>
|
||||
Contribute on GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Stats Section -->
|
||||
<div class="bg-base-200 py-16">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="stats stats-vertical lg:stats-horizontal shadow bg-neutral text-center w-full">
|
||||
<div class="stat">
|
||||
<div class="stat-title">Pokémon caught</div>
|
||||
<div class="stat-value text-primary">
|
||||
{#await stats}–{:then value}{formatNumber(value?.pokemonCaught ?? 0)}{/await}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Users</div>
|
||||
<div class="stat-value text-primary">
|
||||
{#await stats}–{:then value}{formatNumber(value?.users ?? 0)}{/await}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Living Dexes Completed</div>
|
||||
<div class="stat-value text-primary">
|
||||
{#await stats}–{:then value}{formatNumber(value?.livingDexesCompleted ?? 0)}{/await}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- What is a Living Dex Section -->
|
||||
<div class="py-16 bg-base-100">
|
||||
<div class="container mx-auto px-4 max-w-4xl">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-3xl mb-4">What is a Living Dex?</h2>
|
||||
<p class="text-lg text-base-content/80">
|
||||
A Living Dex is a complete Pokédex where you keep one of every Pokémon in your boxes
|
||||
(often including forms/variants). Living Dex Tracker helps you build and maintain that
|
||||
collection with filters, notes, and progress tracking.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features Section -->
|
||||
<div class="py-16 bg-base-200">
|
||||
<div class="container mx-auto px-4 max-w-6xl">
|
||||
<h2 class="text-4xl font-bold mb-12 text-center">Why Choose Living Dex Tracker?</h2>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="card bg-neutral shadow-xl">
|
||||
<div class="card-body">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="w-10 h-10 text-green-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="card-title text-xl mb-2">Social & Shareable</h2>
|
||||
<p class="text-base-content/80">
|
||||
Easily share your Pokédex journey with friends, or find theirs. If you'd rather go
|
||||
it alone, that's okay too!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-neutral shadow-xl">
|
||||
<div class="card-body">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="w-10 h-10 text-green-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="card-title text-xl mb-2">Free & Open Source</h2>
|
||||
<p class="text-base-content/80">
|
||||
Completely open source and free to use, enabling the community to contribute updates
|
||||
as soon as new Pokémon are released.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-neutral shadow-xl">
|
||||
<div class="card-body">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="w-10 h-10 text-green-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="card-title text-xl mb-2">Advanced Filtering</h2>
|
||||
<p class="text-base-content/80">
|
||||
Track simple progress or tackle harder variants like a Living Origin Form Dex with
|
||||
our powerful filtering options for targeted catching sessions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-neutral shadow-xl">
|
||||
<div class="card-body">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="w-10 h-10 text-green-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="card-title text-xl mb-2">100% free</h2>
|
||||
<p class="text-base-content/80">
|
||||
Did we mention it's completely free to use? Oh, we did? Good. Because it is.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA Section -->
|
||||
<div class="py-16 bg-base-100">
|
||||
<div class="container mx-auto px-4 text-center">
|
||||
<h2 class="text-4xl font-bold mb-6">Ready to Start Your Journey?</h2>
|
||||
<p class="text-xl mb-8 text-base-content/80 max-w-2xl mx-auto">
|
||||
Get started today with tracking your Living Pokédex progress. It's free, open source, and
|
||||
built with love for the Pokémon community.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-4 justify-center">
|
||||
<div class="card shrink-0 w-full max-w-sm shadow-2xl bg-neutral">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl mb-4">Get Started Free</h2>
|
||||
<h3 class="card-title text-xl mb-4">Sign Up Now</h3>
|
||||
<SignUp {supabase} on:signedUp={handleSignedUp} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Section -->
|
||||
<div class="bg-base-200 py-16">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="stats stats-vertical lg:stats-horizontal shadow bg-neutral text-center w-full">
|
||||
<div class="stat">
|
||||
<div class="stat-title">Pokémon caught</div>
|
||||
<div class="stat-value text-primary">{pokemonCaught}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Users</div>
|
||||
<div class="stat-value text-primary">{users}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Living Dexes Completed</div>
|
||||
<div class="stat-value text-primary">{livingDexesCompleted}</div>
|
||||
</div>
|
||||
<!-- Legal Section -->
|
||||
<div class="py-8 bg-base-200">
|
||||
<div class="container mx-auto px-4 max-w-4xl">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg mb-2">Legal Disclaimer</h2>
|
||||
<p class="text-sm text-base-content/70">
|
||||
Living Dex Tracker is a fan-made project. We do not claim ownership of any Pokémon
|
||||
characters, images, or other content featured on this website. This project is not
|
||||
affiliated with, endorsed, sponsored, or specifically approved by Nintendo, Game Freak, or
|
||||
The Pokémon Company.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- What is a Living Dex Section -->
|
||||
<div class="py-16 bg-base-100">
|
||||
<div class="container mx-auto px-4 max-w-4xl">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-3xl mb-4">What is a Living Dex?</h2>
|
||||
<p class="text-lg text-base-content/80">
|
||||
A Living Dex is a complete Pokédex where you keep one of every Pokémon in your boxes
|
||||
(often including forms/variants). Living Dex Tracker helps you build and maintain that
|
||||
collection with filters, notes, and progress tracking.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features Section -->
|
||||
<div class="py-16 bg-base-200">
|
||||
<div class="container mx-auto px-4 max-w-6xl">
|
||||
<h2 class="text-4xl font-bold mb-12 text-center">Why Choose Living Dex Tracker?</h2>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="card bg-neutral shadow-xl">
|
||||
<div class="card-body">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="w-10 h-10 text-green-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="card-title text-xl mb-2">Social & Shareable</h2>
|
||||
<p class="text-base-content/80">
|
||||
Easily share your Pokédex journey with friends, or find theirs. If you'd rather go
|
||||
it alone, that's okay too!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-neutral shadow-xl">
|
||||
<div class="card-body">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="w-10 h-10 text-green-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="card-title text-xl mb-2">Free & Open Source</h2>
|
||||
<p class="text-base-content/80">
|
||||
Completely open source and free to use, enabling the community to contribute
|
||||
updates as soon as new Pokémon are released.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-neutral shadow-xl">
|
||||
<div class="card-body">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="w-10 h-10 text-green-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="card-title text-xl mb-2">Advanced Filtering</h2>
|
||||
<p class="text-base-content/80">
|
||||
Track simple progress or tackle harder variants like a Living Origin Form Dex with
|
||||
our powerful filtering options for targeted catching sessions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-neutral shadow-xl">
|
||||
<div class="card-body">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="w-10 h-10 text-green-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="card-title text-xl mb-2">100% free</h2>
|
||||
<p class="text-base-content/80">
|
||||
Did we mention it's completely free to use? Oh, we did? Good. Because it is.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA Section -->
|
||||
<div class="py-16 bg-base-100">
|
||||
<div class="container mx-auto px-4 text-center">
|
||||
<h2 class="text-4xl font-bold mb-6">Ready to Start Your Journey?</h2>
|
||||
<p class="text-xl mb-8 text-base-content/80 max-w-2xl mx-auto">
|
||||
Get started today with tracking your Living Pokédex progress. It's free, open source, and
|
||||
built with love for the Pokémon community.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-4 justify-center">
|
||||
<div class="card shrink-0 w-full max-w-sm shadow-2xl bg-neutral">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-xl mb-4">Sign Up Now</h3>
|
||||
<SignUp {supabase} on:signedUp={handleSignedUp} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legal Section -->
|
||||
<div class="py-8 bg-base-200">
|
||||
<div class="container mx-auto px-4 max-w-4xl">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg mb-2">Legal Disclaimer</h2>
|
||||
<p class="text-sm text-base-content/70">
|
||||
Living Dex Tracker is a fan-made project. We do not claim ownership of any Pokémon
|
||||
characters, images, or other content featured on this website. This project is not
|
||||
affiliated with, endorsed, sponsored, or specifically approved by Nintendo, Game Freak,
|
||||
or The Pokémon Company.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
|
||||
import PokedexRepository from '$lib/repositories/PokedexRepository';
|
||||
import { getOptionalUserId } from '$lib/utils/auth';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { resolveDexScopes } from '$lib/services/PokedexDexScopeService';
|
||||
import { loadCombinedDataPage } from '$lib/services/CombinedDataService';
|
||||
|
||||
// GET: Get combined data (pokédex entries + catch records) for specific pokédex
|
||||
export const GET = async (event: RequestEvent) => {
|
||||
@@ -23,48 +22,29 @@ export const GET = async (event: RequestEvent) => {
|
||||
const region = url.searchParams.get('region') || '';
|
||||
const game = url.searchParams.get('game') || '';
|
||||
|
||||
// If authenticated, verify user owns this pokédex and get its gameScope
|
||||
let pokedex;
|
||||
if (userId) {
|
||||
const pokedexRepo = new PokedexRepository(event.locals.supabase, userId);
|
||||
pokedex = await pokedexRepo.findById(pokedexId);
|
||||
|
||||
if (!pokedex) {
|
||||
// User is authenticated but doesn't own this pokédex (or it doesn't exist)
|
||||
return json({ error: 'Pokedex not found' }, { status: 404 });
|
||||
}
|
||||
} else {
|
||||
if (!userId) {
|
||||
// Anonymous users cannot view pokédexes
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Use pokédex's gameScope as default filter if no manual game filter is set
|
||||
const effectiveGame = game || pokedex.gameScope || '';
|
||||
const dexScopes = await resolveDexScopes(event.locals.supabase, pokedex);
|
||||
// Verify the user owns this pokédex and get its gameScope
|
||||
const pokedexRepo = new PokedexRepository(event.locals.supabase, userId);
|
||||
const pokedex = await pokedexRepo.findById(pokedexId);
|
||||
|
||||
const repo = new CombinedDataRepository(event.locals.supabase, userId, pokedexId);
|
||||
if (!pokedex) {
|
||||
// User is authenticated but doesn't own this pokédex (or it doesn't exist)
|
||||
return json({ error: 'Pokedex not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get paginated combined data
|
||||
const combinedData = await repo.findCombinedData(
|
||||
userId!,
|
||||
page,
|
||||
limit,
|
||||
enableForms,
|
||||
region,
|
||||
effectiveGame,
|
||||
dexScopes
|
||||
return json(
|
||||
await loadCombinedDataPage(event.locals.supabase, userId, pokedex, {
|
||||
page,
|
||||
limit,
|
||||
enableForms,
|
||||
region,
|
||||
game
|
||||
})
|
||||
);
|
||||
|
||||
// Get total count for pagination
|
||||
const totalCount = await repo.countCombinedData(enableForms, region, effectiveGame, dexScopes);
|
||||
const totalPages = Math.ceil(totalCount / limit);
|
||||
|
||||
return json({
|
||||
combinedData,
|
||||
totalPages,
|
||||
currentPage: page,
|
||||
totalCount
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (err && typeof err === 'object' && 'status' in err) {
|
||||
|
||||
@@ -27,6 +27,10 @@ export const GET = async (event: RequestEvent) => {
|
||||
}
|
||||
|
||||
const stats = data[0];
|
||||
// The figures refresh at most daily, so let browsers and the CDN reuse them.
|
||||
event.setHeaders({
|
||||
'cache-control': 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400'
|
||||
});
|
||||
return json({
|
||||
pokemonCaught: stats.pokemon_caught,
|
||||
users: stats.total_users,
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
|
||||
<svelte:head>
|
||||
<title>Using Offline - Living Dex Tracker</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="How Living Dex Tracker keeps your Pokédexes and artwork available offline, and how to save everything before you lose signal."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto p-4 max-w-screen-lg">
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import PokedexRepository from '$lib/repositories/PokedexRepository';
|
||||
import { loadCombinedDataPage } from '$lib/services/CombinedDataService';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
// Must match the page's itemsPerPage: the box view needs the whole dex in one page.
|
||||
const INITIAL_PAGE_SIZE = 9999;
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
const { safeGetSession, supabase } = locals;
|
||||
const { session, user } = await safeGetSession();
|
||||
@@ -22,7 +26,22 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
throw error(404, 'Pokédex not found');
|
||||
}
|
||||
|
||||
// Streamed rather than awaited: the page shell renders straight away and the entries arrive in
|
||||
// the same response, instead of the browser requesting them after hydration. A failure resolves
|
||||
// to null so the page falls back to fetching (and reporting) through the API.
|
||||
const initialCombinedData = loadCombinedDataPage(supabase, user.id, pokedex, {
|
||||
page: 1,
|
||||
limit: INITIAL_PAGE_SIZE,
|
||||
enableForms: pokedex.isFormDex
|
||||
})
|
||||
.then((result) => result.combinedData)
|
||||
.catch((err) => {
|
||||
console.error('Unable to preload combined data', err);
|
||||
return null;
|
||||
});
|
||||
|
||||
return {
|
||||
pokedex
|
||||
pokedex,
|
||||
initialCombinedData
|
||||
};
|
||||
};
|
||||
|
||||
@@ -502,8 +502,35 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch data whenever pagination controls change (client-side only)
|
||||
$: if (browser && pokedexId) getData({ page: currentPage, perPage: itemsPerPage });
|
||||
// Show data whenever the dex or pagination changes (client-side only). The first page is streamed
|
||||
// from the server load, so it only needs fetching when that failed or the page changes.
|
||||
let shownKey = '';
|
||||
function showPage(
|
||||
id: string,
|
||||
page: number,
|
||||
perPage: number,
|
||||
initial: Promise<CombinedData[] | null> | undefined
|
||||
) {
|
||||
const key = `${id}:${page}:${perPage}`;
|
||||
if (key === shownKey) return;
|
||||
shownKey = key;
|
||||
if (page !== 1 || !initial) {
|
||||
void getData({ page, perPage });
|
||||
return;
|
||||
}
|
||||
combinedData = null;
|
||||
void initial.then((rows) => {
|
||||
if (shownKey !== key) return;
|
||||
if (!rows) {
|
||||
void getData({ page, perPage });
|
||||
return;
|
||||
}
|
||||
combinedData = rows;
|
||||
boxNumbers = calculateBoxNumbers(rows.length);
|
||||
});
|
||||
}
|
||||
$: if (browser && pokedexId)
|
||||
showPage(pokedexId, currentPage, itemsPerPage, data?.initialCombinedData);
|
||||
|
||||
onMount(() => {
|
||||
if (!browser) return;
|
||||
|
||||
@@ -22,8 +22,12 @@
|
||||
<div class="min-h-[calc(100vh-16rem)] bg-base-100 py-8 md:py-16 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<!-- Success Badge -->
|
||||
{#if showSuccess}
|
||||
<div class="flex justify-center mb-8 animate-fade-in">
|
||||
<!-- Always rendered (faded in) so the hero doesn't jump down when the badge appears. -->
|
||||
<div
|
||||
class="flex justify-center mb-8 transition-opacity duration-500"
|
||||
class:opacity-0={!showSuccess}
|
||||
>
|
||||
<div class="flex justify-center">
|
||||
<div class="badge badge-success badge-lg gap-2 p-6 shadow-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -42,7 +46,7 @@
|
||||
<span class="text-lg font-semibold">Account Created Successfully!</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Hero Content -->
|
||||
<div class="hero">
|
||||
@@ -315,21 +319,6 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
@@ -346,7 +335,6 @@
|
||||
|
||||
/* Respect user's motion preferences */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-fade-in,
|
||||
.animate-pulse {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user