mappool ui
This commit is contained in:
32
src/routes/(app)/mappools/+page.server.ts
Normal file
32
src/routes/(app)/mappools/+page.server.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
66
src/routes/(app)/mappools/+page.svelte
Normal file
66
src/routes/(app)/mappools/+page.svelte
Normal 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>
|
||||||
118
src/routes/(app)/mappools/[id]/+page.server.ts
Normal file
118
src/routes/(app)/mappools/[id]/+page.server.ts
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
205
src/routes/(app)/mappools/[id]/+page.svelte
Normal file
205
src/routes/(app)/mappools/[id]/+page.svelte
Normal 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>
|
||||||
Reference in New Issue
Block a user