From bf317f819e279ef1fe6f528f26e7936649eb5cdc Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Fri, 18 Sep 2026 14:06:01 -0500 Subject: [PATCH] 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