Merge pull request 'feat(admin): admin card catalog with batch edit + async job queue' (#162) from feat/admin-catalog into main
Some checks failed
CI / Lint (push) Has been cancelled
CI / Schema map up to date (push) Has been cancelled
CI / Forbidden patterns (9 checks) (push) Has been cancelled
CI / Migrations apply (node-pg-migrate) (push) Has been cancelled
CI / Unit tests (vitest) (push) Has been cancelled

Reviewed-on: ⁨#162⁩
This commit is contained in:
rstillwell 2026-09-21 13:03:11 -04:00
commit d5f4a38f44
10 changed files with 1855 additions and 0 deletions

View file

@ -0,0 +1,101 @@
import { useState } from 'react';
import { Button } from './ui';
export default function AdminBulkSelectionToolbar({
selectedCount,
onClearSelection,
onBatchEdit
}) {
const [showActions, setShowActions] = useState(false);
if (selectedCount === 0) return null;
return (
<div className="fixed bottom-4 left-4 right-4 sm:bottom-6 sm:left-1/2 sm:right-auto sm:transform sm:-translate-x-1/2 z-50">
<div
className="glass-panel-strong rounded-2xl px-3 sm:px-6 py-3 sm:py-4 flex items-center justify-between sm:justify-start sm:space-x-4 sm:min-w-96"
style={{
boxShadow: 'var(--rim-light-inner), var(--elevation-pronounced)',
}}
>
{/* Selection Count */}
<div className="flex items-center space-x-2">
<div className="w-8 h-8 rounded-full flex items-center justify-center" style={{ backgroundColor: 'var(--accent-ember)' }}>
<span className="text-white text-sm font-bold">{selectedCount}</span>
</div>
<span className="font-medium text-sm sm:text-base" style={{ color: 'var(--text-primary)' }}>
<span className="hidden sm:inline">{selectedCount} card{selectedCount !== 1 ? 's' : ''} selected</span>
<span className="sm:hidden">{selectedCount}</span>
</span>
</div>
{/* Divider - Hidden on mobile */}
<div className="hidden sm:block h-6 w-px" style={{ backgroundColor: 'var(--border)' }}></div>
{/* Actions */}
<div className="flex items-center space-x-1 sm:space-x-2">
<Button
variant="primary"
size="sm"
onClick={onBatchEdit}
leadingIcon={
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
}
>
Batch Edit
</Button>
{/* More Actions Button */}
<div className="relative">
<button
onClick={() => setShowActions(!showActions)}
className="nav-item-hover p-2 rounded-xl transition-colors"
style={{ color: 'var(--text-secondary)' }}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg>
</button>
{/* Dropdown Actions */}
{showActions && (
<div className="glass-panel-strong absolute bottom-full right-0 mb-2 rounded-xl py-2 min-w-48">
<div
className="my-1 border-t"
style={{ borderColor: 'var(--border)' }}
></div>
<button
onClick={() => {
onClearSelection();
setShowActions(false);
}}
className="nav-item-hover w-full px-4 py-2 text-left text-sm flex items-center space-x-2"
style={{ color: 'rgb(239, 68, 68)' }}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span>Clear Selection</span>
</button>
</div>
)}
</div>
{/* Clear Selection */}
<button
onClick={onClearSelection}
className="nav-item-hover p-2 rounded-xl transition-colors"
style={{ color: 'var(--text-secondary)' }}
title="Clear selection"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -0,0 +1,322 @@
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: 'select', label: '', width: '40px' },
{ key: 'name', label: 'Name', width: '32%' },
{ key: 'game', label: 'Game', width: '8%' },
{ key: 'set_name', label: 'Set', width: '14%' },
{ 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 rarityColors(rarity) {
if (!rarity) return { bg: 'var(--bg-tertiary)', text: 'var(--text-secondary)', border: 'var(--border)' };
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)' }
};
return colors[rarity.toLowerCase()] || colors.common;
}
function gameColors(game) {
if (!game) return { bg: 'var(--bg-tertiary)', text: 'var(--text-secondary)', border: 'var(--border)', label: game };
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' }
};
return styles[game.toLowerCase()] || { bg: 'var(--bg-tertiary)', text: 'var(--text-secondary)', border: 'var(--border)', label: game };
}
function RarityBadge({ rarity }) {
if (!rarity) return null;
const style = rarityColors(rarity);
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 GameBadge({ game }) {
if (!game) return null;
const style = gameColors(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>
);
}
function VerifiedIcon({ verified }) {
const path = "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";
if (verified) {
return (
<svg className="h-5 w-5 mx-auto" fill="currentColor" viewBox="0 0 20 20" style={{ color: '#22c55e' }}>
<path fillRule="evenodd" d={path} clipRule="evenodd" />
</svg>
);
}
return (
<svg className="h-5 w-5 mx-auto" fill="currentColor" viewBox="0 0 20 20" style={{ color: 'var(--text-tertiary)' }}>
<path fillRule="evenodd" d={path} clipRule="evenodd" />
</svg>
);
}
export default function AdminCatalogTable({
cards,
loading,
sortColumn,
sortDirection,
onSort,
hasMore,
loadingMore,
selectedCardIds,
onToggleSelect,
onSelectAll,
allSelected
}) {
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);
if (col.key === 'select') {
return (
<th key="select" scope="col" className="px-4 py-3" style={{ width: col.width }}>
<input
type="checkbox"
checked={allSelected && cards.length > 0}
onChange={onSelectAll}
aria-label="Select all cards on this page"
className="w-4 h-4 rounded border-2 cursor-pointer"
style={{
borderColor: 'var(--border)',
accentColor: 'var(--accent-ember)',
'--tw-ring-color': 'var(--accent-ember)'
}}
/>
</th>
);
}
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) => {
const checked = selectedCardIds.has(card.id);
const rowClass = (index % 2 === 0 ? 'bg-[rgba(255,255,255,0.02)]' : '') + ' border-b transition-colors ' + (checked ? 'bg-[rgba(var(--text-accent),0.03)]' : '');
return (
<tr
key={card.id}
className={rowClass}
style={{ borderColor: 'var(--border)' }}
>
<td className="px-4 py-3">
<input
type="checkbox"
checked={checked}
onChange={() => onToggleSelect(card.id)}
aria-label={'Select ' + card.name}
className="w-4 h-4 rounded border-2 cursor-pointer"
style={{
borderColor: 'var(--border)',
accentColor: 'var(--accent-ember)',
'--tw-ring-color': 'var(--accent-ember)'
}}
/>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-3 min-w-0">
{card.image_url && (
<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">
<GameBadge game={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">
<RarityBadge rarity={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">
<VerifiedIcon verified={card.verified} />
</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>
);
}

View file

@ -0,0 +1,195 @@
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';
import BulkSelectionToolbar from './BulkSelectionToolbar';
import AdminBulkSelectionToolbar from './AdminBulkSelectionToolbar';
import BatchEditModal from './BatchEditModal';
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,
// Selection
selectedCardIds,
selectedCount,
handleToggleSelect,
handleSelectAll,
handleClearSelection,
isSelected,
// Batch edit
batchEditModalOpen,
batchEditLoading,
batchEditError,
openBatchEditModal,
closeBatchEditModal,
executeBatchEdit
} = props;
const [showFilters, setShowFilters] = useState(true);
const cardCount = cards.length;
const totalCount = pagination.total;
const allSelected = selectedCount > 0 && selectedCount === cards.length;
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 &middot; 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>
{/* Bulk Selection Toolbar */}
{selectedCount > 0 && (
<AdminBulkSelectionToolbar
selectedCount={selectedCount}
onClearSelection={handleClearSelection}
onBatchEdit={openBatchEditModal}
/>
)}
{/* 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}
selectedCardIds={selectedCardIds}
onToggleSelect={handleToggleSelect}
onSelectAll={handleSelectAll}
allSelected={allSelected}
/>
{/* Pagination */}
<AdminCatalogPagination
pagination={pagination}
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={handleLoadMore}
onLimitChange={handleLimitChange}
/>
</div>
{/* Batch Edit Modal */}
<BatchEditModal
isOpen={batchEditModalOpen}
onClose={closeBatchEditModal}
onSubmit={executeBatchEdit}
loading={batchEditLoading}
error={batchEditError}
selectedCount={selectedCount}
/>
</div>
);
}

View file

@ -0,0 +1,281 @@
import { useState } from 'react';
import { GlassSurface, Button, Input, Modal } from './ui';
const RARITY_OPTIONS = [
{ value: 'common', label: 'Common' },
{ value: 'uncommon', label: 'Uncommon' },
{ value: 'rare', label: 'Rare' },
{ value: 'mythic', label: 'Mythic' },
{ value: 'special', label: 'Special' },
{ value: 'bonus', label: 'Bonus' }
];
const GAME_OPTIONS = [
{ value: 'mtg', label: 'Magic: The Gathering' },
{ value: 'pokemon', label: 'Pokémon TCG' },
{ value: 'lorcana', label: 'Disney Lorcana' }
];
const LAYOUT_OPTIONS = [
{ value: 'normal', label: 'Normal' },
{ value: 'transform', label: 'Transform' },
{ value: 'modal_dfc', label: 'Modal DFC' },
{ value: 'meld', label: 'Meld' },
{ value: 'split', label: 'Split' },
{ value: 'flip', label: 'Flip' },
{ value: 'leveler', label: 'Leveler' },
{ value: 'saga', label: 'Saga' },
{ value: 'class', label: 'Class' },
{ value: 'adventure', label: 'Adventure' },
{ value: 'planar', label: 'Planar' },
{ value: 'scheme', label: 'Scheme' },
{ value: 'vanguard', label: 'Vanguard' },
{ value: 'token', label: 'Token' },
{ value: 'emblem', label: 'Emblem' },
{ value: 'augment', label: 'Augment' },
{ value: 'host', label: 'Host' },
{ value: 'art_series', label: 'Art Series' }
];
export default function BatchEditModal({
isOpen,
onClose,
onSubmit,
loading,
error,
selectedCount
}) {
const [formData, setFormData] = useState({
rarity: '',
verified: '',
game: '',
mana_cost: '',
cmc: '',
card_type: '',
edhrec_rank: '',
layout: '',
released_at: '',
reserved: '',
reprint: ''
});
const [dirtyFields, setDirtyFields] = useState(new Set());
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
setDirtyFields(prev => new Set([...prev, field]));
};
const handleBlur = (field) => {
// Keep track of touched fields
};
const getFieldsToSubmit = () => {
const updates = {};
dirtyFields.forEach(field => {
const value = formData[field];
if (value === '') return;
if (field === 'verified' || field === 'reserved' || field === 'reprint') {
updates[field] = value === 'true';
} else if (field === 'cmc' || field === 'edhrec_rank') {
updates[field] = value === '' ? null : parseInt(value, 10);
} else {
updates[field] = value;
}
});
return updates;
};
const hasChanges = dirtyFields.size > 0;
const handleSubmit = (e) => {
e.preventDefault();
const updates = getFieldsToSubmit();
if (Object.keys(updates).length > 0) {
onSubmit(updates);
}
};
if (!isOpen) return null;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`Batch Edit ${selectedCount} Card${selectedCount !== 1 ? 's' : ''}`}
size="lg"
>
<form onSubmit={handleSubmit}>
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-2">
<div className="mb-4 p-3 rounded-xl text-sm" style={{
backgroundColor: 'var(--bg-tertiary)',
border: '1px solid var(--border)'
}}>
<strong style={{ color: 'var(--text-primary)' }}>Fields to update:</strong>
<ul className="mt-2 space-y-1">
{Array.from(dirtyFields).map(field => (
<li key={field} style={{ color: 'var(--text-secondary)' }}>
{field}: {field === 'verified' || field === 'reserved' || field === 'reprint'
? (formData[field] === 'true' ? 'Yes' : 'No')
: formData[field] || '(cleared)'}
</li>
))}
{dirtyFields.size === 0 && (
<li style={{ color: 'var(--text-tertiary)' }}>No fields changed yet</li>
)}
</ul>
</div>
<GlassSurface tint="low" rim="none" className="p-4 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Rarity"
type="select"
value={formData.rarity}
onChange={(e) => handleChange('rarity', e.target.value)}
options={RARITY_OPTIONS.map(o => ({ value: o.value, label: o.label }))}
placeholder="Select rarity"
dirty={dirtyFields.has('rarity')}
/>
<div className="flex items-end">
<label className="flex items-center gap-2 cursor-pointer w-full">
<span className="w-1/3 text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Verified</span>
<select
value={formData.verified}
onChange={(e) => handleChange('verified', e.target.value)}
className="glass-input flex-1 rounded-xl px-4 py-2"
style={{
border: dirtyFields.has('verified') ? '1px solid var(--accent-ember)' : '1px solid var(--border)',
color: 'var(--text-primary)',
backgroundColor: 'var(--bg-tertiary)'
}}
>
<option value="">—</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</label>
</div>
<Input
label="Game"
type="select"
value={formData.game}
onChange={(e) => handleChange('game', e.target.value)}
options={GAME_OPTIONS.map(o => ({ value: o.value, label: o.label }))}
placeholder="Select game"
dirty={dirtyFields.has('game')}
/>
<Input
label="Layout"
type="select"
value={formData.layout}
onChange={(e) => handleChange('layout', e.target.value)}
options={LAYOUT_OPTIONS.map(o => ({ value: o.value, label: o.label }))}
placeholder="Select layout"
dirty={dirtyFields.has('layout')}
/>
<Input
label="Mana Cost"
value={formData.mana_cost}
onChange={(e) => handleChange('mana_cost', e.target.value)}
placeholder="{2}{W}{W}"
dirty={dirtyFields.has('mana_cost')}
/>
<Input
label="CMC"
type="number"
min="0"
max="20"
value={formData.cmc}
onChange={(e) => handleChange('cmc', e.target.value)}
placeholder="3"
dirty={dirtyFields.has('cmc')}
/>
<Input
label="Card Type"
value={formData.card_type}
onChange={(e) => handleChange('card_type', e.target.value)}
placeholder="Creature — Human Wizard"
dirty={dirtyFields.has('card_type')}
/>
<Input
label="EDHREC Rank"
type="number"
min="0"
value={formData.edhrec_rank}
onChange={(e) => handleChange('edhrec_rank', e.target.value)}
placeholder="12345"
dirty={dirtyFields.has('edhrec_rank')}
/>
<Input
label="Released At"
type="date"
value={formData.released_at}
onChange={(e) => handleChange('released_at', e.target.value)}
dirty={dirtyFields.has('released_at')}
/>
<div className="flex items-end">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.reserved === 'true'}
onChange={(e) => handleChange('reserved', e.target.checked ? 'true' : 'false')}
className="w-4 h-4 rounded border-2"
style={{
borderColor: 'var(--border)',
accentColor: 'var(--accent-ember)'
}}
/>
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
Reserved List
</span>
</label>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.reprint === 'true'}
onChange={(e) => handleChange('reprint', e.target.checked ? 'true' : 'false')}
className="w-4 h-4 rounded border-2"
style={{
borderColor: 'var(--border)',
accentColor: 'var(--accent-ember)'
}}
/>
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
Reprint
</span>
</label>
</div>
</div>
</GlassSurface>
{error && (
<div className="p-3 rounded-xl text-sm" style={{
backgroundColor: 'rgba(220, 38, 38, 0.1)',
border: '1px solid rgba(220, 38, 38, 0.3)',
color: '#dc2626'
}}>
{error}
</div>
)}
</div>
<div className="mt-6 flex justify-end gap-3 border-t pt-4" style={{ borderColor: 'var(--border)' }}>
<Button variant="ghost" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSubmit}
disabled={loading || !hasChanges}
loading={loading}
>
Apply to {selectedCount} Card{selectedCount !== 1 ? 's' : ''}
</Button>
</div>
</form>
</Modal>
);
}

