improve mappool editor

This commit is contained in:
2026-03-07 04:26:25 +01:00
parent 1ea528249a
commit 31223e7bca
2 changed files with 428 additions and 111 deletions

View File

@@ -1,10 +1,10 @@
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { mappool, mappoolSlot } from '$lib/server/db/schema'; 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 { error, redirect } from '@sveltejs/kit';
import { getBeatmap } from '$lib/server/osu/api'; import { getBeatmap } from '$lib/server/osu/api';
import { proxyImage } from '$lib/server/storage/r2'; 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'; import type { PageServerLoad, Actions } from './$types';
export const load: PageServerLoad = async ({ params, locals }) => { 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'; const isAdmin = locals.user!.role === 'admin';
return { return {
pool: { ...pool, slots: slotsWithBeatmaps }, pool: { ...pool, slots: slotsWithBeatmaps },
canEdit: isOwner || isAdmin, canEdit: isAdmin,
isAdmin isAdmin
}; };
}; };
export const actions: Actions = { export const actions: Actions = {
rename: async ({ params, request, locals }) => { rename: async ({ params, request, locals }) => {
requireAuth(locals);
if (locals.user!.role !== 'admin') error(403, 'Admins only');
const pool = await db.query.mappool.findFirst({ const pool = await db.query.mappool.findFirst({
where: eq(mappool.id, params.id) where: eq(mappool.id, params.id)
}); });
if (!pool) error(404, 'Mappool not found'); if (!pool) error(404, 'Mappool not found');
requireOwnerOrAdmin(locals, pool.createdBy);
const form = await request.formData(); const form = await request.formData();
const name = form.get('name')?.toString()?.trim(); const name = form.get('name')?.toString()?.trim();
@@ -110,11 +111,8 @@ export const actions: Actions = {
}, },
addSlot: async ({ params, request, locals }) => { addSlot: async ({ params, request, locals }) => {
const pool = await db.query.mappool.findFirst({ requireAuth(locals);
where: eq(mappool.id, params.id) if (locals.user!.role !== 'admin') error(403, 'Admins only');
});
if (!pool) error(404, 'Mappool not found');
requireOwnerOrAdmin(locals, pool.createdBy);
const form = await request.formData(); const form = await request.formData();
let beatmapId = form.get('beatmapId')?.toString()?.trim(); let beatmapId = form.get('beatmapId')?.toString()?.trim();
@@ -177,11 +175,8 @@ export const actions: Actions = {
}, },
removeSlot: async ({ params, request, locals }) => { removeSlot: async ({ params, request, locals }) => {
const pool = await db.query.mappool.findFirst({ requireAuth(locals);
where: eq(mappool.id, params.id) if (locals.user!.role !== 'admin') error(403, 'Admins only');
});
if (!pool) error(404, 'Mappool not found');
requireOwnerOrAdmin(locals, pool.createdBy);
const form = await request.formData(); const form = await request.formData();
const slotId = form.get('slotId')?.toString(); const slotId = form.get('slotId')?.toString();
@@ -215,12 +210,83 @@ export const actions: Actions = {
return { success: true }; return { success: true };
}, },
deletePool: async ({ params, locals }) => { moveSlot: async ({ params, request, locals }) => {
const pool = await db.query.mappool.findFirst({ requireAuth(locals);
where: eq(mappool.id, params.id) 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'); if (!slot) return { error: 'Slot not found' };
requireOwnerOrAdmin(locals, pool.createdBy);
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)); await db.delete(mappool).where(eq(mappool.id, params.id));
redirect(303, '/mappools'); redirect(303, '/mappools');

View File

@@ -12,25 +12,122 @@
let addError = $state(''); let addError = $state('');
let searching = $state(false); let searching = $state(false);
// Inline per-category add state
let inlineInputs = $state<Record<string, string>>({});
let inlineSearching = $state<Record<string, boolean>>({});
let inlineErrors = $state<Record<string, string>>({});
// 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 // Rename state
let editing = $state(false); let editing = $state(false);
let editName = $state(data.pool.name); let editName = $derived(data.pool.name);
// Group slots by category // Check if pool has any maps at all
const grouped = $derived(() => { const hasAnySlots = $derived(data.pool.slots.length > 0);
const groups: Record<string, typeof data.pool.slots> = {};
for (const cat of categories) { // Svelte action: attaches drag listeners via addEventListener
const slots = data.pool.slots.filter((s) => s.category === cat); function makeDraggable(
if (slots.length > 0) groups[cat] = slots; node: HTMLElement,
} params: { slotId: string; category: string; index: number; enabled: boolean }
return groups; ) {
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 };
}); });
}
function parseBeatmapId(input: string): string | null { function onDragEnd() {
const urlMatch = input.match(/beatmaps\/(\d+)/); draggedSlot = null;
if (urlMatch) return urlMatch[1]; dropTarget = null;
const match = input.match(/^(\d+)$/); }
return match ? match[1] : 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 { function formatLength(seconds: number): string {
@@ -49,9 +146,19 @@
}; };
</script> </script>
<style>
:global([draggable='true']) {
-webkit-user-select: none;
user-select: none;
}
</style>
<svelte:head> <svelte:head>
<title>{data.pool.name} — Mappool | Vash Esports</title> <title>{data.pool.name} — Mappool | Vash Esports</title>
<meta name="description" content="View the {data.pool.name} mappool with {data.pool.slots.length} maps on Vash Esports." /> <meta
name="description"
content="View the {data.pool.name} mappool with {data.pool.slots.length} maps on Vash Esports."
/>
<meta property="og:title" content="{data.pool.name} — Mappool | Vash Esports" /> <meta property="og:title" content="{data.pool.name} — Mappool | Vash Esports" />
<meta property="og:description" content="{data.pool.slots.length} maps · View on Vash Esports" /> <meta property="og:description" content="{data.pool.slots.length} maps · View on Vash Esports" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
@@ -65,7 +172,9 @@
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<a href="/mappools" class="text-text-secondary transition-colors hover:text-text-primary">←</a> <a href="/mappools" class="text-text-secondary transition-colors hover:text-text-primary"
>&larr;</a
>
<div> <div>
{#if editing && data.canEdit} {#if editing && data.canEdit}
<form <form
@@ -84,9 +193,8 @@
<input <input
type="text" type="text"
name="name" name="name"
bind:value={editName} value={editName}
class="rounded-md border border-accent bg-surface-700 px-3 py-1 text-xl font-700 text-text-primary focus:outline-none" class="rounded-md border border-accent bg-surface-700 px-3 py-1 text-xl font-700 text-text-primary focus:outline-none"
autofocus
/> />
<button <button
type="submit" type="submit"
@@ -98,7 +206,6 @@
type="button" type="button"
onclick={() => { onclick={() => {
editing = false; editing = false;
editName = data.pool.name;
}} }}
class="rounded-md border border-border px-3 py-1 text-xs text-text-secondary hover:bg-surface-700" class="rounded-md border border-border px-3 py-1 text-xs text-text-secondary hover:bg-surface-700"
> >
@@ -109,20 +216,21 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<h1 class="text-2xl font-700 tracking-tight">{data.pool.name}</h1> <h1 class="text-2xl font-700 tracking-tight">{data.pool.name}</h1>
{#if data.pool.verifiedAt} {#if data.pool.verifiedAt}
<span class="flex items-center gap-1 rounded border border-green-500/30 bg-green-500/10 px-2 py-0.5 text-xs font-600 text-green-400"> <span
✓ Verified class="flex items-center gap-1 rounded border border-green-500/30 bg-green-500/10 px-2 py-0.5 text-xs font-600 text-green-400"
>
&#10003; Verified
</span> </span>
{/if} {/if}
{#if data.canEdit} {#if data.canEdit}
<button <button
onclick={() => { onclick={() => {
editName = data.pool.name;
editing = true; editing = true;
}} }}
class="rounded p-1 text-text-secondary transition-colors hover:text-accent" class="rounded p-1 text-text-secondary transition-colors hover:text-accent"
title="Rename" title="Rename"
> >
✎ &#9998;
</button> </button>
{/if} {/if}
</div> </div>
@@ -150,7 +258,7 @@
type="submit" 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" 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 &#10003; Verify
</button> </button>
</form> </form>
{/if} {/if}
@@ -207,7 +315,7 @@
type="text" type="text"
name="beatmapId" name="beatmapId"
bind:value={beatmapInput} 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" 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,11 +336,22 @@
<!-- Map List --> <!-- Map List -->
<div class="mt-6 flex flex-col gap-6"> <div class="mt-6 flex flex-col gap-6">
{#each Object.entries(grouped()) as [category, slots]} {#if hasAnySlots || isDragging}
{#each categories as category (category)}
{@const slots = data.pool.slots.filter((s) => s.category === category)}
{#if slots.length > 0 || isDragging}
<div> <div>
<div class="mb-2 flex items-center gap-2"> <!-- Category header (also a drop zone) -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mb-2 flex items-center gap-2"
ondragover={(e) => handleCategoryDragOver(e, category, slots.length)}
ondrop={handleDrop}
>
<span <span
class="rounded border px-2 py-0.5 text-xs font-600 {categoryColors[category] ?? 'bg-surface-600 text-text-secondary border-border'}" class="rounded border px-2 py-0.5 text-xs font-600 {categoryColors[
category
] ?? 'bg-surface-600 text-text-secondary border-border'}"
> >
{category} {category}
</span> </span>
@@ -241,14 +360,73 @@
> >
</div> </div>
<div class="flex flex-col gap-2"> {#if slots.length === 0}
{#each slots as slot, i} <!-- Empty category drop zone during drag -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
class="group flex items-center gap-3 rounded-lg border border-border bg-surface-800 p-2 transition-colors hover:border-accent/30" class="rounded-lg border-2 border-dashed py-6 text-center transition-colors {dropTarget?.category ===
category
? 'border-accent bg-accent/5'
: 'border-border/50'}"
ondragover={(e) => handleCategoryDragOver(e, category, 0)}
ondrop={handleDrop}
> >
<p class="text-xs text-text-secondary">Drop here to add to {category}</p>
</div>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex flex-col"
ondragover={(e) => {
if (!draggedSlot) return;
e.preventDefault();
e.dataTransfer!.dropEffect = 'move';
}}
ondrop={handleDrop}
>
{#each slots as slot, i (slot.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group relative flex items-center gap-3 rounded-lg border bg-surface-800 p-2 transition-colors
{data.canEdit ? 'cursor-grab active:cursor-grabbing' : ''}
{draggedSlot?.id === slot.id ? 'border-accent/50 opacity-40' : 'border-border hover:border-accent/30'}"
style={dropTarget?.category === category && dropTarget.index === i
? `box-shadow: 0 -3px 0 0 var(--color-accent, #8b5cf6); margin-top: 4px;`
: dropTarget?.category === category &&
dropTarget.index === slots.length &&
i === slots.length - 1
? `box-shadow: 0 3px 0 0 var(--color-accent, #8b5cf6); margin-bottom: 4px;`
: ''}
ondragover={(e) => handleDragOver(e, category, i)}
ondrop={handleDrop}
role={data.canEdit ? 'listitem' : undefined}
use:makeDraggable={{ slotId: slot.id, category, index: i, enabled: data.canEdit }}
>
<!-- Drag grip icon -->
{#if data.canEdit}
<div class="flex flex-col gap-0.5 text-text-secondary/40">
<svg
class="h-4 w-4"
fill="currentColor"
viewBox="0 0 24 24"
>
<circle cx="9" cy="6" r="1.5" />
<circle cx="15" cy="6" r="1.5" />
<circle cx="9" cy="12" r="1.5" />
<circle cx="15" cy="12" r="1.5" />
<circle cx="9" cy="18" r="1.5" />
<circle cx="15" cy="18" r="1.5" />
</svg>
</div>
{/if}
<!-- Cover --> <!-- Cover -->
{#if slot.beatmap?.coverUrl} {#if slot.beatmap?.coverUrl}
<img src={slot.beatmap.coverUrl} alt="" class="h-12 w-24 rounded object-cover" /> <img
src={slot.beatmap.coverUrl}
alt=""
class="pointer-events-none h-12 w-24 rounded object-cover"
/>
{:else} {:else}
<div <div
class="flex h-12 w-24 items-center justify-center rounded bg-surface-700 text-xs text-text-secondary" class="flex h-12 w-24 items-center justify-center rounded bg-surface-700 text-xs text-text-secondary"
@@ -260,22 +438,33 @@
<!-- Info --> <!-- Info -->
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
{#if slot.beatmap} {#if slot.beatmap}
<a <button
href={slot.beatmap.url} type="button"
target="_blank" onclick={() => window.open(slot.beatmap?.url, '_blank')}
rel="noopener" class="truncate text-left text-sm font-500 hover:text-accent"
class="truncate text-sm font-500 hover:text-accent"
> >
{slot.beatmap.artist} - {slot.beatmap.title} {slot.beatmap.artist} - {slot.beatmap.title}
</a> </button>
<div class="mt-0.5 flex items-center gap-3 text-xs text-text-secondary"> <div
class="mt-0.5 flex items-center gap-3 text-xs text-text-secondary"
>
<span>[{slot.beatmap.version}]</span> <span>[{slot.beatmap.version}]</span>
<span>{slot.beatmap.starRating.toFixed(2)}★</span> <span
>{slot.beatmap.starRating.toFixed(
2
)}&#9733;</span
>
<span>{slot.beatmap.bpm} BPM</span> <span>{slot.beatmap.bpm} BPM</span>
<span>{formatLength(slot.beatmap.totalLength)}</span> <span
>{formatLength(
slot.beatmap.totalLength
)}</span
>
</div> </div>
{:else} {:else}
<p class="text-sm text-text-secondary">Beatmap #{slot.beatmapId}</p> <p class="text-sm text-text-secondary">
Beatmap #{slot.beatmapId}
</p>
{/if} {/if}
</div> </div>
@@ -287,23 +476,85 @@
<!-- Delete --> <!-- Delete -->
{#if data.canEdit} {#if data.canEdit}
<form method="post" action="?/removeSlot" use:enhance> <form method="post" action="?/removeSlot" use:enhance>
<input type="hidden" name="slotId" value={slot.id} /> <input
type="hidden"
name="slotId"
value={slot.id}
/>
<button <button
type="submit" 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" class="rounded p-1 text-text-secondary opacity-0 transition-all hover:bg-red-500/10 hover:text-red-400 group-hover:opacity-100"
> >
✕ &#10005;
</button> </button>
</form> </form>
{/if} {/if}
</div> </div>
{/each} {/each}
<!-- Inline add for this category -->
{#if data.canEdit && !isDragging}
<div class="mt-2">
<form
method="post"
action="?/addSlot"
use:enhance={() => {
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"
>
<input
type="hidden"
name="category"
value={category}
/>
<input
type="text"
name="beatmapId"
bind:value={inlineInputs[category]}
placeholder="Add {category}{slots.length +
1} — paste beatmap ID or URL"
class="flex-1 rounded-md border border-dashed border-border bg-transparent px-3 py-1.5 text-sm text-text-primary placeholder:text-text-secondary/40 focus:border-accent focus:outline-none"
/>
<button
type="submit"
disabled={inlineSearching[category] ||
!inlineInputs[category]?.trim()}
class="rounded-md bg-accent/10 px-3 py-1.5 text-xs font-600 text-accent transition-colors hover:bg-accent/20 disabled:opacity-40"
>
{inlineSearching[category]
? 'Adding...'
: '+ Add'}
</button>
</form>
{#if inlineErrors[category]}
<p class="mt-1 text-xs text-red-400">
{inlineErrors[category]}
</p>
{/if}
</div> </div>
{/if}
</div> </div>
{/if}
</div>
{/if}
{/each}
{:else} {:else}
<div class="rounded-lg border border-dashed border-border py-12 text-center"> <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> <p class="text-sm text-text-secondary">No maps yet. Add your first map above.</p>
</div> </div>
{/each} {/if}
</div> </div>
</div> </div>