Extract AuthenticatedCards into hook + view (cards Brief 3). (#79)
Moves card-browser state and handlers to lib/use-cards-page.js and markup to components/CardsPageView.js, leaving a thin ProtectedRoute composer in pages/cards.js. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
84462417ee
commit
6a5c46ab1e
3 changed files with 988 additions and 884 deletions
403
components/CardsPageView.js
Normal file
403
components/CardsPageView.js
Normal file
|
|
@ -0,0 +1,403 @@
|
||||||
|
import CardItem from './CardItem';
|
||||||
|
import BulkSelectionToolbar from './BulkSelectionToolbar';
|
||||||
|
import CollectionSelectionModal from './CollectionSelectionModal';
|
||||||
|
|
||||||
|
export default function CardsPageView(props) {
|
||||||
|
const {
|
||||||
|
cards,
|
||||||
|
loadingMore,
|
||||||
|
searchQuery,
|
||||||
|
selectedTCG,
|
||||||
|
setSelectedTCG,
|
||||||
|
selectedRarity,
|
||||||
|
setSelectedRarity,
|
||||||
|
selectedSet,
|
||||||
|
setSelectedSet,
|
||||||
|
selectedValueRange,
|
||||||
|
setSelectedValueRange,
|
||||||
|
viewMode,
|
||||||
|
setViewMode,
|
||||||
|
hasMore,
|
||||||
|
selectedCards,
|
||||||
|
favoritedCards,
|
||||||
|
showCollectionModal,
|
||||||
|
setShowCollectionModal,
|
||||||
|
cardsToAdd,
|
||||||
|
handleSearchChange,
|
||||||
|
fetchCards,
|
||||||
|
loadMoreCards,
|
||||||
|
hasMoreRef,
|
||||||
|
setPagination,
|
||||||
|
setCards,
|
||||||
|
setHasMore,
|
||||||
|
tcgOptions,
|
||||||
|
rarityOptions,
|
||||||
|
setOptions,
|
||||||
|
valueRangeOptions,
|
||||||
|
handleToggleSelect,
|
||||||
|
handleClearSelection,
|
||||||
|
handleToggleFavorite,
|
||||||
|
handleBulkAddToCollection,
|
||||||
|
handleBulkAddToDeck,
|
||||||
|
handleBulkMarkAsOwned,
|
||||||
|
handleBulkRemoveFromOwned,
|
||||||
|
handleBulkFavorite,
|
||||||
|
handleBulkDelete,
|
||||||
|
handleAddToCollections,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-4 sm:p-6" style={{ backgroundColor: 'var(--bg-secondary)', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
Cards
|
||||||
|
</h1>
|
||||||
|
<p className="text-base sm:text-lg" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Browse and manage your card collection
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-2 sm:space-x-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('grid')}
|
||||||
|
className={`p-2 rounded-xl transition-all duration-200 ${
|
||||||
|
viewMode === 'grid'
|
||||||
|
? 'shadow-lg'
|
||||||
|
: 'hover:shadow-md'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: viewMode === 'grid' ? 'var(--bg-tertiary)' : 'transparent',
|
||||||
|
color: viewMode === 'grid' ? 'var(--text-primary)' : 'var(--text-secondary)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
className={`p-2 rounded-xl transition-all duration-200 ${
|
||||||
|
viewMode === 'list'
|
||||||
|
? 'shadow-lg'
|
||||||
|
: 'hover:shadow-md'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: viewMode === 'list' ? 'var(--bg-tertiary)' : 'transparent',
|
||||||
|
color: viewMode === 'list' ? 'var(--text-primary)' : 'var(--text-secondary)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Filters */}
|
||||||
|
<div className="p-4 sm:p-6" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-base sm:text-lg font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
Quick Filters
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2 sm:gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedTCG('all')}
|
||||||
|
className={`px-4 py-2 rounded-xl font-medium transition-all duration-200 ${
|
||||||
|
selectedTCG === 'all'
|
||||||
|
? 'shadow-lg'
|
||||||
|
: 'hover:shadow-md'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: selectedTCG === 'all' ? 'var(--bg-tertiary)' : 'transparent',
|
||||||
|
color: selectedTCG === 'all' ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||||
|
border: '1px solid var(--border)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
All Games
|
||||||
|
</button>
|
||||||
|
{tcgOptions.map(option => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => setSelectedTCG(option.value)}
|
||||||
|
className={`px-4 py-2 rounded-xl font-medium transition-all duration-200 flex items-center gap-2 ${
|
||||||
|
selectedTCG === option.value
|
||||||
|
? 'shadow-lg'
|
||||||
|
: 'hover:shadow-md'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: selectedTCG === option.value ? 'var(--bg-tertiary)' : 'transparent',
|
||||||
|
color: selectedTCG === option.value ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||||
|
border: '1px solid var(--border)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{option.icon}</span>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced Filters */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
Search
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search cards..."
|
||||||
|
className="search-bar w-full pr-12"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
setPagination(prev => ({ ...prev, page: 1 }));
|
||||||
|
setCards([]);
|
||||||
|
setHasMore(true);
|
||||||
|
hasMoreRef.current = true;
|
||||||
|
fetchCards(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setPagination(prev => ({ ...prev, page: 1 }));
|
||||||
|
setCards([]);
|
||||||
|
setHasMore(true);
|
||||||
|
hasMoreRef.current = true;
|
||||||
|
fetchCards(false);
|
||||||
|
}}
|
||||||
|
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-2 rounded-lg hover:bg-opacity-20 transition-all duration-200"
|
||||||
|
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
Rarity
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedRarity}
|
||||||
|
onChange={(e) => setSelectedRarity(e.target.value)}
|
||||||
|
className="input-field w-full"
|
||||||
|
>
|
||||||
|
{rarityOptions.map(option => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
Set
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedSet}
|
||||||
|
onChange={(e) => setSelectedSet(e.target.value)}
|
||||||
|
className="input-field w-full"
|
||||||
|
>
|
||||||
|
{setOptions.map(option => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
Value Range
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedValueRange}
|
||||||
|
onChange={(e) => setSelectedValueRange(e.target.value)}
|
||||||
|
className="input-field w-full"
|
||||||
|
>
|
||||||
|
{valueRangeOptions.map(option => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Filters Display */}
|
||||||
|
{(selectedTCG !== 'all' || selectedRarity !== 'all' || selectedSet !== 'all' || selectedValueRange !== 'all') && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{selectedTCG !== 'all' && (
|
||||||
|
<span className="px-3 py-1 rounded-full text-sm font-medium" style={{
|
||||||
|
backgroundColor: 'var(--bg-tertiary)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
border: '1px solid var(--border)'
|
||||||
|
}}>
|
||||||
|
{tcgOptions.find(opt => opt.value === selectedTCG)?.label}
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedTCG('all')}
|
||||||
|
className="ml-2 hover:opacity-70"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{selectedRarity !== 'all' && (
|
||||||
|
<span className="px-3 py-1 rounded-full text-sm font-medium" style={{
|
||||||
|
backgroundColor: 'var(--bg-tertiary)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
border: '1px solid var(--border)'
|
||||||
|
}}>
|
||||||
|
{rarityOptions.find(opt => opt.value === selectedRarity)?.label}
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedRarity('all')}
|
||||||
|
className="ml-2 hover:opacity-70"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{selectedSet !== 'all' && (
|
||||||
|
<span className="px-3 py-1 rounded-full text-sm font-medium" style={{
|
||||||
|
backgroundColor: 'var(--bg-tertiary)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
border: '1px solid var(--border)'
|
||||||
|
}}>
|
||||||
|
{setOptions.find(opt => opt.value === selectedSet)?.label}
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedSet('all')}
|
||||||
|
className="ml-2 hover:opacity-70"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{selectedValueRange !== 'all' && (
|
||||||
|
<span className="px-3 py-1 rounded-full text-sm font-medium" style={{
|
||||||
|
backgroundColor: 'var(--bg-tertiary)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
border: '1px solid var(--border)'
|
||||||
|
}}>
|
||||||
|
{valueRangeOptions.find(opt => opt.value === selectedValueRange)?.label}
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedValueRange('all')}
|
||||||
|
className="ml-2 hover:opacity-70"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedTCG('all');
|
||||||
|
setSelectedRarity('all');
|
||||||
|
setSelectedSet('all');
|
||||||
|
setSelectedValueRange('all');
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 rounded-full text-sm font-medium hover:opacity-70" style={{
|
||||||
|
color: 'var(--text-accent)',
|
||||||
|
border: '1px solid var(--border)'
|
||||||
|
}}>
|
||||||
|
Clear All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cards Display */}
|
||||||
|
<div className="p-6 pb-64">
|
||||||
|
{cards.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-6xl mb-4">🃏</div>
|
||||||
|
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
No cards found
|
||||||
|
</h3>
|
||||||
|
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Try adjusting your search or filters
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={viewMode === 'grid' ? 'card-grid-container grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3 sm:gap-4 lg:gap-6 p-3 sm:p-4 lg:p-6 xl:p-8' : 'space-y-4 p-6'}>
|
||||||
|
{cards.map((card) => (
|
||||||
|
<CardItem
|
||||||
|
key={card.id}
|
||||||
|
card={card}
|
||||||
|
viewMode={viewMode}
|
||||||
|
isSelected={selectedCards.some(c => c.id === card.id)}
|
||||||
|
onToggleSelect={handleToggleSelect}
|
||||||
|
onAddToCollection={(card) => handleBulkAddToCollection([card])}
|
||||||
|
onAddToDeck={handleBulkAddToDeck}
|
||||||
|
onToggleFavorite={handleToggleFavorite}
|
||||||
|
isFavorited={favoritedCards.has(card.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Infinite Scroll Loading Indicator */}
|
||||||
|
{loadingMore && (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2" style={{ borderColor: 'var(--text-accent)' }}></div>
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>Loading more cards...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Infinite Scroll Sentinel */}
|
||||||
|
<div id="infinite-scroll-sentinel" className="h-4 w-full" />
|
||||||
|
|
||||||
|
{/* Load More Button */}
|
||||||
|
{hasMore && !loadingMore && (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<button
|
||||||
|
onClick={loadMoreCards}
|
||||||
|
className="px-6 py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-lg hover:from-blue-600 hover:to-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||||
|
>
|
||||||
|
Load More Cards
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* End of results indicator */}
|
||||||
|
{!hasMore && cards.length > 0 && (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>No more cards to load</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk Selection Toolbar */}
|
||||||
|
<BulkSelectionToolbar
|
||||||
|
selectedCards={selectedCards}
|
||||||
|
onClearSelection={handleClearSelection}
|
||||||
|
onAddToCollection={handleBulkAddToCollection}
|
||||||
|
onAddToDeck={handleBulkAddToDeck}
|
||||||
|
onMarkAsOwned={handleBulkMarkAsOwned}
|
||||||
|
onRemoveFromOwned={handleBulkRemoveFromOwned}
|
||||||
|
onBulkFavorite={handleBulkFavorite}
|
||||||
|
onBulkDelete={handleBulkDelete}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Collection Selection Modal */}
|
||||||
|
<CollectionSelectionModal
|
||||||
|
isOpen={showCollectionModal}
|
||||||
|
onClose={() => setShowCollectionModal(false)}
|
||||||
|
cards={cardsToAdd}
|
||||||
|
onAddToCollections={handleAddToCollections}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
578
lib/use-cards-page.js
Normal file
578
lib/use-cards-page.js
Normal file
|
|
@ -0,0 +1,578 @@
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { VOCAB } from './collection-vocabulary.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card browser state: search/filters, infinite scroll, bulk selection, favorites.
|
||||||
|
*/
|
||||||
|
export function useCardsPage() {
|
||||||
|
|
||||||
|
const [cards, setCards] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedTCG, setSelectedTCG] = useState('all');
|
||||||
|
const [selectedRarity, setSelectedRarity] = useState('all');
|
||||||
|
const [selectedSet, setSelectedSet] = useState('all');
|
||||||
|
const [selectedValueRange, setSelectedValueRange] = useState('all');
|
||||||
|
const [viewMode, setViewMode] = useState('grid'); // grid or list
|
||||||
|
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 loadingMoreRef = useRef(false);
|
||||||
|
const hasMoreRef = useRef(true);
|
||||||
|
|
||||||
|
// Bulk selection state
|
||||||
|
const [selectedCards, setSelectedCards] = useState([]);
|
||||||
|
const [favoritedCards, setFavoritedCards] = useState(new Set());
|
||||||
|
|
||||||
|
// Modal states
|
||||||
|
const [showCollectionModal, setShowCollectionModal] = useState(false);
|
||||||
|
const [cardsToAdd, setCardsToAdd] = useState([]);
|
||||||
|
|
||||||
|
// Mana symbol settings
|
||||||
|
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
|
||||||
|
|
||||||
|
// Fetch cards from database
|
||||||
|
const fetchCards = async (isLoadMore = false) => {
|
||||||
|
try {
|
||||||
|
if (isLoadMore) {
|
||||||
|
setLoadingMore(true);
|
||||||
|
loadingMoreRef.current = true;
|
||||||
|
} else {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
query: searchQuery,
|
||||||
|
game: selectedTCG,
|
||||||
|
rarity: selectedRarity,
|
||||||
|
set: selectedSet,
|
||||||
|
minPrice: selectedValueRange === 'under-50' ? '0' :
|
||||||
|
selectedValueRange === '50-100' ? '50' :
|
||||||
|
selectedValueRange === '100-500' ? '100' :
|
||||||
|
selectedValueRange === '500-1000' ? '500' :
|
||||||
|
selectedValueRange === 'over-1000' ? '1000' : '',
|
||||||
|
maxPrice: selectedValueRange === '50-100' ? '100' :
|
||||||
|
selectedValueRange === '100-500' ? '500' :
|
||||||
|
selectedValueRange === '500-1000' ? '1000' : '',
|
||||||
|
page: pagination.page.toString(),
|
||||||
|
limit: pagination.limit.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`/api/cards/search?${params}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
|
||||||
|
|
||||||
|
if (isLoadMore) {
|
||||||
|
setCards(prevCards => [...prevCards, ...data.cards]);
|
||||||
|
} else {
|
||||||
|
setCards(data.cards);
|
||||||
|
}
|
||||||
|
setPagination(data.pagination);
|
||||||
|
setFilters(data.filters);
|
||||||
|
const hasMoreCards = data.pagination.page < data.pagination.pages;
|
||||||
|
setHasMore(hasMoreCards);
|
||||||
|
hasMoreRef.current = hasMoreCards;
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch cards:', data.error);
|
||||||
|
if (!isLoadMore) {
|
||||||
|
setCards([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching cards:', error);
|
||||||
|
if (!isLoadMore) {
|
||||||
|
setCards([]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setLoadingMore(false);
|
||||||
|
loadingMoreRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load user's favorited cards
|
||||||
|
const loadFavoritedCards = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const response = await fetch('/api/favorites?type=card', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const favoriteIds = new Set(data.favorites.map(fav => parseInt(fav.item_id)));
|
||||||
|
setFavoritedCards(favoriteIds);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading favorited cards:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
useEffect(() => {
|
||||||
|
/* eslint-disable react-hooks/set-state-in-effect -- reset list state when filters change, then fetch */
|
||||||
|
setPagination(prev => ({ ...prev, page: 1 }));
|
||||||
|
setCards([]);
|
||||||
|
setHasMore(true);
|
||||||
|
hasMoreRef.current = true;
|
||||||
|
fetchCards(false);
|
||||||
|
loadFavoritedCards(); // Load user's favorites
|
||||||
|
/* eslint-enable react-hooks/set-state-in-effect */
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- filter-driven reload; fetchCards closes over latest search state
|
||||||
|
}, [selectedTCG, selectedRarity, selectedSet, selectedValueRange]);
|
||||||
|
|
||||||
|
// Handle search with debounce
|
||||||
|
const [searchTimeout, setSearchTimeout] = useState(null);
|
||||||
|
|
||||||
|
const handleSearchChange = (value) => {
|
||||||
|
setSearchQuery(value);
|
||||||
|
|
||||||
|
// Clear existing timeout
|
||||||
|
if (searchTimeout) {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new timeout for search
|
||||||
|
const newTimeout = setTimeout(() => {
|
||||||
|
setPagination(prev => ({ ...prev, page: 1 }));
|
||||||
|
setCards([]);
|
||||||
|
setHasMore(true);
|
||||||
|
hasMoreRef.current = true;
|
||||||
|
fetchCards(false);
|
||||||
|
}, 500); // 500ms delay
|
||||||
|
|
||||||
|
setSearchTimeout(newTimeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cleanup timeout on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (searchTimeout) {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [searchTimeout]);
|
||||||
|
|
||||||
|
// Load more cards
|
||||||
|
const loadMoreCards = async () => {
|
||||||
|
|
||||||
|
|
||||||
|
if (!loadingMoreRef.current && hasMoreRef.current) {
|
||||||
|
const nextPage = pagination.page + 1;
|
||||||
|
|
||||||
|
setPagination(prev => ({ ...prev, page: nextPage }));
|
||||||
|
|
||||||
|
// Use the next page number directly in the fetch
|
||||||
|
try {
|
||||||
|
setLoadingMore(true);
|
||||||
|
loadingMoreRef.current = true;
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
query: searchQuery,
|
||||||
|
game: selectedTCG,
|
||||||
|
rarity: selectedRarity,
|
||||||
|
set: selectedSet,
|
||||||
|
minPrice: selectedValueRange === 'under-50' ? '0' :
|
||||||
|
selectedValueRange === '50-100' ? '50' :
|
||||||
|
selectedValueRange === '100-500' ? '100' :
|
||||||
|
selectedValueRange === '500-1000' ? '500' :
|
||||||
|
selectedValueRange === 'over-1000' ? '1000' : '',
|
||||||
|
maxPrice: selectedValueRange === '50-100' ? '100' :
|
||||||
|
selectedValueRange === '100-500' ? '500' :
|
||||||
|
selectedValueRange === '500-1000' ? '1000' : '',
|
||||||
|
page: nextPage.toString(),
|
||||||
|
limit: pagination.limit.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`/api/cards/search?${params}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
|
||||||
|
setCards(prevCards => [...prevCards, ...data.cards]);
|
||||||
|
setPagination(data.pagination);
|
||||||
|
setFilters(data.filters);
|
||||||
|
const hasMoreCards = data.pagination.page < data.pagination.pages;
|
||||||
|
setHasMore(hasMoreCards);
|
||||||
|
hasMoreRef.current = hasMoreCards;
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch more cards:', data.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching more cards:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
loadingMoreRef.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update refs when state changes
|
||||||
|
useEffect(() => {
|
||||||
|
hasMoreRef.current = hasMore;
|
||||||
|
loadingMoreRef.current = loadingMore;
|
||||||
|
}, [hasMore, loadingMore]);
|
||||||
|
|
||||||
|
// Intersection Observer for infinite scroll
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting && hasMoreRef.current && !loadingMoreRef.current) {
|
||||||
|
loadMoreCards();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rootMargin: '50px',
|
||||||
|
threshold: 0.1
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const setupObserver = () => {
|
||||||
|
const sentinel = document.getElementById('infinite-scroll-sentinel');
|
||||||
|
if (sentinel) {
|
||||||
|
observer.observe(sentinel);
|
||||||
|
} else {
|
||||||
|
setTimeout(setupObserver, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setupObserver();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const sentinel = document.getElementById('infinite-scroll-sentinel');
|
||||||
|
if (sentinel) {
|
||||||
|
observer.unobserve(sentinel);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- observer setup once; loadMoreCards uses refs for latest state
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getRarityColor = (rarity) => {
|
||||||
|
// Map database rarity values to colors
|
||||||
|
const rarityColorMap = {
|
||||||
|
'common': '#6B7280',
|
||||||
|
'uncommon': '#10B981',
|
||||||
|
'rare': '#F59E0B',
|
||||||
|
'mythic': '#FFD700',
|
||||||
|
'holographic': '#FF6B6B',
|
||||||
|
'enchanted': '#A855F7',
|
||||||
|
'secret rare': '#FF6B6B',
|
||||||
|
'ultra rare': '#A855F7'
|
||||||
|
};
|
||||||
|
return rarityColorMap[rarity?.toLowerCase()] || '#6B7280';
|
||||||
|
};
|
||||||
|
|
||||||
|
const tcgOptions = [
|
||||||
|
{ value: 'MTG', label: 'Magic: The Gathering', color: 'purple', icon: '🔮' },
|
||||||
|
{ value: 'Pokemon', label: 'Pokemon', color: 'blue', icon: '⚡' },
|
||||||
|
{ value: 'Lorcana', label: 'Disney Lorcana', color: 'pink', icon: '✨' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const rarityOptions = [
|
||||||
|
{ value: 'all', label: 'All Rarities' },
|
||||||
|
{ value: 'common', label: 'Common' },
|
||||||
|
{ value: 'uncommon', label: 'Uncommon' },
|
||||||
|
{ value: 'rare', label: 'Rare' },
|
||||||
|
{ value: 'mythic', label: 'Mythic' },
|
||||||
|
{ value: 'enchanted', label: 'Enchanted' },
|
||||||
|
...filters.rarities.filter(rarity =>
|
||||||
|
!['all', 'common', 'uncommon', 'rare', 'mythic', 'enchanted'].includes(rarity)
|
||||||
|
).map(rarity => ({
|
||||||
|
value: rarity,
|
||||||
|
label: rarity,
|
||||||
|
color: getRarityColor(rarity)
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
|
||||||
|
const setOptions = [
|
||||||
|
{ value: 'all', label: 'All Sets' },
|
||||||
|
...filters.sets.map(set => ({
|
||||||
|
value: set,
|
||||||
|
label: set
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
|
||||||
|
const valueRangeOptions = [
|
||||||
|
{ value: 'all', label: 'All Values' },
|
||||||
|
{ value: 'under-50', label: 'Under $50' },
|
||||||
|
{ value: '50-100', label: '$50 - $100' },
|
||||||
|
{ value: '100-500', label: '$100 - $500' },
|
||||||
|
{ value: '500-1000', label: '$500 - $1,000' },
|
||||||
|
{ value: 'over-1000', label: 'Over $1,000' }
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const formatCurrency = (amount) => {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD'
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRarityLabel = (rarity) => {
|
||||||
|
const rarityOption = rarityOptions.find(option => option.value === rarity);
|
||||||
|
return rarityOption ? rarityOption.label : rarity;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bulk selection handlers
|
||||||
|
const handleToggleSelect = (card) => {
|
||||||
|
setSelectedCards(prev => {
|
||||||
|
const isSelected = prev.some(c => c.id === card.id);
|
||||||
|
if (isSelected) {
|
||||||
|
return prev.filter(c => c.id !== card.id);
|
||||||
|
} else {
|
||||||
|
return [...prev, card];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearSelection = () => {
|
||||||
|
setSelectedCards([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleFavorite = async (card) => {
|
||||||
|
try {
|
||||||
|
const isFavorited = favoritedCards.has(card.id);
|
||||||
|
const method = isFavorited ? 'DELETE' : 'POST';
|
||||||
|
|
||||||
|
const response = await fetch('/api/favorites', {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
itemType: 'card',
|
||||||
|
itemId: card.id
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setFavoritedCards(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (isFavorited) {
|
||||||
|
newSet.delete(card.id);
|
||||||
|
} else {
|
||||||
|
newSet.add(card.id);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling favorite:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bulk action handlers
|
||||||
|
const handleBulkAddToCollection = (cards) => {
|
||||||
|
setCardsToAdd(cards);
|
||||||
|
setShowCollectionModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkAddToDeck = (cards) => {
|
||||||
|
console.log('Adding to deck:', cards);
|
||||||
|
alert(`Adding ${cards.length} cards to deck (functionality coming soon)`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkMarkAsOwned = async (cards) => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
if (!token) {
|
||||||
|
alert(`Please log in to ${VOCAB.ADD_TO_MY_COLLECTION.toLowerCase()}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
for (const card of cards) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/cards/${card.id}/ownership`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ quantity: 1 })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
successCount++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error marking card ${card.id} as owned:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount === cards.length) {
|
||||||
|
alert(`Successfully added ${cards.length} cards to ${VOCAB.MY_COLLECTION}!`);
|
||||||
|
} else {
|
||||||
|
alert(`Added ${successCount} out of ${cards.length} cards to ${VOCAB.MY_COLLECTION}. Some operations may have failed.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear selection after successful operation
|
||||||
|
setSelectedCards([]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in bulk mark as owned:', error);
|
||||||
|
alert(`Failed to add cards to ${VOCAB.MY_COLLECTION}. Please try again.`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkRemoveFromOwned = async (cards) => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
if (!token) {
|
||||||
|
alert(`Please log in to ${VOCAB.REMOVE_FROM_MY_COLLECTION.toLowerCase()}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
for (const card of cards) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/cards/${card.id}/ownership`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ quantity: 0 })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
successCount++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error removing card ${card.id} from owned:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount === cards.length) {
|
||||||
|
alert(`Successfully removed ${cards.length} cards from ${VOCAB.MY_COLLECTION}!`);
|
||||||
|
} else {
|
||||||
|
alert(`Removed ${successCount} out of ${cards.length} cards from ${VOCAB.MY_COLLECTION}. Some operations may have failed.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear selection after successful operation
|
||||||
|
setSelectedCards([]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in bulk remove from owned:', error);
|
||||||
|
alert(`Failed to remove cards from ${VOCAB.MY_COLLECTION}. Please try again.`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkFavorite = async (cards) => {
|
||||||
|
try {
|
||||||
|
for (const card of cards) {
|
||||||
|
if (!favoritedCards.has(card.id)) {
|
||||||
|
await fetch('/api/favorites', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
itemType: 'card',
|
||||||
|
itemId: card.id
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFavoritedCards(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
cards.forEach(card => newSet.add(card.id));
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
|
||||||
|
alert(`Added ${cards.length} cards to favorites`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error bulk favoriting:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkDelete = (cards) => {
|
||||||
|
console.log('Bulk delete:', cards);
|
||||||
|
alert(`Bulk delete functionality coming soon for ${cards.length} cards`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collection modal handlers
|
||||||
|
const handleAddToCollections = (results, selectedCollectionIds, cards) => {
|
||||||
|
const successCount = results.filter(r => r.success).length;
|
||||||
|
const totalAttempts = results.length;
|
||||||
|
|
||||||
|
if (successCount === totalAttempts) {
|
||||||
|
alert(`Successfully added ${cards.length} card${cards.length !== 1 ? 's' : ''} to ${selectedCollectionIds.length} list${selectedCollectionIds.length !== 1 ? 's' : ''}!`);
|
||||||
|
} else {
|
||||||
|
alert(`Added ${successCount} out of ${totalAttempts} cards. Some additions may have failed.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear selection after successful addition
|
||||||
|
setSelectedCards([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showInitialLoading = loading && cards.length === 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cards,
|
||||||
|
loading,
|
||||||
|
loadingMore,
|
||||||
|
showInitialLoading,
|
||||||
|
searchQuery,
|
||||||
|
selectedTCG,
|
||||||
|
setSelectedTCG,
|
||||||
|
selectedRarity,
|
||||||
|
setSelectedRarity,
|
||||||
|
selectedSet,
|
||||||
|
setSelectedSet,
|
||||||
|
selectedValueRange,
|
||||||
|
setSelectedValueRange,
|
||||||
|
viewMode,
|
||||||
|
setViewMode,
|
||||||
|
filters,
|
||||||
|
hasMore,
|
||||||
|
selectedCards,
|
||||||
|
favoritedCards,
|
||||||
|
showCollectionModal,
|
||||||
|
setShowCollectionModal,
|
||||||
|
cardsToAdd,
|
||||||
|
handleSearchChange,
|
||||||
|
fetchCards,
|
||||||
|
loadMoreCards,
|
||||||
|
hasMoreRef,
|
||||||
|
setPagination,
|
||||||
|
setCards,
|
||||||
|
setHasMore,
|
||||||
|
tcgOptions,
|
||||||
|
rarityOptions,
|
||||||
|
setOptions,
|
||||||
|
valueRangeOptions,
|
||||||
|
handleToggleSelect,
|
||||||
|
handleClearSelection,
|
||||||
|
handleToggleFavorite,
|
||||||
|
handleBulkAddToCollection,
|
||||||
|
handleBulkAddToDeck,
|
||||||
|
handleBulkMarkAsOwned,
|
||||||
|
handleBulkRemoveFromOwned,
|
||||||
|
handleBulkFavorite,
|
||||||
|
handleBulkDelete,
|
||||||
|
handleAddToCollections,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
891
pages/cards.js
891
pages/cards.js
|
|
@ -1,547 +1,19 @@
|
||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import CardItem from '../components/CardItem';
|
import CardsPageView from '../components/CardsPageView';
|
||||||
import PublicCardsView from '../components/PublicCardsView';
|
import PublicCardsView from '../components/PublicCardsView';
|
||||||
import BulkSelectionToolbar from '../components/BulkSelectionToolbar';
|
|
||||||
import CollectionSelectionModal from '../components/CollectionSelectionModal';
|
|
||||||
import ProtectedRoute from '../components/ProtectedRoute';
|
import ProtectedRoute from '../components/ProtectedRoute';
|
||||||
import { ManaCost, ColorFilterSymbol } from '../components/ManaSymbols';
|
|
||||||
import ManaSymbolSettings from '../components/ManaSymbolSettings';
|
|
||||||
import { useAuth } from '../lib/use-auth';
|
import { useAuth } from '../lib/use-auth';
|
||||||
import { VOCAB } from '../lib/collection-vocabulary.js';
|
import { useCardsPage } from '../lib/use-cards-page.js';
|
||||||
|
|
||||||
function AuthenticatedCards() {
|
function AuthenticatedCards() {
|
||||||
const router = useRouter();
|
const { user } = useAuth();
|
||||||
const { user, loading: authLoading } = useAuth();
|
const cardsPage = useCardsPage();
|
||||||
|
|
||||||
const [cards, setCards] = useState([]);
|
if (cardsPage.showInitialLoading) {
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [selectedTCG, setSelectedTCG] = useState('all');
|
|
||||||
const [selectedRarity, setSelectedRarity] = useState('all');
|
|
||||||
const [selectedSet, setSelectedSet] = useState('all');
|
|
||||||
const [selectedValueRange, setSelectedValueRange] = useState('all');
|
|
||||||
const [viewMode, setViewMode] = useState('grid'); // grid or list
|
|
||||||
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 loadingMoreRef = useRef(false);
|
|
||||||
const hasMoreRef = useRef(true);
|
|
||||||
|
|
||||||
// Bulk selection state
|
|
||||||
const [selectedCards, setSelectedCards] = useState([]);
|
|
||||||
const [favoritedCards, setFavoritedCards] = useState(new Set());
|
|
||||||
|
|
||||||
// Modal states
|
|
||||||
const [showCollectionModal, setShowCollectionModal] = useState(false);
|
|
||||||
const [cardsToAdd, setCardsToAdd] = useState([]);
|
|
||||||
|
|
||||||
// Mana symbol settings
|
|
||||||
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
|
|
||||||
|
|
||||||
// Fetch cards from database
|
|
||||||
const fetchCards = async (isLoadMore = false) => {
|
|
||||||
try {
|
|
||||||
if (isLoadMore) {
|
|
||||||
setLoadingMore(true);
|
|
||||||
loadingMoreRef.current = true;
|
|
||||||
} else {
|
|
||||||
setLoading(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
query: searchQuery,
|
|
||||||
game: selectedTCG,
|
|
||||||
rarity: selectedRarity,
|
|
||||||
set: selectedSet,
|
|
||||||
minPrice: selectedValueRange === 'under-50' ? '0' :
|
|
||||||
selectedValueRange === '50-100' ? '50' :
|
|
||||||
selectedValueRange === '100-500' ? '100' :
|
|
||||||
selectedValueRange === '500-1000' ? '500' :
|
|
||||||
selectedValueRange === 'over-1000' ? '1000' : '',
|
|
||||||
maxPrice: selectedValueRange === '50-100' ? '100' :
|
|
||||||
selectedValueRange === '100-500' ? '500' :
|
|
||||||
selectedValueRange === '500-1000' ? '1000' : '',
|
|
||||||
page: pagination.page.toString(),
|
|
||||||
limit: pagination.limit.toString()
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(`/api/cards/search?${params}`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
|
|
||||||
|
|
||||||
if (isLoadMore) {
|
|
||||||
setCards(prevCards => [...prevCards, ...data.cards]);
|
|
||||||
} else {
|
|
||||||
setCards(data.cards);
|
|
||||||
}
|
|
||||||
setPagination(data.pagination);
|
|
||||||
setFilters(data.filters);
|
|
||||||
const hasMoreCards = data.pagination.page < data.pagination.pages;
|
|
||||||
setHasMore(hasMoreCards);
|
|
||||||
hasMoreRef.current = hasMoreCards;
|
|
||||||
} else {
|
|
||||||
console.error('Failed to fetch cards:', data.error);
|
|
||||||
if (!isLoadMore) {
|
|
||||||
setCards([]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching cards:', error);
|
|
||||||
if (!isLoadMore) {
|
|
||||||
setCards([]);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
setLoadingMore(false);
|
|
||||||
loadingMoreRef.current = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load user's favorited cards
|
|
||||||
const loadFavoritedCards = async () => {
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('auth_token');
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
const response = await fetch('/api/favorites?type=card', {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
const favoriteIds = new Set(data.favorites.map(fav => parseInt(fav.item_id)));
|
|
||||||
setFavoritedCards(favoriteIds);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading favorited cards:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initial load
|
|
||||||
useEffect(() => {
|
|
||||||
/* eslint-disable react-hooks/set-state-in-effect -- reset list state when filters change, then fetch */
|
|
||||||
setPagination(prev => ({ ...prev, page: 1 }));
|
|
||||||
setCards([]);
|
|
||||||
setHasMore(true);
|
|
||||||
hasMoreRef.current = true;
|
|
||||||
fetchCards(false);
|
|
||||||
loadFavoritedCards(); // Load user's favorites
|
|
||||||
/* eslint-enable react-hooks/set-state-in-effect */
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- filter-driven reload; fetchCards closes over latest search state
|
|
||||||
}, [selectedTCG, selectedRarity, selectedSet, selectedValueRange]);
|
|
||||||
|
|
||||||
// Handle search with debounce
|
|
||||||
const [searchTimeout, setSearchTimeout] = useState(null);
|
|
||||||
|
|
||||||
const handleSearchChange = (value) => {
|
|
||||||
setSearchQuery(value);
|
|
||||||
|
|
||||||
// Clear existing timeout
|
|
||||||
if (searchTimeout) {
|
|
||||||
clearTimeout(searchTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set new timeout for search
|
|
||||||
const newTimeout = setTimeout(() => {
|
|
||||||
setPagination(prev => ({ ...prev, page: 1 }));
|
|
||||||
setCards([]);
|
|
||||||
setHasMore(true);
|
|
||||||
hasMoreRef.current = true;
|
|
||||||
fetchCards(false);
|
|
||||||
}, 500); // 500ms delay
|
|
||||||
|
|
||||||
setSearchTimeout(newTimeout);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cleanup timeout on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (searchTimeout) {
|
|
||||||
clearTimeout(searchTimeout);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [searchTimeout]);
|
|
||||||
|
|
||||||
// Load more cards
|
|
||||||
const loadMoreCards = async () => {
|
|
||||||
|
|
||||||
|
|
||||||
if (!loadingMoreRef.current && hasMoreRef.current) {
|
|
||||||
const nextPage = pagination.page + 1;
|
|
||||||
|
|
||||||
setPagination(prev => ({ ...prev, page: nextPage }));
|
|
||||||
|
|
||||||
// Use the next page number directly in the fetch
|
|
||||||
try {
|
|
||||||
setLoadingMore(true);
|
|
||||||
loadingMoreRef.current = true;
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
query: searchQuery,
|
|
||||||
game: selectedTCG,
|
|
||||||
rarity: selectedRarity,
|
|
||||||
set: selectedSet,
|
|
||||||
minPrice: selectedValueRange === 'under-50' ? '0' :
|
|
||||||
selectedValueRange === '50-100' ? '50' :
|
|
||||||
selectedValueRange === '100-500' ? '100' :
|
|
||||||
selectedValueRange === '500-1000' ? '500' :
|
|
||||||
selectedValueRange === 'over-1000' ? '1000' : '',
|
|
||||||
maxPrice: selectedValueRange === '50-100' ? '100' :
|
|
||||||
selectedValueRange === '100-500' ? '500' :
|
|
||||||
selectedValueRange === '500-1000' ? '1000' : '',
|
|
||||||
page: nextPage.toString(),
|
|
||||||
limit: pagination.limit.toString()
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(`/api/cards/search?${params}`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
|
|
||||||
setCards(prevCards => [...prevCards, ...data.cards]);
|
|
||||||
setPagination(data.pagination);
|
|
||||||
setFilters(data.filters);
|
|
||||||
const hasMoreCards = data.pagination.page < data.pagination.pages;
|
|
||||||
setHasMore(hasMoreCards);
|
|
||||||
hasMoreRef.current = hasMoreCards;
|
|
||||||
} else {
|
|
||||||
console.error('Failed to fetch more cards:', data.error);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching more cards:', error);
|
|
||||||
} finally {
|
|
||||||
setLoadingMore(false);
|
|
||||||
loadingMoreRef.current = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update refs when state changes
|
|
||||||
useEffect(() => {
|
|
||||||
hasMoreRef.current = hasMore;
|
|
||||||
loadingMoreRef.current = loadingMore;
|
|
||||||
}, [hasMore, loadingMore]);
|
|
||||||
|
|
||||||
// Intersection Observer for infinite scroll
|
|
||||||
useEffect(() => {
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (entry.isIntersecting && hasMoreRef.current && !loadingMoreRef.current) {
|
|
||||||
loadMoreCards();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rootMargin: '50px',
|
|
||||||
threshold: 0.1
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const setupObserver = () => {
|
|
||||||
const sentinel = document.getElementById('infinite-scroll-sentinel');
|
|
||||||
if (sentinel) {
|
|
||||||
observer.observe(sentinel);
|
|
||||||
} else {
|
|
||||||
setTimeout(setupObserver, 100);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
setupObserver();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
const sentinel = document.getElementById('infinite-scroll-sentinel');
|
|
||||||
if (sentinel) {
|
|
||||||
observer.unobserve(sentinel);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- observer setup once; loadMoreCards uses refs for latest state
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getRarityColor = (rarity) => {
|
|
||||||
// Map database rarity values to colors
|
|
||||||
const rarityColorMap = {
|
|
||||||
'common': '#6B7280',
|
|
||||||
'uncommon': '#10B981',
|
|
||||||
'rare': '#F59E0B',
|
|
||||||
'mythic': '#FFD700',
|
|
||||||
'holographic': '#FF6B6B',
|
|
||||||
'enchanted': '#A855F7',
|
|
||||||
'secret rare': '#FF6B6B',
|
|
||||||
'ultra rare': '#A855F7'
|
|
||||||
};
|
|
||||||
return rarityColorMap[rarity?.toLowerCase()] || '#6B7280';
|
|
||||||
};
|
|
||||||
|
|
||||||
const tcgOptions = [
|
|
||||||
{ value: 'MTG', label: 'Magic: The Gathering', color: 'purple', icon: '🔮' },
|
|
||||||
{ value: 'Pokemon', label: 'Pokemon', color: 'blue', icon: '⚡' },
|
|
||||||
{ value: 'Lorcana', label: 'Disney Lorcana', color: 'pink', icon: '✨' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const rarityOptions = [
|
|
||||||
{ value: 'all', label: 'All Rarities' },
|
|
||||||
{ value: 'common', label: 'Common' },
|
|
||||||
{ value: 'uncommon', label: 'Uncommon' },
|
|
||||||
{ value: 'rare', label: 'Rare' },
|
|
||||||
{ value: 'mythic', label: 'Mythic' },
|
|
||||||
{ value: 'enchanted', label: 'Enchanted' },
|
|
||||||
...filters.rarities.filter(rarity =>
|
|
||||||
!['all', 'common', 'uncommon', 'rare', 'mythic', 'enchanted'].includes(rarity)
|
|
||||||
).map(rarity => ({
|
|
||||||
value: rarity,
|
|
||||||
label: rarity,
|
|
||||||
color: getRarityColor(rarity)
|
|
||||||
}))
|
|
||||||
];
|
|
||||||
|
|
||||||
const setOptions = [
|
|
||||||
{ value: 'all', label: 'All Sets' },
|
|
||||||
...filters.sets.map(set => ({
|
|
||||||
value: set,
|
|
||||||
label: set
|
|
||||||
}))
|
|
||||||
];
|
|
||||||
|
|
||||||
const valueRangeOptions = [
|
|
||||||
{ value: 'all', label: 'All Values' },
|
|
||||||
{ value: 'under-50', label: 'Under $50' },
|
|
||||||
{ value: '50-100', label: '$50 - $100' },
|
|
||||||
{ value: '100-500', label: '$100 - $500' },
|
|
||||||
{ value: '500-1000', label: '$500 - $1,000' },
|
|
||||||
{ value: 'over-1000', label: 'Over $1,000' }
|
|
||||||
];
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const formatCurrency = (amount) => {
|
|
||||||
return new Intl.NumberFormat('en-US', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'USD'
|
|
||||||
}).format(amount);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRarityLabel = (rarity) => {
|
|
||||||
const rarityOption = rarityOptions.find(option => option.value === rarity);
|
|
||||||
return rarityOption ? rarityOption.label : rarity;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Bulk selection handlers
|
|
||||||
const handleToggleSelect = (card) => {
|
|
||||||
setSelectedCards(prev => {
|
|
||||||
const isSelected = prev.some(c => c.id === card.id);
|
|
||||||
if (isSelected) {
|
|
||||||
return prev.filter(c => c.id !== card.id);
|
|
||||||
} else {
|
|
||||||
return [...prev, card];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClearSelection = () => {
|
|
||||||
setSelectedCards([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleFavorite = async (card) => {
|
|
||||||
try {
|
|
||||||
const isFavorited = favoritedCards.has(card.id);
|
|
||||||
const method = isFavorited ? 'DELETE' : 'POST';
|
|
||||||
|
|
||||||
const response = await fetch('/api/favorites', {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
itemType: 'card',
|
|
||||||
itemId: card.id
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setFavoritedCards(prev => {
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
if (isFavorited) {
|
|
||||||
newSet.delete(card.id);
|
|
||||||
} else {
|
|
||||||
newSet.add(card.id);
|
|
||||||
}
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error toggling favorite:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Bulk action handlers
|
|
||||||
const handleBulkAddToCollection = (cards) => {
|
|
||||||
setCardsToAdd(cards);
|
|
||||||
setShowCollectionModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBulkAddToDeck = (cards) => {
|
|
||||||
console.log('Adding to deck:', cards);
|
|
||||||
alert(`Adding ${cards.length} cards to deck (functionality coming soon)`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBulkMarkAsOwned = async (cards) => {
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('auth_token');
|
|
||||||
if (!token) {
|
|
||||||
alert(`Please log in to ${VOCAB.ADD_TO_MY_COLLECTION.toLowerCase()}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let successCount = 0;
|
|
||||||
for (const card of cards) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/cards/${card.id}/ownership`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ quantity: 1 })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
successCount++;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error marking card ${card.id} as owned:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (successCount === cards.length) {
|
|
||||||
alert(`Successfully added ${cards.length} cards to ${VOCAB.MY_COLLECTION}!`);
|
|
||||||
} else {
|
|
||||||
alert(`Added ${successCount} out of ${cards.length} cards to ${VOCAB.MY_COLLECTION}. Some operations may have failed.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear selection after successful operation
|
|
||||||
setSelectedCards([]);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in bulk mark as owned:', error);
|
|
||||||
alert(`Failed to add cards to ${VOCAB.MY_COLLECTION}. Please try again.`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBulkRemoveFromOwned = async (cards) => {
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('auth_token');
|
|
||||||
if (!token) {
|
|
||||||
alert(`Please log in to ${VOCAB.REMOVE_FROM_MY_COLLECTION.toLowerCase()}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let successCount = 0;
|
|
||||||
for (const card of cards) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/cards/${card.id}/ownership`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ quantity: 0 })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
successCount++;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error removing card ${card.id} from owned:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (successCount === cards.length) {
|
|
||||||
alert(`Successfully removed ${cards.length} cards from ${VOCAB.MY_COLLECTION}!`);
|
|
||||||
} else {
|
|
||||||
alert(`Removed ${successCount} out of ${cards.length} cards from ${VOCAB.MY_COLLECTION}. Some operations may have failed.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear selection after successful operation
|
|
||||||
setSelectedCards([]);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in bulk remove from owned:', error);
|
|
||||||
alert(`Failed to remove cards from ${VOCAB.MY_COLLECTION}. Please try again.`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBulkFavorite = async (cards) => {
|
|
||||||
try {
|
|
||||||
for (const card of cards) {
|
|
||||||
if (!favoritedCards.has(card.id)) {
|
|
||||||
await fetch('/api/favorites', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
itemType: 'card',
|
|
||||||
itemId: card.id
|
|
||||||
})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setFavoritedCards(prev => {
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
cards.forEach(card => newSet.add(card.id));
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
|
|
||||||
alert(`Added ${cards.length} cards to favorites`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error bulk favoriting:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBulkDelete = (cards) => {
|
|
||||||
console.log('Bulk delete:', cards);
|
|
||||||
alert(`Bulk delete functionality coming soon for ${cards.length} cards`);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Collection modal handlers
|
|
||||||
const handleAddToCollections = (results, selectedCollectionIds, cards) => {
|
|
||||||
const successCount = results.filter(r => r.success).length;
|
|
||||||
const totalAttempts = results.length;
|
|
||||||
|
|
||||||
if (successCount === totalAttempts) {
|
|
||||||
alert(`Successfully added ${cards.length} card${cards.length !== 1 ? 's' : ''} to ${selectedCollectionIds.length} list${selectedCollectionIds.length !== 1 ? 's' : ''}!`);
|
|
||||||
} else {
|
|
||||||
alert(`Added ${successCount} out of ${totalAttempts} cards. Some additions may have failed.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear selection after successful addition
|
|
||||||
setSelectedCards([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading && cards.length === 0) {
|
|
||||||
return (
|
return (
|
||||||
<Layout user={user}>
|
<Layout user={user}>
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2" style={{ borderColor: 'var(--text-accent)' }}></div>
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2" style={{ borderColor: 'var(--text-accent)' }} />
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|
@ -549,356 +21,7 @@ function AuthenticatedCards() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout user={user}>
|
<Layout user={user}>
|
||||||
{/* Header */}
|
<CardsPageView {...cardsPage} />
|
||||||
<div className="p-4 sm:p-6" style={{ backgroundColor: 'var(--bg-secondary)', borderBottom: '1px solid var(--border)' }}>
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
Cards
|
|
||||||
</h1>
|
|
||||||
<p className="text-base sm:text-lg" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Browse and manage your card collection
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex space-x-2 sm:space-x-4">
|
|
||||||
<button
|
|
||||||
onClick={() => setViewMode('grid')}
|
|
||||||
className={`p-2 rounded-xl transition-all duration-200 ${
|
|
||||||
viewMode === 'grid'
|
|
||||||
? 'shadow-lg'
|
|
||||||
: 'hover:shadow-md'
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
backgroundColor: viewMode === 'grid' ? 'var(--bg-tertiary)' : 'transparent',
|
|
||||||
color: viewMode === 'grid' ? 'var(--text-primary)' : 'var(--text-secondary)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setViewMode('list')}
|
|
||||||
className={`p-2 rounded-xl transition-all duration-200 ${
|
|
||||||
viewMode === 'list'
|
|
||||||
? 'shadow-lg'
|
|
||||||
: 'hover:shadow-md'
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
backgroundColor: viewMode === 'list' ? 'var(--bg-tertiary)' : 'transparent',
|
|
||||||
color: viewMode === 'list' ? 'var(--text-primary)' : 'var(--text-secondary)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Filters */}
|
|
||||||
<div className="p-4 sm:p-6" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
|
||||||
<div className="mb-4">
|
|
||||||
<h3 className="text-base sm:text-lg font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
Quick Filters
|
|
||||||
</h3>
|
|
||||||
<div className="flex flex-wrap gap-2 sm:gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedTCG('all')}
|
|
||||||
className={`px-4 py-2 rounded-xl font-medium transition-all duration-200 ${
|
|
||||||
selectedTCG === 'all'
|
|
||||||
? 'shadow-lg'
|
|
||||||
: 'hover:shadow-md'
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
backgroundColor: selectedTCG === 'all' ? 'var(--bg-tertiary)' : 'transparent',
|
|
||||||
color: selectedTCG === 'all' ? 'var(--text-primary)' : 'var(--text-secondary)',
|
|
||||||
border: '1px solid var(--border)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
All Games
|
|
||||||
</button>
|
|
||||||
{tcgOptions.map(option => (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
onClick={() => setSelectedTCG(option.value)}
|
|
||||||
className={`px-4 py-2 rounded-xl font-medium transition-all duration-200 flex items-center gap-2 ${
|
|
||||||
selectedTCG === option.value
|
|
||||||
? 'shadow-lg'
|
|
||||||
: 'hover:shadow-md'
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
backgroundColor: selectedTCG === option.value ? 'var(--bg-tertiary)' : 'transparent',
|
|
||||||
color: selectedTCG === option.value ? 'var(--text-primary)' : 'var(--text-secondary)',
|
|
||||||
border: '1px solid var(--border)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>{option.icon}</span>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Advanced Filters */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
Search
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search cards..."
|
|
||||||
className="search-bar w-full pr-12"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => handleSearchChange(e.target.value)}
|
|
||||||
onKeyPress={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
setPagination(prev => ({ ...prev, page: 1 }));
|
|
||||||
setCards([]);
|
|
||||||
setHasMore(true);
|
|
||||||
hasMoreRef.current = true;
|
|
||||||
fetchCards(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setPagination(prev => ({ ...prev, page: 1 }));
|
|
||||||
setCards([]);
|
|
||||||
setHasMore(true);
|
|
||||||
hasMoreRef.current = true;
|
|
||||||
fetchCards(false);
|
|
||||||
}}
|
|
||||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-2 rounded-lg hover:bg-opacity-20 transition-all duration-200"
|
|
||||||
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
Rarity
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={selectedRarity}
|
|
||||||
onChange={(e) => setSelectedRarity(e.target.value)}
|
|
||||||
className="input-field w-full"
|
|
||||||
>
|
|
||||||
{rarityOptions.map(option => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
Set
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={selectedSet}
|
|
||||||
onChange={(e) => setSelectedSet(e.target.value)}
|
|
||||||
className="input-field w-full"
|
|
||||||
>
|
|
||||||
{setOptions.map(option => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
Value Range
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={selectedValueRange}
|
|
||||||
onChange={(e) => setSelectedValueRange(e.target.value)}
|
|
||||||
className="input-field w-full"
|
|
||||||
>
|
|
||||||
{valueRangeOptions.map(option => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Active Filters Display */}
|
|
||||||
{(selectedTCG !== 'all' || selectedRarity !== 'all' || selectedSet !== 'all' || selectedValueRange !== 'all') && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{selectedTCG !== 'all' && (
|
|
||||||
<span className="px-3 py-1 rounded-full text-sm font-medium" style={{
|
|
||||||
backgroundColor: 'var(--bg-tertiary)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
border: '1px solid var(--border)'
|
|
||||||
}}>
|
|
||||||
{tcgOptions.find(opt => opt.value === selectedTCG)?.label}
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedTCG('all')}
|
|
||||||
className="ml-2 hover:opacity-70"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{selectedRarity !== 'all' && (
|
|
||||||
<span className="px-3 py-1 rounded-full text-sm font-medium" style={{
|
|
||||||
backgroundColor: 'var(--bg-tertiary)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
border: '1px solid var(--border)'
|
|
||||||
}}>
|
|
||||||
{rarityOptions.find(opt => opt.value === selectedRarity)?.label}
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedRarity('all')}
|
|
||||||
className="ml-2 hover:opacity-70"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{selectedSet !== 'all' && (
|
|
||||||
<span className="px-3 py-1 rounded-full text-sm font-medium" style={{
|
|
||||||
backgroundColor: 'var(--bg-tertiary)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
border: '1px solid var(--border)'
|
|
||||||
}}>
|
|
||||||
{setOptions.find(opt => opt.value === selectedSet)?.label}
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedSet('all')}
|
|
||||||
className="ml-2 hover:opacity-70"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{selectedValueRange !== 'all' && (
|
|
||||||
<span className="px-3 py-1 rounded-full text-sm font-medium" style={{
|
|
||||||
backgroundColor: 'var(--bg-tertiary)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
border: '1px solid var(--border)'
|
|
||||||
}}>
|
|
||||||
{valueRangeOptions.find(opt => opt.value === selectedValueRange)?.label}
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedValueRange('all')}
|
|
||||||
className="ml-2 hover:opacity-70"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedTCG('all');
|
|
||||||
setSelectedRarity('all');
|
|
||||||
setSelectedSet('all');
|
|
||||||
setSelectedValueRange('all');
|
|
||||||
}}
|
|
||||||
className="px-3 py-1 rounded-full text-sm font-medium hover:opacity-70" style={{
|
|
||||||
color: 'var(--text-accent)',
|
|
||||||
border: '1px solid var(--border)'
|
|
||||||
}}>
|
|
||||||
Clear All
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Cards Display */}
|
|
||||||
<div className="p-6 pb-64">
|
|
||||||
{cards.length === 0 ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<div className="text-6xl mb-4">🃏</div>
|
|
||||||
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
No cards found
|
|
||||||
</h3>
|
|
||||||
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Try adjusting your search or filters
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className={viewMode === 'grid' ? 'card-grid-container grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3 sm:gap-4 lg:gap-6 p-3 sm:p-4 lg:p-6 xl:p-8' : 'space-y-4 p-6'}>
|
|
||||||
{cards.map((card) => (
|
|
||||||
<CardItem
|
|
||||||
key={card.id}
|
|
||||||
card={card}
|
|
||||||
viewMode={viewMode}
|
|
||||||
isSelected={selectedCards.some(c => c.id === card.id)}
|
|
||||||
onToggleSelect={handleToggleSelect}
|
|
||||||
onAddToCollection={(card) => handleBulkAddToCollection([card])}
|
|
||||||
onAddToDeck={handleBulkAddToDeck}
|
|
||||||
onToggleFavorite={handleToggleFavorite}
|
|
||||||
isFavorited={favoritedCards.has(card.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Infinite Scroll Loading Indicator */}
|
|
||||||
{loadingMore && (
|
|
||||||
<div className="flex justify-center py-8">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2" style={{ borderColor: 'var(--text-accent)' }}></div>
|
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>Loading more cards...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Infinite Scroll Sentinel */}
|
|
||||||
<div id="infinite-scroll-sentinel" className="h-4 w-full" />
|
|
||||||
|
|
||||||
{/* Load More Button */}
|
|
||||||
{hasMore && !loadingMore && (
|
|
||||||
<div className="flex justify-center py-8">
|
|
||||||
<button
|
|
||||||
onClick={loadMoreCards}
|
|
||||||
className="px-6 py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-lg hover:from-blue-600 hover:to-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl"
|
|
||||||
>
|
|
||||||
Load More Cards
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* End of results indicator */}
|
|
||||||
{!hasMore && cards.length > 0 && (
|
|
||||||
<div className="flex justify-center py-8">
|
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>No more cards to load</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bulk Selection Toolbar */}
|
|
||||||
<BulkSelectionToolbar
|
|
||||||
selectedCards={selectedCards}
|
|
||||||
onClearSelection={handleClearSelection}
|
|
||||||
onAddToCollection={handleBulkAddToCollection}
|
|
||||||
onAddToDeck={handleBulkAddToDeck}
|
|
||||||
onMarkAsOwned={handleBulkMarkAsOwned}
|
|
||||||
onRemoveFromOwned={handleBulkRemoveFromOwned}
|
|
||||||
onBulkFavorite={handleBulkFavorite}
|
|
||||||
onBulkDelete={handleBulkDelete}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Collection Selection Modal */}
|
|
||||||
<CollectionSelectionModal
|
|
||||||
isOpen={showCollectionModal}
|
|
||||||
onClose={() => setShowCollectionModal(false)}
|
|
||||||
cards={cardsToAdd}
|
|
||||||
onAddToCollections={handleAddToCollections}
|
|
||||||
/>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue