diff --git a/src/routes/(app)/mappools/[id]/+page.server.ts b/src/routes/(app)/mappools/[id]/+page.server.ts index d8f5dfe..ed8ca75 100644 --- a/src/routes/(app)/mappools/[id]/+page.server.ts +++ b/src/routes/(app)/mappools/[id]/+page.server.ts @@ -1,10 +1,10 @@ import { db } from '$lib/server/db'; import { mappool, mappoolSlot } from '$lib/server/db/schema'; -import { eq, and } from 'drizzle-orm'; +import { eq, and, asc } from 'drizzle-orm'; import { error, redirect } from '@sveltejs/kit'; import { getBeatmap } from '$lib/server/osu/api'; import { proxyImage } from '$lib/server/storage/r2'; -import { requireAuth, requireOwnerOrAdmin } from '$lib/server/permissions'; +import { requireAuth } from '$lib/server/permissions'; import type { PageServerLoad, Actions } from './$types'; export const load: PageServerLoad = async ({ params, locals }) => { @@ -83,23 +83,24 @@ export const load: PageServerLoad = async ({ params, locals }) => { }) ); - const isOwner = pool.createdBy === locals.user!.id; const isAdmin = locals.user!.role === 'admin'; return { pool: { ...pool, slots: slotsWithBeatmaps }, - canEdit: isOwner || isAdmin, + canEdit: isAdmin, isAdmin }; }; export const actions: Actions = { rename: async ({ params, request, locals }) => { + requireAuth(locals); + if (locals.user!.role !== 'admin') error(403, 'Admins only'); + const pool = await db.query.mappool.findFirst({ where: eq(mappool.id, params.id) }); if (!pool) error(404, 'Mappool not found'); - requireOwnerOrAdmin(locals, pool.createdBy); const form = await request.formData(); const name = form.get('name')?.toString()?.trim(); @@ -110,11 +111,8 @@ export const actions: Actions = { }, addSlot: async ({ params, request, locals }) => { - const pool = await db.query.mappool.findFirst({ - where: eq(mappool.id, params.id) - }); - if (!pool) error(404, 'Mappool not found'); - requireOwnerOrAdmin(locals, pool.createdBy); + requireAuth(locals); + if (locals.user!.role !== 'admin') error(403, 'Admins only'); const form = await request.formData(); let beatmapId = form.get('beatmapId')?.toString()?.trim(); @@ -177,11 +175,8 @@ export const actions: Actions = { }, removeSlot: async ({ params, request, locals }) => { - const pool = await db.query.mappool.findFirst({ - where: eq(mappool.id, params.id) - }); - if (!pool) error(404, 'Mappool not found'); - requireOwnerOrAdmin(locals, pool.createdBy); + requireAuth(locals); + if (locals.user!.role !== 'admin') error(403, 'Admins only'); const form = await request.formData(); const slotId = form.get('slotId')?.toString(); @@ -215,12 +210,83 @@ export const actions: Actions = { return { success: true }; }, - deletePool: async ({ params, locals }) => { - const pool = await db.query.mappool.findFirst({ - where: eq(mappool.id, params.id) + moveSlot: async ({ params, request, locals }) => { + requireAuth(locals); + if (locals.user!.role !== 'admin') error(403, 'Admins only'); + + const form = await request.formData(); + const slotId = form.get('slotId')?.toString(); + const targetCategory = form.get('targetCategory')?.toString()?.trim()?.toUpperCase(); + const targetIndex = parseInt(form.get('targetIndex')?.toString() ?? '', 10); + + if (!slotId || !targetCategory || isNaN(targetIndex)) { + return { error: 'Missing required fields' }; + } + + // Fetch the slot being moved + const slot = await db.query.mappoolSlot.findFirst({ + where: and(eq(mappoolSlot.id, slotId), eq(mappoolSlot.mappoolId, params.id)) }); - if (!pool) error(404, 'Mappool not found'); - requireOwnerOrAdmin(locals, pool.createdBy); + if (!slot) return { error: 'Slot not found' }; + + const sourceCategory = slot.category; + const isSameCategory = sourceCategory === targetCategory; + + // Fetch target category slots (excluding the moved slot) + const targetSlots = await db.query.mappoolSlot.findMany({ + where: and( + eq(mappoolSlot.mappoolId, params.id), + eq(mappoolSlot.category, targetCategory) + ), + orderBy: [asc(mappoolSlot.orderInCategory)] + }); + const filteredTarget = targetSlots.filter((s) => s.id !== slotId); + + // Clamp targetIndex + const clampedIndex = Math.max(0, Math.min(filteredTarget.length, targetIndex)); + + // Build new order for target category + const newTargetOrder = [...filteredTarget]; + newTargetOrder.splice(clampedIndex, 0, slot); + + // Update the moved slot's category and mods + const newMods = targetCategory === 'NM' || targetCategory === 'TB' ? [] : [targetCategory]; + await db + .update(mappoolSlot) + .set({ category: targetCategory, mods: newMods }) + .where(eq(mappoolSlot.id, slotId)); + + // Re-number target category + for (let i = 0; i < newTargetOrder.length; i++) { + await db + .update(mappoolSlot) + .set({ orderInCategory: i + 1 }) + .where(eq(mappoolSlot.id, newTargetOrder[i].id)); + } + + // If cross-category move, re-number the source category too + if (!isSameCategory) { + const sourceSlots = await db.query.mappoolSlot.findMany({ + where: and( + eq(mappoolSlot.mappoolId, params.id), + eq(mappoolSlot.category, sourceCategory) + ), + orderBy: [asc(mappoolSlot.orderInCategory)] + }); + for (let i = 0; i < sourceSlots.length; i++) { + await db + .update(mappoolSlot) + .set({ orderInCategory: i + 1 }) + .where(eq(mappoolSlot.id, sourceSlots[i].id)); + } + } + + return { success: true }; + }, + + deletePool: async ({ params, locals }) => { + requireAuth(locals); + if (locals.user!.role !== 'admin') error(403, 'Admins only'); 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 index 8c46f45..358622b 100644 --- a/src/routes/(app)/mappools/[id]/+page.svelte +++ b/src/routes/(app)/mappools/[id]/+page.svelte @@ -12,25 +12,122 @@ let addError = $state(''); let searching = $state(false); + // Inline per-category add state + let inlineInputs = $state>({}); + let inlineSearching = $state>({}); + let inlineErrors = $state>({}); + + // Drag-and-drop state + let draggedSlot = $state<{ id: string; category: string; index: number } | null>(null); + let dropTarget = $state<{ category: string; index: number } | null>(null); + const isDragging = $derived(draggedSlot !== null); + // Rename state let editing = $state(false); - let editName = $state(data.pool.name); + let editName = $derived(data.pool.name); - // Group slots by category - const grouped = $derived(() => { - const groups: Record = {}; - for (const cat of categories) { - const slots = data.pool.slots.filter((s) => s.category === cat); - if (slots.length > 0) groups[cat] = slots; + // Check if pool has any maps at all + const hasAnySlots = $derived(data.pool.slots.length > 0); + + // Svelte action: attaches drag listeners via addEventListener + function makeDraggable( + node: HTMLElement, + params: { slotId: string; category: string; index: number; enabled: boolean } + ) { + let current = params; + node.draggable = current.enabled; + + function onDragStart(e: DragEvent) { + if (!current.enabled || !e.dataTransfer) return; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', current.slotId); + + // Delay reactive state update to next frame — setting draggedSlot + // synchronously causes Svelte to insert empty category DOM nodes, + // which shifts the dragged element and makes the browser cancel the drag. + const snapshot = { ...current }; + requestAnimationFrame(() => { + draggedSlot = { id: snapshot.slotId, category: snapshot.category, index: snapshot.index }; + }); } - return groups; - }); - function parseBeatmapId(input: string): string | null { - const urlMatch = input.match(/beatmaps\/(\d+)/); - if (urlMatch) return urlMatch[1]; - const match = input.match(/^(\d+)$/); - return match ? match[1] : null; + function onDragEnd() { + draggedSlot = null; + dropTarget = null; + } + + node.addEventListener('dragstart', onDragStart); + node.addEventListener('dragend', onDragEnd); + + return { + update(newParams: typeof params) { + current = newParams; + node.draggable = current.enabled; + }, + destroy() { + node.removeEventListener('dragstart', onDragStart); + node.removeEventListener('dragend', onDragEnd); + } + }; + } + + function handleDragOver(e: DragEvent, category: string, index: number) { + if (!draggedSlot) return; + e.preventDefault(); + e.dataTransfer!.dropEffect = 'move'; + + const target = e.currentTarget as HTMLElement; + const rect = target.getBoundingClientRect(); + const midY = rect.top + rect.height / 2; + const dropIndex = e.clientY < midY ? index : index + 1; + + if ( + draggedSlot.category === category && + (dropIndex === draggedSlot.index || dropIndex === draggedSlot.index + 1) + ) { + dropTarget = null; + return; + } + + dropTarget = { category, index: dropIndex }; + } + + function handleCategoryDragOver(e: DragEvent, category: string, slotCount: number) { + if (!draggedSlot) return; + e.preventDefault(); + e.dataTransfer!.dropEffect = 'move'; + dropTarget = { category, index: slotCount }; + } + + async function handleDrop(e: DragEvent) { + e.preventDefault(); + if (!draggedSlot || !dropTarget) { + draggedSlot = null; + dropTarget = null; + return; + } + + const { id: slotId } = draggedSlot; + const { category: targetCategory, index: targetIndex } = dropTarget; + + let adjustedIndex = targetIndex; + if (draggedSlot.category === targetCategory && draggedSlot.index < targetIndex) { + adjustedIndex = targetIndex - 1; + } + + draggedSlot = null; + dropTarget = null; + + const formData = new FormData(); + formData.set('slotId', slotId); + formData.set('targetCategory', targetCategory); + formData.set('targetIndex', adjustedIndex.toString()); + + await fetch('?/moveSlot', { + method: 'POST', + body: formData + }); + await invalidateAll(); } function formatLength(seconds: number): string { @@ -49,9 +146,19 @@ }; + + {data.pool.name} — Mappool | Vash Esports - + @@ -65,7 +172,9 @@
- ← + ←
{#if editing && data.canEdit}
{/if}
@@ -150,7 +258,7 @@ type="submit" class="rounded-md border border-green-500/30 px-3 py-1.5 text-sm text-green-400 transition-colors hover:bg-green-500/10" > - ✓ Verify + ✓ Verify {/if} @@ -207,7 +315,7 @@ type="text" name="beatmapId" bind:value={beatmapInput} - placeholder="Beatmap ID or URL (e.g. 75 or https://osu.ppy.sh/beatmaps/75)" + placeholder="Beatmap ID or URL (e.g. 75, /b/75, or 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" /> @@ -228,82 +336,225 @@
- {#each Object.entries(grouped()) as [category, slots]} -
-
- - {category} - - {slots.length} map{slots.length !== 1 ? 's' : ''} -
- -
- {#each slots as slot, i} + {#if hasAnySlots || isDragging} + {#each categories as category (category)} + {@const slots = data.pool.slots.filter((s) => s.category === category)} + {#if slots.length > 0 || isDragging} +
+ +
handleCategoryDragOver(e, category, slots.length)} + ondrop={handleDrop} > - - {#if slot.beatmap?.coverUrl} - - {:else} -
- No cover -
- {/if} + + {category} + + {slots.length} map{slots.length !== 1 ? 's' : ''} +
- -
- {#if slot.beatmap} - + +
handleCategoryDragOver(e, category, 0)} + ondrop={handleDrop} + > +

Drop here to add to {category}

+
+ {:else} + +
{ + if (!draggedSlot) return; + e.preventDefault(); + e.dataTransfer!.dropEffect = 'move'; + }} + ondrop={handleDrop} + > + {#each slots as slot, i (slot.id)} + +
handleDragOver(e, category, i)} + ondrop={handleDrop} + role={data.canEdit ? 'listitem' : undefined} + use:makeDraggable={{ slotId: slot.id, category, index: i, enabled: data.canEdit }} > - {slot.beatmap.artist} - {slot.beatmap.title} - -
- [{slot.beatmap.version}] - {slot.beatmap.starRating.toFixed(2)}★ - {slot.beatmap.bpm} BPM - {formatLength(slot.beatmap.totalLength)} + + {#if data.canEdit} +
+ + + + + + + + +
+ {/if} + + + {#if slot.beatmap?.coverUrl} + + {:else} +
+ No cover +
+ {/if} + + +
+ {#if slot.beatmap} + +
+ [{slot.beatmap.version}] + {slot.beatmap.starRating.toFixed( + 2 + )}★ + {slot.beatmap.bpm} BPM + {formatLength( + slot.beatmap.totalLength + )} +
+ {:else} +

+ Beatmap #{slot.beatmapId} +

+ {/if} +
+ + + + {category}{i + 1} + + + + {#if data.canEdit} +
+ + +
+ {/if} +
+ {/each} + + + {#if data.canEdit && !isDragging} +
+
{ + inlineSearching[category] = true; + inlineErrors[category] = ''; + return async ({ result, update }) => { + inlineSearching[category] = false; + if (result.type === 'success') { + inlineInputs[category] = ''; + await update(); + } else if (result.type === 'failure') { + inlineErrors[category] = + (result.data as { error?: string }) + ?.error ?? 'Failed to add map'; + } + }; + }} + class="flex items-center gap-2" + > + + + +
+ {#if inlineErrors[category]} +

+ {inlineErrors[category]} +

+ {/if}
- {:else} -

Beatmap #{slot.beatmapId}

{/if}
- - - - {category}{i + 1} - - - - {#if data.canEdit} -
- - -
- {/if} -
- {/each} -
-
+ {/if} +
+ {/if} + {/each} {:else}

No maps yet. Add your first map above.

- {/each} + {/if}