improve mappool editor
This commit is contained in:
@@ -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');
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
) {
|
||||||
|
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 {
|
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"
|
||||||
|
>←</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"
|
||||||
|
>
|
||||||
|
✓ 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"
|
||||||
>
|
>
|
||||||
✎
|
✎
|
||||||
</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
|
✓ 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,82 +336,225 @@
|
|||||||
|
|
||||||
<!-- 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}
|
||||||
<div>
|
{#each categories as category (category)}
|
||||||
<div class="mb-2 flex items-center gap-2">
|
{@const slots = data.pool.slots.filter((s) => s.category === category)}
|
||||||
<span
|
{#if slots.length > 0 || isDragging}
|
||||||
class="rounded border px-2 py-0.5 text-xs font-600 {categoryColors[category] ?? 'bg-surface-600 text-text-secondary border-border'}"
|
<div>
|
||||||
>
|
<!-- Category header (also a drop zone) -->
|
||||||
{category}
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
</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
|
<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="mb-2 flex items-center gap-2"
|
||||||
|
ondragover={(e) => handleCategoryDragOver(e, category, slots.length)}
|
||||||
|
ondrop={handleDrop}
|
||||||
>
|
>
|
||||||
<!-- Cover -->
|
<span
|
||||||
{#if slot.beatmap?.coverUrl}
|
class="rounded border px-2 py-0.5 text-xs font-600 {categoryColors[
|
||||||
<img src={slot.beatmap.coverUrl} alt="" class="h-12 w-24 rounded object-cover" />
|
category
|
||||||
{:else}
|
] ?? 'bg-surface-600 text-text-secondary border-border'}"
|
||||||
<div
|
>
|
||||||
class="flex h-12 w-24 items-center justify-center rounded bg-surface-700 text-xs text-text-secondary"
|
{category}
|
||||||
>
|
</span>
|
||||||
No cover
|
<span class="text-xs text-text-secondary"
|
||||||
</div>
|
>{slots.length} map{slots.length !== 1 ? 's' : ''}</span
|
||||||
{/if}
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Info -->
|
{#if slots.length === 0}
|
||||||
<div class="min-w-0 flex-1">
|
<!-- Empty category drop zone during drag -->
|
||||||
{#if slot.beatmap}
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<a
|
<div
|
||||||
href={slot.beatmap.url}
|
class="rounded-lg border-2 border-dashed py-6 text-center transition-colors {dropTarget?.category ===
|
||||||
target="_blank"
|
category
|
||||||
rel="noopener"
|
? 'border-accent bg-accent/5'
|
||||||
class="truncate text-sm font-500 hover:text-accent"
|
: '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 }}
|
||||||
>
|
>
|
||||||
{slot.beatmap.artist} - {slot.beatmap.title}
|
<!-- Drag grip icon -->
|
||||||
</a>
|
{#if data.canEdit}
|
||||||
<div class="mt-0.5 flex items-center gap-3 text-xs text-text-secondary">
|
<div class="flex flex-col gap-0.5 text-text-secondary/40">
|
||||||
<span>[{slot.beatmap.version}]</span>
|
<svg
|
||||||
<span>{slot.beatmap.starRating.toFixed(2)}★</span>
|
class="h-4 w-4"
|
||||||
<span>{slot.beatmap.bpm} BPM</span>
|
fill="currentColor"
|
||||||
<span>{formatLength(slot.beatmap.totalLength)}</span>
|
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 -->
|
||||||
|
{#if slot.beatmap?.coverUrl}
|
||||||
|
<img
|
||||||
|
src={slot.beatmap.coverUrl}
|
||||||
|
alt=""
|
||||||
|
class="pointer-events-none 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}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => window.open(slot.beatmap?.url, '_blank')}
|
||||||
|
class="truncate text-left text-sm font-500 hover:text-accent"
|
||||||
|
>
|
||||||
|
{slot.beatmap.artist} - {slot.beatmap.title}
|
||||||
|
</button>
|
||||||
|
<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 -->
|
||||||
|
{#if data.canEdit}
|
||||||
|
<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>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/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>
|
||||||
{:else}
|
|
||||||
<p class="text-sm text-text-secondary">Beatmap #{slot.beatmapId}</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
<!-- Slot label -->
|
</div>
|
||||||
<span class="text-xs font-600 text-text-secondary">
|
{/if}
|
||||||
{category}{i + 1}
|
{/each}
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- Delete -->
|
|
||||||
{#if data.canEdit}
|
|
||||||
<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>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{: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>
|
||||||
|
|||||||
Reference in New Issue
Block a user