Add catalog sync button to admin panel #50

Merged
varutasu merged 1 commit from feat/admin-catalog-sync-button into main 2026-05-28 10:18:02 -04:00
3 changed files with 148 additions and 0 deletions
Showing only changes of commit d3b38e1063 - Show all commits

View file

@ -10,6 +10,41 @@ const CardImport = () => {
const [setCode, setSetCode] = useState('');
const [isImporting, setIsImporting] = useState(false);
const [importResult, setImportResult] = useState(null);
const [isSyncing, setIsSyncing] = useState(false);
const [syncResult, setSyncResult] = useState(null);
const handleCatalogSync = async () => {
setIsSyncing(true);
setSyncResult(null);
try {
const response = await fetch('/api/admin/sync-catalog', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
},
});
const result = await response.json();
if (response.ok) {
setSyncResult({ success: true, ...result });
} else {
setSyncResult({
success: false,
message: result.error || 'Catalog sync failed',
});
}
} catch (error) {
setSyncResult({
success: false,
message: `Network error: ${error.message}`,
});
} finally {
setIsSyncing(false);
}
};
const handleImport = async () => {
if (!setCode.trim()) {
@ -130,6 +165,85 @@ const CardImport = () => {
</p>
</div>
{/* Catalog sync */}
<div className="mb-8 p-6 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h2 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
Catalog sync
</h2>
<p className="text-sm max-w-2xl" style={{ color: 'var(--text-secondary)' }}>
Import up to three newest missing MTG and Pokémon sets the same job the weekly Vercel cron runs.
Use this to backfill recent releases without curl.
</p>
</div>
<button
type="button"
onClick={handleCatalogSync}
disabled={isSyncing}
className={`shrink-0 py-3 px-6 rounded-xl font-medium transition-all duration-200 ${
isSyncing
? 'opacity-50 cursor-not-allowed'
: 'gradient-bg-purple text-white hover:shadow-lg'
}`}
>
{isSyncing ? 'Syncing catalog…' : 'Run catalog sync'}
</button>
</div>
{syncResult && (
<div
className={`mt-4 p-4 rounded-xl ${
syncResult.success
? 'bg-green-100 border border-green-300'
: 'bg-red-100 border border-red-300'
}`}
>
<h3
className={`font-semibold mb-2 ${
syncResult.success ? 'text-green-800' : 'text-red-800'
}`}
>
{syncResult.success ? 'Catalog sync complete' : 'Catalog sync failed'}
</h3>
{syncResult.success ? (
<div className="text-sm space-y-2 text-green-900">
<p>
Imported {syncResult.imported} cards ({syncResult.skipped} skipped).
</p>
{(syncResult.pendingMtgSets != null || syncResult.pendingPokemonSets != null) && (
<p>
Still pending: {syncResult.pendingMtgSets} MTG sets,{' '}
{syncResult.pendingPokemonSets} Pokémon sets.
</p>
)}
{syncResult.lorcana && <p>{syncResult.lorcana}</p>}
{Array.isArray(syncResult.setsProcessed) && syncResult.setsProcessed.length > 0 && (
<ul className="list-disc pl-5 space-y-1">
{syncResult.setsProcessed.map((set) => (
<li key={`${set.game}-${set.setCode}`}>
{set.name} ({set.game}/{set.setCode}) {set.imported} imported
</li>
))}
</ul>
)}
{Array.isArray(syncResult.errors) && syncResult.errors.length > 0 && (
<ul className="list-disc pl-5 space-y-1 text-red-800">
{syncResult.errors.map((entry) => (
<li key={`${entry.game}-${entry.setCode}`}>
{entry.game}/{entry.setCode}: {entry.message}
</li>
))}
</ul>
)}
</div>
) : (
<p className="text-sm text-red-800">{syncResult.message}</p>
)}
</div>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Import Form */}
<div className="space-y-6">

View file

@ -0,0 +1,31 @@
import { getUserFromRequest } from '../../../lib/permission-middleware';
import { checkImportRateLimit } from '../../../lib/rate-limit.js';
import { runCatalogSync } from '../../../lib/card-import/sync-catalog.js';
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const user = await getUserFromRequest(req);
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (user.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
const { allowed, reset } = await checkImportRateLimit(req, user.userId);
if (!allowed) {
res.setHeader('Retry-After', Math.ceil((reset - Date.now()) / 1000));
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
}
try {
const summary = await runCatalogSync();
return res.status(200).json({ success: true, ...summary });
} catch (error) {
console.error('[POST /api/admin/sync-catalog]', error);
return res.status(500).json({ error: 'Catalog sync failed', details: error.message });
}
}

View file

@ -9,6 +9,9 @@
"functions": {
"pages/api/cron/sync-catalog.js": {
"maxDuration": 300
},
"pages/api/admin/sync-catalog.js": {
"maxDuration": 300
}
}
}