320
lib/use-admin-catalog.js Normal file
View file

@ -0,0 +1,320 @@
import { useState, useEffect, useRef, useCallback } 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);
// Batch selection state
const [selectedCardIds, setSelectedCardIds] = useState(new Set());
const [selectAllPages, setSelectAllPages] = useState(false);
const [batchEditModalOpen, setBatchEditModalOpen] = useState(false);
const [batchEditLoading, setBatchEditLoading] = useState(false);
const [batchEditError, setBatchEditError] = useState(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([]);
};
// Selection handlers
const handleToggleSelect = useCallback((cardId) => {
setSelectedCardIds(prev => {
const next = new Set(prev);
if (next.has(cardId)) {
next.delete(cardId);
} else {
next.add(cardId);
}
return next;
});
}, []);
const handleSelectAll = useCallback(() => {
if (selectAllPages) {
setSelectAllPages(false);
setSelectedCardIds(new Set());
} else {
// Select all cards in current view
const ids = new Set(cards.map(c => c.id));
setSelectedCardIds(ids);
setSelectAllPages(false); // Only current page by default
}
}, [cards, selectAllPages]);
const handleClearSelection = useCallback(() => {
setSelectedCardIds(new Set());
setSelectAllPages(false);
}, []);
const isSelected = useCallback((cardId) => selectedCardIds.has(cardId), [selectedCardIds]);
const selectedCount = selectedCardIds.size;
// Batch edit handlers
const openBatchEditModal = useCallback(() => {
if (selectedCount > 0) {
setBatchEditModalOpen(true);
setBatchEditError(null);
}
}, [selectedCount]);
const closeBatchEditModal = useCallback(() => {
setBatchEditModalOpen(false);
setBatchEditError(null);
}, []);
const executeBatchEdit = useCallback(async (updates) => {
if (selectedCount === 0) return;
setBatchEditLoading(true);
setBatchEditError(null);
try {
const response = await fetch('/api/admin/cards/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cardIds: Array.from(selectedCardIds),
updates
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Batch edit failed');
// Refresh cards to reflect changes
await fetchCards(false);
closeBatchEditModal();
handleClearSelection();
} catch (err) {
console.error('Batch edit error:', err);
setBatchEditError(err.message);
} finally {
setBatchEditLoading(false);
}
}, [selectedCardIds, selectedCount, fetchCards, closeBatchEditModal, handleClearSelection]);
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,
// Selection
selectedCardIds,
selectAllPages,
selectedCount,
handleToggleSelect,
handleSelectAll,
handleClearSelection,
isSelected,
// Batch edit
batchEditModalOpen,
batchEditLoading,
batchEditError,
openBatchEditModal,
closeBatchEditModal,
executeBatchEdit,
setSearchQuery,
setSelectedGame,
setSelectedRarity,
setSelectedSet,
setMinPrice,
setMaxPrice,
setVerifiedOnly,
setCards,
setPagination,
setHasMore,
setFilters
};
}

