deckhearth/pages/admin/card-import.js
varutasu 708ef45a96
feat(security): rate-limit search/upload/import + gate import routes (P0 #6 - closes last P0)
Closes P0 #6 from PARTIAL to RESOLVED. 8/8 P0s now closed. Extends lib/rate-limit.js from single-class to 5 named limiters (auth/search/upload/generate/import). Atomically gates the 3 import routes (auth + admin-role check + rate limit) and fixes pages/admin/card-import.js's missing Bearer header in the same commit (architect's critical discovery: API gating alone would have broken the admin UI). Per Decision 1 Option A. 10 files +185/-23. Local: lint 128 baseline, vitest 21/21. CI: Playwright smoke 3/3 in 3.8s, forbidden-cors-headers pass, all gates green. PR #20 architect-commit 60b842e, implementer-commit 51a3a97. Brief 4's login.js + register.js byte-identical.
2026-05-24 22:59:59 -05:00

310 lines
No EOL
12 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import dynamic from 'next/dynamic';
import Layout from '../../components/Layout';
import AdminProtected from '../../components/AdminProtected';
const CardImport = () => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<Layout user={null}>
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-32 w-32 border-b-2" style={{ borderColor: 'var(--text-accent)' }}></div>
</div>
</Layout>
);
}
const router = useRouter();
const [importType, setImportType] = useState('mtg');
const [setCode, setSetCode] = useState('');
const [isImporting, setIsImporting] = useState(false);
const [importResult, setImportResult] = useState(null);
const handleImport = async () => {
if (!setCode.trim()) {
alert('Please enter a set code');
return;
}
setIsImporting(true);
setImportResult(null);
try {
const endpoint = importType === 'mtg'
? '/api/cards/import-mtg'
: '/api/cards/import-pokemon';
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`,
},
body: JSON.stringify({ setCode: setCode.trim() }),
});
const result = await response.json();
if (response.ok) {
setImportResult({
success: true,
message: result.message,
imported: result.imported,
skipped: result.skipped,
total: result.total
});
} else {
setImportResult({
success: false,
message: result.error || 'Import failed'
});
}
} catch (error) {
setImportResult({
success: false,
message: 'Network error: ' + error.message
});
} finally {
setIsImporting(false);
}
};
const popularSets = {
mtg: [
{ code: 'neo', name: 'Kamigawa: Neon Dynasty' },
{ code: 'vow', name: 'Innistrad: Crimson Vow' },
{ code: 'mid', name: 'Innistrad: Midnight Hunt' },
{ code: 'afr', name: 'Adventures in the Forgotten Realms' },
{ code: 'stx', name: 'Strixhaven: School of Mages' },
{ code: 'khm', name: 'Kaldheim' },
{ code: 'znr', name: 'Zendikar Rising' },
{ code: 'iko', name: 'Ikoria: Lair of Behemoths' },
{ code: 'thb', name: 'Theros Beyond Death' },
{ code: 'eld', name: 'Throne of Eldraine' }
],
pokemon: [
{ code: 'swsh1', name: 'Sword & Shield' },
{ code: 'swsh2', name: 'Rebel Clash' },
{ code: 'swsh3', name: 'Darkness Ablaze' },
{ code: 'swsh4', name: 'Vivid Voltage' },
{ code: 'swsh5', name: 'Battle Styles' },
{ code: 'swsh6', name: 'Chilling Reign' },
{ code: 'swsh7', name: 'Evolving Skies' },
{ code: 'swsh8', name: 'Fusion Strike' },
{ code: 'swsh9', name: 'Brilliant Stars' },
{ code: 'swsh10', name: 'Astral Radiance' }
]
};
return (
<AdminProtected>
{(user) => (
<Layout user={user}>
<div className="container mx-auto px-6 py-8">
{/* Admin Navigation */}
<div className="mb-8 p-4 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>
Admin Tools
</h1>
<div className="flex gap-4">
<button
onClick={() => router.push('/admin/card-editor')}
className="px-4 py-2 rounded-lg font-medium transition-all duration-200 border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
>
🖊 Card Editor
</button>
<button
onClick={() => router.push('/admin/card-import')}
className="px-4 py-2 rounded-lg font-medium gradient-bg-purple text-white"
>
📥 Card Import
</button>
</div>
</div>
</div>
{/* Card Import Section */}
<div className="mb-6">
<h2 className="text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
Card Import Manager
</h2>
<p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
Import cards from external APIs into your database
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Import Form */}
<div className="space-y-6">
<div className="p-6 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<h2 className="text-xl font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Import Settings
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
TCG Type
</label>
<select
value={importType}
onChange={(e) => setImportType(e.target.value)}
className="input-field w-full"
>
<option value="mtg">Magic: The Gathering</option>
<option value="pokemon">Pokemon TCG</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Set Code
</label>
<input
type="text"
value={setCode}
onChange={(e) => setSetCode(e.target.value)}
placeholder="e.g., neo, vow, swsh1"
className="input-field w-full"
/>
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
Enter the set code (usually 3-4 characters)
</p>
</div>
<button
onClick={handleImport}
disabled={isImporting || !setCode.trim()}
className={`w-full py-3 px-6 rounded-xl font-medium transition-all duration-200 ${
isImporting || !setCode.trim()
? 'opacity-50 cursor-not-allowed'
: 'gradient-bg-purple text-white hover:shadow-lg'
}`}
>
{isImporting ? 'Importing...' : 'Import Cards'}
</button>
</div>
</div>
{/* Import Result */}
{importResult && (
<div className={`p-6 rounded-xl ${
importResult.success
? 'bg-green-100 border border-green-300'
: 'bg-red-100 border border-red-300'
}`}>
<h3 className={`font-semibold mb-2 ${
importResult.success ? 'text-green-800' : 'text-red-800'
}`}>
{importResult.success ? 'Import Successful' : 'Import Failed'}
</h3>
<p className="text-sm mb-2">
{importResult.message}
</p>
{importResult.success && (
<div className="text-sm space-y-1">
<p>Imported: {importResult.imported}</p>
<p>Skipped: {importResult.skipped}</p>
<p>Total: {importResult.total}</p>
</div>
)}
</div>
)}
</div>
{/* Popular Sets */}
<div className="space-y-6">
<div className="p-6 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<h2 className="text-xl font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Popular Sets
</h2>
<div className="space-y-4">
<div>
<h3 className="font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Magic: The Gathering
</h3>
<div className="grid grid-cols-2 gap-2">
{popularSets.mtg.map((set) => (
<button
key={set.code}
onClick={() => {
setImportType('mtg');
setSetCode(set.code);
}}
className="text-left p-2 rounded-lg text-sm hover:bg-opacity-20 transition-all duration-200"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-secondary)'
}}
>
<div className="font-medium">{set.code.toUpperCase()}</div>
<div className="text-xs opacity-75">{set.name}</div>
</button>
))}
</div>
</div>
<div>
<h3 className="font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Pokemon TCG
</h3>
<div className="grid grid-cols-2 gap-2">
{popularSets.pokemon.map((set) => (
<button
key={set.code}
onClick={() => {
setImportType('pokemon');
setSetCode(set.code);
}}
className="text-left p-2 rounded-lg text-sm hover:bg-opacity-20 transition-all duration-200"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-secondary)'
}}
>
<div className="font-medium">{set.code.toUpperCase()}</div>
<div className="text-xs opacity-75">{set.name}</div>
</button>
))}
</div>
</div>
</div>
</div>
{/* Import Tips */}
<div className="p-6 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<h2 className="text-xl font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Import Tips
</h2>
<ul className="space-y-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
<li> Set codes are case-insensitive</li>
<li> Duplicate cards will be skipped automatically</li>
<li> Import may take several minutes for large sets</li>
<li> Prices are fetched from TCGPlayer when available</li>
<li> Images are stored as URLs to external sources</li>
</ul>
</div>
</div>
</div>
</div>
</Layout>
)}
</AdminProtected>
);
};
// Export with dynamic import to disable SSR
export default dynamic(() => Promise.resolve(CardImport), { ssr: false });