refactor(card-editor): useCardEditor + CardEditorView (Brief 3) (#94)
Extract admin editor state into useCardEditor and UI into CardEditorView; keep dynamic(ssr: false) on the page export. Page is ~40 lines. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
d10bf1614b
commit
036e303b9e
3 changed files with 298 additions and 249 deletions
100
components/CardEditorView.js
Normal file
100
components/CardEditorView.js
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import CardEditorForm from './CardEditorForm';
|
||||
import CardEditorPreview from './CardEditorPreview';
|
||||
import CardEditorSearchPanel from './CardEditorSearchPanel';
|
||||
|
||||
export default function CardEditorView({
|
||||
card,
|
||||
formData,
|
||||
handleColorChange,
|
||||
handleInputChange,
|
||||
handleSave,
|
||||
message,
|
||||
onSearchQueryChange,
|
||||
router,
|
||||
saving,
|
||||
searchLoading,
|
||||
searchQuery,
|
||||
searchResults,
|
||||
selectCard,
|
||||
showEmptyState,
|
||||
}) {
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-8">
|
||||
<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
|
||||
type="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
|
||||
type="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>
|
||||
|
||||
<CardEditorSearchPanel
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={onSearchQueryChange}
|
||||
searchLoading={searchLoading}
|
||||
searchResults={searchResults}
|
||||
onSelectCard={selectCard}
|
||||
/>
|
||||
|
||||
{card && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-1">
|
||||
<CardEditorPreview formData={formData} />
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<CardEditorForm
|
||||
formData={formData}
|
||||
onInputChange={handleInputChange}
|
||||
onColorChange={handleColorChange}
|
||||
onSave={handleSave}
|
||||
saving={saving}
|
||||
message={message}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showEmptyState && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
185
lib/use-card-editor.js
Normal file
185
lib/use-card-editor.js
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
|
||||
const EMPTY_FORM = {
|
||||
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: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Admin card editor state and handlers (god-component split).
|
||||
*/
|
||||
export function useCardEditor(router) {
|
||||
const { id } = router.query;
|
||||
|
||||
const [card, setCard] = useState(null);
|
||||
const [fetchLoading, setFetchLoading] = useState(false);
|
||||
const loading = id ? fetchLoading : false;
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const [formData, setFormData] = useState(EMPTY_FORM);
|
||||
|
||||
const fetchCard = async (cardId) => {
|
||||
setFetchLoading(true);
|
||||
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 {
|
||||
setFetchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- load card when route id is set
|
||||
fetchCard(id);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
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, 10) : 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!');
|
||||
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 onSearchQueryChange = (value) => {
|
||||
setSearchQuery(value);
|
||||
searchCards(value);
|
||||
};
|
||||
|
||||
return {
|
||||
card,
|
||||
formData,
|
||||
handleColorChange,
|
||||
handleInputChange,
|
||||
handleSave,
|
||||
loading,
|
||||
message,
|
||||
onSearchQueryChange,
|
||||
router,
|
||||
saving,
|
||||
searchLoading,
|
||||
searchQuery,
|
||||
searchResults,
|
||||
selectCard,
|
||||
showEmptyState: !card && !loading,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,180 +1,24 @@
|
|||
/* eslint-disable @next/next/no-img-element -- External or generated image URLs; next/image migration is out of scope. */
|
||||
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';
|
||||
import CardEditorForm from '../../components/CardEditorForm';
|
||||
import CardEditorPreview from '../../components/CardEditorPreview';
|
||||
import CardEditorSearchPanel from '../../components/CardEditorSearchPanel';
|
||||
import CardEditorView from '../../components/CardEditorView';
|
||||
import { useCardEditor } from '../../lib/use-card-editor.js';
|
||||
|
||||
// Make this component client-side only to avoid SSR issues
|
||||
const CardEditor = () => {
|
||||
function CardEditorPage() {
|
||||
const router = useRouter();
|
||||
const { id } = router.query;
|
||||
const editor = useCardEditor(router);
|
||||
|
||||
const [card, setCard] = useState(null);
|
||||
const [fetchLoading, setFetchLoading] = useState(false);
|
||||
const loading = id ? fetchLoading : false;
|
||||
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: ''
|
||||
});
|
||||
|
||||
const fetchCard = async (cardId) => {
|
||||
setFetchLoading(true);
|
||||
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 {
|
||||
setFetchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load card data when editing an existing card
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- load card when route id is set
|
||||
fetchCard(id);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
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}`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
if (editor.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
|
||||
className="animate-spin rounded-full h-32 w-32 border-b-2"
|
||||
style={{ borderColor: 'var(--text-accent)' }}
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
)}
|
||||
|
|
@ -186,91 +30,11 @@ const CardEditor = () => {
|
|||
<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>
|
||||
|
||||
<CardEditorSearchPanel
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={(value) => {
|
||||
setSearchQuery(value);
|
||||
searchCards(value);
|
||||
}}
|
||||
searchLoading={searchLoading}
|
||||
searchResults={searchResults}
|
||||
onSelectCard={selectCard}
|
||||
/>
|
||||
|
||||
{/* Card Editor Form */}
|
||||
{card && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-1">
|
||||
<CardEditorPreview formData={formData} />
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<CardEditorForm
|
||||
formData={formData}
|
||||
onInputChange={handleInputChange}
|
||||
onColorChange={handleColorChange}
|
||||
onSave={handleSave}
|
||||
saving={saving}
|
||||
message={message}
|
||||
/>
|
||||
</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>
|
||||
<CardEditorView {...editor} />
|
||||
</Layout>
|
||||
)}
|
||||
</AdminProtected>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// Export with dynamic import to disable SSR
|
||||
export default dynamic(() => Promise.resolve(CardEditor), { ssr: false });
|
||||
export default dynamic(() => Promise.resolve(CardEditorPage), { ssr: false });
|
||||
|
|
|
|||
Loading…
Reference in a new issue