mappool ui

This commit is contained in:
2026-02-28 01:58:03 +01:00
parent 0f08bd5d22
commit 133b9e552b
4 changed files with 421 additions and 0 deletions

View File

@@ -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}`);
}
};

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data } = $props();
let creating = $state(false);
</script>
<div class="mx-auto max-w-4xl">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-700 tracking-tight">Mappools</h1>
<p class="mt-1 text-sm text-text-secondary">Create and manage your beatmap pools</p>
</div>
<button
onclick={() => (creating = !creating)}
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
>
{creating ? 'Cancel' : 'New Pool'}
</button>
</div>
{#if creating}
<form
method="post"
action="?/create"
use:enhance
class="mt-4 flex gap-3 rounded-lg border border-border bg-surface-800 p-4"
>
<input
type="text"
name="name"
placeholder="Pool name (e.g. Grand Finals Mappool)"
required
class="flex-1 rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
/>
<button
type="submit"
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
>
Create
</button>
</form>
{/if}
<div class="mt-6 flex flex-col gap-3">
{#each data.mappools as pool}
<a
href="/mappools/{pool.id}"
class="group flex items-center justify-between rounded-lg border border-border bg-surface-800 p-4 transition-colors hover:border-accent/40 hover:bg-surface-700"
>
<div>
<h2 class="text-sm font-600">{pool.name}</h2>
<p class="mt-1 text-xs text-text-secondary">
{pool.slots.length} map{pool.slots.length !== 1 ? 's' : ''}
· Created {new Date(pool.createdAt).toLocaleDateString()}
</p>
</div>
<span class="text-text-secondary transition-colors group-hover:text-accent">→</span>
</a>
{:else}
<div class="rounded-lg border border-dashed border-border py-12 text-center">
<p class="text-sm text-text-secondary">No mappools yet. Create one to get started.</p>
</div>
{/each}
</div>
</div>

View File

@@ -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');
}
};

View File

@@ -0,0 +1,205 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
let { data } = $props();
const categories = ['NM', 'HD', 'HR', 'DT', 'FM', 'TB'];
let addingCategory = $state('NM');
let beatmapInput = $state('');
let addError = $state('');
let searching = $state(false);
// Group slots by category
const grouped = $derived(() => {
const groups: Record<string, typeof data.pool.slots> = {};
for (const cat of categories) {
const slots = data.pool.slots.filter((s) => s.category === cat);
if (slots.length > 0) groups[cat] = slots;
}
return groups;
});
function parseBeatmapId(input: string): string | null {
// Accept raw ID or osu! URL
const urlMatch = input.match(/beatmaps\/(\d+)/);
if (urlMatch) return urlMatch[1];
const match = input.match(/^(\d+)$/);
return match ? match[1] : null;
}
function formatLength(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
const categoryColors: Record<string, string> = {
NM: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
HD: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
HR: 'bg-red-500/20 text-red-400 border-red-500/30',
DT: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
FM: 'bg-green-500/20 text-green-400 border-green-500/30',
TB: 'bg-pink-500/20 text-pink-400 border-pink-500/30'
};
</script>
<div class="mx-auto max-w-4xl">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<a href="/mappools" class="text-text-secondary transition-colors hover:text-text-primary">←</a
>
<div>
<h1 class="text-2xl font-700 tracking-tight">{data.pool.name}</h1>
<p class="mt-1 text-sm text-text-secondary">
{data.pool.slots.length} map{data.pool.slots.length !== 1 ? 's' : ''}
</p>
</div>
</div>
<form method="post" action="?/deletePool" use:enhance>
<button
type="submit"
onclick={(e) => {
if (!confirm('Delete this mappool?')) e.preventDefault();
}}
class="rounded-md border border-red-500/30 px-3 py-1.5 text-sm text-red-400 transition-colors hover:bg-red-500/10"
>
Delete Pool
</button>
</form>
</div>
<!-- Add Map -->
<form
method="post"
action="?/addSlot"
use:enhance={() => {
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"
>
<h2 class="text-sm font-600">Add Map</h2>
<div class="mt-3 flex gap-3">
<select
name="category"
bind:value={addingCategory}
class="rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
>
{#each categories as cat}
<option value={cat}>{cat}</option>
{/each}
</select>
<input
type="text"
name="beatmapId"
bind:value={beatmapInput}
placeholder="Beatmap ID or URL (e.g. 75 or https://osu.ppy.sh/beatmaps/75)"
class="flex-1 rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
/>
<button
type="submit"
disabled={searching || !beatmapInput.trim()}
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{searching ? 'Adding...' : 'Add'}
</button>
</div>
{#if addError}
<p class="mt-2 text-sm text-red-400">{addError}</p>
{/if}
</form>
<!-- Map List -->
<div class="mt-6 flex flex-col gap-6">
{#each Object.entries(grouped()) as [category, slots]}
<div>
<div class="mb-2 flex items-center gap-2">
<span
class="rounded border px-2 py-0.5 text-xs font-600 {categoryColors[category] ?? 'bg-surface-600 text-text-secondary border-border'}"
>
{category}
</span>
<span class="text-xs text-text-secondary">{slots.length} map{slots.length !== 1 ? 's' : ''}</span>
</div>
<div class="flex flex-col gap-2">
{#each slots as slot, i}
<div
class="group flex items-center gap-3 rounded-lg border border-border bg-surface-800 p-2 transition-colors hover:border-accent/30"
>
<!-- Cover -->
{#if slot.beatmap?.coverUrl}
<img
src={slot.beatmap.coverUrl}
alt=""
class="h-12 w-24 rounded object-cover"
/>
{:else}
<div class="flex h-12 w-24 items-center justify-center rounded bg-surface-700 text-xs text-text-secondary">
No cover
</div>
{/if}
<!-- Info -->
<div class="min-w-0 flex-1">
{#if slot.beatmap}
<a
href={slot.beatmap.url}
target="_blank"
rel="noopener"
class="truncate text-sm font-500 hover:text-accent"
>
{slot.beatmap.artist} - {slot.beatmap.title}
</a>
<div class="mt-0.5 flex items-center gap-3 text-xs text-text-secondary">
<span>[{slot.beatmap.version}]</span>
<span>★ {slot.beatmap.starRating.toFixed(2)}</span>
<span>{slot.beatmap.bpm} BPM</span>
<span>{formatLength(slot.beatmap.totalLength)}</span>
</div>
{:else}
<p class="text-sm text-text-secondary">Beatmap #{slot.beatmapId}</p>
{/if}
</div>
<!-- Slot label -->
<span class="text-xs font-600 text-text-secondary">
{category}{i + 1}
</span>
<!-- Delete -->
<form method="post" action="?/removeSlot" use:enhance>
<input type="hidden" name="slotId" value={slot.id} />
<button
type="submit"
class="rounded p-1 text-text-secondary opacity-0 transition-all hover:bg-red-500/10 hover:text-red-400 group-hover:opacity-100"
>
✕
</button>
</form>
</div>
{/each}
</div>
</div>
{:else}
<div class="rounded-lg border border-dashed border-border py-12 text-center">
<p class="text-sm text-text-secondary">No maps yet. Add your first map above.</p>
</div>
{/each}
</div>
</div>