From bf317f819e279ef1fe6f528f26e7936649eb5cdc Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Fri, 18 Sep 2026 14:06:01 -0500 Subject: [PATCH 1/2] feat(admin): add admin card catalog (Phase 1) - 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 --- components/AdminCatalogFilters.js | 191 +++++++++++++++++++ components/AdminCatalogPagination.js | 99 ++++++++++ components/AdminCatalogTable.js | 274 +++++++++++++++++++++++++++ components/AdminCatalogView.js | 154 +++++++++++++++ lib/use-admin-catalog.js | 224 ++++++++++++++++++++++ pages/admin/catalog.js | 20 ++ pages/api/admin/cards.js | 199 +++++++++++++++++++ 7 files changed, 1161 insertions(+) create mode 100644 components/AdminCatalogFilters.js create mode 100644 components/AdminCatalogPagination.js create mode 100644 components/AdminCatalogTable.js create mode 100644 components/AdminCatalogView.js create mode 100644 lib/use-admin-catalog.js create mode 100644 pages/admin/catalog.js create mode 100644 pages/api/admin/cards.js diff --git a/components/AdminCatalogFilters.js b/components/AdminCatalogFilters.js new file mode 100644 index 0000000..9e1ae10 --- /dev/null +++ b/components/AdminCatalogFilters.js @@ -0,0 +1,191 @@ +import { SearchBar, Input, GlassSurface, Button } from './ui'; + +const GAME_OPTIONS = [ + { value: 'all', label: 'All Games' }, + { value: 'mtg', label: 'Magic: The Gathering' }, + { value: 'pokemon', label: 'Pokémon TCG' }, + { value: 'lorcana', label: 'Disney Lorcana' } +]; + +const RARITY_OPTIONS = [ + { value: 'all', label: 'All Rarities' }, + { value: 'common', label: 'Common' }, + { value: 'uncommon', label: 'Uncommon' }, + { value: 'rare', label: 'Rare' }, + { value: 'mythic', label: 'Mythic' }, + { value: 'special', label: 'Special' }, + { value: 'bonus', label: 'Bonus' } +]; + +export default function AdminCatalogFilters({ + searchQuery, + selectedGame, + selectedRarity, + selectedSet, + minPrice, + maxPrice, + verifiedOnly, + filters, + onSearchChange, + onGameChange, + onRarityChange, + onSetChange, + onMinPriceChange, + onMaxPriceChange, + onVerifiedChange, + loading +}) { + return ( + +
+
+ onSearchChange(e.target.value)} + placeholder="Search card name..." + disabled={loading} + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+
+ + onMinPriceChange(e.target.value)} + placeholder="0.00" + disabled={loading} + style={{ width: '100%' }} + /> +
+
+ + onMaxPriceChange(e.target.value)} + placeholder="1000.00" + disabled={loading} + style={{ width: '100%' }} + /> +
+
+ +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/components/AdminCatalogPagination.js b/components/AdminCatalogPagination.js new file mode 100644 index 0000000..52b37a3 --- /dev/null +++ b/components/AdminCatalogPagination.js @@ -0,0 +1,99 @@ +import { GlassSurface, Button, Input } from './ui'; + +export default function AdminCatalogPagination({ + pagination, + hasMore, + loadingMore, + onLoadMore, + onLimitChange +}) { + const { page, limit, total, pages } = pagination; + + if (total === 0) return null; + + return ( + +
+
+ + Showing {(page - 1) * limit + 1}–{Math.min(page * limit, total)} of {total.toLocaleString()} + +
+ + +
+
+ +
+ + + {pages > 1 && ( + <> + + + Page {page} of {pages} + + + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/components/AdminCatalogTable.js b/components/AdminCatalogTable.js new file mode 100644 index 0000000..018f533 --- /dev/null +++ b/components/AdminCatalogTable.js @@ -0,0 +1,274 @@ +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 ( + + {rarity.charAt(0).toUpperCase() + rarity.slice(1)} + + ); +} + +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 ( + + {style.label} + + ); +} + +export default function AdminCatalogTable({ + cards, + loading, + sortColumn, + sortDirection, + onSort, + hasMore, + loadingMore +}) { + if (loading && cards.length === 0) { + return ( + +
+
+

Loading card catalog...

+
+ + ); + } + + if (cards.length === 0) { + return ( + +
+ + + +

No cards found matching your filters.

+
+
+ ); + } + + return ( + +
+ + + + {COLUMNS.map(col => { + const sortable = SORTABLE_COLUMNS.some(sc => sc.key === col.key); + return ( + + ); + })} + + + + {cards.map((card, index) => ( + + + + + + + + + + + + ))} + +
sortable && onSort(col.key)} + > +
+ {col.label} + {sortable && sortColumn === col.key && ( + + + + )} +
+
+
+ {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)}]} +

