Add catalog sync button to admin panel #50
3 changed files with 148 additions and 0 deletions
|
|
@ -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">
|
||||
|
|
|
|||
31
pages/api/admin/sync-catalog.js
Normal file
31
pages/api/admin/sync-catalog.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,9 @@
|
|||
"functions": {
|
||||
"pages/api/cron/sync-catalog.js": {
|
||||
"maxDuration": 300
|
||||
},
|
||||
"pages/api/admin/sync-catalog.js": {
|
||||
"maxDuration": 300
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue