Scanner was sending logged-in users to /login while useAuth was still loading. Admin card-editor/card-import crashed on login because hooks ran after a mounted early return (Rules of Hooks violation). Co-authored-by: Cursor <cursoragent@cursor.com>
295 lines
No EOL
11 KiB
JavaScript
295 lines
No EOL
11 KiB
JavaScript
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 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 }); |