diff --git a/src/routes/(app)/mappools/+page.server.ts b/src/routes/(app)/mappools/+page.server.ts new file mode 100644 index 0000000..0120554 --- /dev/null +++ b/src/routes/(app)/mappools/+page.server.ts @@ -0,0 +1,32 @@ +import { db } from '$lib/server/db'; +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { mappool } from '$lib/server/db/schema'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!locals.user) redirect(302, '/'); + + const mappools = await db.query.mappool.findMany({ + with: { slots: true }, + orderBy: (m, { desc }) => [desc(m.createdAt)] + }); + + return { mappools }; +}; + +export const actions: Actions = { + create: async ({ request, locals }) => { + if (!locals.user) redirect(302, '/'); + + const form = await request.formData(); + const name = form.get('name')?.toString()?.trim(); + if (!name) return { error: 'Name is required' }; + + const [created] = await db + .insert(mappool) + .values({ name, createdBy: locals.user.id }) + .returning(); + + redirect(303, `/mappools/${created.id}`); + } +}; diff --git a/src/routes/(app)/mappools/+page.svelte b/src/routes/(app)/mappools/+page.svelte new file mode 100644 index 0000000..d54b111 --- /dev/null +++ b/src/routes/(app)/mappools/+page.svelte @@ -0,0 +1,66 @@ + + +
+
+
+

Mappools

+

Create and manage your beatmap pools

+
+ +
+ + {#if creating} +
+ + +
+ {/if} + +
+ {#each data.mappools as pool} + +
+

{pool.name}

+

+ {pool.slots.length} map{pool.slots.length !== 1 ? 's' : ''} + · Created {new Date(pool.createdAt).toLocaleDateString()} +

+
+ → +
+ {:else} +
+

No mappools yet. Create one to get started.

+
+ {/each} +
+
diff --git a/src/routes/(app)/mappools/[id]/+page.server.ts b/src/routes/(app)/mappools/[id]/+page.server.ts new file mode 100644 index 0000000..e2a05b0 --- /dev/null +++ b/src/routes/(app)/mappools/[id]/+page.server.ts @@ -0,0 +1,118 @@ +import { db } from '$lib/server/db'; +import { mappool, mappoolSlot } from '$lib/server/db/schema'; +import { eq, and } from 'drizzle-orm'; +import { error, redirect } from '@sveltejs/kit'; +import { getBeatmap } from '$lib/server/osu/api'; +import type { PageServerLoad, Actions } from './$types'; + +export const load: PageServerLoad = async ({ params, locals }) => { + if (!locals.user) redirect(302, '/'); + + const pool = await db.query.mappool.findFirst({ + where: eq(mappool.id, params.id), + with: { + slots: { + orderBy: (s, { asc }) => [asc(s.category), asc(s.orderInCategory)] + } + } + }); + + if (!pool) error(404, 'Mappool not found'); + + // Fetch beatmap metadata for all slots + const slotsWithBeatmaps = await Promise.all( + pool.slots.map(async (slot) => { + try { + const beatmap = await getBeatmap(slot.beatmapId); + return { + ...slot, + beatmap: { + title: beatmap.beatmapset.title, + artist: beatmap.beatmapset.artist, + version: beatmap.version, + starRating: beatmap.difficulty_rating, + bpm: beatmap.bpm, + totalLength: beatmap.total_length, + coverUrl: beatmap.beatmapset.covers['card@2x'], + url: beatmap.url + } + }; + } catch { + return { + ...slot, + beatmap: null + }; + } + }) + ); + + return { + pool: { ...pool, slots: slotsWithBeatmaps } + }; +}; + +export const actions: Actions = { + addSlot: async ({ params, request, locals }) => { + if (!locals.user) redirect(302, '/'); + + const form = await request.formData(); + let beatmapId = form.get('beatmapId')?.toString()?.trim(); + const category = form.get('category')?.toString()?.trim()?.toUpperCase(); + + if (!beatmapId || !category) { + return { error: 'Beatmap ID and category are required' }; + } + + // Parse URL if needed + const urlMatch = beatmapId.match(/beatmaps\/(\d+)/); + const setMatch = beatmapId.match(/beatmapsets\/\d+#\w+\/(\d+)/); + if (urlMatch) beatmapId = urlMatch[1]; + else if (setMatch) beatmapId = setMatch[1]; + + // Strip non-numeric + if (!/^\d+$/.test(beatmapId)) { + return { error: 'Invalid beatmap ID' }; + } + + try { + await getBeatmap(beatmapId); + } catch { + return { error: 'Beatmap not found on osu!' }; + } + + const existing = await db.query.mappoolSlot.findMany({ + where: and( + eq(mappoolSlot.mappoolId, params.id), + eq(mappoolSlot.category, category) + ) + }); + + await db.insert(mappoolSlot).values({ + mappoolId: params.id, + beatmapId, + category, + orderInCategory: existing.length + 1, + mods: category === 'NM' || category === 'TB' || category === 'FM' ? [] : [category] + }); + + return { success: true }; +}, + + removeSlot: async ({ request, locals }) => { + if (!locals.user) redirect(302, '/'); + + const form = await request.formData(); + const slotId = form.get('slotId')?.toString(); + if (!slotId) return { error: 'Missing slot ID' }; + + await db.delete(mappoolSlot).where(eq(mappoolSlot.id, slotId)); + return { success: true }; + }, + + deletePool: async ({ params, locals }) => { + if (!locals.user) redirect(302, '/'); + + await db.delete(mappool).where(eq(mappool.id, params.id)); + redirect(303, '/mappools'); + } +}; diff --git a/src/routes/(app)/mappools/[id]/+page.svelte b/src/routes/(app)/mappools/[id]/+page.svelte new file mode 100644 index 0000000..9616476 --- /dev/null +++ b/src/routes/(app)/mappools/[id]/+page.svelte @@ -0,0 +1,205 @@ + + +
+ +
+
+ ← +
+

{data.pool.name}

+

+ {data.pool.slots.length} map{data.pool.slots.length !== 1 ? 's' : ''} +

+
+
+ +
+ +
+
+ + +
{ + searching = true; + addError = ''; + return async ({ result, update }) => { + searching = false; + if (result.type === 'success') { + beatmapInput = ''; + await update(); + } else if (result.type === 'failure') { + addError = (result.data as { error?: string })?.error ?? 'Failed to add map'; } + }; + }} + class="mt-6 rounded-lg border border-border bg-surface-800 p-4" + > +

Add Map

+
+ + + + + +
+ + {#if addError} +

{addError}

+ {/if} +
+ + +
+ {#each Object.entries(grouped()) as [category, slots]} +
+
+ + {category} + + {slots.length} map{slots.length !== 1 ? 's' : ''} +
+ +
+ {#each slots as slot, i} +
+ + {#if slot.beatmap?.coverUrl} + + {:else} +
+ No cover +
+ {/if} + + +
+ {#if slot.beatmap} + + {slot.beatmap.artist} - {slot.beatmap.title} + +
+ [{slot.beatmap.version}] + ★ {slot.beatmap.starRating.toFixed(2)} + {slot.beatmap.bpm} BPM + {formatLength(slot.beatmap.totalLength)} +
+ {:else} +

Beatmap #{slot.beatmapId}

+ {/if} +
+ + + + {category}{i + 1} + + + +
+ + +
+
+ {/each} +
+
+ {:else} +
+

No maps yet. Add your first map above.

+
+ {/each} +
+