457 lines
16 KiB
JavaScript
457 lines
16 KiB
JavaScript
|
|
import { useState, useEffect, useRef } from 'react';
|
||
|
|
import { useRouter } from 'next/router';
|
||
|
|
import Link from 'next/link';
|
||
|
|
import Layout from '../components/Layout';
|
||
|
|
import CardItem from '../components/CardItem';
|
||
|
|
import BulkSelectionToolbar from '../components/BulkSelectionToolbar';
|
||
|
|
import CollectionSelectionModal from '../components/CollectionSelectionModal';
|
||
|
|
import { ManaCost, ColorFilterSymbol } from '../components/ManaSymbols';
|
||
|
|
import ManaSymbolSettings from '../components/ManaSymbolSettings';
|
||
|
|
import { useAuth } from '../lib/use-auth';
|
||
|
|
|
||
|
|
export default function MyCards() {
|
||
|
|
const router = useRouter();
|
||
|
|
const { user, loading: authLoading } = useAuth();
|
||
|
|
|
||
|
|
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 });
|
||
|
|
|
||
|
|
// Redirect to login if not authenticated
|
||
|
|
useEffect(() => {
|
||
|
|
if (!authLoading && !user) {
|
||
|
|
router.push('/login');
|
||
|
|
}
|
||
|
|
}, [authLoading, user, router]);
|
||
|
|
|
||
|
|
// Fetch owned cards from database
|
||
|
|
const fetchCards = async (isLoadMore = false) => {
|
||
|
|
try {
|
||
|
|
if (isLoadMore) {
|
||
|
|
setLoadingMore(true);
|
||
|
|
loadingMoreRef.current = true;
|
||
|
|
} else {
|
||
|
|
setLoading(true);
|
||
|
|
setPagination(prev => ({ ...prev, page: 1 }));
|
||
|
|
}
|
||
|
|
|
||
|
|
const currentPage = isLoadMore ? pagination.page + 1 : 1;
|
||
|
|
|
||
|
|
// Build query parameters for owned cards only
|
||
|
|
const params = new URLSearchParams({
|
||
|
|
page: currentPage.toString(),
|
||
|
|
limit: pagination.limit.toString(),
|
||
|
|
...(searchQuery && { search: searchQuery }),
|
||
|
|
...(selectedTCG !== 'all' && { tcg: selectedTCG }),
|
||
|
|
...(selectedRarity !== 'all' && { rarity: selectedRarity }),
|
||
|
|
...(selectedSet !== 'all' && { set: selectedSet }),
|
||
|
|
...(selectedValueRange !== 'all' && { valueRange: selectedValueRange })
|
||
|
|
});
|
||
|
|
|
||
|
|
const token = localStorage.getItem('auth_token');
|
||
|
|
const headers = {
|
||
|
|
'Content-Type': 'application/json',
|
||
|
|
};
|
||
|
|
|
||
|
|
if (token) {
|
||
|
|
headers.Authorization = `Bearer ${token}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
const response = await fetch(`/api/cards/owned?${params}`, { headers });
|
||
|
|
|
||
|
|
if (response.ok) {
|
||
|
|
const data = await response.json();
|
||
|
|
|
||
|
|
if (isLoadMore) {
|
||
|
|
setCards(prev => [...prev, ...data.cards]);
|
||
|
|
setPagination(prev => ({ ...prev, page: currentPage }));
|
||
|
|
} else {
|
||
|
|
setCards(data.cards);
|
||
|
|
setPagination(data.pagination);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Update hasMore state
|
||
|
|
const newHasMore = currentPage < data.pagination.pages;
|
||
|
|
setHasMore(newHasMore);
|
||
|
|
hasMoreRef.current = newHasMore;
|
||
|
|
|
||
|
|
// Load filter options on first load
|
||
|
|
if (!isLoadMore && data.filters) {
|
||
|
|
setFilters(data.filters);
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
console.error('Failed to fetch owned cards');
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Error fetching owned cards:', error);
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
setLoadingMore(false);
|
||
|
|
loadingMoreRef.current = false;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// Load favorited cards
|
||
|
|
const loadFavoritedCards = async () => {
|
||
|
|
try {
|
||
|
|
const token = localStorage.getItem('auth_token');
|
||
|
|
const headers = {
|
||
|
|
'Content-Type': 'application/json',
|
||
|
|
};
|
||
|
|
|
||
|
|
if (token) {
|
||
|
|
headers.Authorization = `Bearer ${token}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
const response = await fetch('/api/favorites?type=card', { headers });
|
||
|
|
if (response.ok) {
|
||
|
|
const data = await response.json();
|
||
|
|
// Handle both array and object responses
|
||
|
|
const favorites = Array.isArray(data) ? data : (data.favorites || []);
|
||
|
|
setFavoritedCards(new Set(favorites.map(fav => fav.item_id)));
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Error loading favorited cards:', error);
|
||
|
|
// Set empty set on error to prevent crashes
|
||
|
|
setFavoritedCards(new Set());
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// Initial load
|
||
|
|
useEffect(() => {
|
||
|
|
if (user) {
|
||
|
|
fetchCards();
|
||
|
|
loadFavoritedCards();
|
||
|
|
}
|
||
|
|
}, [user, searchQuery, selectedTCG, selectedRarity, selectedSet, selectedValueRange]);
|
||
|
|
|
||
|
|
// Infinite scroll
|
||
|
|
useEffect(() => {
|
||
|
|
const observer = new IntersectionObserver(
|
||
|
|
(entries) => {
|
||
|
|
const target = entries[0];
|
||
|
|
if (target.isIntersecting && hasMoreRef.current && !loadingMoreRef.current) {
|
||
|
|
fetchCards(true);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
{ threshold: 0.1 }
|
||
|
|
);
|
||
|
|
|
||
|
|
const sentinel = document.getElementById('scroll-sentinel');
|
||
|
|
if (sentinel) {
|
||
|
|
observer.observe(sentinel);
|
||
|
|
}
|
||
|
|
|
||
|
|
return () => {
|
||
|
|
if (sentinel) {
|
||
|
|
observer.unobserve(sentinel);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
}, [cards]);
|
||
|
|
|
||
|
|
// Handle favorite toggle
|
||
|
|
const handleFavoriteToggle = async (cardId) => {
|
||
|
|
try {
|
||
|
|
const token = localStorage.getItem('auth_token');
|
||
|
|
const headers = {
|
||
|
|
'Content-Type': 'application/json',
|
||
|
|
};
|
||
|
|
|
||
|
|
if (token) {
|
||
|
|
headers.Authorization = `Bearer ${token}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
const isFavorited = favoritedCards.has(cardId);
|
||
|
|
|
||
|
|
if (isFavorited) {
|
||
|
|
// Remove from favorites
|
||
|
|
const response = await fetch('/api/favorites', {
|
||
|
|
method: 'DELETE',
|
||
|
|
headers,
|
||
|
|
body: JSON.stringify({
|
||
|
|
itemType: 'card',
|
||
|
|
itemId: cardId
|
||
|
|
})
|
||
|
|
});
|
||
|
|
|
||
|
|
if (response.ok) {
|
||
|
|
setFavoritedCards(prev => {
|
||
|
|
const newSet = new Set(prev);
|
||
|
|
newSet.delete(cardId);
|
||
|
|
return newSet;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// Add to favorites
|
||
|
|
const response = await fetch('/api/favorites', {
|
||
|
|
method: 'POST',
|
||
|
|
headers,
|
||
|
|
body: JSON.stringify({
|
||
|
|
itemType: 'card',
|
||
|
|
itemId: cardId
|
||
|
|
})
|
||
|
|
});
|
||
|
|
|
||
|
|
if (response.ok) {
|
||
|
|
setFavoritedCards(prev => {
|
||
|
|
const newSet = new Set(prev);
|
||
|
|
newSet.add(cardId);
|
||
|
|
return newSet;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Error toggling favorite:', error);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// Handle bulk actions
|
||
|
|
const handleAddToCollections = async (selectedCollectionIds) => {
|
||
|
|
// Implementation for adding cards to collections
|
||
|
|
console.log('Adding cards to collections:', selectedCollectionIds);
|
||
|
|
setShowCollectionModal(false);
|
||
|
|
setSelectedCards([]);
|
||
|
|
};
|
||
|
|
|
||
|
|
if (loading && cards.length === 0) {
|
||
|
|
return (
|
||
|
|
<Layout user={user}>
|
||
|
|
<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(--accent-ember)' }}></div>
|
||
|
|
</div>
|
||
|
|
</Layout>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Layout user={user}>
|
||
|
|
{/* 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)' }}>
|
||
|
|
My Cards
|
||
|
|
</h1>
|
||
|
|
<p className="text-base sm:text-lg" style={{ color: 'var(--text-secondary)' }}>
|
||
|
|
Browse and manage your owned trading cards
|
||
|
|
</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(--accent-ember)' : 'var(--bg-tertiary)',
|
||
|
|
color: viewMode === 'grid' ? 'white' : 'var(--text-primary)'
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<svg className="w-5 h-5" 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(--accent-ember)' : 'var(--bg-tertiary)',
|
||
|
|
color: viewMode === 'list' ? 'white' : 'var(--text-primary)'
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<svg className="w-5 h-5" 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>
|
||
|
|
|
||
|
|
{/* Filters */}
|
||
|
|
<div className="p-4 sm:p-6" style={{ backgroundColor: 'var(--bg-primary)' }}>
|
||
|
|
<div className="flex flex-wrap gap-4 mb-6">
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
placeholder="Search your cards..."
|
||
|
|
value={searchQuery}
|
||
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||
|
|
className="flex-1 min-w-[200px] px-4 py-2 rounded-xl border"
|
||
|
|
style={{
|
||
|
|
backgroundColor: 'var(--bg-secondary)',
|
||
|
|
borderColor: 'var(--border)',
|
||
|
|
color: 'var(--text-primary)'
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<select
|
||
|
|
value={selectedTCG}
|
||
|
|
onChange={(e) => setSelectedTCG(e.target.value)}
|
||
|
|
className="px-4 py-2 rounded-xl border"
|
||
|
|
style={{
|
||
|
|
backgroundColor: 'var(--bg-secondary)',
|
||
|
|
borderColor: 'var(--border)',
|
||
|
|
color: 'var(--text-primary)'
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<option value="all">All Games</option>
|
||
|
|
{filters.games.map(game => (
|
||
|
|
<option key={game} value={game}>{game}</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
|
||
|
|
<select
|
||
|
|
value={selectedRarity}
|
||
|
|
onChange={(e) => setSelectedRarity(e.target.value)}
|
||
|
|
className="px-4 py-2 rounded-xl border"
|
||
|
|
style={{
|
||
|
|
backgroundColor: 'var(--bg-secondary)',
|
||
|
|
borderColor: 'var(--border)',
|
||
|
|
color: 'var(--text-primary)'
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<option value="all">All Rarities</option>
|
||
|
|
{filters.rarities.map(rarity => (
|
||
|
|
<option key={rarity} value={rarity}>{rarity}</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Results Summary */}
|
||
|
|
<div className="flex items-center justify-between mb-4">
|
||
|
|
<p style={{ color: 'var(--text-secondary)' }}>
|
||
|
|
{pagination.total} owned cards found
|
||
|
|
</p>
|
||
|
|
{selectedCards.length > 0 && (
|
||
|
|
<p style={{ color: 'var(--text-primary)' }}>
|
||
|
|
{selectedCards.length} cards selected
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Bulk Selection Toolbar */}
|
||
|
|
{selectedCards.length > 0 && (
|
||
|
|
<BulkSelectionToolbar
|
||
|
|
selectedCount={selectedCards.length}
|
||
|
|
onAddToCollection={() => {
|
||
|
|
setCardsToAdd(selectedCards);
|
||
|
|
setShowCollectionModal(true);
|
||
|
|
}}
|
||
|
|
onClearSelection={() => setSelectedCards([])}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Cards Grid */}
|
||
|
|
<div className={`
|
||
|
|
${viewMode === 'grid'
|
||
|
|
? 'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4'
|
||
|
|
: 'space-y-4'
|
||
|
|
}
|
||
|
|
`}>
|
||
|
|
{cards.map((card) => (
|
||
|
|
<CardItem
|
||
|
|
key={card.id}
|
||
|
|
card={card}
|
||
|
|
viewMode={viewMode}
|
||
|
|
isSelected={selectedCards.includes(card.id)}
|
||
|
|
isFavorited={favoritedCards.has(card.id)}
|
||
|
|
onSelect={(cardId) => {
|
||
|
|
setSelectedCards(prev =>
|
||
|
|
prev.includes(cardId)
|
||
|
|
? prev.filter(id => id !== cardId)
|
||
|
|
: [...prev, cardId]
|
||
|
|
);
|
||
|
|
}}
|
||
|
|
onFavorite={(cardId) => {
|
||
|
|
handleFavoriteToggle(cardId);
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Loading More */}
|
||
|
|
{loadingMore && (
|
||
|
|
<div className="flex justify-center py-8">
|
||
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2" style={{ borderColor: 'var(--accent-ember)' }}></div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Infinite Scroll Sentinel */}
|
||
|
|
{hasMore && <div id="scroll-sentinel" className="h-4"></div>}
|
||
|
|
|
||
|
|
{/* No More Cards */}
|
||
|
|
{!hasMore && cards.length > 0 && (
|
||
|
|
<div className="text-center py-8">
|
||
|
|
<p style={{ color: 'var(--text-secondary)' }}>
|
||
|
|
You've reached the end of your collection!
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* No Cards Found */}
|
||
|
|
{cards.length === 0 && !loading && (
|
||
|
|
<div className="text-center py-20">
|
||
|
|
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl flex items-center justify-center" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||
|
|
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: 'var(--text-secondary)' }}>
|
||
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
||
|
|
</svg>
|
||
|
|
</div>
|
||
|
|
<h3 className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>No Owned Cards Found</h3>
|
||
|
|
<p className="mb-4" style={{ color: 'var(--text-secondary)' }}>
|
||
|
|
{searchQuery ? 'Try adjusting your search filters' : 'Start building your collection by browsing available cards'}
|
||
|
|
</p>
|
||
|
|
<Link href="/cards">
|
||
|
|
<button className="px-6 py-3 font-medium rounded-xl transition-all duration-200 hover:opacity-90" style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}>
|
||
|
|
Browse All Cards
|
||
|
|
</button>
|
||
|
|
</Link>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Collection Selection Modal */}
|
||
|
|
<CollectionSelectionModal
|
||
|
|
isOpen={showCollectionModal}
|
||
|
|
onClose={() => setShowCollectionModal(false)}
|
||
|
|
cards={cardsToAdd}
|
||
|
|
onAddToCollections={handleAddToCollections}
|
||
|
|
/>
|
||
|
|
</Layout>
|
||
|
|
);
|
||
|
|
}
|