feat(#67): Add support for multiple pokedexes per user

This commit is contained in:
Josh Creek
2026-01-04 22:06:36 +00:00
parent 578df57a45
commit 19a571e315
28 changed files with 1401 additions and 215 deletions
+1 -1
View File
@@ -149,7 +149,7 @@
>
{#if localUser}
<li>
<a href="/mydex"> My Dex </a>
<a href="/my-pokedexes"> My Pokédexes </a>
</li>
<li>
<a href="/profile"> Profile </a>
-52
View File
@@ -1,52 +0,0 @@
import { json } from '@sveltejs/kit';
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
import { getOptionalUserId } from '$lib/utils/auth';
import type { RequestEvent } from '@sveltejs/kit';
export const GET = async (event: RequestEvent) => {
const { url } = event;
const page = parseInt(url.searchParams.get('page') || '1', 10);
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
const enableForms = url.searchParams.get('enableForms') === 'true';
const region = url.searchParams.get('region') || '';
const game = url.searchParams.get('game') || '';
try {
// Get userId if authenticated, null if not
const userId = await getOptionalUserId(event);
// If user is authenticated, set their session on the Supabase client
if (userId) {
const { session } = await event.locals.safeGetSession();
if (session) {
await event.locals.supabase.auth.setSession(session);
}
}
const repo = new CombinedDataRepository(event.locals.supabase, userId || '');
const combinedData = await repo.findCombinedData(
userId || '',
page,
limit,
enableForms,
region,
game
);
// Return empty array instead of 404 for better UX
if (combinedData.length === 0) {
return json({ combinedData: [], totalPages: 0 });
}
const totalCount = await repo.countCombinedData(enableForms, region, game);
const totalPages = Math.ceil(totalCount / limit);
return json({ combinedData, totalPages });
} catch (err) {
console.error(err);
if (err && typeof err === 'object' && 'status' in err) {
throw err;
}
return json({ error: 'Internal Server Error' }, { status: 500 });
}
};
@@ -1,36 +0,0 @@
import { json } from '@sveltejs/kit';
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
import { getOptionalUserId } from '$lib/utils/auth';
import type { RequestEvent } from '@sveltejs/kit';
export const GET = async (event: RequestEvent) => {
const { url } = event;
const enableForms = url.searchParams.get('enableForms') === 'true';
const region = url.searchParams.get('region') || '';
const game = url.searchParams.get('game') || '';
try {
// Get userId if authenticated, null if not
const userId = await getOptionalUserId(event);
// If user is authenticated, set their session on the Supabase client
if (userId) {
const { session } = await event.locals.safeGetSession();
if (session) {
await event.locals.supabase.auth.setSession(session);
}
}
const repo = new CombinedDataRepository(event.locals.supabase, userId || '');
const combinedData = await repo.findAllCombinedData(userId || '', enableForms, region, game);
// Return empty array instead of 404 for better UX
return json(combinedData);
} catch (err) {
console.error(err);
if (err && typeof err === 'object' && 'status' in err) {
throw err;
}
return json({ error: 'Internal Server Error' }, { status: 500 });
}
};
+47
View File
@@ -0,0 +1,47 @@
import { json } from '@sveltejs/kit';
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';
// GET: List all pokedexes for user
export const GET = async (event: RequestEvent) => {
try {
const userId = await requireAuth(event);
const repo = new PokedexRepository(event.locals.supabase, userId);
const pokedexes = await repo.findAll();
return json(pokedexes);
} catch (err) {
console.error('Error in GET /api/pokedexes:', err);
if (err && typeof err === 'object' && 'status' in err) {
throw err;
}
return json({ error: 'Internal Server Error' }, { status: 500 });
}
};
// POST: Create new pokedex
export const POST = async (event: RequestEvent) => {
try {
console.log('POST /api/pokedexes - Starting request');
const userId = await requireAuth(event);
console.log('POST /api/pokedexes - User authenticated:', userId);
const data: Partial<Pokedex> = await event.request.json();
console.log('POST /api/pokedexes - Request data:', data);
data.userId = userId;
const repo = new PokedexRepository(event.locals.supabase, userId);
const pokedex = await repo.create(data);
console.log('POST /api/pokedexes - Pokédex created:', pokedex._id);
return json(pokedex);
} catch (err) {
console.error('Error in POST /api/pokedexes:', err);
if (err && typeof err === 'object' && 'status' in err) {
throw err;
}
return json({ error: 'Internal Server Error' }, { status: 500 });
}
};
+107
View File
@@ -0,0 +1,107 @@
import { json } from '@sveltejs/kit';
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';
// GET: Get single pokedex by ID
export const GET = async (event: RequestEvent) => {
try {
const userId = await requireAuth(event);
const { id } = event.params;
if (!id) {
return json({ error: 'Pokedex ID is required' }, { status: 400 });
}
const { session } = await event.locals.safeGetSession();
if (session) {
await event.locals.supabase.auth.setSession(session);
}
const repo = new PokedexRepository(event.locals.supabase, userId);
const pokedex = await repo.findById(id);
if (!pokedex) {
// RLS ensures user can only access own pokédexes
// Return 404 whether it doesn't exist or user doesn't own it (don't leak info)
return json({ error: 'Pokedex not found' }, { status: 404 });
}
return json(pokedex);
} catch (err) {
console.error(err);
if (err && typeof err === 'object' && 'status' in err) {
throw err;
}
return json({ error: 'Internal Server Error' }, { status: 500 });
}
};
// PUT: Update pokedex
export const PUT = async (event: RequestEvent) => {
try {
const userId = await requireAuth(event);
const { id } = event.params;
const data: Partial<Pokedex> = await event.request.json();
if (!id) {
return json({ error: 'Pokedex ID is required' }, { status: 400 });
}
const { session } = await event.locals.safeGetSession();
if (session) {
await event.locals.supabase.auth.setSession(session);
}
const repo = new PokedexRepository(event.locals.supabase, userId);
const pokedex = await repo.update(id, data);
if (!pokedex) {
return json({ error: 'Pokedex not found' }, { status: 404 });
}
return json(pokedex);
} catch (err) {
console.error(err);
if (err && typeof err === 'object' && 'status' in err) {
throw err;
}
return json({ error: 'Internal Server Error' }, { status: 500 });
}
};
// DELETE: Delete pokedex
export const DELETE = async (event: RequestEvent) => {
try {
const userId = await requireAuth(event);
const { id } = event.params;
if (!id) {
return json({ error: 'Pokedex ID is required' }, { status: 400 });
}
const { session } = await event.locals.safeGetSession();
if (session) {
await event.locals.supabase.auth.setSession(session);
}
const repo = new PokedexRepository(event.locals.supabase, userId);
// Verify pokedex exists and user owns it
const pokedex = await repo.findById(id);
if (!pokedex) {
return json({ error: 'Pokedex not found' }, { status: 404 });
}
await repo.delete(id);
return json({ success: true });
} catch (err) {
console.error(err);
if (err && typeof err === 'object' && 'status' in err) {
throw err;
}
return json({ error: 'Internal Server Error' }, { status: 500 });
}
};
@@ -1,22 +1,35 @@
import { json } from '@sveltejs/kit';
import { type CatchRecord } from '$lib/models/CatchRecord';
import CatchRecordRepository from '$lib/repositories/CatchRecordRepository';
import PokedexRepository from '$lib/repositories/PokedexRepository';
import { requireAuth } from '$lib/utils/auth';
import type { RequestEvent } from '@sveltejs/kit';
// GET: Get catch records for specific pokédex
export const GET = async (event: RequestEvent) => {
try {
const userId = await requireAuth(event);
const { id: pokedexId } = event.params;
if (!pokedexId) {
return json({ error: 'Pokedex ID is required' }, { status: 400 });
}
// Get the user's session and set it on the Supabase client
const { session } = await event.locals.safeGetSession();
if (session) {
await event.locals.supabase.auth.setSession(session);
}
const repo = new CatchRecordRepository(event.locals.supabase, userId);
const catchData = await repo.findByUserId(userId);
// order by pokedexEntryId property, ascending
// Verify user owns this pokédex (RLS will also check, but explicit verification is better UX)
const pokedexRepo = new PokedexRepository(event.locals.supabase, userId);
const pokedex = await pokedexRepo.findById(pokedexId);
if (!pokedex) {
return json({ error: 'Pokedex not found' }, { status: 404 });
}
const repo = new CatchRecordRepository(event.locals.supabase, userId, pokedexId);
const catchData = await repo.findAll();
const sortedData = catchData.sort(
(a, b) => Number(a.pokedexEntryId) - Number(b.pokedexEntryId)
);
@@ -30,24 +43,35 @@ export const GET = async (event: RequestEvent) => {
}
};
// PUT: Upsert single catch record for specific pokédex
export const PUT = async (event: RequestEvent) => {
try {
// Check if we can get a session first
const { session, user } = await event.locals.safeGetSession();
const userId = await requireAuth(event);
const { id: pokedexId } = event.params;
const data: Partial<CatchRecord> = await event.request.json();
// Get the user's session and set it on the Supabase client
if (!pokedexId) {
return json({ error: 'Pokedex ID is required' }, { status: 400 });
}
const { session } = await event.locals.safeGetSession();
if (session) {
await event.locals.supabase.auth.setSession(session);
}
// Ensure the userId is set to the authenticated user
data.userId = userId;
// Verify user owns this pokédex
const pokedexRepo = new PokedexRepository(event.locals.supabase, userId);
const pokedex = await pokedexRepo.findById(pokedexId);
const repo = new CatchRecordRepository(event.locals.supabase, userId);
if (!pokedex) {
return json({ error: 'Pokedex not found' }, { status: 404 });
}
// Ensure the data is scoped to this pokédex and user
data.userId = userId;
data.pokedexId = pokedexId;
const repo = new CatchRecordRepository(event.locals.supabase, userId, pokedexId);
const upsertedRecord = await repo.upsert(data);
return json(upsertedRecord);
} catch (err) {
@@ -59,23 +83,37 @@ export const PUT = async (event: RequestEvent) => {
}
};
// POST: Bulk upsert catch records for specific pokédex
export const POST = async (event: RequestEvent) => {
try {
const userId = await requireAuth(event);
const { id: pokedexId } = event.params;
const records: Partial<CatchRecord>[] = await event.request.json();
// Get the user's session and set it on the Supabase client
if (!pokedexId) {
return json({ error: 'Pokedex ID is required' }, { status: 400 });
}
const { session } = await event.locals.safeGetSession();
if (session) {
await event.locals.supabase.auth.setSession(session);
}
const repo = new CatchRecordRepository(event.locals.supabase, userId);
// Verify user owns this pokédex
const pokedexRepo = new PokedexRepository(event.locals.supabase, userId);
const pokedex = await pokedexRepo.findById(pokedexId);
if (!pokedex) {
return json({ error: 'Pokedex not found' }, { status: 404 });
}
const repo = new CatchRecordRepository(event.locals.supabase, userId, pokedexId);
const insertedRecords = [];
for (const record of records) {
// Ensure the userId is set to the authenticated user
// Ensure the data is scoped to this pokédex and user
record.userId = userId;
record.pokedexId = pokedexId;
const upsertedRecord = await repo.upsert(record);
insertedRecords.push(upsertedRecord);
}
@@ -0,0 +1,72 @@
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';
// GET: Get combined data (pokédex entries + catch records) for specific pokédex
export const GET = async (event: RequestEvent) => {
try {
const userId = await getOptionalUserId(event);
const { id: pokedexId } = event.params;
if (!pokedexId) {
return json({ error: 'Pokedex ID is required' }, { status: 400 });
}
// Parse query parameters
const url = new URL(event.request.url);
const page = parseInt(url.searchParams.get('page') || '1');
const limit = parseInt(url.searchParams.get('limit') || '20');
const enableForms = url.searchParams.get('enableForms') === 'true';
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 {
// 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 repo = new CombinedDataRepository(event.locals.supabase, userId, pokedexId);
// Get paginated combined data
const combinedData = await repo.findCombinedData(
userId!,
page,
limit,
enableForms,
region,
effectiveGame
);
// Get total count for pagination
const totalCount = await repo.countCombinedData(enableForms, region, effectiveGame);
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) {
throw err;
}
return json({ error: 'Internal Server Error' }, { status: 500 });
}
};
+196
View File
@@ -0,0 +1,196 @@
<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[] = [];
let showModal = false;
let editingPokedex: Pokedex | null = null;
let formData: Partial<Pokedex> = {
name: '',
description: '',
isLivingDex: false,
isShinyDex: false,
isOriginDex: false,
isFormDex: false,
gameScope: null
};
$: 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) {
pokedexes = await response.json();
} else {
console.error('Failed to load pokédexes:', response.status, await response.text());
}
}
function openCreateModal() {
editingPokedex = null;
formData = {
name: '',
description: '',
isLivingDex: false,
isShinyDex: false,
isOriginDex: false,
isFormDex: false,
gameScope: null
};
showModal = true;
}
function openEditModal(pokedex: Pokedex) {
editingPokedex = pokedex;
formData = { ...pokedex };
showModal = true;
}
function closeModal() {
showModal = false;
editingPokedex = null;
}
async function handleSubmit() {
try {
if (editingPokedex) {
// Update existing pokédex
const response = await fetch(`/api/pokedexes/${editingPokedex._id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
credentials: 'include'
});
if (!response.ok) {
const errorText = await response.text();
console.error('Failed to update pokédex:', response.status, errorText);
alert(`Failed to update pokédex: ${errorText}`);
return;
}
} else {
// Create new pokédex
const response = await fetch('/api/pokedexes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
credentials: 'include'
});
if (!response.ok) {
const errorText = await response.text();
console.error('Failed to create pokédex:', response.status, errorText);
alert(`Failed to create pokédex: ${errorText}`);
return;
}
// If this is the first pokédex, redirect to it after creation
const createdPokedex = await response.json();
if (pokedexes.length === 0) {
goto(`/pokedex/${createdPokedex._id}`);
return;
}
}
closeModal();
await loadPokedexes();
} catch (error) {
console.error('Error saving pokédex:', error);
alert('An error occurred');
}
}
async function handleDelete(pokedex: Pokedex) {
// TODO: Get catch record count and show in confirmation
const confirmed = confirm(
`Are you sure you want to delete "${pokedex.name}"? This will also delete all associated catch records. This action cannot be undone.`
);
if (!confirmed) return;
try {
const response = await fetch(`/api/pokedexes/${pokedex._id}`, {
method: 'DELETE',
credentials: 'include'
});
if (!response.ok) {
const errorText = await response.text();
console.error('Failed to delete pokédex:', response.status, errorText);
alert(`Failed to delete pokédex: ${errorText}`);
return;
}
await loadPokedexes();
} catch (error) {
console.error('Error deleting pokédex:', error);
alert('An error occurred');
}
}
function handleView(pokedex: Pokedex) {
goto(`/pokedex/${pokedex._id}`);
}
</script>
<svelte:head>
<title>My Pokédexes - Living Dex Tracker</title>
</svelte:head>
<div class="container mx-auto p-4">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">My Pokédexes</h1>
<button class="btn btn-primary" on:click={openCreateModal}> Create New Pokédex </button>
</div>
{#if pokedexes.length === 0}
<div class="text-center py-12">
<h2 class="text-2xl font-semibold mb-4">You haven't created any pokédexes yet!</h2>
<p class="mb-6">Get started by creating your first pokédex to track your collection.</p>
<button class="btn btn-primary btn-lg" on:click={openCreateModal}>
Create Your First Pokédex
</button>
</div>
{:else}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{#each pokedexes as pokedex (pokedex._id)}
<PokedexCard
{pokedex}
onEdit={() => openEditModal(pokedex)}
onDelete={() => handleDelete(pokedex)}
onView={() => handleView(pokedex)}
/>
{/each}
</div>
{/if}
</div>
<!-- Modal -->
{#if showModal}
<div class="modal modal-open">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">
{mode === 'create' ? 'Create New Pokédex' : 'Edit Pokédex'}
</h3>
<PokedexForm
bind:pokedex={formData}
{mode}
onSubmit={handleSubmit}
onCancel={closeModal}
/>
</div>
<div class="modal-backdrop" on:click={closeModal}></div>
</div>
{/if}
+28
View File
@@ -0,0 +1,28 @@
import { error, redirect } from '@sveltejs/kit';
import PokedexRepository from '$lib/repositories/PokedexRepository';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
const { safeGetSession, supabase } = locals;
const { session, user } = await safeGetSession();
// Require authentication
if (!session || !user) {
throw redirect(303, '/signin');
}
const { id } = params;
// Fetch pokédex to verify ownership (RLS will also block, but we want a proper 404)
const repo = new PokedexRepository(supabase, user.id);
const pokedex = await repo.findById(id);
if (!pokedex) {
// Either doesn't exist or user doesn't own it
throw error(404, 'Pokédex not found');
}
return {
pokedex
};
};
@@ -12,7 +12,9 @@
export let data: PageData;
$: ({ supabase, session } = data);
// Get pokédex from server load
$: pokedex = data.pokedex;
$: pokedexId = pokedex._id;
let combinedData = null as CombinedData[] | null;
let currentPage = 1 as number;
@@ -64,20 +66,24 @@
if (setCombinedDataToNull) {
combinedData = null;
}
let endpoint = viewAsBoxes
? `/api/combined-data/all?enableForms=${showForms}&region=${catchRegion}&game=${catchGame}`
: `/api/combined-data?page=${currentPage}&limit=${itemsPerPage}&enableForms=${showForms}&region=${catchRegion}&game=${catchGame}`;
// Use new pokédex-scoped endpoint
let endpoint = `/api/pokedexes/${pokedexId}/combined-data?page=${currentPage}&limit=${viewAsBoxes ? 9999 : itemsPerPage}&enableForms=${showForms}&region=${catchRegion}&game=${catchGame}`;
const response = await fetch(endpoint);
const data = await response.json();
if (data.error) {
const fetchedData = await response.json();
if (fetchedData.error) {
failedToLoad = true;
return;
}
combinedData = viewAsBoxes ? data : data.combinedData;
totalPages = data.totalPages || 0;
combinedData = fetchedData.combinedData;
totalPages = fetchedData.totalPages || 0;
if (viewAsBoxes) {
boxNumbers = [
...new Set(combinedData.map(({ pokedexEntry }) => pokedexEntry[currentPlacement].box))
...new Set(
combinedData.map(({ pokedexEntry }) =>
showForms ? pokedexEntry.boxPlacementForms.box : pokedexEntry.boxPlacement.box
)
)
].filter(Boolean);
}
}
@@ -99,7 +105,8 @@
};
try {
const response = await fetch('/api/catch-records', requestOptions);
// Use new pokédex-scoped endpoint
const response = await fetch(`/api/pokedexes/${pokedexId}/catch-records`, requestOptions);
if (!response.ok) {
const errorText = await response.text();
console.error('Server response:', response.status, errorText);
@@ -121,13 +128,17 @@
inHome: boolean | null = null
) {
let catchRecordsToUpdate = combinedData
.filter(({ pokedexEntry }) => pokedexEntry[currentPlacement].box === boxNumber)
.filter(({ pokedexEntry }) =>
(showForms ? pokedexEntry.boxPlacementForms.box : pokedexEntry.boxPlacement.box) ===
boxNumber
)
.map(({ pokedexEntry, catchRecord }) => {
// Create default record if null
const baseRecord = catchRecord || {
_id: '',
userId: localUser?.id || '',
pokedexEntryId: pokedexEntry._id,
pokedexId: pokedexId,
haveToEvolve: false,
caught: false,
inHome: false,
@@ -150,7 +161,8 @@
}
return updatedRecord;
});
await fetch('/api/catch-records', {
// Use new pokédex-scoped endpoint
await fetch(`/api/pokedexes/${pokedexId}/catch-records`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@@ -198,6 +210,7 @@
const newCatchRecords = pokedexEntries.map((entry: PokedexEntry) => ({
userId: localUser?.id,
pokedexEntryId: entry._id,
pokedexId: pokedexId,
haveToEvolve: false,
caught: false,
inHome: false,
@@ -221,7 +234,8 @@
};
try {
const response = await fetch('/api/catch-records', requestOptions);
// Use new pokédex-scoped endpoint
const response = await fetch(`/api/pokedexes/${pokedexId}/catch-records`, requestOptions);
if (!response.ok) {
throw new Error('Failed to create catch records');
}
@@ -253,7 +267,7 @@
</script>
<svelte:head>
<title>Living Dex Tracker - My Dex</title>
<title>{pokedex.name} - Living Dex Tracker</title>
</svelte:head>
{#if !localUser}
@@ -272,6 +286,7 @@
{#if viewAsBoxes}
<PokedexViewBoxes
bind:showShiny
bind:showForms
bind:combinedData
bind:boxNumbers
bind:currentPlacement
@@ -293,6 +308,7 @@
bind:totalRecordsCreated
bind:failedToLoad
userId={localUser?.id}
pokedexId={pokedexId}
{updateACatch}
{createCatchRecords}
/>
@@ -301,6 +317,7 @@
<div class="drawer-side lg:relative">
<label for="my-drawer" class="drawer-overlay lg:hidden"></label>
<PokedexSidebar
{pokedex}
bind:viewAsBoxes
bind:currentPage
bind:itemsPerPage
+1 -1
View File
@@ -24,7 +24,7 @@
if (session) {
localUser = session.user;
user.set(localUser);
await goto('/mydex', { replace: true });
await goto('/my-pokedexes');
} else {
localUser = null;
user.set(localUser);