add bulk import to pool editor for admins

This commit is contained in:
2026-03-10 03:15:31 +01:00
parent de1e5a7f21
commit 50aa40b3d5
2 changed files with 170 additions and 0 deletions

View File

@@ -284,6 +284,94 @@ export const actions: Actions = {
return { success: true }; 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 }) => { deletePool: async ({ params, locals }) => {
requireAuth(locals); requireAuth(locals);
if (locals.user!.role !== 'admin') error(403, 'Admins only'); if (locals.user!.role !== 'admin') error(403, 'Admins only');

View File

@@ -22,6 +22,12 @@
let dropTarget = $state<{ category: string; index: number } | null>(null); let dropTarget = $state<{ category: string; index: number } | null>(null);
const isDragging = $derived(draggedSlot !== 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 // Rename state
let editing = $state(false); let editing = $state(false);
let editName = $derived(data.pool.name); let editName = $derived(data.pool.name);
@@ -334,6 +340,82 @@
</form> </form>
{/if} {/if}
<!-- Bulk Import -->
{#if data.isAdmin}
<div class="mt-4 rounded-lg border border-border bg-surface-800">
<button
type="button"
onclick={() => { bulkOpen = !bulkOpen; }}
class="flex w-full items-center justify-between px-4 py-3 text-sm font-600 text-text-secondary hover:text-text-primary transition-colors"
>
Bulk Import
<span class="text-xs">{bulkOpen ? '▲' : '▼'}</span>
</button>
{#if bulkOpen}
<form
method="post"
action="?/bulkImport"
use:enhance={() => {
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"
>
<textarea
name="data"
bind:value={bulkData}
placeholder={"NM1\t181589\nNM2\t2603758\nHR1\t948389\nTB\t457061"}
rows="6"
class="w-full rounded-md border border-border bg-surface-700 px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-secondary/40 focus:border-accent focus:outline-none"
></textarea>
<div class="mt-2 flex items-center gap-3">
<button
type="submit"
disabled={bulkImporting || !bulkData.trim()}
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{bulkImporting ? 'Importing...' : 'Import'}
</button>
<span class="text-xs text-text-secondary">
Paste tab-separated lines: mod code + beatmap ID/URL
</span>
</div>
{#if bulkResult}
<div class="mt-3 rounded-md border border-border bg-surface-700 p-3 text-sm">
{#if bulkResult.successCount > 0}
<p class="text-green-400">{bulkResult.successCount} map{bulkResult.successCount !== 1 ? 's' : ''} imported successfully.</p>
{/if}
{#if bulkResult.errors.length > 0}
<div class="mt-1 space-y-1">
{#each bulkResult.errors as err}
<p class="text-red-400">{err}</p>
{/each}
</div>
{/if}
</div>
{/if}
</form>
{/if}
</div>
{/if}
<!-- Map List --> <!-- Map List -->
<div class="mt-6 flex flex-col gap-6"> <div class="mt-6 flex flex-col gap-6">
{#if hasAnySlots || isDragging} {#if hasAnySlots || isDragging}