20
pages/admin/catalog.js Normal file
View 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
View 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
});
}
});

View file

@ -0,0 +1,127 @@
import { sql } from '../../../../lib/sql.js';
import { withAdmin } from '../../../../lib/permission-middleware';
import { checkImportRateLimit } from '../../../../lib/rate-limit.js';
const ALLOWED_UPDATE_FIELDS = new Set([
'rarity',
'verified',
'game',
'mana_cost',
'cmc',
'card_type',
'edhrec_rank',
'layout',
'released_at',
'reserved',
'reprint'
]);
const FIELD_VALIDATORS = {
rarity: (v) => typeof v === 'string' && v.length > 0 && v.length <= 50,
verified: (v) => typeof v === 'boolean',
game: (v) => ['mtg', 'pokemon', 'lorcana'].includes(v),
mana_cost: (v) => v === null || (typeof v === 'string' && v.length <= 50),
cmc: (v) => v === null || (Number.isInteger(v) && v >= 0 && v <= 20),
card_type: (v) => v === null || (typeof v === 'string' && v.length <= 255),
edhrec_rank: (v) => v === null || (Number.isInteger(v) && v >= 0),
layout: (v) => v === null || (typeof v === 'string' && v.length <= 50),
released_at: (v) => v === null || (typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v)),
reserved: (v) => typeof v === 'boolean',
reprint: (v) => typeof v === 'boolean'
};
export default withAdmin(async function handler(req, res, user) {
if (req.method !== 'POST') {
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 { cardIds, updates } = req.body;
if (!Array.isArray(cardIds) || cardIds.length === 0) {
return res.status(400).json({ error: 'cardIds array is required' });
}
if (!updates || typeof updates !== 'object') {
return res.status(400).json({ error: 'updates object is required' });
}
// Validate and filter allowed fields
const validUpdates = {};
const errors = [];
for (const [field, value] of Object.entries(updates)) {
if (!ALLOWED_UPDATE_FIELDS.has(field)) {
errors.push(`Field '${field}' is not allowed for batch update`);
continue;
}
const validator = FIELD_VALIDATORS[field];
if (validator && !validator(value)) {
errors.push(`Invalid value for field '${field}'`);
continue;
}
validUpdates[field] = value;
}
if (errors.length > 0) {
return res.status(400).json({ error: 'Validation failed', details: errors });
}
if (Object.keys(validUpdates).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
// Safety cap
if (cardIds.length > 1000) {
return res.status(400).json({ error: 'Maximum 1000 cards per batch operation' });
}
// Build dynamic update query
const setClauses = [];
const values = [];
let paramIdx = 1;
for (const [field, value] of Object.entries(validUpdates)) {
setClauses.push(`${field} = $${paramIdx}`);
values.push(value);
paramIdx++;
}
// Add updated_at
setClauses.push(`updated_at = CURRENT_TIMESTAMP`);
const placeholders = cardIds.map((_, i) => `$${paramIdx + i}`).join(',');
values.push(...cardIds);
const query = `
UPDATE cards
SET ${setClauses.join(', ')}
WHERE id IN (${placeholders})
`;
const result = await sql.query(query, values);
// Log the batch edit for audit
console.log(`[admin batch edit] User ${user.email} updated ${result.rowCount} cards:`, validUpdates);
res.status(200).json({
success: true,
updated: result.rowCount,
fields: Object.keys(validUpdates)
});
} catch (error) {
console.error('Error in batch edit:', error);
res.status(500).json({
success: false,
error: 'Internal server error',
message: error.message
});
}
});