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 [setCode, setSetCode] = useState('');
|
||||||
const [isImporting, setIsImporting] = useState(false);
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
const [importResult, setImportResult] = useState(null);
|
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 () => {
|
const handleImport = async () => {
|
||||||
if (!setCode.trim()) {
|
if (!setCode.trim()) {
|
||||||
|
|
@ -130,6 +165,85 @@ const CardImport = () => {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
{/* Import Form */}
|
{/* Import Form */}
|
||||||
<div className="space-y-6">
|
<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": {
|
"functions": {
|
||||||
"pages/api/cron/sync-catalog.js": {
|
"pages/api/cron/sync-catalog.js": {
|
||||||
"maxDuration": 300
|
"maxDuration": 300
|
||||||
|
},
|
||||||
|
"pages/api/admin/sync-catalog.js": {
|
||||||
|
"maxDuration": 300
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue