Created comprehensive admin card editor

- Built complete admin interface for editing all card properties
- Added card search functionality with live results
- Created comprehensive form with sections for:
  * Basic information (name, game, set, rarity, etc.)
  * Game mechanics (type, mana cost, power/toughness, colors)
  * Card text and oracle text
  * Image URLs (primary and stock images)
  * Pricing information (current and market prices)
- Added real-time card preview that updates as you edit
- Implemented PUT API endpoint for updating cards
- Added proper validation and error handling
- Added database schema updates (updated_at column)
- Integrated admin navigation between card editor and import tools
- Full responsive design with modern UI components
- Live search with card thumbnails and metadata
- Proper form state management and data persistence
This commit is contained in:
Randall Stillwell 2025-07-24 15:16:57 -05:00
parent bb60b4f6b0
commit f27a7333db
4 changed files with 923 additions and 34 deletions

753
pages/admin/card-editor.js Normal file
View file

@ -0,0 +1,753 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Layout from '../../components/Layout';
export default function CardEditor() {
const router = useRouter();
const { id } = router.query;
const user = {
email: 'admin@tcgvault.com',
role: 'admin'
};
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 (
<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>
);
}
return (
<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>
);
}

View file

@ -1,7 +1,9 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/router';
import Layout from '../../components/Layout'; import Layout from '../../components/Layout';
export default function CardImport() { export default function CardImport() {
const router = useRouter();
const [importType, setImportType] = useState('mtg'); const [importType, setImportType] = useState('mtg');
const [setCode, setSetCode] = useState(''); const [setCode, setSetCode] = useState('');
const [isImporting, setIsImporting] = useState(false); const [isImporting, setIsImporting] = useState(false);
@ -82,13 +84,47 @@ export default function CardImport() {
] ]
}; };
const user = {
email: 'admin@tcgvault.com',
role: 'admin'
};
return ( return (
<Layout user={{ email: 'admin@tcgvault.com', role: 'admin' }}> <Layout user={user}>
<div className="p-6"> <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"> <div className="mb-6">
<h1 className="text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}> <h2 className="text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
Card Import Manager Card Import Manager
</h1> </h2>
<p className="text-lg" style={{ color: 'var(--text-secondary)' }}> <p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
Import cards from external APIs into your database Import cards from external APIs into your database
</p> </p>

View file

@ -1,42 +1,106 @@
import { sql } from '@vercel/postgres'; import { sql } from '@vercel/postgres';
export default async function handler(req, res) { export default async function handler(req, res) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
const { id } = req.query; const { id } = req.query;
try { if (req.method === 'GET') {
const result = await sql` try {
SELECT const result = await sql`
id, name, set_name, set_code, card_number, rarity, game, SELECT
id, 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, scryfall_id, verified,
quantity
FROM cards
WHERE id = ${id}
`;
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Card not found' });
}
const card = result.rows[0];
// Parse colors if it's a JSON string
if (card.colors && typeof card.colors === 'string') {
try {
card.colors = JSON.parse(card.colors);
} catch (e) {
card.colors = [];
}
}
res.status(200).json(card);
} catch (error) {
console.error('Error fetching card:', error);
res.status(500).json({ error: 'Failed to fetch card' });
}
} else if (req.method === 'PUT') {
try {
const {
name, set_name, set_code, card_number, rarity, game,
mana_cost, cmc, card_type, colors, oracle_text, mana_cost, cmc, card_type, colors, oracle_text,
power, toughness, image_url, stock_image_url, power, toughness, image_url, stock_image_url,
current_price, market_price, scryfall_id, verified, current_price, market_price
quantity } = req.body;
FROM cards
WHERE id = ${id}
`;
if (result.rows.length === 0) { // Validate required fields
return res.status(404).json({ error: 'Card not found' }); if (!name || !game) {
} return res.status(400).json({ error: 'Name and game are required fields' });
const card = result.rows[0];
// Parse colors if it's a JSON string
if (card.colors && typeof card.colors === 'string') {
try {
card.colors = JSON.parse(card.colors);
} catch (e) {
card.colors = [];
} }
}
res.status(200).json(card); // Update the card
} catch (error) { const result = await sql`
console.error('Error fetching card:', error); UPDATE cards SET
res.status(500).json({ error: 'Failed to fetch card' }); name = ${name},
set_name = ${set_name || null},
set_code = ${set_code || null},
card_number = ${card_number || null},
rarity = ${rarity || null},
game = ${game},
mana_cost = ${mana_cost || null},
cmc = ${cmc || null},
card_type = ${card_type || null},
colors = ${JSON.stringify(colors || [])},
oracle_text = ${oracle_text || null},
power = ${power || null},
toughness = ${toughness || null},
image_url = ${image_url || null},
stock_image_url = ${stock_image_url || null},
current_price = ${current_price || null},
market_price = ${market_price || null},
updated_at = CURRENT_TIMESTAMP
WHERE id = ${id}
RETURNING
id, 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, scryfall_id, verified,
quantity, created_at, updated_at
`;
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Card not found' });
}
const updatedCard = result.rows[0];
// Parse colors if it's a JSON string
if (updatedCard.colors && typeof updatedCard.colors === 'string') {
try {
updatedCard.colors = JSON.parse(updatedCard.colors);
} catch (e) {
updatedCard.colors = [];
}
}
res.status(200).json(updatedCard);
} catch (error) {
console.error('Error updating card:', error);
res.status(500).json({ error: 'Failed to update card' });
}
} else {
res.status(405).json({ error: 'Method not allowed' });
} }
} }

View file

@ -0,0 +1,36 @@
#!/usr/bin/env node
/**
* Add updated_at column to cards table
*
* This script adds the updated_at column to the cards table if it doesn't exist.
*/
import dotenv from 'dotenv';
import { neon } from '@neondatabase/serverless';
// Load environment variables from .env.local
dotenv.config({ path: '.env.local' });
async function addUpdatedAtColumn() {
const sql = neon(process.env.POSTGRES_URL);
try {
console.log('✅ Connecting to Neon database...');
// Add updated_at column
await sql`
ALTER TABLE cards
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
`;
console.log('✅ Added updated_at column to cards table');
console.log('🎉 Updated_at column added successfully!');
} catch (error) {
console.error('❌ Failed to add column:', error.message);
process.exit(1);
}
}
addUpdatedAtColumn();