add bulk import to pool editor for admins
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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 @@
|
||||
</form>
|
||||
{/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 -->
|
||||
<div class="mt-6 flex flex-col gap-6">
|
||||
{#if hasAnySlots || isDragging}
|
||||
|
||||
Reference in New Issue
Block a user