Some checks failed
CI / Lint (pull_request) Has been cancelled
CI / Schema map up to date (pull_request) Has been cancelled
CI / Forbidden patterns (9 checks) (pull_request) Has been cancelled
CI / Migrations apply (node-pg-migrate) (pull_request) Has been cancelled
CI / Unit tests (vitest) (pull_request) Has been cancelled
Convoy metrics gate / Require role-event telemetry on convoy PRs (pull_request) Has been cancelled
PR Health rollup / Aggregate gate status (pull_request) Has been cancelled
Preview smoke / Should run? (pull_request) Has been cancelled
Visual diff / Should run? (pull_request) Has been cancelled
Preview smoke / Playwright smoke (pull_request) Has been cancelled
Visual diff / Screenshot diff (pull_request) Has been cancelled
- AdminBulkSelectionToolbar: floating toolbar with batch edit action + clear selection - BatchEditModal: form for editing allowed fields (rarity, verified, game, mana_cost, cmc, card_type, edhrec_rank, layout, released_at, reserved, reprint) with live preview - API: POST /api/admin/cards/batch with validation, allowlist, 1000-card cap, audit logging - Hook: selection state (Set), select-all, batch edit workflow - Table: checkbox column, row highlighting, select-all header - Build: all routes compile, lint clean (pre-existing only), 342 tests pass
322 lines
No EOL
13 KiB
JavaScript
322 lines
No EOL
13 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: '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>
|
|
);
|
|
} |