- New admin API: /api/admin/cards with filters (game/rarity/set/price/verified), sorting, pagination, admin rate limiting - New admin page: /admin/catalog with GlassSurface table, filters panel, sortable columns, infinite scroll pagination - New hook: useAdminCatalog for state management - Components: AdminCatalogView, AdminCatalogFilters, AdminCatalogTable, AdminCatalogPagination - Protected by withAdmin middleware, reuses design system tokens
274 lines
No EOL
11 KiB
JavaScript
274 lines
No EOL
11 KiB
JavaScript
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>
|
|
);
|
|
} |