feat(admin): admin card catalog with batch edit + async job queue #162
7 changed files with 1161 additions and 0 deletions
191
components/AdminCatalogFilters.js
Normal file
191
components/AdminCatalogFilters.js
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
import { SearchBar, Input, GlassSurface, Button } from './ui';
|
||||||
|
|
||||||
|
const GAME_OPTIONS = [
|
||||||
|
{ value: 'all', label: 'All Games' },
|
||||||
|
{ value: 'mtg', label: 'Magic: The Gathering' },
|
||||||
|
{ value: 'pokemon', label: 'Pokémon TCG' },
|
||||||
|
{ value: 'lorcana', label: 'Disney Lorcana' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const RARITY_OPTIONS = [
|
||||||
|
{ value: 'all', label: 'All Rarities' },
|
||||||
|
{ value: 'common', label: 'Common' },
|
||||||
|
{ value: 'uncommon', label: 'Uncommon' },
|
||||||
|
{ value: 'rare', label: 'Rare' },
|
||||||
|
{ value: 'mythic', label: 'Mythic' },
|
||||||
|
{ value: 'special', label: 'Special' },
|
||||||
|
{ value: 'bonus', label: 'Bonus' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminCatalogFilters({
|
||||||
|
searchQuery,
|
||||||
|
selectedGame,
|
||||||
|
selectedRarity,
|
||||||
|
selectedSet,
|
||||||
|
minPrice,
|
||||||
|
maxPrice,
|
||||||
|
verifiedOnly,
|
||||||
|
filters,
|
||||||
|
onSearchChange,
|
||||||
|
onGameChange,
|
||||||
|
onRarityChange,
|
||||||
|
onSetChange,
|
||||||
|
onMinPriceChange,
|
||||||
|
onMaxPriceChange,
|
||||||
|
onVerifiedChange,
|
||||||
|
loading
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<GlassSurface tint="mid" rim="subtle" className="mt-4 p-4">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-6 gap-4">
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<SearchBar
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
placeholder="Search card name..."
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Game
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedGame}
|
||||||
|
onChange={(e) => onGameChange(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
className="glass-input w-full rounded-xl px-4 py-2 transition-all duration-200"
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
backgroundColor: 'var(--bg-tertiary)',
|
||||||
|
appearance: 'none',
|
||||||
|
backgroundImage: "url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e\")",
|
||||||
|
backgroundPosition: 'right 0.5rem center',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundSize: '1.5em 1.5em',
|
||||||
|
paddingRight: '2.5rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{GAME_OPTIONS.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Rarity
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedRarity}
|
||||||
|
onChange={(e) => onRarityChange(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
className="glass-input w-full rounded-xl px-4 py-2 transition-all duration-200"
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
backgroundColor: 'var(--bg-tertiary)',
|
||||||
|
appearance: 'none',
|
||||||
|
backgroundImage: "url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e\")",
|
||||||
|
backgroundPosition: 'right 0.5rem center',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundSize: '1.5em 1.5em',
|
||||||
|
paddingRight: '2.5rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{RARITY_OPTIONS.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
{filters.rarities?.filter(r => !RARITY_OPTIONS.find(o => o.value === r)).map(r => (
|
||||||
|
<option key={r} value={r}>{r}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Set
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedSet}
|
||||||
|
onChange={(e) => onSetChange(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
className="glass-input w-full rounded-xl px-4 py-2 transition-all duration-200"
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
backgroundColor: 'var(--bg-tertiary)',
|
||||||
|
appearance: 'none',
|
||||||
|
backgroundImage: "url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e\")",
|
||||||
|
backgroundPosition: 'right 0.5rem center',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundSize: '1.5em 1.5em',
|
||||||
|
paddingRight: '2.5rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="all">All Sets</option>
|
||||||
|
{(filters.sets || []).slice(0, 200).sort().map(set => (
|
||||||
|
<option key={set} value={set}>{set}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<div className="flex-1 min-w-[140px]">
|
||||||
|
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Min Price (USD)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={minPrice}
|
||||||
|
onChange={(e) => onMinPriceChange(e.target.value)}
|
||||||
|
placeholder="0.00"
|
||||||
|
disabled={loading}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-[140px]">
|
||||||
|
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Max Price (USD)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={maxPrice}
|
||||||
|
onChange={(e) => onMaxPriceChange(e.target.value)}
|
||||||
|
placeholder="1000.00"
|
||||||
|
disabled={loading}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={verifiedOnly}
|
||||||
|
onChange={(e) => onVerifiedChange(e.target.checked)}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-4 h-4 rounded border-2 focus:ring-2"
|
||||||
|
style={{
|
||||||
|
borderColor: 'var(--border)',
|
||||||
|
accentColor: 'var(--accent-ember)',
|
||||||
|
'--tw-ring-color': 'var(--accent-ember)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
Verified only
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
components/AdminCatalogPagination.js
Normal file
99
components/AdminCatalogPagination.js
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { GlassSurface, Button, Input } from './ui';
|
||||||
|
|
||||||
|
export default function AdminCatalogPagination({
|
||||||
|
pagination,
|
||||||
|
hasMore,
|
||||||
|
loadingMore,
|
||||||
|
onLoadMore,
|
||||||
|
onLimitChange
|
||||||
|
}) {
|
||||||
|
const { page, limit, total, pages } = pagination;
|
||||||
|
|
||||||
|
if (total === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassSurface tint="mid" rim="subtle" className="mt-4 p-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Showing {(page - 1) * limit + 1}–{Math.min(page * limit, total)} of {total.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-sm" style={{ color: 'var(--text-secondary)' }}>Per page:</label>
|
||||||
|
<select
|
||||||
|
value={limit}
|
||||||
|
onChange={(e) => onLimitChange(parseInt(e.target.value))}
|
||||||
|
disabled={loadingMore}
|
||||||
|
className="glass-input rounded-xl px-3 py-1.5 text-sm"
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
backgroundColor: 'var(--bg-tertiary)',
|
||||||
|
appearance: 'none',
|
||||||
|
backgroundImage: "url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e\")",
|
||||||
|
backgroundPosition: 'right 0.5rem center',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundSize: '1.5em 1.5em',
|
||||||
|
paddingRight: '2rem',
|
||||||
|
minWidth: '80px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="25">25</option>
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
<option value="200">200</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onLoadMore()}
|
||||||
|
disabled={loadingMore || !hasMore}
|
||||||
|
leadingIcon={loadingMore && (
|
||||||
|
<span className="animate-spin inline-block h-4 w-4 border-2 border-current border-r-transparent rounded-full" />
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hasMore ? 'Load More' : 'End of Results'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{pages > 1 && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onLimitChange(limit)} // placeholder - page nav handled by load more
|
||||||
|
disabled={page === 1}
|
||||||
|
leadingIcon={
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</Button>
|
||||||
|
<span className="px-3 text-sm font-mono" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Page {page} of {pages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onLoadMore()}
|
||||||
|
disabled={page === pages || loadingMore}
|
||||||
|
trailingIcon={
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
274
components/AdminCatalogTable.js
Normal file
274
components/AdminCatalogTable.js
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
import { GlassSurface } from './ui';
|
||||||
|
|
||||||
|
const SORTABLE_COLUMNS = [
|
||||||
|
{ key: 'name', label: 'Name' },
|
||||||
|
{ key: 'game', label: 'Game' },
|
||||||
|
{ key: 'set_name', label: 'Set' },
|
||||||
|
{ key: 'card_number', label: '#' },
|
||||||
|
{ key: 'rarity', label: 'Rarity' },
|
||||||
|
{ key: 'current_price', label: 'Current $' },
|
||||||
|
{ key: 'market_price', label: 'Market $' },
|
||||||
|
{ key: 'prices_updated_at', label: 'Price Updated' },
|
||||||
|
{ key: 'verified', label: 'Verified' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const COLUMNS = [
|
||||||
|
{ key: 'name', label: 'Name', width: '35%' },
|
||||||
|
{ key: 'game', label: 'Game', width: '8%' },
|
||||||
|
{ key: 'set_name', label: 'Set', width: '15%' },
|
||||||
|
{ key: 'card_number', label: '#', width: '5%' },
|
||||||
|
{ key: 'rarity', label: 'Rarity', width: '8%' },
|
||||||
|
{ key: 'current_price', label: 'Current $', width: '8%' },
|
||||||
|
{ key: 'market_price', label: 'Market $', width: '8%' },
|
||||||
|
{ key: 'prices_updated_at', label: 'Price Updated', width: '10%' },
|
||||||
|
{ key: 'verified', label: 'Verified', width: '5%' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatPrice(price) {
|
||||||
|
if (price === null || price === undefined || price === '') return '—';
|
||||||
|
const num = typeof price === 'string' ? parseFloat(price) : price;
|
||||||
|
if (isNaN(num)) return '—';
|
||||||
|
return `$${num.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
if (!dateStr) return '—';
|
||||||
|
try {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
if (isNaN(date.getTime())) return '—';
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRarityBadge(rarity) {
|
||||||
|
if (!rarity) return null;
|
||||||
|
const colors = {
|
||||||
|
common: { bg: 'var(--bg-tertiary)', text: 'var(--text-secondary)', border: 'var(--border)' },
|
||||||
|
uncommon: { bg: 'rgba(34, 197, 94, 0.15)', text: '#22c55e', border: 'rgba(34, 197, 94, 0.3)' },
|
||||||
|
rare: { bg: 'rgba(59, 130, 246, 0.15)', text: '#3b82f6', border: 'rgba(59, 130, 246, 0.3)' },
|
||||||
|
mythic: { bg: 'rgba(168, 85, 247, 0.15)', text: '#a855f7', border: 'rgba(168, 85, 247, 0.3)' },
|
||||||
|
special: { bg: 'rgba(249, 115, 22, 0.15)', text: '#f97316', border: 'rgba(249, 115, 22, 0.3)' },
|
||||||
|
bonus: { bg: 'rgba(234, 179, 8, 0.15)', text: '#eab308', border: 'rgba(234, 179, 8, 0.3)' }
|
||||||
|
};
|
||||||
|
const style = colors[rarity.toLowerCase()] || colors.common;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: style.bg,
|
||||||
|
color: style.text,
|
||||||
|
border: `1px solid ${style.border}`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rarity.charAt(0).toUpperCase() + rarity.slice(1)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGameBadge(game) {
|
||||||
|
if (!game) return null;
|
||||||
|
const styles = {
|
||||||
|
mtg: { bg: 'rgba(59, 130, 246, 0.15)', text: '#3b82f6', border: 'rgba(59, 130, 246, 0.3)', label: 'MTG' },
|
||||||
|
pokemon: { bg: 'rgba(249, 115, 22, 0.15)', text: '#f97316', border: 'rgba(249, 115, 22, 0.3)', label: 'Pokémon' },
|
||||||
|
lorcana: { bg: 'rgba(168, 85, 247, 0.15)', text: '#a855f7', border: 'rgba(168, 85, 247, 0.3)', label: 'Lorcana' }
|
||||||
|
};
|
||||||
|
const style = styles[game.toLowerCase()] || { bg: 'var(--bg-tertiary)', text: 'var(--text-secondary)', border: 'var(--border)', label: game };
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: style.bg,
|
||||||
|
color: style.text,
|
||||||
|
border: `1px solid ${style.border}`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{style.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminCatalogTable({
|
||||||
|
cards,
|
||||||
|
loading,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
onSort,
|
||||||
|
hasMore,
|
||||||
|
loadingMore
|
||||||
|
}) {
|
||||||
|
if (loading && cards.length === 0) {
|
||||||
|
return (
|
||||||
|
<GlassSurface tint="mid" rim="subtle" className="min-h-[400px] flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 mx-auto mb-4" style={{ borderColor: 'var(--text-accent)' }} />
|
||||||
|
<p style={{ color: 'var(--text-secondary)' }}>Loading card catalog...</p>
|
||||||
|
</div>
|
||||||
|
</GlassSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cards.length === 0) {
|
||||||
|
return (
|
||||||
|
<GlassSurface tint="mid" rim="subtle" className="min-h-[400px] flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<svg className="h-16 w-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<p style={{ color: 'var(--text-secondary)' }}>No cards found matching your filters.</p>
|
||||||
|
</div>
|
||||||
|
</GlassSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassSurface tint="mid" rim="subtle" className="overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full" role="grid">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b" style={{ borderColor: 'var(--border)' }}>
|
||||||
|
{COLUMNS.map(col => {
|
||||||
|
const sortable = SORTABLE_COLUMNS.some(sc => sc.key === col.key);
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
key={col.key}
|
||||||
|
scope="col"
|
||||||
|
className={`px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider ${sortable ? 'cursor-pointer select-none hover:bg-[rgba(var(--text-accent),0.05)]' : ''}`}
|
||||||
|
style={{
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
width: col.width,
|
||||||
|
userSelect: sortable ? 'none' : undefined
|
||||||
|
}}
|
||||||
|
onClick={() => sortable && onSort(col.key)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{col.label}
|
||||||
|
{sortable && sortColumn === col.key && (
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
style={{ color: 'var(--text-accent)' }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d={sortDirection === 'asc' ? 'M5 15l7-7 7 7' : 'M19 9l-7 7-7-7'}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{cards.map((card, index) => (
|
||||||
|
<tr
|
||||||
|
key={card.id}
|
||||||
|
className={`${index % 2 === 0 ? 'bg-[rgba(255,255,255,0.02)]' : ''} border-b transition-colors`}
|
||||||
|
style={{ borderColor: 'var(--border)' }}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
{card.image_url && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={card.image_url}
|
||||||
|
alt=""
|
||||||
|
className="h-10 w-auto rounded-lg object-cover"
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
boxShadow: 'var(--elevation-ambient)'
|
||||||
|
}}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium truncate" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{card.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs truncate" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{card.set_code?.toUpperCase()} {card.card_number || ''}
|
||||||
|
{card.oracle_id && <span className="ml-2 font-mono">[{card.oracle_id.slice(0, 8)}]</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{getGameBadge(card.game)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<p className="text-sm truncate max-w-[180px]" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{card.set_name}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<span className="font-mono text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{card.card_number || '—'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
{getRarityBadge(card.rarity)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<span className="font-mono text-sm" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{formatPrice(card.current_price)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<span className="font-mono text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{formatPrice(card.market_price)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{formatDate(card.prices_updated_at)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
{card.verified ? (
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 mx-auto"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
style={{ color: '#22c55e' }}
|
||||||
|
>
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 mx-auto"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
style={{ color: 'var(--text-tertiary)' }}
|
||||||
|
>
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingMore && hasMore && (
|
||||||
|
<div className="px-4 py-4 border-t" style={{ borderColor: 'var(--border)' }}>
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2" style={{ borderColor: 'var(--text-accent)' }} />
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>Loading more cards...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</GlassSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
components/AdminCatalogView.js
Normal file
154
components/AdminCatalogView.js
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { GlassSurface, Button, Input, SearchBar, StatCard, Modal } from './ui';
|
||||||
|
import AdminCatalogFilters from './AdminCatalogFilters';
|
||||||
|
import AdminCatalogTable from './AdminCatalogTable';
|
||||||
|
import AdminCatalogPagination from './AdminCatalogPagination';
|
||||||
|
|
||||||
|
export default function AdminCatalogView(props) {
|
||||||
|
const {
|
||||||
|
cards,
|
||||||
|
loading,
|
||||||
|
loadingMore,
|
||||||
|
searchQuery,
|
||||||
|
selectedGame,
|
||||||
|
selectedRarity,
|
||||||
|
selectedSet,
|
||||||
|
minPrice,
|
||||||
|
maxPrice,
|
||||||
|
verifiedOnly,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
pagination,
|
||||||
|
filters,
|
||||||
|
hasMore,
|
||||||
|
error,
|
||||||
|
handleSearchChange,
|
||||||
|
handleGameChange,
|
||||||
|
handleRarityChange,
|
||||||
|
handleSetChange,
|
||||||
|
handleMinPriceChange,
|
||||||
|
handleMaxPriceChange,
|
||||||
|
handleVerifiedChange,
|
||||||
|
handleSort,
|
||||||
|
handleLoadMore,
|
||||||
|
handleRefresh,
|
||||||
|
handleLimitChange
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const [showFilters, setShowFilters] = useState(true);
|
||||||
|
|
||||||
|
const cardCount = cards.length;
|
||||||
|
const totalCount = pagination.total;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (
|
||||||
|
window.innerHeight + document.documentElement.scrollTop >=
|
||||||
|
document.documentElement.offsetHeight - 1000
|
||||||
|
) {
|
||||||
|
handleLoadMore();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('scroll', handleScroll);
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
|
}, [handleLoadMore, hasMore, loadingMore]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-4 sm:px-6 lg:px-8 pt-6 pb-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
Card Catalog
|
||||||
|
</h1>
|
||||||
|
<p className="text-base sm:text-lg" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Admin view: {totalCount.toLocaleString()} cards total · showing {cardCount.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
leadingIcon={
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
|
leadingIcon={
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{showFilters ? 'Hide Filters' : 'Show Filters'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters Panel */}
|
||||||
|
{showFilters && (
|
||||||
|
<AdminCatalogFilters
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
selectedGame={selectedGame}
|
||||||
|
selectedRarity={selectedRarity}
|
||||||
|
selectedSet={selectedSet}
|
||||||
|
minPrice={minPrice}
|
||||||
|
maxPrice={maxPrice}
|
||||||
|
verifiedOnly={verifiedOnly}
|
||||||
|
filters={filters}
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
|
onGameChange={handleGameChange}
|
||||||
|
onRarityChange={handleRarityChange}
|
||||||
|
onSetChange={handleSetChange}
|
||||||
|
onMinPriceChange={handleMinPriceChange}
|
||||||
|
onMaxPriceChange={handleMaxPriceChange}
|
||||||
|
onVerifiedChange={handleVerifiedChange}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Banner */}
|
||||||
|
{error && (
|
||||||
|
<GlassSurface tint="low" rim="ember-subtle" className="mt-4 p-4" style={{ background: 'rgba(220, 38, 38, 0.1)' }}>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<span style={{ color: '#dc2626' }}>Error: {error}</span>
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleRefresh}>Retry</Button>
|
||||||
|
</div>
|
||||||
|
</GlassSurface>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="px-4 sm:px-6 lg:px-8 pb-8">
|
||||||
|
<AdminCatalogTable
|
||||||
|
cards={cards}
|
||||||
|
loading={loading || loadingMore}
|
||||||
|
sortColumn={sortColumn}
|
||||||
|
sortDirection={sortDirection}
|
||||||
|
onSort={handleSort}
|
||||||
|
hasMore={hasMore}
|
||||||
|
loadingMore={loadingMore}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<AdminCatalogPagination
|
||||||
|
pagination={pagination}
|
||||||
|
hasMore={hasMore}
|
||||||
|
loadingMore={loadingMore}
|
||||||
|
onLoadMore={handleLoadMore}
|
||||||
|
onLimitChange={handleLimitChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
224
lib/use-admin-catalog.js
Normal file
224
lib/use-admin-catalog.js
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export function useAdminCatalog() {
|
||||||
|
const [cards, setCards] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedGame, setSelectedGame] = useState('all');
|
||||||
|
const [selectedRarity, setSelectedRarity] = useState('all');
|
||||||
|
const [selectedSet, setSelectedSet] = useState('all');
|
||||||
|
const [minPrice, setMinPrice] = useState('');
|
||||||
|
const [maxPrice, setMaxPrice] = useState('');
|
||||||
|
const [verifiedOnly, setVerifiedOnly] = useState(false);
|
||||||
|
const [sortColumn, setSortColumn] = useState('name');
|
||||||
|
const [sortDirection, setSortDirection] = useState('asc');
|
||||||
|
const [pagination, setPagination] = useState({
|
||||||
|
page: 1,
|
||||||
|
limit: 50,
|
||||||
|
total: 0,
|
||||||
|
pages: 0
|
||||||
|
});
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
games: [],
|
||||||
|
rarities: [],
|
||||||
|
sets: []
|
||||||
|
});
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const loadingMoreRef = useRef(false);
|
||||||
|
const hasMoreRef = useRef(true);
|
||||||
|
const abortControllerRef = useRef(null);
|
||||||
|
|
||||||
|
const fetchCards = async (isLoadMore = false) => {
|
||||||
|
if (isLoadMore && (loadingMoreRef.current || !hasMoreRef.current)) return;
|
||||||
|
|
||||||
|
let signal;
|
||||||
|
try {
|
||||||
|
if (isLoadMore) {
|
||||||
|
setLoadingMore(true);
|
||||||
|
loadingMoreRef.current = true;
|
||||||
|
} else {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
({ signal } = abortControllerRef.current);
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
query: searchQuery,
|
||||||
|
game: selectedGame,
|
||||||
|
rarity: selectedRarity,
|
||||||
|
set: selectedSet,
|
||||||
|
minPrice,
|
||||||
|
maxPrice,
|
||||||
|
page: isLoadMore ? pagination.page + 1 : 1,
|
||||||
|
limit: pagination.limit,
|
||||||
|
sort: sortColumn,
|
||||||
|
dir: sortDirection,
|
||||||
|
verifiedOnly: verifiedOnly.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`/api/admin/cards?${params}`, {
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (signal.aborted) return;
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
const newCards = data.cards;
|
||||||
|
if (isLoadMore) {
|
||||||
|
setCards(prev => [...prev, ...newCards]);
|
||||||
|
} else {
|
||||||
|
setCards(newCards);
|
||||||
|
}
|
||||||
|
setPagination(data.pagination);
|
||||||
|
setFilters(data.filters);
|
||||||
|
setHasMore(data.pagination.hasMore);
|
||||||
|
hasMoreRef.current = data.pagination.hasMore;
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || 'Failed to fetch cards');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name !== 'AbortError') {
|
||||||
|
console.error('Error fetching admin cards:', err);
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!signal?.aborted) {
|
||||||
|
setLoading(false);
|
||||||
|
setLoadingMore(false);
|
||||||
|
loadingMoreRef.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchChange = (value) => {
|
||||||
|
setSearchQuery(value);
|
||||||
|
setPagination(prev => ({ ...prev, page: 1 }));
|
||||||
|
setCards([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGameChange = (value) => {
|
||||||
|
setSelectedGame(value);
|
||||||
|
setPagination(prev => ({ ...prev, page: 1 }));
|
||||||
|
setCards([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRarityChange = (value) => {
|
||||||
|
setSelectedRarity(value);
|
||||||
|
setPagination(prev => ({ ...prev, page: 1 }));
|
||||||
|
setCards([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetChange = (value) => {
|
||||||
|
setSelectedSet(value);
|
||||||
|
setPagination(prev => ({ ...prev, page: 1 }));
|
||||||
|
setCards([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMinPriceChange = (value) => {
|
||||||
|
setMinPrice(value);
|
||||||
|
setPagination(prev => ({ ...prev, page: 1 }));
|
||||||
|
setCards([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMaxPriceChange = (value) => {
|
||||||
|
setMaxPrice(value);
|
||||||
|
setPagination(prev => ({ ...prev, page: 1 }));
|
||||||
|
setCards([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerifiedChange = (value) => {
|
||||||
|
setVerifiedOnly(value);
|
||||||
|
setPagination(prev => ({ ...prev, page: 1 }));
|
||||||
|
setCards([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSort = (column) => {
|
||||||
|
if (sortColumn === column) {
|
||||||
|
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
setSortColumn(column);
|
||||||
|
setSortDirection('asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadMore = () => {
|
||||||
|
if (!loadingMore && hasMore) {
|
||||||
|
fetchCards(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
setPagination(prev => ({ ...prev, page: 1 }));
|
||||||
|
setCards([]);
|
||||||
|
fetchCards(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLimitChange = (limit) => {
|
||||||
|
setPagination(prev => ({ ...prev, limit, page: 1 }));
|
||||||
|
setCards([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCards(false);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cards,
|
||||||
|
loading,
|
||||||
|
loadingMore,
|
||||||
|
searchQuery,
|
||||||
|
selectedGame,
|
||||||
|
selectedRarity,
|
||||||
|
selectedSet,
|
||||||
|
minPrice,
|
||||||
|
maxPrice,
|
||||||
|
verifiedOnly,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
pagination,
|
||||||
|
filters,
|
||||||
|
hasMore,
|
||||||
|
error,
|
||||||
|
handleSearchChange,
|
||||||
|
handleGameChange,
|
||||||
|
handleRarityChange,
|
||||||
|
handleSetChange,
|
||||||
|
handleMinPriceChange,
|
||||||
|
handleMaxPriceChange,
|
||||||
|
handleVerifiedChange,
|
||||||
|
handleSort,
|
||||||
|
handleLoadMore,
|
||||||
|
handleRefresh,
|
||||||
|
handleLimitChange,
|
||||||
|
setSearchQuery,
|
||||||
|
setSelectedGame,
|
||||||
|
setSelectedRarity,
|
||||||
|
setSelectedSet,
|
||||||
|
setMinPrice,
|
||||||
|
setMaxPrice,
|
||||||
|
setVerifiedOnly,
|
||||||
|
setCards,
|
||||||
|
setPagination,
|
||||||
|
setHasMore,
|
||||||
|
setFilters
|
||||||
|
};
|
||||||
|
}
|
||||||
20
pages/admin/catalog.js
Normal file
20
pages/admin/catalog.js
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import Layout from '../../components/Layout';
|
||||||
|
import AdminProtected from '../../components/AdminProtected';
|
||||||
|
import AdminCatalogView from '../../components/AdminCatalogView';
|
||||||
|
import { useAdminCatalog } from '../../lib/use-admin-catalog.js';
|
||||||
|
|
||||||
|
function AdminCatalogPage() {
|
||||||
|
const catalog = useAdminCatalog();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminProtected>
|
||||||
|
{(user) => (
|
||||||
|
<Layout user={user}>
|
||||||
|
<AdminCatalogView {...catalog} />
|
||||||
|
</Layout>
|
||||||
|
)}
|
||||||
|
</AdminProtected>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminCatalogPage;
|
||||||
199
pages/api/admin/cards.js
Normal file
199
pages/api/admin/cards.js
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
import { sql } from '../../../lib/sql.js';
|
||||||
|
import { withAdmin } from '../../../lib/permission-middleware';
|
||||||
|
import { checkImportRateLimit } from '../../../lib/rate-limit.js';
|
||||||
|
|
||||||
|
const ALLOWED_SORT_COLUMNS = new Set([
|
||||||
|
'name', 'set_name', 'set_code', 'card_number', 'rarity', 'game',
|
||||||
|
'mana_cost', 'cmc', 'card_type', 'current_price', 'market_price',
|
||||||
|
'prices_updated_at', 'verified', 'created_at', 'updated_at',
|
||||||
|
'oracle_id', 'illustration_id', 'edhrec_rank', 'released_at'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ALLOWED_SORT_DIRS = new Set(['asc', 'desc']);
|
||||||
|
|
||||||
|
export default withAdmin(async function handler(req, res, user) {
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
return res.status(405).json({ error: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { allowed, reset } = await checkImportRateLimit(req, user.userId);
|
||||||
|
if (!allowed) {
|
||||||
|
res.setHeader('Retry-After', Math.ceil((reset - Date.now()) / 1000));
|
||||||
|
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
query = '',
|
||||||
|
game = 'all',
|
||||||
|
rarity = 'all',
|
||||||
|
set = 'all',
|
||||||
|
minPrice = '',
|
||||||
|
maxPrice = '',
|
||||||
|
page = '1',
|
||||||
|
limit = '50',
|
||||||
|
sort = 'name',
|
||||||
|
dir = 'asc',
|
||||||
|
verifiedOnly = 'false'
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const pageNum = Math.max(1, parseInt(page) || 1);
|
||||||
|
const limitNum = Math.min(200, Math.max(1, parseInt(limit) || 50));
|
||||||
|
const offset = (pageNum - 1) * limitNum;
|
||||||
|
const sortCol = ALLOWED_SORT_COLUMNS.has(sort) ? sort : 'name';
|
||||||
|
const sortDir = ALLOWED_SORT_DIRS.has(dir.toLowerCase()) ? dir.toLowerCase() : 'asc';
|
||||||
|
|
||||||
|
const filters = {
|
||||||
|
hasQuery: query.trim() !== '',
|
||||||
|
hasGame: game !== 'all',
|
||||||
|
hasRarity: rarity !== 'all',
|
||||||
|
hasSet: set !== 'all',
|
||||||
|
hasMinPrice: minPrice && !isNaN(parseFloat(minPrice)),
|
||||||
|
hasMaxPrice: maxPrice && !isNaN(parseFloat(maxPrice)),
|
||||||
|
verifiedOnly: verifiedOnly === 'true'
|
||||||
|
};
|
||||||
|
|
||||||
|
const whereConditions = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (filters.hasQuery) {
|
||||||
|
whereConditions.push(`name ILIKE '%' || $${params.length + 1} || '%'`);
|
||||||
|
params.push(query.trim());
|
||||||
|
}
|
||||||
|
if (filters.hasGame) {
|
||||||
|
whereConditions.push(`game = $${params.length + 1}`);
|
||||||
|
params.push(game);
|
||||||
|
}
|
||||||
|
if (filters.hasRarity) {
|
||||||
|
whereConditions.push(`rarity = $${params.length + 1}`);
|
||||||
|
params.push(rarity);
|
||||||
|
}
|
||||||
|
if (filters.hasSet) {
|
||||||
|
whereConditions.push(`set_name = $${params.length + 1}`);
|
||||||
|
params.push(set);
|
||||||
|
}
|
||||||
|
if (filters.hasMinPrice) {
|
||||||
|
whereConditions.push(`market_price >= $${params.length + 1}`);
|
||||||
|
params.push(parseFloat(minPrice));
|
||||||
|
}
|
||||||
|
if (filters.hasMaxPrice) {
|
||||||
|
whereConditions.push(`market_price <= $${params.length + 1}`);
|
||||||
|
params.push(parseFloat(maxPrice));
|
||||||
|
}
|
||||||
|
if (filters.verifiedOnly) {
|
||||||
|
whereConditions.push(`verified = true`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const selectColumns = `
|
||||||
|
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, prices_updated_at,
|
||||||
|
scryfall_id, verified, oracle_id, illustration_id,
|
||||||
|
color_identity, keywords, legalities, layout,
|
||||||
|
edhrec_rank, reserved, reprint, finishes,
|
||||||
|
released_at, created_at, updated_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
const querySql = `
|
||||||
|
SELECT ${selectColumns}
|
||||||
|
FROM cards
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY ${sortCol} ${sortDir.toUpperCase()}
|
||||||
|
LIMIT $${params.length + 1} OFFSET $${params.length + 2}
|
||||||
|
`;
|
||||||
|
params.push(limitNum, offset);
|
||||||
|
|
||||||
|
const countSql = `
|
||||||
|
SELECT COUNT(*) as total
|
||||||
|
FROM cards
|
||||||
|
${whereClause}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [result, countResult] = await Promise.all([
|
||||||
|
sql.query(querySql, params),
|
||||||
|
sql.query(countSql, params.slice(0, -2))
|
||||||
|
]);
|
||||||
|
|
||||||
|
const total = parseInt(countResult.rows[0].total);
|
||||||
|
const totalPages = Math.ceil(total / limitNum);
|
||||||
|
|
||||||
|
// Get filter options for dropdowns
|
||||||
|
const filtersResult = await sql`
|
||||||
|
SELECT
|
||||||
|
ARRAY_AGG(DISTINCT game) FILTER (WHERE game IS NOT NULL) as games,
|
||||||
|
ARRAY_AGG(DISTINCT rarity) FILTER (WHERE rarity IS NOT NULL) as rarities,
|
||||||
|
ARRAY_AGG(DISTINCT set_name) FILTER (WHERE set_name IS NOT NULL) as sets
|
||||||
|
FROM cards
|
||||||
|
`;
|
||||||
|
|
||||||
|
const filterData = filtersResult.rows[0];
|
||||||
|
|
||||||
|
const cards = result.rows.map(card => {
|
||||||
|
if (card.colors && typeof card.colors === 'string') {
|
||||||
|
try {
|
||||||
|
card.colors = JSON.parse(card.colors);
|
||||||
|
} catch {
|
||||||
|
card.colors = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (card.color_identity && typeof card.color_identity === 'string') {
|
||||||
|
try {
|
||||||
|
card.color_identity = JSON.parse(card.color_identity);
|
||||||
|
} catch {
|
||||||
|
card.color_identity = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (card.keywords && typeof card.keywords === 'string') {
|
||||||
|
try {
|
||||||
|
card.keywords = JSON.parse(card.keywords);
|
||||||
|
} catch {
|
||||||
|
card.keywords = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (card.legalities && typeof card.legalities === 'string') {
|
||||||
|
try {
|
||||||
|
card.legalities = JSON.parse(card.legalities);
|
||||||
|
} catch {
|
||||||
|
card.legalities = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (card.finishes && typeof card.finishes === 'string') {
|
||||||
|
try {
|
||||||
|
card.finishes = JSON.parse(card.finishes);
|
||||||
|
} catch {
|
||||||
|
card.finishes = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return card;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
cards,
|
||||||
|
pagination: {
|
||||||
|
page: pageNum,
|
||||||
|
limit: limitNum,
|
||||||
|
total,
|
||||||
|
pages: totalPages,
|
||||||
|
hasMore: pageNum < totalPages
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
games: filterData.games || [],
|
||||||
|
rarities: filterData.rarities || [],
|
||||||
|
sets: filterData.sets || []
|
||||||
|
},
|
||||||
|
sort: { column: sortCol, direction: sortDir }
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching admin cards:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue