mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-07-12 18:43:45 +00:00
Merge pull request #78 from jcreek/64-update-styling-on-the-about-page
feat(#64): Improve the about page
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let email = '';
|
||||
let password = '';
|
||||
|
||||
@@ -17,13 +20,18 @@
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
console.error('Sign up error:', error);
|
||||
alert(`Sign up failed: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle success (optional)
|
||||
} catch (error) {
|
||||
console.error('Sign up error:', error.message);
|
||||
// Handle error
|
||||
if (data) {
|
||||
// Emit signedUp event to notify parent component
|
||||
dispatch('signedUp', {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Sign up error:', err);
|
||||
alert('Sign up failed');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -54,6 +62,12 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control mt-6">
|
||||
<button class="btn btn btn-primary" on:click={signUpNewUser}>Sign Up</button>
|
||||
<button class="btn btn-primary" on:click={signUpNewUser}>Sign Up</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.card-body {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { Pokedex } from '$lib/models/Pokedex';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
/**
|
||||
* Calculate expected pokedex entries based on pokedex configuration
|
||||
* Applies the same filtering logic as CombinedDataRepository
|
||||
* @throws Error if the Supabase query fails
|
||||
*/
|
||||
export async function calculateExpectedEntries(
|
||||
supabase: SupabaseClient,
|
||||
pokedex: Pokedex
|
||||
): Promise<number[]> {
|
||||
let query = supabase.from('pokedex_entries').select('id');
|
||||
|
||||
// Apply form filter: if isFormDex is false, only include base forms
|
||||
// Base forms have form IS NULL, except Unown which has no base form (use 'A')
|
||||
if (!pokedex.isFormDex) {
|
||||
query = query.or('form.is.null,and(pokemon.eq.Unown,form.eq.A)');
|
||||
}
|
||||
|
||||
// Apply region filter: if gameScope is specified, filter by region
|
||||
if (pokedex.gameScope) {
|
||||
// Determine region from gameScope using region_game_mappings
|
||||
const { data: regionData, error: regionError } = await supabase
|
||||
.from('region_game_mappings')
|
||||
.select('region')
|
||||
.eq('game', pokedex.gameScope)
|
||||
.maybeSingle();
|
||||
|
||||
if (regionError) {
|
||||
console.error('Error looking up region for gameScope:', pokedex.gameScope, regionError);
|
||||
throw new Error(
|
||||
`Failed to lookup region for gameScope "${pokedex.gameScope}": ${regionError.message}`
|
||||
);
|
||||
}
|
||||
|
||||
if (regionData?.region) {
|
||||
query = query.eq('regionToCatchIn', regionData.region);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply game filter: if gameScope is specified, filter by gamesToCatchIn array
|
||||
if (pokedex.gameScope) {
|
||||
query = query.contains('gamesToCatchIn', [pokedex.gameScope]);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
|
||||
if (error) {
|
||||
console.error('Error calculating expected entries:', error);
|
||||
throw new Error(`Failed to calculate expected entries: ${error.message}`);
|
||||
}
|
||||
|
||||
return data?.map((entry) => entry.id) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate pokedex_entries_mapping table for a pokedex
|
||||
* Uses chunked upsert to avoid UNIQUE constraint violations and request size limits
|
||||
* @throws Error if the Supabase upsert fails
|
||||
*/
|
||||
export async function populatePokedexMappings(
|
||||
supabase: SupabaseClient,
|
||||
pokedexId: string,
|
||||
pokedex: Pokedex
|
||||
): Promise<void> {
|
||||
const expectedEntryIds = await calculateExpectedEntries(supabase, pokedex);
|
||||
|
||||
if (expectedEntryIds.length === 0) {
|
||||
console.warn(`No expected entries calculated for pokedex ${pokedexId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const mappings = expectedEntryIds.map((pokedexEntryId) => ({
|
||||
pokedexId,
|
||||
pokedexEntryId
|
||||
}));
|
||||
|
||||
// Process in chunks to avoid request size limits
|
||||
const CHUNK_SIZE = 500;
|
||||
for (let i = 0; i < mappings.length; i += CHUNK_SIZE) {
|
||||
const chunk = mappings.slice(i, i + CHUNK_SIZE);
|
||||
const { error } = await supabase.from('pokedex_entries_mapping').upsert(chunk, {
|
||||
onConflict: 'pokedexId,pokedexEntryId',
|
||||
ignoreDuplicates: true
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Error populating pokedex mappings:', error);
|
||||
throw new Error(`Failed to populate pokedex mappings: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate pokedex_entries_mapping table for a pokedex (used on update)
|
||||
* Uses a single atomic RPC call to prevent orphaned pokedexes
|
||||
* @throws Error if the Supabase RPC call fails
|
||||
*/
|
||||
export async function recalculatePokedexMappings(
|
||||
supabase: SupabaseClient,
|
||||
pokedexId: string,
|
||||
pokedex: Pokedex
|
||||
): Promise<void> {
|
||||
const expectedEntryIds = await calculateExpectedEntries(supabase, pokedex);
|
||||
|
||||
if (expectedEntryIds.length === 0) {
|
||||
console.warn(`No expected entries calculated for pokedex ${pokedexId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Call the atomic RPC function to delete old mappings and insert new ones
|
||||
const { error } = await supabase.rpc('recalculate_pokedex_mappings', {
|
||||
p_pokedex_id: pokedexId,
|
||||
p_entry_ids: expectedEntryIds
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Error recalculating pokedex mappings:', error);
|
||||
throw new Error(`Failed to recalculate pokedex mappings: ${error.message}`);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,19 @@
|
||||
onMount(async () => {
|
||||
await getUser();
|
||||
|
||||
// Listen for auth state changes to keep the user store in sync
|
||||
const {
|
||||
data: { subscription }
|
||||
} = supabase.auth.onAuthStateChange((event, session) => {
|
||||
console.log('[Layout] Auth state changed:', event, session ? 'Session found' : 'No session');
|
||||
if (session) {
|
||||
localUser = session.user;
|
||||
} else {
|
||||
localUser = null;
|
||||
}
|
||||
user.set(localUser);
|
||||
});
|
||||
|
||||
if (pwaInfo) {
|
||||
const { registerSW } = await import('virtual:pwa-register');
|
||||
registerSW({
|
||||
@@ -41,6 +54,10 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
async function getUser() {
|
||||
@@ -91,7 +108,7 @@
|
||||
<header>
|
||||
<div class="navbar bg-primary text-primary-content">
|
||||
<div class="navbar-start">
|
||||
<div class="dropdown">
|
||||
<!-- <div class="dropdown">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost btn-circle">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -110,12 +127,11 @@
|
||||
<ul
|
||||
class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-primary text-primary-content rounded-box w-52"
|
||||
>
|
||||
<li><a href="/about">About</a></li>
|
||||
<li>
|
||||
<a href="https://github.com/jcreek/LivingDexTracker" target="_blank">Contribute</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
<div class="navbar-center">
|
||||
<a class="btn btn-ghost text-xl" href="/">Living Dex Tracker</a>
|
||||
@@ -151,10 +167,6 @@
|
||||
<li>
|
||||
<a href="/my-pokedexes"> My Pokédexes </a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/profile"> Profile </a>
|
||||
</li>
|
||||
<li><a href="/settings">Settings</a></li>
|
||||
<li><SignOut {supabase} on:signedOut={getUser} /></li>
|
||||
{:else}
|
||||
<li><SignIn {supabase} on:signedIn={getUser} /></li>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
// Fetch stats from database
|
||||
const statsResponse = await fetch('/api/stats');
|
||||
const statsData = await statsResponse.json();
|
||||
|
||||
return {
|
||||
stats: statsData.error ? null : statsData
|
||||
};
|
||||
};
|
||||
+369
-118
@@ -1,141 +1,392 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { onDestroy, 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 { goto } from '$app/navigation';
|
||||
|
||||
export let data;
|
||||
let { supabase } = data;
|
||||
$: ({ supabase } = data);
|
||||
let { supabase, stats } = data;
|
||||
$: ({ supabase, stats } = data);
|
||||
|
||||
let localUser: User | null;
|
||||
const unsubscribe = user.subscribe((value) => {
|
||||
localUser = value;
|
||||
});
|
||||
onDestroy(unsubscribe);
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
// Format large numbers (e.g., 1000 -> 1K, 1000000 -> 1M)
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(0) + 'K';
|
||||
}
|
||||
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>
|
||||
|
||||
<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">
|
||||
<h1 class="text-5xl font-bold">Start Your Pokédex Journey!</h1>
|
||||
</div>
|
||||
<div class="card shrink-0 w-full max-w-sm shadow-2xl bg-neutral">
|
||||
<SignUp {supabase} />
|
||||
<svelte:head>
|
||||
<title>Living Dex Tracker - Track Your Pokédex Journey</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A free, open source tool to track your Living Pokédex progress. Join thousands of trainers worldwide in completing their collection."
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="md:container md:mx-auto justify-self-center flex justify-center items-center h-full flex-col mb-10"
|
||||
>
|
||||
<div class="stats stats-vertical lg:stats-horizontal shadow bg-neutral text-center">
|
||||
<div class="stat">
|
||||
<div class="stat-title">Pokémon caught</div>
|
||||
<div class="stat-value">310K</div>
|
||||
<div class="stat-desc">Since January 2024</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-title">New Users</div>
|
||||
<div class="stat-value">4,200</div>
|
||||
<div class="stat-desc">↗︎ 400 (22%)</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-title">Living Dexes Completed</div>
|
||||
<div class="stat-value">1,200</div>
|
||||
<div class="stat-desc">↘︎ 90 (14%)</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>
|
||||
<div class="badge badge-accent 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="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
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div class="features mt-8">
|
||||
<h1 class="flex justify-center text-4xl font-bold mb-5">Features</h1>
|
||||
|
||||
<ul class="space-y-6">
|
||||
<li class="p-6 rounded-lg shadow-md bg-neutral">
|
||||
<div class="flex items-start space-x-4">
|
||||
<svg
|
||||
class="w-8 h-8 text-green-500 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"
|
||||
></path>
|
||||
</svg>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Social</h2>
|
||||
<p>
|
||||
Easily share your Pokédex journey with friends, or find theirs. If you'd rather go it
|
||||
alone, that's okay too!
|
||||
</p>
|
||||
</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>
|
||||
</li>
|
||||
<li class="p-6 rounded-lg shadow-md bg-neutral">
|
||||
<div class="flex items-start space-x-4">
|
||||
<svg
|
||||
class="w-8 h-8 text-green-500 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"
|
||||
></path>
|
||||
</svg>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Free & Open Source</h2>
|
||||
<p>
|
||||
Completely open source and free to use, enabling the community to contribute updates
|
||||
as soon as new Pokémon are released.
|
||||
</p>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Users</div>
|
||||
<div class="stat-value text-primary">{users}</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="p-6 rounded-lg shadow-md bg-neutral">
|
||||
<div class="flex items-start space-x-4">
|
||||
<svg
|
||||
class="w-8 h-8 text-green-500 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"
|
||||
></path>
|
||||
</svg>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Easy to use with advanced filtering</h2>
|
||||
<p>
|
||||
If you just want to track your progress completing your Pokédex, you can do that. But
|
||||
if you are attempting a harder variant, like a Living Origin Forme Dex, you might find
|
||||
our advanced filtering options helpful for tracking target Pokémon for specific
|
||||
catching sessions.
|
||||
</p>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Living Dexes Completed</div>
|
||||
<div class="stat-value text-primary">{livingDexesCompleted}</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="p-6 rounded-lg shadow-md bg-neutral">
|
||||
<div class="flex items-start space-x-4">
|
||||
<svg
|
||||
class="w-8 h-8 text-green-500 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"
|
||||
></path>
|
||||
</svg>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">100% free</h2>
|
||||
<p>Did we mention it's completely free to use? Oh, we did? Good. Because it is.</p>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</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">
|
||||
<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}
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
<svelte:head>
|
||||
<title>About Us</title>
|
||||
<meta name="description" content="About this app" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Pixelify+Sans:wght@400..700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&family=Staatliches&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="content h-full bg-base inline-block">
|
||||
<div id="grad">
|
||||
<div id="banner-content">
|
||||
<p class=" text-xs opacity-70 tracking-wide">Learn</p>
|
||||
<h2 class=" mt-0 pt-0 text-5xl tracking-widest text-center font-bold p-24">
|
||||
About<br /> Us
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="introduction">
|
||||
<p class="font-semibold text-xl mb-5 ml-5">
|
||||
Introducing <span class="typewriter-text underline decoration-accent">Living Dex Tracker</span
|
||||
>
|
||||
</p>
|
||||
<p class="text-base mt-10 mb-5 ml-5 mr-5">
|
||||
<img src="/placeholder-bulb.png" alt="bulb" class="float-right w-28" />
|
||||
Living Dex Tracker is dolor sit amet, consectetur adipiscing elit. Proin dignissim risus eu quam
|
||||
feugiat malesuada. Vivamus arcu sapien, feugiat sed lacus ut, malesuada congue nibh. In lacinia
|
||||
lacus quis bibendum pulvinar. Aliquam in suscipit risus. Aliquam ut justo dolor.
|
||||
</p>
|
||||
|
||||
<p class="font-semibold text-xl mt-20 mb-5 ml-5">Features</p>
|
||||
|
||||
<!-- TODO (#41) - Make this a card carousel -->
|
||||
<div class="card md:card-side bg-base-00 shadow-m">
|
||||
<figure>
|
||||
<img src="/OIG3.jpeg" alt="Album" />
|
||||
</figure>
|
||||
<div class="card-body">
|
||||
<p class="card-title">Player Dex Search</p>
|
||||
<p>Find and explore your friends' Pokémon collections effortlessly.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-neutral pb-20">
|
||||
<p class="font-semibold text-white text-xl mt-0 mb-5 ml-5">Meet The Team</p>
|
||||
<div class="avatar flex items-center justify-end mb-20">
|
||||
<p class="text-m mb-5 ml-5 mr-5">Team Member 1</p>
|
||||
<div class="w-24 rounded-full flex justify-end">
|
||||
<img src="/OIG5.jpg" alt="profile" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="avatar">
|
||||
<div class="w-24 rounded-full">
|
||||
<img src="/OIG5.jpg" alt="profile " />
|
||||
</div>
|
||||
|
||||
<p class="text-m mb-5 ml-5 mr-5">Team Member 2</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="project-details">
|
||||
<p class="font-semibold text-xl mt-10 mb-5 ml-5">The Developement</p>
|
||||
<p class="text-sm mb-5 ml-5 mr-5">
|
||||
Share insights into the development process, challenges faced, and any interesting anecdotes
|
||||
or milestones achieved during the project's journey.
|
||||
</p>
|
||||
|
||||
<ul class="timeline timeline-vertical">
|
||||
<li>
|
||||
<div class="timeline-start timeline-box">Example</div>
|
||||
<div class="timeline-middle">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-5 h-5 text-primary"
|
||||
><path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clip-rule="evenodd"
|
||||
/></svg
|
||||
>
|
||||
</div>
|
||||
<hr class="bg-primary" />
|
||||
</li>
|
||||
<li>
|
||||
<hr class="bg-primary" />
|
||||
<div class="timeline-middle">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-5 h-5 text-primary"
|
||||
><path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clip-rule="evenodd"
|
||||
/></svg
|
||||
>
|
||||
</div>
|
||||
<div class="timeline-end timeline-box">Example</div>
|
||||
<hr class="bg-primary" />
|
||||
</li>
|
||||
<li>
|
||||
<hr class="bg-primary" />
|
||||
<div class="timeline-start timeline-box">Example</div>
|
||||
<div class="timeline-middle">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-5 h-5 text-primary"
|
||||
><path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clip-rule="evenodd"
|
||||
/></svg
|
||||
>
|
||||
</div>
|
||||
<hr />
|
||||
</li>
|
||||
<li>
|
||||
<hr />
|
||||
<div class="timeline-middle">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-5 h-5"
|
||||
><path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clip-rule="evenodd"
|
||||
/></svg
|
||||
>
|
||||
</div>
|
||||
<div class="timeline-end timeline-box">Example</div>
|
||||
<hr />
|
||||
</li>
|
||||
<li>
|
||||
<hr />
|
||||
<div class="timeline-start timeline-box">Example</div>
|
||||
<div class="timeline-middle">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-5 h-5"
|
||||
><path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clip-rule="evenodd"
|
||||
/></svg
|
||||
>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p class="font-semibold text-xl mt-10 mb-5 ml-5">Join Us On Discord!</p>
|
||||
|
||||
<div id="contact-information ">
|
||||
<a href="https://discord.gg/2ytj4pkUPY">
|
||||
<button
|
||||
class="relative inline-flex items-center justify-center w-16 p-0.5 ml-5 mb-20 me-2 overflow-hidden text-sm font-medium text-gray-900 rounded-lg group bg-gradient-to-br from-cyan-500 to-blue-500 group-hover:from-cyan-500 group-hover:to-blue-500 hover:text-white dark:text-white focus:ring-4 focus:outline-none focus:ring-cyan-200 dark:focus:ring-cyan-800"
|
||||
>
|
||||
<img src="/discord1.svg" class="w-12" /></button
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="contact-form">
|
||||
<p class="font-semibold text-xl mt-0 mb-5 text-white pt-10">Contact Form</p>
|
||||
<input type="text" name="first" placeholder="First Name" autocomplete="off" required />
|
||||
<input type="text" name="last" placeholder="Last Name" autocomplete="off" required />
|
||||
<input type="email" name="Email" placeholder="Email Address" autocomplete="off" required />
|
||||
<textarea
|
||||
rows="5"
|
||||
cols="50"
|
||||
name="message"
|
||||
placeholder="Enter text"
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
<button type="submit" id="submit-button">Send Message</button>
|
||||
</div>
|
||||
|
||||
<div id="legal-information">
|
||||
<p class="font-semibold text-xl mt-0 mb-5 ml-5 text-white pt-10">Legal</p>
|
||||
|
||||
<p class="text-sm mb-5 ml-5 mr-5">
|
||||
Living Dex Tracker is a fan-made website dedicated to providing information about Pokemon
|
||||
creatures. We do not claim ownership of any Pokemon characters, images, or other content
|
||||
featured on this website.
|
||||
</p>
|
||||
<p class="text-sm mb-5 ml-5 mr-5">
|
||||
We do not generate revenue from the use of Pokemon content. Any advertisements or sponsored
|
||||
content on this website are unrelated to Pokemon and are used solely to support the
|
||||
maintenance and operation of the website.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="Acknowledgments">
|
||||
<p class="font-semibold text-xl mt-0 mb-5 ml-5 text-white pt-10">Acknowledgments</p>
|
||||
<p class="text-sm mb-5 ml-5 mr-5">
|
||||
Express gratitude to any individuals or organisations that have contributed to the project's
|
||||
success, including sponsors, supporters, or open-source contributors.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#banner-content {
|
||||
padding-top: 170px;
|
||||
}
|
||||
#grad {
|
||||
font-family: 'Pixelify Sans', sans-serif;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
|
||||
width: 100%;
|
||||
height: 422px;
|
||||
margin: 0 auto;
|
||||
background: linear-gradient(45deg, #d2001a, #7462ff, #f48e21, #23d5ab);
|
||||
color: #fff;
|
||||
background-size: 300% 300%;
|
||||
animation: color 12s ease-in-out infinite;
|
||||
text-align: center;
|
||||
}
|
||||
@keyframes color {
|
||||
0% {
|
||||
background-position: 0 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0 50%;
|
||||
}
|
||||
}
|
||||
|
||||
#introduction .font-semibold {
|
||||
font-family: 'Staatliches', sans-serif;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-size: 25px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.contact-form {
|
||||
background-color: rgb(216, 224, 178);
|
||||
width: 380px;
|
||||
height: 590px;
|
||||
border-radius: 20px;
|
||||
margin-bottom: 40px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.contact-form > p {
|
||||
margin-left: 9px;
|
||||
}
|
||||
input {
|
||||
background-color: rgb(156, 186, 139);
|
||||
width: 70%;
|
||||
margin-left: 12%;
|
||||
padding: 16px 16px;
|
||||
border-radius: 10px;
|
||||
border-color: rgb(131, 167, 124);
|
||||
border-width: 2px;
|
||||
margin-bottom: 20px;
|
||||
font-family: 'Staatliches', sans-serif;
|
||||
}
|
||||
|
||||
textarea {
|
||||
background-color: rgb(156, 186, 139);
|
||||
width: 70%;
|
||||
margin-left: 12%;
|
||||
padding: 16px 16px;
|
||||
border-radius: 10px;
|
||||
border-color: rgb(83, 145, 50);
|
||||
margin-bottom: 25px;
|
||||
font-family: 'Staatliches', sans-serif;
|
||||
}
|
||||
|
||||
input:focus-within {
|
||||
outline: none;
|
||||
border-color: rgb(169, 227, 157);
|
||||
border-width: 3px;
|
||||
font-family: 'Staatliches', sans-serif;
|
||||
}
|
||||
textarea:focus-within {
|
||||
outline: none;
|
||||
border-color: rgb(169, 227, 157);
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
#submit-button {
|
||||
background-color: rgb(185, 201, 135);
|
||||
border-radius: 20px;
|
||||
border-color: #fff;
|
||||
padding: 10px 18px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
margin: auto;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
opacity: 80%;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +0,0 @@
|
||||
import { dev } from '$app/environment';
|
||||
|
||||
// we don't need any JS on this page, though we'll load
|
||||
// it in dev so that we get hot module replacement...
|
||||
export const csr = dev;
|
||||
|
||||
// since there's no dynamic data here, we can prerender
|
||||
// it so that it gets served as a static asset in prod
|
||||
export const prerender = true;
|
||||
@@ -3,6 +3,7 @@ import type { Pokedex } from '$lib/models/Pokedex';
|
||||
import PokedexRepository from '$lib/repositories/PokedexRepository';
|
||||
import { requireAuth } from '$lib/utils/auth';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { populatePokedexMappings } from '$lib/services/PokedexMappingService';
|
||||
|
||||
// GET: List all pokedexes for user
|
||||
export const GET = async (event: RequestEvent) => {
|
||||
@@ -23,6 +24,8 @@ export const GET = async (event: RequestEvent) => {
|
||||
// POST: Create new pokedex
|
||||
export const POST = async (event: RequestEvent) => {
|
||||
let requestedName: string | undefined;
|
||||
let createdPokedexId: string | undefined;
|
||||
let createdPokedexName: string | undefined;
|
||||
try {
|
||||
const userId = await requireAuth(event);
|
||||
const data: Partial<Pokedex> = await event.request.json();
|
||||
@@ -31,6 +34,32 @@ export const POST = async (event: RequestEvent) => {
|
||||
|
||||
const repo = new PokedexRepository(event.locals.supabase, userId);
|
||||
const pokedex = await repo.create(data);
|
||||
createdPokedexId = pokedex._id;
|
||||
createdPokedexName = pokedex.name;
|
||||
|
||||
// Populate pokedex_entries_mapping table with expected entries
|
||||
try {
|
||||
await populatePokedexMappings(event.locals.supabase, pokedex._id, pokedex);
|
||||
} catch (mappingError) {
|
||||
// Rollback: delete the partially-initialised pokedex to prevent dangling records
|
||||
console.error(
|
||||
`Failed to populate pokedex mappings for pokedex id="${createdPokedexId}" name="${createdPokedexName}":`,
|
||||
mappingError
|
||||
);
|
||||
try {
|
||||
await repo.delete(pokedex._id);
|
||||
console.log(
|
||||
`Successfully rolled back pokedex id="${createdPokedexId}" name="${createdPokedexName}"`
|
||||
);
|
||||
} catch (deleteError) {
|
||||
console.error(
|
||||
`Failed to rollback pokedex id="${createdPokedexId}" name="${createdPokedexName}":`,
|
||||
deleteError
|
||||
);
|
||||
}
|
||||
return json({ error: 'Failed to initialise Pokédex. Please try again.' }, { status: 500 });
|
||||
}
|
||||
|
||||
return json(pokedex);
|
||||
} catch (err) {
|
||||
console.error('Error in POST /api/pokedexes:', err);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Pokedex } from '$lib/models/Pokedex';
|
||||
import PokedexRepository from '$lib/repositories/PokedexRepository';
|
||||
import { requireAuth } from '$lib/utils/auth';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { recalculatePokedexMappings } from '$lib/services/PokedexMappingService';
|
||||
|
||||
// GET: Get single pokedex by ID
|
||||
export const GET = async (event: RequestEvent) => {
|
||||
@@ -57,12 +58,30 @@ export const PUT = async (event: RequestEvent) => {
|
||||
}
|
||||
|
||||
const repo = new PokedexRepository(event.locals.supabase, userId);
|
||||
|
||||
// Fetch the existing pokedex before update to compare values
|
||||
const existingPokedex = await repo.findById(id);
|
||||
if (!existingPokedex) {
|
||||
return json({ error: 'Pokedex not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const pokedex = await repo.update(id, data);
|
||||
|
||||
if (!pokedex) {
|
||||
return json({ error: 'Pokedex not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Recalculate pokedex_entries_mapping if configuration changed
|
||||
// Only recalculate if isFormDex or gameScope actually changed (these are the only fields that affect mapping)
|
||||
// Compare the persisted result (pokedex) to existingPokedex to catch changes that repo.update may normalize or default
|
||||
const configChanged =
|
||||
pokedex.isFormDex !== existingPokedex.isFormDex ||
|
||||
pokedex.gameScope !== existingPokedex.gameScope;
|
||||
|
||||
if (configChanged) {
|
||||
await recalculatePokedexMappings(event.locals.supabase, id, pokedex);
|
||||
}
|
||||
|
||||
return json(pokedex);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
/**
|
||||
* GET /api/stats
|
||||
*
|
||||
* Returns public statistics about the application:
|
||||
* - pokemonCaught: Total count of caught Pokémon
|
||||
* - users: Total count of users
|
||||
* - livingDexesCompleted: Total count of completed pokedexes
|
||||
*
|
||||
* Stats are cached for 24 hours to improve performance.
|
||||
* The first request after 24 hours will trigger a cache refresh.
|
||||
*/
|
||||
export const GET = async (event: RequestEvent) => {
|
||||
try {
|
||||
// Call the database function to get stats (with caching)
|
||||
const { data, error } = await event.locals.supabase.rpc('get_public_stats');
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching stats:', error);
|
||||
return json({ error: 'Failed to fetch stats' }, { status: 500 });
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return json({ error: 'No stats data found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const stats = data[0];
|
||||
return json({
|
||||
pokemonCaught: stats.pokemon_caught,
|
||||
users: stats.total_users,
|
||||
livingDexesCompleted: stats.completed_pokedexes,
|
||||
updatedAt: stats.updated_at
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error in GET /api/stats:', err);
|
||||
return json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import PokedexRepository from '$lib/repositories/PokedexRepository';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const { safeGetSession, supabase } = locals;
|
||||
const { session, user } = await safeGetSession();
|
||||
|
||||
// Require authentication
|
||||
if (!session || !user) {
|
||||
throw redirect(303, '/signin');
|
||||
}
|
||||
|
||||
// Fetch pokedexes for the authenticated user
|
||||
const repo = new PokedexRepository(supabase, user.id);
|
||||
const pokedexes = await repo.findAll();
|
||||
|
||||
return {
|
||||
pokedexes
|
||||
};
|
||||
};
|
||||
@@ -1,12 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { user } from '$lib/stores/user';
|
||||
import type { Pokedex } from '$lib/models/Pokedex';
|
||||
import PokedexCard from '$lib/components/pokedex/PokedexCard.svelte';
|
||||
import PokedexForm from '$lib/components/pokedex/PokedexForm.svelte';
|
||||
|
||||
let pokedexes: Pokedex[] = [];
|
||||
export let data;
|
||||
let { pokedexes } = data;
|
||||
let showModal = false;
|
||||
let editingPokedex: Pokedex | null = null;
|
||||
let formData: Partial<Pokedex> = {
|
||||
@@ -21,14 +20,6 @@
|
||||
|
||||
$: mode = editingPokedex ? ('edit' as const) : ('create' as const);
|
||||
|
||||
onMount(async () => {
|
||||
if (!$user) {
|
||||
goto('/signin');
|
||||
return;
|
||||
}
|
||||
await loadPokedexes();
|
||||
});
|
||||
|
||||
async function loadPokedexes() {
|
||||
const response = await fetch('/api/pokedexes', { credentials: 'include' });
|
||||
if (response.ok) {
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { user } from '$lib/stores/user.js';
|
||||
import { type User } from '@supabase/auth-js';
|
||||
|
||||
export let data;
|
||||
let { supabase } = data;
|
||||
$: ({ supabase } = data);
|
||||
|
||||
let localUser: User | null;
|
||||
const unsubscribe = user.subscribe((value) => {
|
||||
localUser = value;
|
||||
});
|
||||
onDestroy(unsubscribe);
|
||||
</script>
|
||||
|
||||
<div class="h-screen">
|
||||
{#if localUser}
|
||||
<div id="banner">
|
||||
<div id="avatar">
|
||||
<div
|
||||
class="avatar mt-10 ml-20 w-14 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2"
|
||||
>
|
||||
<div class="rounded-full"><img src="/OIG5.jpg" alt="profile icon" /></div>
|
||||
</div>
|
||||
<button class="btn btn-outline btn-primary">Upload new avatar</button>
|
||||
</div>
|
||||
<div id="personal-information">
|
||||
<p class="text-white font-mono text-3xl font-bold">{localUser?.email}</p>
|
||||
<p class="text-white font-mono text-3xl font-bold">Title</p>
|
||||
<p class="text-white font-mono text-3xl font-bold">Level</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="profile-content">
|
||||
<div id="pokemon-collection-summary">
|
||||
<div class="radial-progress top-0 left-0" style="--value:70;" role="progressbar">70%</div>
|
||||
<div class="caught-text top-20 left-30 font-mono text-xl">
|
||||
<p>Total Pokemon Caught</p>
|
||||
</div>
|
||||
|
||||
<h2 class="">Types Collected</h2>
|
||||
<ul>
|
||||
<li class="type-list-item font-mono">Fire</li>
|
||||
</ul>
|
||||
<br />
|
||||
any other relevant statistics or achievements
|
||||
</div>
|
||||
<div id="customisation-options">
|
||||
theme selection
|
||||
<br />
|
||||
profile background
|
||||
<br />
|
||||
any other features that enhance user experience and personalisation
|
||||
</div>
|
||||
<div id="social-sharing">
|
||||
options for users to share their profile or achievements on social media platforms,
|
||||
encouraging engagement and interaction with other users
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p>Please log in to view this page</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#banner {
|
||||
background: rgb(41, 106, 170);
|
||||
background: linear-gradient(0deg, rgba(41, 106, 170, 1) 0%, rgba(123, 225, 255, 1) 100%);
|
||||
}
|
||||
|
||||
#profile-content {
|
||||
background-color: rgb(255, 247, 238);
|
||||
color: black;
|
||||
}
|
||||
.ring {
|
||||
box-shadow: 0 0 0 4px rgb(149, 175, 230);
|
||||
}
|
||||
</style>
|
||||
@@ -97,10 +97,10 @@
|
||||
<p class="text-sm opacity-70 mt-2">
|
||||
Still having trouble?
|
||||
<a
|
||||
href="mailto:support@example.com"
|
||||
href="https://github.com/jcreek/LivingDexTracker/issues"
|
||||
class="link link-primary font-semibold hover:underline"
|
||||
>
|
||||
Contact support
|
||||
Raise an issue on our GitHub
|
||||
</a> for assistance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Create stats_cache table to store cached statistics
|
||||
-- This table will be updated once per day to improve performance
|
||||
|
||||
CREATE TABLE stats_cache (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
pokemon_caught BIGINT NOT NULL DEFAULT 0,
|
||||
total_users BIGINT NOT NULL DEFAULT 0,
|
||||
completed_pokedexes BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create index for faster lookups
|
||||
CREATE INDEX idx_stats_cache_updated_at ON stats_cache(updated_at DESC);
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE stats_cache ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS policies: Only allow reads (stats are public)
|
||||
CREATE POLICY "Anyone can view stats cache" ON stats_cache
|
||||
FOR SELECT USING (true);
|
||||
|
||||
-- No insert/update/delete policies - only database functions can modify this table
|
||||
@@ -0,0 +1,53 @@
|
||||
-- Create function to calculate and update stats in the cache table
|
||||
-- This function runs the expensive queries and stores the results
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_and_get_stats()
|
||||
RETURNS TABLE (
|
||||
pokemon_caught BIGINT,
|
||||
total_users BIGINT,
|
||||
completed_pokedexes BIGINT,
|
||||
updated_at TIMESTAMPTZ
|
||||
)
|
||||
SECURITY DEFINER
|
||||
SET search_path = public, pg_temp
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_pokemon_caught BIGINT;
|
||||
v_total_users BIGINT;
|
||||
v_completed_pokedexes BIGINT;
|
||||
v_updated_at TIMESTAMPTZ := NOW();
|
||||
BEGIN
|
||||
-- Calculate current stats: count of caught Pokémon
|
||||
SELECT COUNT(*) INTO v_pokemon_caught
|
||||
FROM catch_records
|
||||
WHERE caught = true;
|
||||
|
||||
-- Calculate current stats: count of total users
|
||||
SELECT COUNT(*) INTO v_total_users
|
||||
FROM auth.users;
|
||||
|
||||
-- Calculate current stats: count of completed pokedexes
|
||||
-- A pokedex is completed when all its entries have caught = true
|
||||
SELECT COUNT(*) INTO v_completed_pokedexes
|
||||
FROM (
|
||||
SELECT "pokedexId"
|
||||
FROM catch_records
|
||||
GROUP BY "pokedexId"
|
||||
HAVING COUNT(*) = COUNT(*) FILTER (WHERE caught = true)
|
||||
) completed;
|
||||
|
||||
-- Update cache using UPSERT pattern
|
||||
INSERT INTO stats_cache (id, pokemon_caught, total_users, completed_pokedexes, updated_at)
|
||||
VALUES (1, v_pokemon_caught, v_total_users, v_completed_pokedexes, v_updated_at)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET pokemon_caught = EXCLUDED.pokemon_caught,
|
||||
total_users = EXCLUDED.total_users,
|
||||
completed_pokedexes = EXCLUDED.completed_pokedexes,
|
||||
updated_at = v_updated_at;
|
||||
|
||||
-- Return the stats
|
||||
RETURN QUERY
|
||||
SELECT v_pokemon_caught, v_total_users, v_completed_pokedexes, v_updated_at AS updated_at;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,36 @@
|
||||
-- Create function to get public stats with caching
|
||||
-- Returns cached stats if fresh (within 24 hours), otherwise refreshes cache
|
||||
|
||||
CREATE OR REPLACE FUNCTION get_public_stats()
|
||||
RETURNS TABLE (
|
||||
pokemon_caught BIGINT,
|
||||
total_users BIGINT,
|
||||
completed_pokedexes BIGINT,
|
||||
updated_at TIMESTAMPTZ
|
||||
)
|
||||
SECURITY DEFINER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_cache_exists BOOLEAN;
|
||||
BEGIN
|
||||
-- Check if cache exists and is fresh (within 24 hours)
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM stats_cache
|
||||
WHERE stats_cache.updated_at > NOW() - INTERVAL '24 hours'
|
||||
) INTO v_cache_exists;
|
||||
|
||||
IF v_cache_exists THEN
|
||||
-- Return cached stats
|
||||
RETURN QUERY
|
||||
SELECT sc.pokemon_caught, sc.total_users, sc.completed_pokedexes, sc.updated_at
|
||||
FROM stats_cache sc
|
||||
ORDER BY sc.updated_at DESC
|
||||
LIMIT 1;
|
||||
ELSE
|
||||
-- Cache is stale or doesn't exist, update it
|
||||
RETURN QUERY
|
||||
SELECT * FROM update_and_get_stats();
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,95 @@
|
||||
-- Fix ambiguous column reference errors in stats functions.
|
||||
--
|
||||
-- If earlier migrations have already been applied, editing them will not update
|
||||
-- the deployed database. This migration re-defines the functions using
|
||||
-- CREATE OR REPLACE so the corrected definitions take effect.
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_and_get_stats()
|
||||
RETURNS TABLE (
|
||||
pokemon_caught BIGINT,
|
||||
total_users BIGINT,
|
||||
completed_pokedexes BIGINT,
|
||||
updated_at TIMESTAMPTZ
|
||||
)
|
||||
SECURITY DEFINER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_pokemon_caught BIGINT;
|
||||
v_total_users BIGINT;
|
||||
v_completed_pokedexes BIGINT;
|
||||
v_updated_at TIMESTAMPTZ := NOW();
|
||||
BEGIN
|
||||
-- Calculate current stats: count of caught Pokémon
|
||||
SELECT COUNT(*) INTO v_pokemon_caught
|
||||
FROM catch_records
|
||||
WHERE caught = true;
|
||||
|
||||
-- Calculate current stats: count of total users
|
||||
SELECT COUNT(*) INTO v_total_users
|
||||
FROM auth.users;
|
||||
|
||||
-- Calculate current stats: count of completed pokedexes
|
||||
-- A pokedex is completed when all its expected entries have caught = true
|
||||
-- Join catch_records to pokedex_entries_mapping to validate against explicit expected entries
|
||||
SELECT COUNT(*) INTO v_completed_pokedexes
|
||||
FROM (
|
||||
SELECT cr."pokedexId"
|
||||
FROM catch_records cr
|
||||
INNER JOIN pokedex_entries_mapping pem
|
||||
ON cr."pokedexId" = pem."pokedexId"
|
||||
AND cr."pokedexEntryId" = pem."pokedexEntryId"
|
||||
GROUP BY cr."pokedexId"
|
||||
HAVING COUNT(*) = COUNT(*) FILTER (WHERE cr.caught = true)
|
||||
AND COUNT(*) = (SELECT COUNT(*) FROM pokedex_entries_mapping WHERE "pokedexId" = cr."pokedexId")
|
||||
) completed;
|
||||
|
||||
-- Update cache using UPSERT pattern
|
||||
INSERT INTO stats_cache (id, pokemon_caught, total_users, completed_pokedexes, updated_at)
|
||||
VALUES (1, v_pokemon_caught, v_total_users, v_completed_pokedexes, v_updated_at)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET pokemon_caught = EXCLUDED.pokemon_caught,
|
||||
total_users = EXCLUDED.total_users,
|
||||
completed_pokedexes = EXCLUDED.completed_pokedexes,
|
||||
updated_at = v_updated_at;
|
||||
|
||||
-- Return stats
|
||||
RETURN QUERY
|
||||
SELECT v_pokemon_caught, v_total_users, v_completed_pokedexes, v_updated_at AS updated_at;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION get_public_stats()
|
||||
RETURNS TABLE (
|
||||
pokemon_caught BIGINT,
|
||||
total_users BIGINT,
|
||||
completed_pokedexes BIGINT,
|
||||
updated_at TIMESTAMPTZ
|
||||
)
|
||||
SECURITY DEFINER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_cache_exists BOOLEAN;
|
||||
BEGIN
|
||||
-- Check if cache exists and is fresh (within 24 hours)
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM stats_cache
|
||||
WHERE stats_cache.updated_at > NOW() - INTERVAL '24 hours'
|
||||
) INTO v_cache_exists;
|
||||
|
||||
IF v_cache_exists THEN
|
||||
-- Return cached stats (qualify updated_at to avoid ambiguity with output parameter)
|
||||
RETURN QUERY
|
||||
SELECT sc.pokemon_caught, sc.total_users, sc.completed_pokedexes, sc.updated_at
|
||||
FROM stats_cache sc
|
||||
ORDER BY sc.updated_at DESC
|
||||
LIMIT 1;
|
||||
ELSE
|
||||
-- Cache is stale or doesn't exist, update it
|
||||
RETURN QUERY
|
||||
SELECT * FROM update_and_get_stats();
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,46 @@
|
||||
-- Add pokedex_entries_mapping table to explicitly define which entries belong to each pokedex
|
||||
-- This fixes the stats functions ambiguity issue by providing an explicit definition of expected entries
|
||||
|
||||
-- Create pokedex_entries_mapping table
|
||||
CREATE TABLE pokedex_entries_mapping (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
"pokedexId" UUID NOT NULL REFERENCES pokedexes(id) ON DELETE CASCADE,
|
||||
"pokedexEntryId" BIGINT NOT NULL REFERENCES pokedex_entries(id) ON DELETE CASCADE,
|
||||
UNIQUE("pokedexId", "pokedexEntryId")
|
||||
);
|
||||
|
||||
-- Create indexes for performance
|
||||
CREATE INDEX idx_pokedex_entries_mapping_pokedex_id ON pokedex_entries_mapping("pokedexId");
|
||||
CREATE INDEX idx_pokedex_entries_mapping_entry_id ON pokedex_entries_mapping("pokedexEntryId");
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE pokedex_entries_mapping ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS Policies
|
||||
|
||||
-- Public read access (mapping is reference data)
|
||||
CREATE POLICY "Anyone can view pokedex entries mapping"
|
||||
ON pokedex_entries_mapping
|
||||
FOR SELECT USING (true);
|
||||
|
||||
-- Users can only insert mappings for their own pokedexes
|
||||
CREATE POLICY "Users can insert own pokedex mappings"
|
||||
ON pokedex_entries_mapping
|
||||
FOR INSERT WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM pokedexes
|
||||
WHERE pokedexes.id = pokedex_entries_mapping."pokedexId"
|
||||
AND pokedexes."userId" = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Users can only delete mappings for their own pokedexes
|
||||
CREATE POLICY "Users can delete own pokedex mappings"
|
||||
ON pokedex_entries_mapping
|
||||
FOR DELETE USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM pokedexes
|
||||
WHERE pokedexes.id = pokedex_entries_mapping."pokedexId"
|
||||
AND pokedexes."userId" = auth.uid()
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Populate pokedex_entries_mapping for existing pokedexes
|
||||
-- This is a one-time migration to backfill data for pokedexes created before the mapping table
|
||||
|
||||
-- Insert mappings for all existing pokedexes
|
||||
-- Apply the same filtering logic that the application uses to determine expected entries
|
||||
INSERT INTO pokedex_entries_mapping ("pokedexId", "pokedexEntryId")
|
||||
SELECT
|
||||
p.id AS "pokedexId",
|
||||
pe.id AS "pokedexEntryId"
|
||||
FROM pokedexes p
|
||||
LEFT JOIN region_game_mappings rg ON rg.game = p."gameScope"
|
||||
CROSS JOIN pokedex_entries pe
|
||||
WHERE
|
||||
-- Form filter: if isFormDex is false, only include base forms
|
||||
-- Base forms have form IS NULL, except Unown which has no base form (use 'A')
|
||||
(p."isFormDex" = true OR (pe.form IS NULL OR (pe.pokemon = 'Unown' AND pe.form = 'A')))
|
||||
-- Game scope filter: if gameScope is specified, filter by game
|
||||
AND (p."gameScope" IS NULL OR pe."gamesToCatchIn" @> ARRAY[p."gameScope"]::TEXT[])
|
||||
-- Region filter: only enforce when a mapping exists
|
||||
AND (p."gameScope" IS NULL OR rg.region IS NULL OR pe."regionToCatchIn" = rg.region)
|
||||
ON CONFLICT ("pokedexId", "pokedexEntryId") DO NOTHING;
|
||||
|
||||
-- Add comment to document this migration
|
||||
COMMENT ON TABLE pokedex_entries_mapping IS 'Junction table defining which pokedex_entries belong to each pokedex. Used for accurate stats calculation.';
|
||||
@@ -0,0 +1,44 @@
|
||||
-- Create function to atomically recalculate pokedex entries mapping
|
||||
-- This function replaces the two-step delete+insert process with a single atomic operation
|
||||
-- to prevent orphaned pokedexes if repopulation fails or yields an empty list
|
||||
|
||||
CREATE OR REPLACE FUNCTION recalculate_pokedex_mappings(
|
||||
p_pokedex_id UUID,
|
||||
p_entry_ids BIGINT[]
|
||||
)
|
||||
RETURNS VOID
|
||||
SECURITY DEFINER
|
||||
SET search_path = public, pg_temp
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user_id UUID;
|
||||
BEGIN
|
||||
-- Validate that entry_ids is not empty to avoid accidental deletion
|
||||
IF p_entry_ids IS NULL OR array_length(p_entry_ids, 1) IS NULL THEN
|
||||
RAISE EXCEPTION 'entry_ids cannot be null or empty';
|
||||
END IF;
|
||||
|
||||
-- Verify that the caller owns the target pokedex
|
||||
SELECT "userId" INTO v_user_id
|
||||
FROM pokedexes
|
||||
WHERE id = p_pokedex_id;
|
||||
|
||||
IF v_user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'pokedex not found';
|
||||
END IF;
|
||||
|
||||
IF auth.uid() IS NULL OR v_user_id IS DISTINCT FROM auth.uid() THEN
|
||||
RAISE EXCEPTION 'not authorized: you do not own this pokedex';
|
||||
END IF;
|
||||
|
||||
-- Delete old mappings and insert new mappings in a single transaction
|
||||
-- This ensures atomicity - either both operations succeed or both fail
|
||||
DELETE FROM pokedex_entries_mapping
|
||||
WHERE "pokedexId" = p_pokedex_id;
|
||||
|
||||
INSERT INTO pokedex_entries_mapping ("pokedexId", "pokedexEntryId")
|
||||
SELECT p_pokedex_id, unnest(p_entry_ids)
|
||||
ON CONFLICT ("pokedexId", "pokedexEntryId") DO NOTHING;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Fix RLS SELECT policy on pokedex_entries_mapping to restrict reads to owning user
|
||||
-- This prevents exposing all user mappings and ensures consistency with INSERT/DELETE policies
|
||||
|
||||
-- Drop the overly permissive public SELECT policy
|
||||
DROP POLICY IF EXISTS "Anyone can view pokedex entries mapping" ON pokedex_entries_mapping;
|
||||
|
||||
-- Create a new SELECT policy that restricts reads to the owning user
|
||||
CREATE POLICY "Users can view own pokedex entries mapping"
|
||||
ON pokedex_entries_mapping
|
||||
FOR SELECT USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM pokedexes
|
||||
WHERE pokedexes.id = pokedex_entries_mapping."pokedexId"
|
||||
AND pokedexes."userId" = auth.uid()
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user