add bulk import to pool editor for admins
This commit is contained in:
@@ -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');
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user