Files
pokemon/src/pages/admin.astro

226 lines
9.2 KiB
Plaintext

---
export const prerender = false;
import Layout from '../layouts/Main.astro';
import NavItems from '../components/NavItems.astro';
import NavBar from '../components/NavBar.astro';
import Footer from '../components/Footer.astro';
---
<Layout title="Admin Panel">
<NavBar slot="navbar">
<NavItems slot="navItems" />
</NavBar>
<div slot="page">
<div class="container my-4">
<div class="row mb-4">
<div class="col-12">
<h1>Admin Panel</h1>
<div class="accordion" id="adminAccordion">
<div class="accordion-item">
<h2 class="accordion-header" id="reindexHeading">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#reindexCollapse" aria-expanded="false" aria-controls="reindexCollapse">
Reindex
</button>
</h2>
<div id="reindexCollapse" class="accordion-collapse collapse" aria-labelledby="reindexHeading"
data-bs-parent="#adminAccordion">
<div class="accordion-body">
<form id="reindexForm">
<div class="mb-3">
<div class="form-text mb-2">Select collections to reindex:</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="reindexCards" name="cards" checked />
<label class="form-check-label" for="reindexCards">Cards</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="reindexSkus" name="skus" checked />
<label class="form-check-label" for="reindexSkus">SKUs</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="reindexInventory" name="inventory" checked />
<label class="form-check-label" for="reindexInventory">Inventory</label>
</div>
</div>
<div class="mb-3 form-check">
<input class="form-check-input" type="checkbox" id="reindexRecreate" name="recreate" />
<label class="form-check-label" for="reindexRecreate">
Recreate index (drops and recreates collections; otherwise updates in place)
</label>
</div>
<button type="submit" class="btn btn-purple" id="reindexRun">Run Reindex</button>
</form>
</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>
</div>
<!-- Reusable scrollable progress modal. Open via window.AdminProgress.open(title). -->
<div class="modal fade" id="adminProgressModal" tabindex="-1" aria-labelledby="adminProgressLabel" aria-hidden="true"
data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="adminProgressLabel">Progress</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"
id="adminProgressClose" disabled></button>
</div>
<div class="modal-body p-0">
<pre id="adminProgressLog"
class="m-0 p-3 small"
style="max-height: 60vh; overflow-y: auto; white-space: pre-wrap; word-break: break-word;"></pre>
</div>
<div class="modal-footer">
<span class="me-auto small text-secondary" id="adminProgressStatus">Idle</span>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"
id="adminProgressDismiss" disabled>Close</button>
</div>
</div>
</div>
</div>
</div>
<Footer slot="footer" />
</Layout>
<script>
// Reusable progress modal. Other admin features can call window.AdminProgress.
type ProgressHandle = {
append: (line: string) => void;
setStatus: (text: string) => void;
done: (text?: string) => void;
};
declare global {
interface Window {
AdminProgress: {
open: (title: string) => Promise<ProgressHandle>;
};
}
}
const getBootstrap = (): any => (window as any).bootstrap;
const openProgress = async (title: string): Promise<ProgressHandle> => {
const modalEl = document.getElementById('adminProgressModal')!;
const labelEl = document.getElementById('adminProgressLabel')!;
const logEl = document.getElementById('adminProgressLog')!;
const statusEl = document.getElementById('adminProgressStatus')!;
const closeBtn = document.getElementById('adminProgressClose') as HTMLButtonElement;
const dismissBtn = document.getElementById('adminProgressDismiss') as HTMLButtonElement;
labelEl.textContent = title;
logEl.textContent = '';
statusEl.textContent = 'Running...';
closeBtn.disabled = true;
dismissBtn.disabled = true;
const modal = getBootstrap().Modal.getOrCreateInstance(modalEl);
modal.show();
return {
append: (line: string) => {
logEl.textContent += (logEl.textContent ? '\n' : '') + line;
logEl.scrollTop = logEl.scrollHeight;
},
setStatus: (text: string) => { statusEl.textContent = text; },
done: (text = 'Done') => {
statusEl.textContent = text;
closeBtn.disabled = false;
dismissBtn.disabled = false;
},
};
};
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 reindexForm = document.getElementById('reindexForm') as HTMLFormElement | null;
if (reindexForm) {
reindexForm.addEventListener('submit', (e) => {
e.preventDefault();
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);
});
}
// 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>