[feat] tcg player import added to admin page

This commit is contained in:
2026-05-28 16:12:48 -04:00
parent b0dbe7ced5
commit 2cf47d2b15
5 changed files with 246 additions and 167 deletions

View File

@@ -10,7 +10,7 @@ declare global {
}
const isProtectedRoute = createRouteMatcher(['/pokemon']);
const isAdminRoute = createRouteMatcher(['/admin', '/api/reindex']);
const isAdminRoute = createRouteMatcher(['/admin', '/api/reindex', '/api/preload-tcgplayer']);
const TARGET_ORG_ID = "org_3Baav9czkRLLlC7g89oJWqRRulK";
const ADMIN_ORG_IDS = new Set([

View File

@@ -53,6 +53,29 @@ import Footer from '../components/Footer.astro';
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="tcgImportHeading">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#tcgImportCollapse" aria-expanded="false" aria-controls="tcgImportCollapse">
TCG Player Import
</button>
</h2>
<div id="tcgImportCollapse" class="accordion-collapse collapse" aria-labelledby="tcgImportHeading"
data-bs-parent="#adminAccordion">
<div class="accordion-body">
<form id="tcgImportForm">
<div class="mb-3">
<label for="tcgImportSetName" class="form-label">Set Name</label>
<input type="text" class="form-control" id="tcgImportSetName" name="setName"
placeholder="e.g. Surging Sparks" autocomplete="off" required />
<div class="form-text">Matches any set whose name contains this text (case-insensitive).</div>
</div>
<button type="submit" class="btn btn-purple" id="tcgImportRun">Run Import</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -137,54 +160,66 @@ import Footer from '../components/Footer.astro';
window.AdminProgress = { open: openProgress };
// Stream a POST JSON request line-by-line into a progress modal.
const streamToProgress = async (url: string, body: unknown, title: string, runBtn: HTMLButtonElement) => {
runBtn.disabled = true;
const progress = await window.AdminProgress.open(title);
try {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok || !resp.body) {
progress.append(`Request failed: ${resp.status} ${resp.statusText}`);
progress.done('Failed');
return;
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop() ?? '';
for (const line of lines) progress.append(line);
}
if (buf) progress.append(buf);
progress.done('Done');
} catch (err: any) {
progress.append(`Error: ${err?.message || String(err)}`);
progress.done('Failed');
} finally {
runBtn.disabled = false;
}
};
// Reindex form wiring
const form = document.getElementById('reindexForm') as HTMLFormElement | null;
if (form) {
form.addEventListener('submit', async (e) => {
const reindexForm = document.getElementById('reindexForm') as HTMLFormElement | null;
if (reindexForm) {
reindexForm.addEventListener('submit', (e) => {
e.preventDefault();
const runBtn = document.getElementById('reindexRun') as HTMLButtonElement;
const body = {
cards: (document.getElementById('reindexCards') as HTMLInputElement).checked,
skus: (document.getElementById('reindexSkus') as HTMLInputElement).checked,
inventory: (document.getElementById('reindexInventory') as HTMLInputElement).checked,
recreate: (document.getElementById('reindexRecreate') as HTMLInputElement).checked,
};
streamToProgress('/api/reindex', body, 'Reindex',
document.getElementById('reindexRun') as HTMLButtonElement);
});
}
runBtn.disabled = true;
const progress = await window.AdminProgress.open('Reindex');
try {
const resp = await fetch('/api/reindex', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok || !resp.body) {
progress.append(`Request failed: ${resp.status} ${resp.statusText}`);
progress.done('Failed');
return;
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop() ?? '';
for (const line of lines) progress.append(line);
}
if (buf) progress.append(buf);
progress.done('Done');
} catch (err: any) {
progress.append(`Error: ${err?.message || String(err)}`);
progress.done('Failed');
} finally {
runBtn.disabled = false;
}
// TCG Player import form wiring
const tcgImportForm = document.getElementById('tcgImportForm') as HTMLFormElement | null;
if (tcgImportForm) {
tcgImportForm.addEventListener('submit', (e) => {
e.preventDefault();
const setName = (document.getElementById('tcgImportSetName') as HTMLInputElement).value.trim();
streamToProgress('/api/preload-tcgplayer', { setName }, `TCG Player Import: ${setName || '(none)'}`,
document.getElementById('tcgImportRun') as HTMLButtonElement);
});
}
</script>

View File

@@ -0,0 +1,43 @@
import type { APIRoute } from 'astro';
import { runImport } from '../../../scripts/preload-tcgplayer';
export const POST: APIRoute = async ({ request }) => {
const { setName } = await request.json().catch(() => ({} as any));
const trimmed = typeof setName === 'string' ? setName.trim() : '';
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const log = (msg: string) => {
controller.enqueue(encoder.encode(msg + '\n'));
};
try {
if (!trimmed) {
log('Set name is required.');
return;
}
log(`Starting TCGPlayer import for set: "${trimmed}"`);
await runImport({ sets: [trimmed], log });
log('TCGPlayer import complete.');
} catch (e: any) {
const cause = e?.cause;
const causeMsg = cause?.message || (cause ? String(cause) : '');
log(`Error: ${e?.message || String(e)}`);
if (causeMsg) log(`Caused by: ${causeMsg}`);
console.error('TCGPlayer import error:', e);
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no',
},
});
};