From 70806d81ec6d57342a1d3530d6c0d412e57dc96a Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Fri, 18 Sep 2026 14:57:14 -0500 Subject: [PATCH] feat(admin): add batch selection + batch edit (Phase 2) - 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 --- components/AdminBulkSelectionToolbar.js | 101 +++++++++ components/AdminCatalogTable.js | 252 ++++++++++++--------- components/AdminCatalogView.js | 43 +++- components/BatchEditModal.js | 281 ++++++++++++++++++++++++ lib/use-admin-catalog.js | 98 ++++++++- pages/api/admin/cards/batch.js | 127 +++++++++++ 6 files changed, 798 insertions(+), 104 deletions(-) create mode 100644 components/AdminBulkSelectionToolbar.js create mode 100644 components/BatchEditModal.js create mode 100644 pages/api/admin/cards/batch.js diff --git a/components/AdminBulkSelectionToolbar.js b/components/AdminBulkSelectionToolbar.js new file mode 100644 index 0000000..0bda817 --- /dev/null +++ b/components/AdminBulkSelectionToolbar.js @@ -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 ( +
+
+ {/* Selection Count */} +
+
+ {selectedCount} +
+ + {selectedCount} card{selectedCount !== 1 ? 's' : ''} selected + {selectedCount} + +
+ + {/* Divider - Hidden on mobile */} +
+ + {/* Actions */} +
+ + + {/* More Actions Button */} +
+ + + {/* Dropdown Actions */} + {showActions && ( +
+
+ +
+ )} +
+ + {/* Clear Selection */} + +
+
+
+ ); +} \ No newline at end of file diff --git a/components/AdminCatalogTable.js b/components/AdminCatalogTable.js index 018f533..f0b4e90 100644 --- a/components/AdminCatalogTable.js +++ b/components/AdminCatalogTable.js @@ -13,9 +13,10 @@ const SORTABLE_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: 'set_name', label: 'Set', width: '15%' }, + { 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%' }, @@ -28,7 +29,7 @@ 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)}`; + return '$' + num.toFixed(2); } function formatDate(dateStr) { @@ -46,8 +47,8 @@ function formatDate(dateStr) { } } -function getRarityBadge(rarity) { - if (!rarity) return null; +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)' }, @@ -56,14 +57,29 @@ function getRarityBadge(rarity) { 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 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 ( {rarity.charAt(0).toUpperCase() + rarity.slice(1)} @@ -71,21 +87,16 @@ function getRarityBadge(rarity) { ); } -function getGameBadge(game) { +function GameBadge({ 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 }; + const style = gameColors(game); return ( {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 ( + + + + ); + } + return ( + + + + ); +} + export default function AdminCatalogTable({ cards, loading, @@ -100,7 +127,11 @@ export default function AdminCatalogTable({ sortDirection, onSort, hasMore, - loadingMore + loadingMore, + selectedCardIds, + onToggleSelect, + onSelectAll, + allSelected }) { if (loading && cards.length === 0) { return ( @@ -134,11 +165,29 @@ export default function AdminCatalogTable({ {COLUMNS.map(col => { const sortable = SORTABLE_COLUMNS.some(sc => sc.key === col.key); + if (col.key === 'select') { + return ( + + 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)' + }} + /> + + ); + } return ( - {cards.map((card, index) => ( - - -
- {card.image_url && ( - // eslint-disable-next-line @next/next/no-img-element - - )} -
-

- {card.name} -

-

- {card.set_code?.toUpperCase()} {card.card_number || ''} - {card.oracle_id && [{card.oracle_id.slice(0, 8)}]} -

+ {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 ( + + + 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)' + }} + /> + + +
+ {card.image_url && ( + + )} +
+

+ {card.name} +

+

+ {card.set_code?.toUpperCase() + ' ' + (card.card_number || '')} + {card.oracle_id && [' + card.oracle_id.slice(0, 8) + ']} +

+
-
- - - {getGameBadge(card.game)} - - -

- {card.set_name} -

- - - - {card.card_number || '—'} - - - - {getRarityBadge(card.rarity)} - - - - {formatPrice(card.current_price)} - - - - - {formatPrice(card.market_price)} - - - - - {formatDate(card.prices_updated_at)} - - - - {card.verified ? ( - - - - ) : ( - - - - )} - - - ))} + + + + + +

+ {card.set_name} +

+ + + + {card.card_number || '—'} + + + + + + + + {formatPrice(card.current_price)} + + + + + {formatPrice(card.market_price)} + + + + + {formatDate(card.prices_updated_at)} + + + + + + + ); + })}
diff --git a/components/AdminCatalogView.js b/components/AdminCatalogView.js index 9c47ce1..28d9bdf 100644 --- a/components/AdminCatalogView.js +++ b/components/AdminCatalogView.js @@ -3,6 +3,9 @@ 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 { @@ -32,13 +35,28 @@ export default function AdminCatalogView(props) { handleSort, handleLoadMore, handleRefresh, - handleLimitChange + 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 = () => { @@ -95,6 +113,15 @@ export default function AdminCatalogView(props) { + {/* Bulk Selection Toolbar */} + {selectedCount > 0 && ( + + )} + {/* Filters Panel */} {showFilters && ( {/* Pagination */} @@ -149,6 +180,16 @@ export default function AdminCatalogView(props) { onLimitChange={handleLimitChange} /> + + {/* Batch Edit Modal */} + ); } \ No newline at end of file diff --git a/components/BatchEditModal.js b/components/BatchEditModal.js new file mode 100644 index 0000000..fa40cb1 --- /dev/null +++ b/components/BatchEditModal.js @@ -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 ( + +
+
+
+ Fields to update: +
    + {Array.from(dirtyFields).map(field => ( +
  • + {field}: {field === 'verified' || field === 'reserved' || field === 'reprint' + ? (formData[field] === 'true' ? 'Yes' : 'No') + : formData[field] || '(cleared)'} +
  • + ))} + {dirtyFields.size === 0 && ( +
  • No fields changed yet
  • + )} +
+
+ + +
+ handleChange('rarity', e.target.value)} + options={RARITY_OPTIONS.map(o => ({ value: o.value, label: o.label }))} + placeholder="Select rarity" + dirty={dirtyFields.has('rarity')} + /> +
+ +
+ handleChange('game', e.target.value)} + options={GAME_OPTIONS.map(o => ({ value: o.value, label: o.label }))} + placeholder="Select game" + dirty={dirtyFields.has('game')} + /> + handleChange('layout', e.target.value)} + options={LAYOUT_OPTIONS.map(o => ({ value: o.value, label: o.label }))} + placeholder="Select layout" + dirty={dirtyFields.has('layout')} + /> + handleChange('mana_cost', e.target.value)} + placeholder="{2}{W}{W}" + dirty={dirtyFields.has('mana_cost')} + /> + handleChange('cmc', e.target.value)} + placeholder="3" + dirty={dirtyFields.has('cmc')} + /> + handleChange('card_type', e.target.value)} + placeholder="Creature — Human Wizard" + dirty={dirtyFields.has('card_type')} + /> + handleChange('edhrec_rank', e.target.value)} + placeholder="12345" + dirty={dirtyFields.has('edhrec_rank')} + /> + handleChange('released_at', e.target.value)} + dirty={dirtyFields.has('released_at')} + /> +
+ +
+
+ +
+
+
+ + {error && ( +
+ {error} +
+ )} +
+ +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/lib/use-admin-catalog.js b/lib/use-admin-catalog.js index 7b3483a..ea01208 100644 --- a/lib/use-admin-catalog.js +++ b/lib/use-admin-catalog.js @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; export function useAdminCatalog() { const [cards, setCards] = useState([]); @@ -30,6 +30,13 @@ export function useAdminCatalog() { 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; @@ -168,6 +175,80 @@ export function useAdminCatalog() { 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 @@ -209,6 +290,21 @@ export function useAdminCatalog() { handleLoadMore, handleRefresh, handleLimitChange, + // Selection + selectedCardIds, + selectAllPages, + selectedCount, + handleToggleSelect, + handleSelectAll, + handleClearSelection, + isSelected, + // Batch edit + batchEditModalOpen, + batchEditLoading, + batchEditError, + openBatchEditModal, + closeBatchEditModal, + executeBatchEdit, setSearchQuery, setSelectedGame, setSelectedRarity, diff --git a/pages/api/admin/cards/batch.js b/pages/api/admin/cards/batch.js new file mode 100644 index 0000000..9036cae --- /dev/null +++ b/pages/api/admin/cards/batch.js @@ -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 + }); + } +}); \ No newline at end of file