+
+
+
+ {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 ? ( + + + + ) : ( + + + + )} +
+
+ + {loadingMore && hasMore && ( +
+
+
+ Loading more cards... +
+
+ )} + + ); +} \ No newline at end of file diff --git a/components/AdminCatalogView.js b/components/AdminCatalogView.js new file mode 100644 index 0000000..9c47ce1 --- /dev/null +++ b/components/AdminCatalogView.js @@ -0,0 +1,154 @@ +import { useState, useEffect } from 'react'; +import { GlassSurface, Button, Input, SearchBar, StatCard, Modal } from './ui'; +import AdminCatalogFilters from './AdminCatalogFilters'; +import AdminCatalogTable from './AdminCatalogTable'; +import AdminCatalogPagination from './AdminCatalogPagination'; + +export default function AdminCatalogView(props) { + const { + cards, + loading, + loadingMore, + searchQuery, + selectedGame, + selectedRarity, + selectedSet, + minPrice, + maxPrice, + verifiedOnly, + sortColumn, + sortDirection, + pagination, + filters, + hasMore, + error, + handleSearchChange, + handleGameChange, + handleRarityChange, + handleSetChange, + handleMinPriceChange, + handleMaxPriceChange, + handleVerifiedChange, + handleSort, + handleLoadMore, + handleRefresh, + handleLimitChange + } = props; + + const [showFilters, setShowFilters] = useState(true); + + const cardCount = cards.length; + const totalCount = pagination.total; + + useEffect(() => { + const handleScroll = () => { + if ( + window.innerHeight + document.documentElement.scrollTop >= + document.documentElement.offsetHeight - 1000 + ) { + handleLoadMore(); + } + }; + window.addEventListener('scroll', handleScroll); + return () => window.removeEventListener('scroll', handleScroll); + }, [handleLoadMore, hasMore, loadingMore]); + + return ( +
+ {/* Header */} +
+
+
+

+ Card Catalog +

+

+ Admin view: {totalCount.toLocaleString()} cards total · showing {cardCount.toLocaleString()} +

+
+
+ + +
+
+ + {/* Filters Panel */} + {showFilters && ( + + )} + + {/* Error Banner */} + {error && ( + +
+ Error: {error} + +
+
+ )} +
+ + {/* Table */} +
+ + + {/* Pagination */} + +
+
+ ); +} \ No newline at end of file diff --git a/lib/use-admin-catalog.js b/lib/use-admin-catalog.js new file mode 100644 index 0000000..7b3483a --- /dev/null +++ b/lib/use-admin-catalog.js @@ -0,0 +1,224 @@ +import { useState, useEffect, useRef } from 'react'; + +export function useAdminCatalog() { + const [cards, setCards] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedGame, setSelectedGame] = useState('all'); + const [selectedRarity, setSelectedRarity] = useState('all'); + const [selectedSet, setSelectedSet] = useState('all'); + const [minPrice, setMinPrice] = useState(''); + const [maxPrice, setMaxPrice] = useState(''); + const [verifiedOnly, setVerifiedOnly] = useState(false); + const [sortColumn, setSortColumn] = useState('name'); + const [sortDirection, setSortDirection] = useState('asc'); + const [pagination, setPagination] = useState({ + page: 1, + limit: 50, + total: 0, + pages: 0 + }); + const [filters, setFilters] = useState({ + games: [], + rarities: [], + sets: [] + }); + const [hasMore, setHasMore] = useState(true); + const [error, setError] = useState(null); + const loadingMoreRef = useRef(false); + const hasMoreRef = useRef(true); + const abortControllerRef = useRef(null); + + const fetchCards = async (isLoadMore = false) => { + if (isLoadMore && (loadingMoreRef.current || !hasMoreRef.current)) return; + + let signal; + try { + if (isLoadMore) { + setLoadingMore(true); + loadingMoreRef.current = true; + } else { + setLoading(true); + setError(null); + } + + abortControllerRef.current = new AbortController(); + ({ signal } = abortControllerRef.current); + + const params = new URLSearchParams({ + query: searchQuery, + game: selectedGame, + rarity: selectedRarity, + set: selectedSet, + minPrice, + maxPrice, + page: isLoadMore ? pagination.page + 1 : 1, + limit: pagination.limit, + sort: sortColumn, + dir: sortDirection, + verifiedOnly: verifiedOnly.toString() + }); + + const response = await fetch(`/api/admin/cards?${params}`, { + signal + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + + if (signal.aborted) return; + + if (data.success) { + const newCards = data.cards; + if (isLoadMore) { + setCards(prev => [...prev, ...newCards]); + } else { + setCards(newCards); + } + setPagination(data.pagination); + setFilters(data.filters); + setHasMore(data.pagination.hasMore); + hasMoreRef.current = data.pagination.hasMore; + } else { + throw new Error(data.error || 'Failed to fetch cards'); + } + } catch (err) { + if (err.name !== 'AbortError') { + console.error('Error fetching admin cards:', err); + setError(err.message); + } + } finally { + if (!signal?.aborted) { + setLoading(false); + setLoadingMore(false); + loadingMoreRef.current = false; + } + } + }; + + const handleSearchChange = (value) => { + setSearchQuery(value); + setPagination(prev => ({ ...prev, page: 1 })); + setCards([]); + }; + + const handleGameChange = (value) => { + setSelectedGame(value); + setPagination(prev => ({ ...prev, page: 1 })); + setCards([]); + }; + + const handleRarityChange = (value) => { + setSelectedRarity(value); + setPagination(prev => ({ ...prev, page: 1 })); + setCards([]); + }; + + const handleSetChange = (value) => { + setSelectedSet(value); + setPagination(prev => ({ ...prev, page: 1 })); + setCards([]); + }; + + const handleMinPriceChange = (value) => { + setMinPrice(value); + setPagination(prev => ({ ...prev, page: 1 })); + setCards([]); + }; + + const handleMaxPriceChange = (value) => { + setMaxPrice(value); + setPagination(prev => ({ ...prev, page: 1 })); + setCards([]); + }; + + const handleVerifiedChange = (value) => { + setVerifiedOnly(value); + setPagination(prev => ({ ...prev, page: 1 })); + setCards([]); + }; + + const handleSort = (column) => { + if (sortColumn === column) { + setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc'); + } else { + setSortColumn(column); + setSortDirection('asc'); + } + }; + + const handleLoadMore = () => { + if (!loadingMore && hasMore) { + fetchCards(true); + } + }; + + const handleRefresh = () => { + setPagination(prev => ({ ...prev, page: 1 })); + setCards([]); + fetchCards(false); + }; + + const handleLimitChange = (limit) => { + setPagination(prev => ({ ...prev, limit, page: 1 })); + setCards([]); + }; + + useEffect(() => { + fetchCards(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, []); + + return { + cards, + loading, + loadingMore, + searchQuery, + selectedGame, + selectedRarity, + selectedSet, + minPrice, + maxPrice, + verifiedOnly, + sortColumn, + sortDirection, + pagination, + filters, + hasMore, + error, + handleSearchChange, + handleGameChange, + handleRarityChange, + handleSetChange, + handleMinPriceChange, + handleMaxPriceChange, + handleVerifiedChange, + handleSort, + handleLoadMore, + handleRefresh, + handleLimitChange, + setSearchQuery, + setSelectedGame, + setSelectedRarity, + setSelectedSet, + setMinPrice, + setMaxPrice, + setVerifiedOnly, + setCards, + setPagination, + setHasMore, + setFilters + }; +} \ No newline at end of file diff --git a/pages/admin/catalog.js b/pages/admin/catalog.js new file mode 100644 index 0000000..d41a488 --- /dev/null +++ b/pages/admin/catalog.js @@ -0,0 +1,20 @@ +import Layout from '../../components/Layout'; +import AdminProtected from '../../components/AdminProtected'; +import AdminCatalogView from '../../components/AdminCatalogView'; +import { useAdminCatalog } from '../../lib/use-admin-catalog.js'; + +function AdminCatalogPage() { + const catalog = useAdminCatalog(); + + return ( + + {(user) => ( + + + + )} + + ); +} + +export default AdminCatalogPage; \ No newline at end of file diff --git a/pages/api/admin/cards.js b/pages/api/admin/cards.js new file mode 100644 index 0000000..bcfd155 --- /dev/null +++ b/pages/api/admin/cards.js @@ -0,0 +1,199 @@ +import { sql } from '../../../lib/sql.js'; +import { withAdmin } from '../../../lib/permission-middleware'; +import { checkImportRateLimit } from '../../../lib/rate-limit.js'; + +const ALLOWED_SORT_COLUMNS = new Set([ + 'name', 'set_name', 'set_code', 'card_number', 'rarity', 'game', + 'mana_cost', 'cmc', 'card_type', 'current_price', 'market_price', + 'prices_updated_at', 'verified', 'created_at', 'updated_at', + 'oracle_id', 'illustration_id', 'edhrec_rank', 'released_at' +]); + +const ALLOWED_SORT_DIRS = new Set(['asc', 'desc']); + +export default withAdmin(async function handler(req, res, user) { + if (req.method !== 'GET') { + 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 { + query = '', + game = 'all', + rarity = 'all', + set = 'all', + minPrice = '', + maxPrice = '', + page = '1', + limit = '50', + sort = 'name', + dir = 'asc', + verifiedOnly = 'false' + } = req.query; + + const pageNum = Math.max(1, parseInt(page) || 1); + const limitNum = Math.min(200, Math.max(1, parseInt(limit) || 50)); + const offset = (pageNum - 1) * limitNum; + const sortCol = ALLOWED_SORT_COLUMNS.has(sort) ? sort : 'name'; + const sortDir = ALLOWED_SORT_DIRS.has(dir.toLowerCase()) ? dir.toLowerCase() : 'asc'; + + const filters = { + hasQuery: query.trim() !== '', + hasGame: game !== 'all', + hasRarity: rarity !== 'all', + hasSet: set !== 'all', + hasMinPrice: minPrice && !isNaN(parseFloat(minPrice)), + hasMaxPrice: maxPrice && !isNaN(parseFloat(maxPrice)), + verifiedOnly: verifiedOnly === 'true' + }; + + const whereConditions = []; + const params = []; + + if (filters.hasQuery) { + whereConditions.push(`name ILIKE '%' || $${params.length + 1} || '%'`); + params.push(query.trim()); + } + if (filters.hasGame) { + whereConditions.push(`game = $${params.length + 1}`); + params.push(game); + } + if (filters.hasRarity) { + whereConditions.push(`rarity = $${params.length + 1}`); + params.push(rarity); + } + if (filters.hasSet) { + whereConditions.push(`set_name = $${params.length + 1}`); + params.push(set); + } + if (filters.hasMinPrice) { + whereConditions.push(`market_price >= $${params.length + 1}`); + params.push(parseFloat(minPrice)); + } + if (filters.hasMaxPrice) { + whereConditions.push(`market_price <= $${params.length + 1}`); + params.push(parseFloat(maxPrice)); + } + if (filters.verifiedOnly) { + whereConditions.push(`verified = true`); + } + + const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : ''; + + const selectColumns = ` + id, name, set_name, set_code, card_number, rarity, game, + mana_cost, cmc, card_type, colors, oracle_text, + power, toughness, image_url, stock_image_url, + current_price, market_price, prices_updated_at, + scryfall_id, verified, oracle_id, illustration_id, + color_identity, keywords, legalities, layout, + edhrec_rank, reserved, reprint, finishes, + released_at, created_at, updated_at + `; + + const querySql = ` + SELECT ${selectColumns} + FROM cards + ${whereClause} + ORDER BY ${sortCol} ${sortDir.toUpperCase()} + LIMIT $${params.length + 1} OFFSET $${params.length + 2} + `; + params.push(limitNum, offset); + + const countSql = ` + SELECT COUNT(*) as total + FROM cards + ${whereClause} + `; + + const [result, countResult] = await Promise.all([ + sql.query(querySql, params), + sql.query(countSql, params.slice(0, -2)) + ]); + + const total = parseInt(countResult.rows[0].total); + const totalPages = Math.ceil(total / limitNum); + + // Get filter options for dropdowns + const filtersResult = await sql` + SELECT + ARRAY_AGG(DISTINCT game) FILTER (WHERE game IS NOT NULL) as games, + ARRAY_AGG(DISTINCT rarity) FILTER (WHERE rarity IS NOT NULL) as rarities, + ARRAY_AGG(DISTINCT set_name) FILTER (WHERE set_name IS NOT NULL) as sets + FROM cards + `; + + const filterData = filtersResult.rows[0]; + + const cards = result.rows.map(card => { + if (card.colors && typeof card.colors === 'string') { + try { + card.colors = JSON.parse(card.colors); + } catch { + card.colors = []; + } + } + if (card.color_identity && typeof card.color_identity === 'string') { + try { + card.color_identity = JSON.parse(card.color_identity); + } catch { + card.color_identity = []; + } + } + if (card.keywords && typeof card.keywords === 'string') { + try { + card.keywords = JSON.parse(card.keywords); + } catch { + card.keywords = []; + } + } + if (card.legalities && typeof card.legalities === 'string') { + try { + card.legalities = JSON.parse(card.legalities); + } catch { + card.legalities = {}; + } + } + if (card.finishes && typeof card.finishes === 'string') { + try { + card.finishes = JSON.parse(card.finishes); + } catch { + card.finishes = []; + } + } + return card; + }); + + res.status(200).json({ + success: true, + cards, + pagination: { + page: pageNum, + limit: limitNum, + total, + pages: totalPages, + hasMore: pageNum < totalPages + }, + filters: { + games: filterData.games || [], + rarities: filterData.rarities || [], + sets: filterData.sets || [] + }, + sort: { column: sortCol, direction: sortDir } + }); + + } catch (error) { + console.error('Error searching admin cards:', error); + res.status(500).json({ + success: false, + error: 'Internal server error', + message: error.message + }); + } +}); \ No newline at end of file From 70806d81ec6d57342a1d3530d6c0d412e57dc96a Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Fri, 18 Sep 2026 14:57:14 -0500 Subject: [PATCH 2/2] 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