feat(admin): add batch selection + batch edit (Phase 2)
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
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
This commit is contained in:
parent
bf317f819e
commit
70806d81ec
6 changed files with 798 additions and 104 deletions
101
components/AdminBulkSelectionToolbar.js
Normal file
101
components/AdminBulkSelectionToolbar.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -13,9 +13,10 @@ const SORTABLE_COLUMNS = [
|
||||||
];
|
];
|
||||||
|
|
||||||
const COLUMNS = [
|
const COLUMNS = [
|
||||||
{ key: 'name', label: 'Name', width: '35%' },
|
{ key: 'select', label: '', width: '40px' },
|
||||||
|
{ key: 'name', label: 'Name', width: '32%' },
|
||||||
{ key: 'game', label: 'Game', width: '8%' },
|
{ key: 'game', label: 'Game', width: '8%' },
|
||||||
{ key: 'set_name', label: 'Set', width: '15%' },
|
{ key: 'set_name', label: 'Set', width: '14%' },
|
||||||
{ key: 'card_number', label: '#', width: '5%' },
|
{ key: 'card_number', label: '#', width: '5%' },
|
||||||
{ key: 'rarity', label: 'Rarity', width: '8%' },
|
{ key: 'rarity', label: 'Rarity', width: '8%' },
|
||||||
{ key: 'current_price', label: 'Current $', width: '8%' },
|
{ key: 'current_price', label: 'Current $', width: '8%' },
|
||||||
|
|
@ -28,7 +29,7 @@ function formatPrice(price) {
|
||||||
if (price === null || price === undefined || price === '') return '—';
|
if (price === null || price === undefined || price === '') return '—';
|
||||||
const num = typeof price === 'string' ? parseFloat(price) : price;
|
const num = typeof price === 'string' ? parseFloat(price) : price;
|
||||||
if (isNaN(num)) return '—';
|
if (isNaN(num)) return '—';
|
||||||
return `$${num.toFixed(2)}`;
|
return '$' + num.toFixed(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
function formatDate(dateStr) {
|
||||||
|
|
@ -46,8 +47,8 @@ function formatDate(dateStr) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRarityBadge(rarity) {
|
function rarityColors(rarity) {
|
||||||
if (!rarity) return null;
|
if (!rarity) return { bg: 'var(--bg-tertiary)', text: 'var(--text-secondary)', border: 'var(--border)' };
|
||||||
const colors = {
|
const colors = {
|
||||||
common: { bg: 'var(--bg-tertiary)', text: 'var(--text-secondary)', border: 'var(--border)' },
|
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)' },
|
uncommon: { bg: 'rgba(34, 197, 94, 0.15)', text: '#22c55e', border: 'rgba(34, 197, 94, 0.3)' },
|
||||||
|
|
@ -56,14 +57,29 @@ function getRarityBadge(rarity) {
|
||||||
special: { bg: 'rgba(249, 115, 22, 0.15)', text: '#f97316', border: 'rgba(249, 115, 22, 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)' }
|
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 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 (
|
return (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium"
|
className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: style.bg,
|
backgroundColor: style.bg,
|
||||||
color: style.text,
|
color: style.text,
|
||||||
border: `1px solid ${style.border}`
|
border: '1px solid ' + style.border
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{rarity.charAt(0).toUpperCase() + rarity.slice(1)}
|
{rarity.charAt(0).toUpperCase() + rarity.slice(1)}
|
||||||
|
|
@ -71,21 +87,16 @@ function getRarityBadge(rarity) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGameBadge(game) {
|
function GameBadge({ game }) {
|
||||||
if (!game) return null;
|
if (!game) return null;
|
||||||
const styles = {
|
const style = gameColors(game);
|
||||||
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 (
|
return (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium"
|
className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: style.bg,
|
backgroundColor: style.bg,
|
||||||
color: style.text,
|
color: style.text,
|
||||||
border: `1px solid ${style.border}`
|
border: '1px solid ' + style.border
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{style.label}
|
{style.label}
|
||||||
|
|
@ -93,6 +104,22 @@ function getGameBadge(game) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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({
|
export default function AdminCatalogTable({
|
||||||
cards,
|
cards,
|
||||||
loading,
|
loading,
|
||||||
|
|
@ -100,7 +127,11 @@ export default function AdminCatalogTable({
|
||||||
sortDirection,
|
sortDirection,
|
||||||
onSort,
|
onSort,
|
||||||
hasMore,
|
hasMore,
|
||||||
loadingMore
|
loadingMore,
|
||||||
|
selectedCardIds,
|
||||||
|
onToggleSelect,
|
||||||
|
onSelectAll,
|
||||||
|
allSelected
|
||||||
}) {
|
}) {
|
||||||
if (loading && cards.length === 0) {
|
if (loading && cards.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -134,11 +165,29 @@ export default function AdminCatalogTable({
|
||||||
<tr className="border-b" style={{ borderColor: 'var(--border)' }}>
|
<tr className="border-b" style={{ borderColor: 'var(--border)' }}>
|
||||||
{COLUMNS.map(col => {
|
{COLUMNS.map(col => {
|
||||||
const sortable = SORTABLE_COLUMNS.some(sc => sc.key === col.key);
|
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 (
|
return (
|
||||||
<th
|
<th
|
||||||
key={col.key}
|
key={col.key}
|
||||||
scope="col"
|
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)]' : ''}`}
|
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={{
|
style={{
|
||||||
color: 'var(--text-secondary)',
|
color: 'var(--text-secondary)',
|
||||||
width: col.width,
|
width: col.width,
|
||||||
|
|
@ -171,92 +220,91 @@ export default function AdminCatalogTable({
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{cards.map((card, index) => (
|
{cards.map((card, index) => {
|
||||||
<tr
|
const checked = selectedCardIds.has(card.id);
|
||||||
key={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)]' : '');
|
||||||
className={`${index % 2 === 0 ? 'bg-[rgba(255,255,255,0.02)]' : ''} border-b transition-colors`}
|
return (
|
||||||
style={{ borderColor: 'var(--border)' }}
|
<tr
|
||||||
>
|
key={card.id}
|
||||||
<td className="px-4 py-3">
|
className={rowClass}
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
style={{ borderColor: 'var(--border)' }}
|
||||||
{card.image_url && (
|
>
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
<td className="px-4 py-3">
|
||||||
<img
|
<input
|
||||||
src={card.image_url}
|
type="checkbox"
|
||||||
alt=""
|
checked={checked}
|
||||||
className="h-10 w-auto rounded-lg object-cover"
|
onChange={() => onToggleSelect(card.id)}
|
||||||
style={{
|
aria-label={'Select ' + card.name}
|
||||||
border: '1px solid var(--border)',
|
className="w-4 h-4 rounded border-2 cursor-pointer"
|
||||||
boxShadow: 'var(--elevation-ambient)'
|
style={{
|
||||||
}}
|
borderColor: 'var(--border)',
|
||||||
loading="lazy"
|
accentColor: 'var(--accent-ember)',
|
||||||
/>
|
'--tw-ring-color': 'var(--accent-ember)'
|
||||||
)}
|
}}
|
||||||
<div className="min-w-0">
|
/>
|
||||||
<p className="font-medium truncate" style={{ color: 'var(--text-primary)' }}>
|
</td>
|
||||||
{card.name}
|
<td className="px-4 py-3">
|
||||||
</p>
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<p className="text-xs truncate" style={{ color: 'var(--text-tertiary)' }}>
|
{card.image_url && (
|
||||||
{card.set_code?.toUpperCase()} {card.card_number || ''}
|
<img
|
||||||
{card.oracle_id && <span className="ml-2 font-mono">[{card.oracle_id.slice(0, 8)}]</span>}
|
src={card.image_url}
|
||||||
</p>
|
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>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
<td className="px-4 py-3">
|
||||||
<td className="px-4 py-3">
|
<GameBadge game={card.game} />
|
||||||
{getGameBadge(card.game)}
|
</td>
|
||||||
</td>
|
<td className="px-4 py-3">
|
||||||
<td className="px-4 py-3">
|
<p className="text-sm truncate max-w-[180px]" style={{ color: 'var(--text-primary)' }}>
|
||||||
<p className="text-sm truncate max-w-[180px]" style={{ color: 'var(--text-primary)' }}>
|
{card.set_name}
|
||||||
{card.set_name}
|
</p>
|
||||||
</p>
|
</td>
|
||||||
</td>
|
<td className="px-4 py-3 text-center">
|
||||||
<td className="px-4 py-3 text-center">
|
<span className="font-mono text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
<span className="font-mono text-sm" style={{ color: 'var(--text-secondary)' }}>
|
{card.card_number || '—'}
|
||||||
{card.card_number || '—'}
|
</span>
|
||||||
</span>
|
</td>
|
||||||
</td>
|
<td className="px-4 py-3 text-center">
|
||||||
<td className="px-4 py-3 text-center">
|
<RarityBadge rarity={card.rarity} />
|
||||||
{getRarityBadge(card.rarity)}
|
</td>
|
||||||
</td>
|
<td className="px-4 py-3 text-right">
|
||||||
<td className="px-4 py-3 text-right">
|
<span className="font-mono text-sm" style={{ color: 'var(--text-primary)' }}>
|
||||||
<span className="font-mono text-sm" style={{ color: 'var(--text-primary)' }}>
|
{formatPrice(card.current_price)}
|
||||||
{formatPrice(card.current_price)}
|
</span>
|
||||||
</span>
|
</td>
|
||||||
</td>
|
<td className="px-4 py-3 text-right">
|
||||||
<td className="px-4 py-3 text-right">
|
<span className="font-mono text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
<span className="font-mono text-sm" style={{ color: 'var(--text-secondary)' }}>
|
{formatPrice(card.market_price)}
|
||||||
{formatPrice(card.market_price)}
|
</span>
|
||||||
</span>
|
</td>
|
||||||
</td>
|
<td className="px-4 py-3 text-center">
|
||||||
<td className="px-4 py-3 text-center">
|
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
{formatDate(card.prices_updated_at)}
|
||||||
{formatDate(card.prices_updated_at)}
|
</span>
|
||||||
</span>
|
</td>
|
||||||
</td>
|
<td className="px-4 py-3 text-center">
|
||||||
<td className="px-4 py-3 text-center">
|
<VerifiedIcon verified={card.verified} />
|
||||||
{card.verified ? (
|
</td>
|
||||||
<svg
|
</tr>
|
||||||
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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ import { GlassSurface, Button, Input, SearchBar, StatCard, Modal } from './ui';
|
||||||
import AdminCatalogFilters from './AdminCatalogFilters';
|
import AdminCatalogFilters from './AdminCatalogFilters';
|
||||||
import AdminCatalogTable from './AdminCatalogTable';
|
import AdminCatalogTable from './AdminCatalogTable';
|
||||||
import AdminCatalogPagination from './AdminCatalogPagination';
|
import AdminCatalogPagination from './AdminCatalogPagination';
|
||||||
|
import BulkSelectionToolbar from './BulkSelectionToolbar';
|
||||||
|
import AdminBulkSelectionToolbar from './AdminBulkSelectionToolbar';
|
||||||
|
import BatchEditModal from './BatchEditModal';
|
||||||
|
|
||||||
export default function AdminCatalogView(props) {
|
export default function AdminCatalogView(props) {
|
||||||
const {
|
const {
|
||||||
|
|
@ -32,13 +35,28 @@ export default function AdminCatalogView(props) {
|
||||||
handleSort,
|
handleSort,
|
||||||
handleLoadMore,
|
handleLoadMore,
|
||||||
handleRefresh,
|
handleRefresh,
|
||||||
handleLimitChange
|
handleLimitChange,
|
||||||
|
// Selection
|
||||||
|
selectedCardIds,
|
||||||
|
selectedCount,
|
||||||
|
handleToggleSelect,
|
||||||
|
handleSelectAll,
|
||||||
|
handleClearSelection,
|
||||||
|
isSelected,
|
||||||
|
// Batch edit
|
||||||
|
batchEditModalOpen,
|
||||||
|
batchEditLoading,
|
||||||
|
batchEditError,
|
||||||
|
openBatchEditModal,
|
||||||
|
closeBatchEditModal,
|
||||||
|
executeBatchEdit
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const [showFilters, setShowFilters] = useState(true);
|
const [showFilters, setShowFilters] = useState(true);
|
||||||
|
|
||||||
const cardCount = cards.length;
|
const cardCount = cards.length;
|
||||||
const totalCount = pagination.total;
|
const totalCount = pagination.total;
|
||||||
|
const allSelected = selectedCount > 0 && selectedCount === cards.length;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
|
|
@ -95,6 +113,15 @@ export default function AdminCatalogView(props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk Selection Toolbar */}
|
||||||
|
{selectedCount > 0 && (
|
||||||
|
<AdminBulkSelectionToolbar
|
||||||
|
selectedCount={selectedCount}
|
||||||
|
onClearSelection={handleClearSelection}
|
||||||
|
onBatchEdit={openBatchEditModal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Filters Panel */}
|
{/* Filters Panel */}
|
||||||
{showFilters && (
|
{showFilters && (
|
||||||
<AdminCatalogFilters
|
<AdminCatalogFilters
|
||||||
|
|
@ -138,6 +165,10 @@ export default function AdminCatalogView(props) {
|
||||||
onSort={handleSort}
|
onSort={handleSort}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
loadingMore={loadingMore}
|
loadingMore={loadingMore}
|
||||||
|
selectedCardIds={selectedCardIds}
|
||||||
|
onToggleSelect={handleToggleSelect}
|
||||||
|
onSelectAll={handleSelectAll}
|
||||||
|
allSelected={allSelected}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
|
|
@ -149,6 +180,16 @@ export default function AdminCatalogView(props) {
|
||||||
onLimitChange={handleLimitChange}
|
onLimitChange={handleLimitChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Batch Edit Modal */}
|
||||||
|
<BatchEditModal
|
||||||
|
isOpen={batchEditModalOpen}
|
||||||
|
onClose={closeBatchEditModal}
|
||||||
|
onSubmit={executeBatchEdit}
|
||||||
|
loading={batchEditLoading}
|
||||||
|
error={batchEditError}
|
||||||
|
selectedCount={selectedCount}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
281
components/BatchEditModal.js
Normal file
281
components/BatchEditModal.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
export function useAdminCatalog() {
|
export function useAdminCatalog() {
|
||||||
const [cards, setCards] = useState([]);
|
const [cards, setCards] = useState([]);
|
||||||
|
|
@ -30,6 +30,13 @@ export function useAdminCatalog() {
|
||||||
const hasMoreRef = useRef(true);
|
const hasMoreRef = useRef(true);
|
||||||
const abortControllerRef = useRef(null);
|
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) => {
|
const fetchCards = async (isLoadMore = false) => {
|
||||||
if (isLoadMore && (loadingMoreRef.current || !hasMoreRef.current)) return;
|
if (isLoadMore && (loadingMoreRef.current || !hasMoreRef.current)) return;
|
||||||
|
|
||||||
|
|
@ -168,6 +175,80 @@ export function useAdminCatalog() {
|
||||||
setCards([]);
|
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(() => {
|
useEffect(() => {
|
||||||
fetchCards(false);
|
fetchCards(false);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
|
@ -209,6 +290,21 @@ export function useAdminCatalog() {
|
||||||
handleLoadMore,
|
handleLoadMore,
|
||||||
handleRefresh,
|
handleRefresh,
|
||||||
handleLimitChange,
|
handleLimitChange,
|
||||||
|
// Selection
|
||||||
|
selectedCardIds,
|
||||||
|
selectAllPages,
|
||||||
|
selectedCount,
|
||||||
|
handleToggleSelect,
|
||||||
|
handleSelectAll,
|
||||||
|
handleClearSelection,
|
||||||
|
isSelected,
|
||||||
|
// Batch edit
|
||||||
|
batchEditModalOpen,
|
||||||
|
batchEditLoading,
|
||||||
|
batchEditError,
|
||||||
|
openBatchEditModal,
|
||||||
|
closeBatchEditModal,
|
||||||
|
executeBatchEdit,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
setSelectedGame,
|
setSelectedGame,
|
||||||
setSelectedRarity,
|
setSelectedRarity,
|
||||||
|
|
|
||||||
127
pages/api/admin/cards/batch.js
Normal file
127
pages/api/admin/cards/batch.js
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue