deckhearth/components/AdminCatalogTable.js

274 lines
11 KiB
JavaScript
Raw Normal View History

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