improve mappool editor
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -12,25 +12,122 @@
|
||||
let addError = $state('');
|
||||
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
|
||||
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<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;
|
||||
// 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 };
|
||||
});
|
||||
}
|
||||
|
||||
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 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:global([draggable='true']) {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<svelte:head>
|
||||
<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:description" content="{data.pool.slots.length} maps · View on Vash Esports" />
|
||||
<meta property="og:type" content="website" />
|
||||
@@ -65,7 +172,9 @@
|
||||
<!-- 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>
|
||||
<a href="/mappools" class="text-text-secondary transition-colors hover:text-text-primary"
|
||||
>←</a
|
||||
>
|
||||
<div>
|
||||
{#if editing && data.canEdit}
|
||||
<form
|
||||
@@ -84,9 +193,8 @@
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
autofocus
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -98,7 +206,6 @@
|
||||
type="button"
|
||||
onclick={() => {
|
||||
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"
|
||||
>
|
||||
@@ -109,20 +216,21 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="text-2xl font-700 tracking-tight">{data.pool.name}</h1>
|
||||
{#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">
|
||||
✓ Verified
|
||||
<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"
|
||||
>
|
||||
✓ Verified
|
||||
</span>
|
||||
{/if}
|
||||
{#if data.canEdit}
|
||||
<button
|
||||
onclick={() => {
|
||||
editName = data.pool.name;
|
||||
editing = true;
|
||||
}}
|
||||
class="rounded p-1 text-text-secondary transition-colors hover:text-accent"
|
||||
title="Rename"
|
||||
>
|
||||
✎
|
||||
✎
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -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
|
||||
</button>
|
||||
</form>
|
||||
{/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,11 +336,22 @@
|
||||
|
||||
<!-- Map List -->
|
||||
<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 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
|
||||
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}
|
||||
</span>
|
||||
@@ -241,14 +360,73 @@
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each slots as slot, i}
|
||||
{#if slots.length === 0}
|
||||
<!-- Empty category drop zone during drag -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<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 -->
|
||||
{#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}
|
||||
<div
|
||||
class="flex h-12 w-24 items-center justify-center rounded bg-surface-700 text-xs text-text-secondary"
|
||||
@@ -260,22 +438,33 @@
|
||||
<!-- 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"
|
||||
<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}
|
||||
</a>
|
||||
<div class="mt-0.5 flex items-center gap-3 text-xs text-text-secondary">
|
||||
</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.starRating.toFixed(
|
||||
2
|
||||
)}★</span
|
||||
>
|
||||
<span>{slot.beatmap.bpm} BPM</span>
|
||||
<span>{formatLength(slot.beatmap.totalLength)}</span>
|
||||
<span
|
||||
>{formatLength(
|
||||
slot.beatmap.totalLength
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-text-secondary">Beatmap #{slot.beatmapId}</p>
|
||||
<p class="text-sm text-text-secondary">
|
||||
Beatmap #{slot.beatmapId}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -287,23 +476,85 @@
|
||||
<!-- Delete -->
|
||||
{#if data.canEdit}
|
||||
<form method="post" action="?/removeSlot" use:enhance>
|
||||
<input type="hidden" name="slotId" value={slot.id} />
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{: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}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user