From 50aa40b3d5989749617d959ede1711b2c5c21ea0 Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Tue, 10 Mar 2026 03:15:31 +0100 Subject: [PATCH] add bulk import to pool editor for admins --- .../(app)/mappools/[id]/+page.server.ts | 88 +++++++++++++++++++ src/routes/(app)/mappools/[id]/+page.svelte | 82 +++++++++++++++++ 2 files changed, 170 insertions(+) diff --git a/src/routes/(app)/mappools/[id]/+page.server.ts b/src/routes/(app)/mappools/[id]/+page.server.ts index ed8ca75..faacc8e 100644 --- a/src/routes/(app)/mappools/[id]/+page.server.ts +++ b/src/routes/(app)/mappools/[id]/+page.server.ts @@ -284,6 +284,94 @@ export const actions: Actions = { return { success: true }; }, + bulkImport: async ({ params, request, locals }) => { + requireAuth(locals); + if (locals.user!.role !== 'admin') error(403, 'Admins only'); + + const form = await request.formData(); + const raw = form.get('data')?.toString()?.trim(); + if (!raw) return { error: 'No data provided' }; + + const lines = raw.split(/\n/).map((l) => l.trim()).filter(Boolean); + const validMods = ['NM', 'HD', 'HR', 'DT', 'FM', 'TB']; + const errors: string[] = []; + let successCount = 0; + + for (let i = 0; i < lines.length; i++) { + const lineNum = i + 1; + const parts = lines[i].split(/[\t\s]+/); + if (parts.length < 2) { + errors.push(`Line ${lineNum}: expected mod code and beatmap ID`); + continue; + } + + const modCode = parts[0]; + const modMatch = modCode.match(/^(NM|HD|HR|DT|FM|TB)\d*$/i); + if (!modMatch) { + errors.push(`Line ${lineNum}: unknown mod "${modCode}"`); + continue; + } + const category = modMatch[1].toUpperCase(); + + let beatmapId = parts[1]; + const urlMatch = beatmapId.match(/beatmaps\/(\d+)/); + const setMatch = beatmapId.match(/beatmapsets\/\d+#\w+\/(\d+)/); + const shortMatch = beatmapId.match(/\/b\/(\d+)/); + if (urlMatch) beatmapId = urlMatch[1]; + else if (setMatch) beatmapId = setMatch[1]; + else if (shortMatch) beatmapId = shortMatch[1]; + + if (!/^\d+$/.test(beatmapId)) { + errors.push(`Line ${lineNum}: invalid beatmap ID "${parts[1]}"`); + continue; + } + + let beatmap; + try { + beatmap = await getBeatmap(beatmapId); + } catch { + errors.push(`Line ${lineNum}: beatmap ${beatmapId} not found on osu!`); + continue; + } + + const existing = await db.query.mappoolSlot.findMany({ + where: and(eq(mappoolSlot.mappoolId, params.id), eq(mappoolSlot.category, category)) + }); + + let coverUrl: string | null = null; + let listCoverUrl: string | null = null; + try { + coverUrl = await proxyImage(beatmap.beatmapset.covers['card@2x']); + listCoverUrl = await proxyImage(beatmap.beatmapset.covers['list@2x']); + } catch (err: any) { + console.warn('[Mappool] Failed to proxy covers to R2:', err.message); + } + + await db.insert(mappoolSlot).values({ + mappoolId: params.id, + beatmapId, + category, + orderInCategory: existing.length + 1, + starRating: beatmap.difficulty_rating, + bpm: beatmap.bpm, + totalLength: beatmap.total_length, + mods: category === 'NM' || category === 'TB' ? [] : [category], + title: beatmap.beatmapset.title, + artist: beatmap.beatmapset.artist, + version: beatmap.version, + coverUrl, + listCoverUrl + }); + + successCount++; + } + + if (errors.length > 0) { + return { bulkImportResult: { successCount, errors } }; + } + return { bulkImportResult: { successCount, errors: [] } }; + }, + deletePool: async ({ params, locals }) => { requireAuth(locals); if (locals.user!.role !== 'admin') error(403, 'Admins only'); diff --git a/src/routes/(app)/mappools/[id]/+page.svelte b/src/routes/(app)/mappools/[id]/+page.svelte index 358622b..2c0d4d7 100644 --- a/src/routes/(app)/mappools/[id]/+page.svelte +++ b/src/routes/(app)/mappools/[id]/+page.svelte @@ -22,6 +22,12 @@ let dropTarget = $state<{ category: string; index: number } | null>(null); const isDragging = $derived(draggedSlot !== null); + // Bulk import state + let bulkOpen = $state(false); + let bulkData = $state(''); + let bulkImporting = $state(false); + let bulkResult = $state<{ successCount: number; errors: string[] } | null>(null); + // Rename state let editing = $state(false); let editName = $derived(data.pool.name); @@ -334,6 +340,82 @@ {/if} + + {#if data.isAdmin} +
+ + + {#if bulkOpen} +
{ + bulkImporting = true; + bulkResult = null; + return async ({ result, update }) => { + bulkImporting = false; + if (result.type === 'success') { + const r = (result.data as any)?.bulkImportResult; + if (r) { + bulkResult = r; + if (r.successCount > 0) { + bulkData = ''; + await update(); + } + } + } else if (result.type === 'failure') { + bulkResult = { successCount: 0, errors: [(result.data as any)?.error ?? 'Import failed'] }; + } + }; + }} + class="px-4 pb-4" + > + +
+ + + Paste tab-separated lines: mod code + beatmap ID/URL + +
+ + {#if bulkResult} +
+ {#if bulkResult.successCount > 0} +

{bulkResult.successCount} map{bulkResult.successCount !== 1 ? 's' : ''} imported successfully.

+ {/if} + {#if bulkResult.errors.length > 0} +
+ {#each bulkResult.errors as err} +

{err}

+ {/each} +
+ {/if} +
+ {/if} +
+ {/if} +
+ {/if} +
{#if hasAnySlots || isDragging}