diff --git a/src/components/Collection.tsx b/src/components/Collection.tsx index 398d7b1..690cd28 100644 --- a/src/components/Collection.tsx +++ b/src/components/Collection.tsx @@ -1,17 +1,23 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { Search, Loader2, Trash2, CheckCircle, XCircle, RefreshCw, Plus, Minus, X } from 'lucide-react'; import { Card } from '../types'; -import { getUserCollection, getCardsByIds, addCardToCollection } from '../services/api'; +import { getUserCollectionPaginated, getCardsByIds, addCardToCollection } from '../services/api'; import { useAuth } from '../contexts/AuthContext'; import { supabase } from '../lib/supabase'; import ConfirmModal from './ConfirmModal'; +const PAGE_SIZE = 50; + export default function Collection() { const { user } = useAuth(); const [searchQuery, setSearchQuery] = useState(''); const [collection, setCollection] = useState<{ card: Card; quantity: number }[]>([]); const [filteredCollection, setFilteredCollection] = useState<{ card: Card; quantity: number }[]>([]); const [isLoadingCollection, setIsLoadingCollection] = useState(true); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(false); + const [offset, setOffset] = useState(0); + const [totalCount, setTotalCount] = useState(0); const [hoveredCard, setHoveredCard] = useState(null); const [selectedCard, setSelectedCard] = useState<{ card: Card; quantity: number } | null>(null); const [cardFaceIndex, setCardFaceIndex] = useState>(new Map()); @@ -22,6 +28,7 @@ export default function Collection() { cardId: string; cardName: string; }>({ isOpen: false, cardId: '', cardName: '' }); + const observerTarget = useRef(null); // Helper function to check if a card has an actual back face (not adventure/split/etc) const isDoubleFaced = (card: Card) => { @@ -72,26 +79,33 @@ export default function Collection() { try { setIsLoadingCollection(true); - // Get collection from Supabase (returns Map) - const collectionMap = await getUserCollection(user.id); + setOffset(0); + setCollection([]); - if (collectionMap.size === 0) { + // Get paginated collection from Supabase + const result = await getUserCollectionPaginated(user.id, PAGE_SIZE, 0); + setTotalCount(result.totalCount); + setHasMore(result.hasMore); + + if (result.items.size === 0) { setCollection([]); + setFilteredCollection([]); return; } - // Get the actual card data from Scryfall for all cards in collection - const cardIds = Array.from(collectionMap.keys()); + // Get the actual card data from Scryfall for all cards in this page + const cardIds = Array.from(result.items.keys()); const cards = await getCardsByIds(cardIds); // Combine card data with quantities const collectionWithCards = cards.map(card => ({ card, - quantity: collectionMap.get(card.id) || 0, + quantity: result.items.get(card.id) || 0, })); setCollection(collectionWithCards); setFilteredCollection(collectionWithCards); + setOffset(PAGE_SIZE); } catch (error) { console.error('Error loading collection:', error); setSnackbar({ message: 'Failed to load collection', type: 'error' }); @@ -103,6 +117,64 @@ export default function Collection() { loadCollection(); }, [user]); + // Load more cards for infinite scroll + const loadMoreCards = useCallback(async () => { + if (!user || isLoadingMore || !hasMore) return; + + try { + setIsLoadingMore(true); + + // Get next page of collection + const result = await getUserCollectionPaginated(user.id, PAGE_SIZE, offset); + setHasMore(result.hasMore); + + if (result.items.size === 0) { + return; + } + + // Get card data from Scryfall + const cardIds = Array.from(result.items.keys()); + const cards = await getCardsByIds(cardIds); + + // Combine card data with quantities + const newCards = cards.map(card => ({ + card, + quantity: result.items.get(card.id) || 0, + })); + + setCollection(prev => [...prev, ...newCards]); + setOffset(prev => prev + PAGE_SIZE); + } catch (error) { + console.error('Error loading more cards:', error); + setSnackbar({ message: 'Failed to load more cards', type: 'error' }); + } finally { + setIsLoadingMore(false); + } + }, [user, offset, hasMore, isLoadingMore]); + + // Intersection Observer for infinite scroll + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasMore && !isLoadingMore) { + loadMoreCards(); + } + }, + { threshold: 0.1 } + ); + + const currentTarget = observerTarget.current; + if (currentTarget) { + observer.observe(currentTarget); + } + + return () => { + if (currentTarget) { + observer.unobserve(currentTarget); + } + }; + }, [hasMore, isLoadingMore, loadMoreCards]); + // Filter collection based on search query useEffect(() => { if (!searchQuery.trim()) { @@ -209,9 +281,21 @@ export default function Collection() { {/* Collection */}
-

- {searchQuery ? `Found ${filteredCollection.length} card(s)` : `My Cards (${collection.length} unique, ${collection.reduce((acc, c) => acc + c.quantity, 0)} total)`} -

+
+

+ {searchQuery ? `Found ${filteredCollection.length} card(s)` : `My Cards (${collection.length} unique, ${collection.reduce((acc, c) => acc + c.quantity, 0)} total)`} +

+ {/* Collection Value Summary */} +
+
Total Collection Value
+
+ ${(searchQuery ? filteredCollection : collection).reduce((total, { card, quantity }) => { + const price = card.prices?.usd ? parseFloat(card.prices.usd) : 0; + return total + (price * quantity); + }, 0).toFixed(2)} +
+
+
{isLoadingCollection ? (
@@ -255,6 +339,12 @@ export default function Collection() {
x{quantity}
+ {/* Price badge */} + {card.prices?.usd && ( +
+ ${card.prices.usd} +
+ )} {/* Flip button for double-faced cards */} {isMultiFaced && (
diff --git a/src/components/Community.tsx b/src/components/Community.tsx index ca8b33f..92c1593 100644 --- a/src/components/Community.tsx +++ b/src/components/Community.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { Search, Globe, Users, Eye, ArrowLeftRight, Loader2, Clock, History, UserPlus, UserMinus, Check, X, Send, Settings, Save, ChevronLeft, RefreshCw, Plus, Minus } from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; import { useToast } from '../contexts/ToastContext'; @@ -23,7 +23,7 @@ import { Trade, TradeItem, } from '../services/tradesService'; -import { getUserCollection, getCardsByIds } from '../services/api'; +import { getUserCollection, getUserCollectionPaginated, getCardsByIds } from '../services/api'; import { Card } from '../types'; import TradeCreator from './TradeCreator'; import TradeDetail from './TradeDetail'; @@ -50,6 +50,8 @@ const VISIBILITY_OPTIONS = [ { value: 'private', label: 'Private', description: 'Only you' }, ] as const; +const PAGE_SIZE = 50; + export default function Community() { const { user } = useAuth(); const toast = useToast(); @@ -62,11 +64,16 @@ export default function Community() { const [selectedUser, setSelectedUser] = useState(null); const [selectedUserCollection, setSelectedUserCollection] = useState([]); const [loadingCollection, setLoadingCollection] = useState(false); + const [isLoadingMoreUserCards, setIsLoadingMoreUserCards] = useState(false); + const [hasMoreUserCards, setHasMoreUserCards] = useState(false); + const [userCollectionOffset, setUserCollectionOffset] = useState(0); + const [userCollectionTotalCount, setUserCollectionTotalCount] = useState(0); const [showTradeCreator, setShowTradeCreator] = useState(false); const [userCollectionSearch, setUserCollectionSearch] = useState(''); const [hoveredUserCard, setHoveredUserCard] = useState(null); const [selectedUserCard, setSelectedUserCard] = useState(null); const [userCardFaceIndex, setUserCardFaceIndex] = useState>(new Map()); + const userCollectionObserverTarget = useRef(null); // Friends state const [friendsSubTab, setFriendsSubTab] = useState('list'); @@ -298,18 +305,25 @@ export default function Community() { const loadUserCollection = async (userId: string) => { setLoadingCollection(true); + setSelectedUserCollection([]); + setUserCollectionOffset(0); try { - const collectionMap = await getUserCollection(userId); - if (collectionMap.size === 0) { + const result = await getUserCollectionPaginated(userId, PAGE_SIZE, 0); + setUserCollectionTotalCount(result.totalCount); + setHasMoreUserCards(result.hasMore); + + if (result.items.size === 0) { setSelectedUserCollection([]); return; } - const cardIds = Array.from(collectionMap.keys()); + + const cardIds = Array.from(result.items.keys()); const cards = await getCardsByIds(cardIds); setSelectedUserCollection(cards.map((card) => ({ card, - quantity: collectionMap.get(card.id) || 0, + quantity: result.items.get(card.id) || 0, }))); + setUserCollectionOffset(PAGE_SIZE); } catch (error) { console.error('Error loading collection:', error); setSelectedUserCollection([]); @@ -318,6 +332,66 @@ export default function Community() { } }; + // Load more cards for infinite scroll in user collection view + const loadMoreUserCards = useCallback(async () => { + if (!selectedUser || isLoadingMoreUserCards || !hasMoreUserCards) return; + + try { + setIsLoadingMoreUserCards(true); + + const result = await getUserCollectionPaginated( + selectedUser.id, + PAGE_SIZE, + userCollectionOffset + ); + setHasMoreUserCards(result.hasMore); + + if (result.items.size === 0) { + return; + } + + const cardIds = Array.from(result.items.keys()); + const cards = await getCardsByIds(cardIds); + + const newCards = cards.map(card => ({ + card, + quantity: result.items.get(card.id) || 0, + })); + + setSelectedUserCollection(prev => [...prev, ...newCards]); + setUserCollectionOffset(prev => prev + PAGE_SIZE); + } catch (error) { + console.error('Error loading more cards:', error); + } finally { + setIsLoadingMoreUserCards(false); + } + }, [selectedUser, userCollectionOffset, hasMoreUserCards, isLoadingMoreUserCards]); + + // Intersection Observer for infinite scroll in user collection view + useEffect(() => { + if (!selectedUser) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasMoreUserCards && !isLoadingMoreUserCards) { + loadMoreUserCards(); + } + }, + { threshold: 0.1 } + ); + + const currentTarget = userCollectionObserverTarget.current; + if (currentTarget) { + observer.observe(currentTarget); + } + + return () => { + if (currentTarget) { + observer.unobserve(currentTarget); + } + }; + }, [selectedUser, hasMoreUserCards, isLoadingMoreUserCards, loadMoreUserCards]); + // ============ FRIENDS FUNCTIONS ============ const loadFriendsData = async () => { if (!user) return; @@ -617,12 +691,24 @@ export default function Community() { {/* Collection */}
-

- {userCollectionSearch - ? `Found ${filteredUserCollection.length} card(s)` - : `Cards (${selectedUserCollection.length} unique, ${selectedUserCollection.reduce((acc, c) => acc + c.quantity, 0)} total)` - } -

+
+

+ {userCollectionSearch + ? `Found ${filteredUserCollection.length} card(s)` + : `Cards (${selectedUserCollection.length} unique, ${selectedUserCollection.reduce((acc, c) => acc + c.quantity, 0)} total)` + } +

+ {/* Collection Value Summary */} +
+
Total Collection Value
+
+ ${(userCollectionSearch ? filteredUserCollection : selectedUserCollection).reduce((total, { card, quantity }) => { + const price = card.prices?.usd ? parseFloat(card.prices.usd) : 0; + return total + (price * quantity); + }, 0).toFixed(2)} +
+
+
{loadingCollection ? (
@@ -665,6 +751,12 @@ export default function Community() {
x{quantity}
+ {/* Price badge */} + {card.prices?.usd && ( +
+ ${card.prices.usd} +
+ )} {/* Flip button for double-faced cards */} {isMultiFaced && (
diff --git a/src/services/api.ts b/src/services/api.ts index 65730f6..d5e947c 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -75,6 +75,55 @@ export const getUserCollection = async (userId: string): Promise; // card_id -> quantity + totalCount: number; + hasMore: boolean; +} + +export const getUserCollectionPaginated = async ( + userId: string, + pageSize: number = 50, + offset: number = 0 +): Promise => { + // First, get the total count + const { count: totalCount, error: countError } = await supabase + .from('collections') + .select('*', { count: 'exact', head: true }) + .eq('user_id', userId); + + if (countError) { + console.error('Error counting user collection:', countError); + throw countError; + } + + // Then get the paginated data + const { data, error } = await supabase + .from('collections') + .select('card_id, quantity') + .eq('user_id', userId) + .order('created_at', { ascending: false }) + .range(offset, offset + pageSize - 1); + + if (error) { + console.error('Error fetching user collection:', error); + throw error; + } + + // Create a map of card_id to quantity for easy lookup + const collectionMap = new Map(); + data?.forEach((item) => { + collectionMap.set(item.card_id, item.quantity); + }); + + return { + items: collectionMap, + totalCount: totalCount || 0, + hasMore: offset + pageSize < (totalCount || 0), + }; +}; + export const addCardToCollection = async ( userId: string, cardId: string,