deckhearth/pages/admin/card-editor.js
varutasu ecb3ee12fc
fix(auth): wait for verify before scanner redirect; fix admin hooks (#36)
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>
2026-05-27 12:36:58 -05:00

762 lines
No EOL
31 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';
// Make this component client-side only to avoid SSR issues
const CardEditor = () => {
const router = useRouter();
const { id } = router.query;
const [card, setCard] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState([]);
const [searchLoading, setSearchLoading] = useState(false);
// Form state
const [formData, setFormData] = useState({
name: '',
set_name: '',
set_code: '',
card_number: '',
rarity: '',
game: '',
mana_cost: '',
cmc: '',
card_type: '',
colors: [],
oracle_text: '',
power: '',
toughness: '',
image_url: '',
stock_image_url: '',
current_price: '',
market_price: ''
});
// Load card data if ID is provided
useEffect(() => {
if (id) {
fetchCard(id);
} else {
setLoading(false);
}
}, [id]);
const fetchCard = async (cardId) => {
try {
const response = await fetch(`/api/cards/${cardId}`);
if (response.ok) {
const cardData = await response.json();
setCard(cardData);
setFormData({
name: cardData.name || '',
set_name: cardData.set_name || '',
set_code: cardData.set_code || '',
card_number: cardData.card_number || '',
rarity: cardData.rarity || '',
game: cardData.game || '',
mana_cost: cardData.mana_cost || '',
cmc: cardData.cmc || '',
card_type: cardData.card_type || '',
colors: Array.isArray(cardData.colors) ? cardData.colors : [],
oracle_text: cardData.oracle_text || '',
power: cardData.power || '',
toughness: cardData.toughness || '',
image_url: cardData.image_url || '',
stock_image_url: cardData.stock_image_url || '',
current_price: cardData.current_price || '',
market_price: cardData.market_price || ''
});
} else {
setMessage('Card not found');
}
} catch (error) {
setMessage('Error loading card: ' + error.message);
} finally {
setLoading(false);
}
};
const searchCards = async (query) => {
if (!query.trim()) {
setSearchResults([]);
return;
}
setSearchLoading(true);
try {
const response = await fetch(`/api/cards/search?query=${encodeURIComponent(query)}&limit=20`);
if (response.ok) {
const data = await response.json();
setSearchResults(data.cards || []);
}
} catch (error) {
console.error('Search error:', error);
} finally {
setSearchLoading(false);
}
};
const handleInputChange = (field, value) => {
setFormData(prev => ({
...prev,
[field]: value
}));
};
const handleColorChange = (color, checked) => {
setFormData(prev => ({
...prev,
colors: checked
? [...prev.colors, color]
: prev.colors.filter(c => c !== color)
}));
};
const handleSave = async () => {
if (!card?.id) {
setMessage('No card selected to edit');
return;
}
setSaving(true);
setMessage('');
try {
const response = await fetch(`/api/cards/${card.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...formData,
cmc: formData.cmc ? parseInt(formData.cmc) : null,
current_price: formData.current_price ? parseFloat(formData.current_price) : null,
market_price: formData.market_price ? parseFloat(formData.market_price) : null
})
});
if (response.ok) {
const updatedCard = await response.json();
setCard(updatedCard);
setMessage('Card updated successfully!');
// Clear message after 3 seconds
setTimeout(() => setMessage(''), 3000);
} else {
const errorData = await response.json();
setMessage('Error updating card: ' + (errorData.error || 'Unknown error'));
}
} catch (error) {
setMessage('Error updating card: ' + error.message);
} finally {
setSaving(false);
}
};
const selectCard = (selectedCard) => {
router.push(`/admin/card-editor?id=${selectedCard.id}`);
};
const rarityOptions = [
'common', 'uncommon', 'rare', 'mythic', 'legendary',
'holographic', 'enchanted', 'super rare', 'ultra rare'
];
const gameOptions = ['MTG', 'Pokemon', 'Lorcana'];
const colorOptions = ['White', 'Blue', 'Black', 'Red', 'Green', 'Colorless'];
if (loading) {
return (
<AdminProtected>
{(user) => (
<Layout user={user}>
<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>
)}
</AdminProtected>
);
}
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 gradient-bg-purple text-white"
>
🖊 Card Editor
</button>
<button
onClick={() => router.push('/admin/card-import')}
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 Import
</button>
</div>
</div>
</div>
<div className="mb-8">
<h2 className="text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
Admin Card Editor
</h2>
<p style={{ color: 'var(--text-secondary)' }}>
Search for a card to edit its details, or continue editing the selected card.
</p>
</div>
{/* Card Search */}
<div className="mb-8 p-6 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<h2 className="text-xl font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Find Card to Edit
</h2>
<div className="flex gap-4 mb-4">
<div className="flex-1">
<input
type="text"
placeholder="Search for cards by name..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
searchCards(e.target.value);
}}
className="w-full p-3 rounded-lg border transition-all duration-200"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
/>
</div>
</div>
{/* Search Results */}
{searchLoading && (
<div className="text-center py-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 mx-auto" style={{ borderColor: 'var(--text-accent)' }}></div>
</div>
)}
{searchResults.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{searchResults.map((searchCard) => (
<div
key={searchCard.id}
onClick={() => selectCard(searchCard)}
className="p-4 rounded-lg border cursor-pointer transition-all duration-200 hover:shadow-lg hover:scale-105"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border)'
}}
>
<div className="flex items-center gap-3">
<div className="w-12 h-16 rounded-lg overflow-hidden flex-shrink-0" style={{ backgroundColor: 'var(--bg-secondary)' }}>
{searchCard.image_url ? (
<img
src={searchCard.image_url}
alt={searchCard.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-xs" style={{ color: 'var(--text-secondary)' }}>
{searchCard.game}
</div>
)}
</div>
<div className="flex-1">
<h3 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
{searchCard.name}
</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{searchCard.set_name} {searchCard.game}
</p>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{searchCard.rarity}
</p>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Card Editor Form */}
{card && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Card Preview */}
<div className="lg:col-span-1">
<div className="sticky top-8">
<h2 className="text-xl font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Card Preview
</h2>
<div className="p-6 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="w-full max-w-xs mx-auto">
<div
className="w-full rounded-lg overflow-hidden shadow-lg"
style={{ aspectRatio: '5/7' }}
>
{formData.image_url ? (
<img
src={formData.image_url}
alt={formData.name}
className="w-full h-full object-cover"
onError={(e) => {
e.target.style.display = 'none';
e.target.nextSibling.style.display = 'flex';
}}
/>
) : null}
<div
className={`w-full h-full flex items-center justify-center ${formData.image_url ? 'hidden' : 'flex'}`}
style={{ backgroundColor: 'var(--bg-primary)' }}
>
<div className="text-center p-4">
<div className="text-4xl mb-2">🃏</div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{formData.name || 'Card Name'}
</div>
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{formData.set_name || 'Set Name'}
</div>
</div>
</div>
</div>
</div>
<div className="mt-4 text-center">
<h3 className="font-bold" style={{ color: 'var(--text-primary)' }}>
{formData.name || 'Card Name'}
</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{formData.set_name} {formData.game}
</p>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{formData.rarity} {formData.card_type}
</p>
{formData.current_price && (
<p className="text-lg font-bold mt-2 gradient-text-gold">
${parseFloat(formData.current_price).toFixed(2)}
</p>
)}
</div>
</div>
</div>
</div>
{/* Editor Form */}
<div className="lg:col-span-2">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>
Edit Card Details
</h2>
<button
onClick={handleSave}
disabled={saving}
className={`px-6 py-3 rounded-xl font-medium transition-all duration-200 ${
saving
? 'opacity-50 cursor-not-allowed'
: 'gradient-bg-purple text-white hover:shadow-lg'
}`}
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
{message && (
<div className={`p-4 rounded-lg mb-6 ${
message.includes('Error')
? 'bg-red-100 text-red-700 border border-red-200'
: 'bg-green-100 text-green-700 border border-green-200'
}`}>
{message}
</div>
)}
<div className="space-y-6">
{/* Basic Information */}
<div className="p-6 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<h3 className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Basic Information
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Card Name *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
className="w-full p-3 rounded-lg border transition-all duration-200"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Game *
</label>
<select
value={formData.game}
onChange={(e) => handleInputChange('game', e.target.value)}
className="w-full p-3 rounded-lg border transition-all duration-200"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
>
<option value="">Select Game</option>
{gameOptions.map(game => (
<option key={game} value={game}>{game}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Set Name
</label>
<input
type="text"
value={formData.set_name}
onChange={(e) => handleInputChange('set_name', e.target.value)}
className="w-full p-3 rounded-lg border transition-all duration-200"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Set Code
</label>
<input
type="text"
value={formData.set_code}
onChange={(e) => handleInputChange('set_code', e.target.value)}
className="w-full p-3 rounded-lg border transition-all duration-200"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Card Number
</label>
<input
type="text"
value={formData.card_number}
onChange={(e) => handleInputChange('card_number', e.target.value)}
className="w-full p-3 rounded-lg border transition-all duration-200"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Rarity
</label>
<select
value={formData.rarity}
onChange={(e) => handleInputChange('rarity', e.target.value)}
className="w-full p-3 rounded-lg border transition-all duration-200"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
>
<option value="">Select Rarity</option>
{rarityOptions.map(rarity => (
<option key={rarity} value={rarity}>{rarity}</option>
))}
</select>
</div>
</div>
</div>
{/* Game Mechanics */}
<div className="p-6 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<h3 className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Game Mechanics
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Card Type
</label>
<input
type="text"
value={formData.card_type}
onChange={(e) => handleInputChange('card_type', e.target.value)}
placeholder="e.g., Creature, Instant, Pokemon, Character"
className="w-full p-3 rounded-lg border transition-all duration-200"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Mana Cost
</label>
<input
type="text"
value={formData.mana_cost}
onChange={(e) => handleInputChange('mana_cost', e.target.value)}
placeholder="e.g., {2}{U}{U}, 3, etc."
className="w-full p-3 rounded-lg border transition-all duration-200"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
CMC (Converted Mana Cost)
</label>
<input
type="number"
value={formData.cmc}
onChange={(e) => handleInputChange('cmc', e.target.value)}
className="w-full p-3 rounded-lg border transition-all duration-200"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Power
</label>
<input
type="text"
value={formData.power}
onChange={(e) => handleInputChange('power', e.target.value)}
placeholder="e.g., 2, *, X"
className="w-full p-3 rounded-lg border transition-all duration-200"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Toughness
</label>
<input
type="text"
value={formData.toughness}
onChange={(e) => handleInputChange('toughness', e.target.value)}
placeholder="e.g., 2, *, X"
className="w-full p-3 rounded-lg border transition-all duration-200"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
/>
</div>
</div>
{/* Colors */}
<div className="mt-4">
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Colors
</label>
<div className="flex flex-wrap gap-3">
{colorOptions.map(color => (
<label key={color} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.colors.includes(color)}
onChange={(e) => handleColorChange(color, e.target.checked)}
className="rounded"
/>
<span style={{ color: 'var(--text-primary)' }}>{color}</span>
</label>
))}
</div>
</div>
</div>
{/* Card Text */}
<div className="p-6 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<h3 className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Card Text
</h3>
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Oracle Text / Abilities
</label>
<textarea
value={formData.oracle_text}
onChange={(e) => handleInputChange('oracle_text', e.target.value)}
rows={4}
placeholder="Enter the card's rules text, abilities, or flavor text..."
className="w-full p-3 rounded-lg border transition-all duration-200"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
/>
</div>
</div>
{/* Images */}
<div className="p-6 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<h3 className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Card Images
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Primary Image URL
</label>
<input
type="url"
value={formData.image_url}
onChange={(e) => handleInputChange('image_url', e.target.value)}
placeholder="https://example.com/card-image.jpg"
className="w-full p-3 rounded-lg border transition-all duration-200"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Stock Image URL
</label>
<input
type="url"
value={formData.stock_image_url}
onChange={(e) => handleInputChange('stock_image_url', e.target.value)}
placeholder="https://example.com/stock-image.jpg"
className="w-full p-3 rounded-lg border transition-all duration-200"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
/>
</div>
</div>
</div>
{/* Pricing */}
<div className="p-6 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<h3 className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Pricing Information
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Current Price ($)
</label>
<input
type="number"
step="0.01"
value={formData.current_price}
onChange={(e) => handleInputChange('current_price', e.target.value)}
placeholder="0.00"
className="w-full p-3 rounded-lg border transition-all duration-200"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Market Price ($)
</label>
<input
type="number"
step="0.01"
value={formData.market_price}
onChange={(e) => handleInputChange('market_price', e.target.value)}
placeholder="0.00"
className="w-full p-3 rounded-lg border transition-all duration-200"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
/>
</div>
</div>
</div>
</div>
</div>
</div>
)}
{!card && !loading && (
<div className="text-center py-12">
<div className="text-6xl mb-4">🔍</div>
<h2 className="text-2xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
Search for a Card to Edit
</h2>
<p style={{ color: 'var(--text-secondary)' }}>
Use the search box above to find a card you want to edit.
</p>
</div>
)}
</div>
</Layout>
)}
</AdminProtected>
);
};
// Export with dynamic import to disable SSR
export default dynamic(() => Promise.resolve(CardEditor), { ssr: false });