diff --git a/src/App.tsx b/src/App.tsx index 4e714cd..2f95bcb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,105 +1,99 @@ import React, { useState } from 'react'; - import DeckManager from './components/DeckManager'; - import DeckList from './components/DeckList'; - import LoginForm from './components/LoginForm'; - import Navigation from './components/Navigation'; - import Collection from './components/Collection'; - import DeckEditor from './components/DeckEditor'; - import Profile from './components/Profile'; - import CardSearch from './components/CardSearch'; - import LifeCounter from './components/LifeCounter'; - import Friends from './components/Friends'; - import Trades from './components/Trades'; - import Community from './components/Community'; - import PWAInstallPrompt from './components/PWAInstallPrompt'; - import { AuthProvider, useAuth } from './contexts/AuthContext'; +import DeckManager from './components/DeckManager'; +import DeckList from './components/DeckList'; +import LoginForm from './components/LoginForm'; +import Navigation from './components/Navigation'; +import Collection from './components/Collection'; +import DeckEditor from './components/DeckEditor'; +import CardSearch from './components/CardSearch'; +import LifeCounter from './components/LifeCounter'; +import Community from './components/Community'; +import PWAInstallPrompt from './components/PWAInstallPrompt'; +import { AuthProvider, useAuth } from './contexts/AuthContext'; +import { ToastProvider } from './contexts/ToastContext'; - type Page = 'home' | 'deck' | 'login' | 'collection' | 'edit-deck' | 'profile' | 'search' | 'life-counter' | 'friends' | 'trades' | 'community'; +type Page = 'home' | 'deck' | 'login' | 'collection' | 'edit-deck' | 'search' | 'life-counter' | 'community'; - function AppContent() { - const [currentPage, setCurrentPage] = useState('home'); - const [selectedDeckId, setSelectedDeckId] = useState(null); - const { user, loading } = useAuth(); +function AppContent() { + const [currentPage, setCurrentPage] = useState('home'); + const [selectedDeckId, setSelectedDeckId] = useState(null); + const { user, loading } = useAuth(); - if (loading) { + if (loading) { + return ( +
+
+
+ ); + } + + if (!user && currentPage !== 'login') { + return ; + } + + const handleDeckEdit = (deckId: string) => { + setSelectedDeckId(deckId); + setCurrentPage('edit-deck'); + }; + + const renderPage = () => { + switch (currentPage) { + case 'home': return ( -
-
+
+
+

My Decks

+ setCurrentPage('deck')} + /> +
); - } - - if (!user && currentPage !== 'login') { + case 'deck': + return ; + case 'edit-deck': + return selectedDeckId ? ( + { + setSelectedDeckId(null); + setCurrentPage('home'); + }} + /> + ) : null; + case 'collection': + return ; + case 'search': + return ; + case 'life-counter': + return ; + case 'community': + return ; + case 'login': return ; - } - - const handleDeckEdit = (deckId: string) => { - setSelectedDeckId(deckId); - setCurrentPage('edit-deck'); - }; - - const renderPage = () => { - switch (currentPage) { - case 'home': - return ( -
-
-

My Decks

- setCurrentPage('deck')} - /> -
-
- ); - case 'deck': - return ; - case 'edit-deck': - return selectedDeckId ? ( - { - setSelectedDeckId(null); - setCurrentPage('home'); - }} - /> - ) : null; - case 'collection': - return ; - case 'profile': - return ; - case 'search': - return ; - case 'life-counter': - return ; - case 'friends': - return ; - case 'trades': - return ; - case 'community': - return ; - case 'login': - return ; - default: - return null; - } - }; - - return ( -
- - {renderPage()} - -
- ); + default: + return null; } + }; - function App() { - return ( - - - - ); - } + return ( +
+ + {renderPage()} + +
+ ); +} - export default App; +function App() { + return ( + + + + + + ); +} + +export default App; diff --git a/src/components/Community.tsx b/src/components/Community.tsx index d6cd93a..19436b8 100644 --- a/src/components/Community.tsx +++ b/src/components/Community.tsx @@ -1,11 +1,32 @@ import React, { useState, useEffect } from 'react'; -import { Search, Globe, Users, Eye, ArrowLeftRight, Loader2 } from 'lucide-react'; +import { Search, Globe, Users, Eye, ArrowLeftRight, Loader2, Clock, History, UserPlus, UserMinus, Check, X, Send, Settings, Save } from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; +import { useToast } from '../contexts/ToastContext'; import { supabase } from '../lib/supabase'; -import { getFriends, Friend } from '../services/friendsService'; +import { + getFriends, + getPendingRequests, + getSentRequests, + searchUsers, + sendFriendRequest, + acceptFriendRequest, + declineFriendRequest, + removeFriend, + Friend, +} from '../services/friendsService'; +import { + getTrades, + getTradeHistory, + acceptTrade, + declineTrade, + cancelTrade, + Trade, + TradeItem, +} from '../services/tradesService'; import { getUserCollection, getCardsByIds } from '../services/api'; import { Card } from '../types'; import TradeCreator from './TradeCreator'; +import ConfirmModal from './ConfirmModal'; interface UserProfile { id: string; @@ -18,44 +39,85 @@ interface CollectionItem { quantity: number; } -type Tab = 'public' | 'friends'; +type Tab = 'browse' | 'friends' | 'trades' | 'profile'; +type FriendsSubTab = 'list' | 'requests' | 'search'; +type TradesSubTab = 'pending' | 'history'; + +const VISIBILITY_OPTIONS = [ + { value: 'public', label: 'Public', description: 'Anyone can view your collection' }, + { value: 'friends', label: 'Friends Only', description: 'Only friends can view' }, + { value: 'private', label: 'Private', description: 'Only you can view' }, +] as const; export default function Community() { const { user } = useAuth(); - const [activeTab, setActiveTab] = useState('public'); - const [searchQuery, setSearchQuery] = useState(''); + const toast = useToast(); + const [activeTab, setActiveTab] = useState('browse'); + const [loading, setLoading] = useState(true); + + // Browse state + const [browseSearch, setBrowseSearch] = useState(''); const [publicUsers, setPublicUsers] = useState([]); - const [friends, setFriends] = useState([]); const [selectedUser, setSelectedUser] = useState(null); const [selectedUserCollection, setSelectedUserCollection] = useState([]); - const [loading, setLoading] = useState(true); const [loadingCollection, setLoadingCollection] = useState(false); const [showTradeCreator, setShowTradeCreator] = useState(false); + // Friends state + const [friendsSubTab, setFriendsSubTab] = useState('list'); + const [friends, setFriends] = useState([]); + const [pendingRequests, setPendingRequests] = useState([]); + const [sentRequests, setSentRequests] = useState([]); + const [friendSearch, setFriendSearch] = useState(''); + const [friendSearchResults, setFriendSearchResults] = useState<{ id: string; username: string | null }[]>([]); + const [searchingFriends, setSearchingFriends] = useState(false); + + // Trades state + const [tradesSubTab, setTradesSubTab] = useState('pending'); + const [pendingTrades, setPendingTrades] = useState([]); + const [tradeHistory, setTradeHistory] = useState([]); + const [tradeCardDetails, setTradeCardDetails] = useState>(new Map()); + const [processingTradeId, setProcessingTradeId] = useState(null); + + // Profile state + const [username, setUsername] = useState(''); + const [collectionVisibility, setCollectionVisibility] = useState<'public' | 'friends' | 'private'>('private'); + const [savingProfile, setSavingProfile] = useState(false); + + // Confirm modal state + const [confirmModal, setConfirmModal] = useState<{ + isOpen: boolean; + title: string; + message: string; + onConfirm: () => void; + variant: 'danger' | 'warning' | 'info' | 'success'; + }>({ isOpen: false, title: '', message: '', onConfirm: () => {}, variant: 'danger' }); + useEffect(() => { if (user) { - loadData(); + loadAllData(); } }, [user]); - const loadData = async () => { + const loadAllData = async () => { if (!user) return; setLoading(true); try { - const [publicData, friendsData] = await Promise.all([ + await Promise.all([ loadPublicUsers(), - getFriends(user.id), + loadFriendsData(), + loadTradesData(), + loadProfile(), ]); - setPublicUsers(publicData); - setFriends(friendsData); } catch (error) { - console.error('Error loading community data:', error); + console.error('Error loading data:', error); } finally { setLoading(false); } }; - const loadPublicUsers = async (): Promise => { + // ============ BROWSE FUNCTIONS ============ + const loadPublicUsers = async () => { const { data, error } = await supabase .from('profiles') .select('id, username, collection_visibility') @@ -63,29 +125,25 @@ export default function Community() { .neq('id', user?.id) .order('username'); - if (error) throw error; - return data || []; + if (!error && data) { + setPublicUsers(data); + } }; const loadUserCollection = async (userId: string) => { setLoadingCollection(true); try { const collectionMap = await getUserCollection(userId); - if (collectionMap.size === 0) { setSelectedUserCollection([]); return; } - const cardIds = Array.from(collectionMap.keys()); const cards = await getCardsByIds(cardIds); - - const collectionWithCards = cards.map((card) => ({ + setSelectedUserCollection(cards.map((card) => ({ card, quantity: collectionMap.get(card.id) || 0, - })); - - setSelectedUserCollection(collectionWithCards); + }))); } catch (error) { console.error('Error loading collection:', error); setSelectedUserCollection([]); @@ -94,24 +152,227 @@ export default function Community() { } }; - const handleViewCollection = async (userProfile: UserProfile) => { - setSelectedUser(userProfile); - await loadUserCollection(userProfile.id); + // ============ FRIENDS FUNCTIONS ============ + const loadFriendsData = async () => { + if (!user) return; + const [friendsData, pendingData, sentData] = await Promise.all([ + getFriends(user.id), + getPendingRequests(user.id), + getSentRequests(user.id), + ]); + setFriends(friendsData); + setPendingRequests(pendingData); + setSentRequests(sentData); }; - const filteredPublicUsers = publicUsers.filter( - (u) => !searchQuery || u.username?.toLowerCase().includes(searchQuery.toLowerCase()) - ); + const handleSearchFriends = async () => { + if (!user || friendSearch.trim().length < 2) return; + setSearchingFriends(true); + try { + const results = await searchUsers(friendSearch, user.id); + setFriendSearchResults(results || []); + } catch (error) { + console.error('Error searching users:', error); + } finally { + setSearchingFriends(false); + } + }; - const friendProfiles: UserProfile[] = friends.map((f) => ({ - id: f.id, - username: f.username, - collection_visibility: 'friends' as const, - })); + const handleSendRequest = async (addresseeId: string) => { + if (!user) return; + try { + await sendFriendRequest(user.id, addresseeId); + setFriendSearchResults((prev) => prev.filter((u) => u.id !== addresseeId)); + await loadFriendsData(); + toast.success('Friend request sent!'); + } catch (error) { + toast.error('Failed to send friend request'); + } + }; - const filteredFriends = friendProfiles.filter( - (f) => !searchQuery || f.username?.toLowerCase().includes(searchQuery.toLowerCase()) - ); + const handleAcceptRequest = async (friendshipId: string) => { + try { + await acceptFriendRequest(friendshipId); + await loadFriendsData(); + toast.success('Friend request accepted!'); + } catch (error) { + toast.error('Failed to accept request'); + } + }; + + const handleDeclineRequest = async (friendshipId: string) => { + try { + await declineFriendRequest(friendshipId); + await loadFriendsData(); + toast.info('Friend request declined'); + } catch (error) { + toast.error('Failed to decline request'); + } + }; + + const handleRemoveFriend = (friendshipId: string, friendName: string) => { + setConfirmModal({ + isOpen: true, + title: 'Remove Friend', + message: `Are you sure you want to remove ${friendName} from your friends?`, + variant: 'danger', + onConfirm: async () => { + try { + await removeFriend(friendshipId); + await loadFriendsData(); + toast.success('Friend removed'); + } catch (error) { + toast.error('Failed to remove friend'); + } + }, + }); + }; + + const isAlreadyFriendOrPending = (userId: string) => { + return friends.some((f) => f.id === userId) || + pendingRequests.some((f) => f.id === userId) || + sentRequests.some((f) => f.id === userId); + }; + + // ============ TRADES FUNCTIONS ============ + const loadTradesData = async () => { + if (!user) return; + const [pending, history] = await Promise.all([ + getTrades(user.id).then((trades) => trades.filter((t) => t.status === 'pending')), + getTradeHistory(user.id), + ]); + + const allCardIds = new Set(); + [...pending, ...history].forEach((trade) => { + trade.items?.forEach((item) => allCardIds.add(item.card_id)); + }); + + if (allCardIds.size > 0) { + const cards = await getCardsByIds(Array.from(allCardIds)); + const cardMap = new Map(); + cards.forEach((card) => cardMap.set(card.id, card)); + setTradeCardDetails(cardMap); + } + + setPendingTrades(pending); + setTradeHistory(history); + }; + + const handleAcceptTrade = async (tradeId: string) => { + setProcessingTradeId(tradeId); + try { + const success = await acceptTrade(tradeId); + if (success) { + await loadTradesData(); + toast.success('Trade accepted! Cards have been exchanged.'); + } else { + toast.error('Failed to execute trade. Check your collection.'); + } + } catch (error) { + toast.error('Error accepting trade'); + } finally { + setProcessingTradeId(null); + } + }; + + const handleDeclineTrade = async (tradeId: string) => { + setProcessingTradeId(tradeId); + try { + await declineTrade(tradeId); + await loadTradesData(); + toast.info('Trade declined'); + } catch (error) { + toast.error('Error declining trade'); + } finally { + setProcessingTradeId(null); + } + }; + + const handleCancelTrade = (tradeId: string) => { + setConfirmModal({ + isOpen: true, + title: 'Cancel Trade', + message: 'Are you sure you want to cancel this trade offer?', + variant: 'warning', + onConfirm: async () => { + setProcessingTradeId(tradeId); + try { + await cancelTrade(tradeId); + await loadTradesData(); + toast.info('Trade cancelled'); + } catch (error) { + toast.error('Error cancelling trade'); + } finally { + setProcessingTradeId(null); + } + }, + }); + }; + + // ============ PROFILE FUNCTIONS ============ + const loadProfile = async () => { + if (!user) return; + const { data } = await supabase + .from('profiles') + .select('username, collection_visibility') + .eq('id', user.id) + .single(); + + if (data) { + setUsername(data.username || ''); + setCollectionVisibility(data.collection_visibility || 'private'); + } + }; + + const handleSaveProfile = async () => { + if (!user) return; + setSavingProfile(true); + try { + const { error } = await supabase + .from('profiles') + .upsert({ + id: user.id, + username, + collection_visibility: collectionVisibility, + updated_at: new Date(), + }); + + if (error) throw error; + toast.success('Profile updated!'); + } catch (error) { + toast.error('Failed to update profile'); + } finally { + setSavingProfile(false); + } + }; + + // ============ RENDER HELPERS ============ + const renderTradeItems = (items: TradeItem[] | undefined, ownerId: string, label: string) => { + const ownerItems = items?.filter((i) => i.owner_id === ownerId) || []; + if (ownerItems.length === 0) { + return
{label}: Nothing (gift)
; + } + + return ( +
+
{label}:
+
+ {ownerItems.map((item) => { + const card = tradeCardDetails.get(item.card_id); + return ( +
+ {card?.image_uris?.small && ( + {card.name} + )} + {card?.name || item.card_id} + {item.quantity > 1 && x{item.quantity}} +
+ ); + })} +
+
+ ); + }; if (loading) { return ( @@ -121,22 +382,18 @@ export default function Community() { ); } - // If viewing a user's collection + // Viewing a user's collection if (selectedUser) { return (
- {/* Header */}

{selectedUser.username}'s Collection

@@ -149,7 +406,6 @@ export default function Community() {
- {/* Collection Grid */} {loadingCollection ? (
@@ -159,16 +415,10 @@ export default function Community() { ) : (
{selectedUserCollection.map(({ card, quantity }) => ( -
+
- {card.name} -
- x{quantity} -
+ {card.name} +
x{quantity}
{card.name}
@@ -176,7 +426,6 @@ export default function Community() {
)} - {/* Trade Creator Modal */} {showTradeCreator && ( setShowTradeCreator(false)} onTradeCreated={() => { setShowTradeCreator(false); - alert('Trade proposal sent!'); + toast.success('Trade proposal sent!'); }} /> )} @@ -194,112 +443,354 @@ export default function Community() { ); } + const filteredPublicUsers = publicUsers.filter( + (u) => !browseSearch || u.username?.toLowerCase().includes(browseSearch.toLowerCase()) + ); + + const friendProfiles: UserProfile[] = friends.map((f) => ({ + id: f.id, + username: f.username, + collection_visibility: 'friends', + })); + return (

Community

- {/* Search */} -
- - setSearchQuery(e.target.value)} - placeholder="Search users..." - className="w-full pl-10 pr-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" - /> + {/* Main Tabs */} +
+ {[ + { id: 'browse' as Tab, label: 'Browse', icon: Globe }, + { id: 'friends' as Tab, label: `Friends (${friends.length})`, icon: Users }, + { id: 'trades' as Tab, label: `Trades (${pendingTrades.length})`, icon: ArrowLeftRight }, + { id: 'profile' as Tab, label: 'Profile', icon: Settings }, + ].map((tab) => ( + + ))}
- {/* Tabs */} -
- - -
+ {/* ============ BROWSE TAB ============ */} + {activeTab === 'browse' && ( +
+
+ + setBrowseSearch(e.target.value)} + placeholder="Search public collections..." + className="w-full pl-10 pr-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500" + /> +
- {/* User List */} -
- {activeTab === 'public' && ( - <> - {filteredPublicUsers.length === 0 ? ( -

- No public collections found -

- ) : ( - filteredPublicUsers.map((userProfile) => ( -
+
+ + +
+ + {filteredPublicUsers.length === 0 ? ( +

No public collections found

+ ) : ( +
+ {filteredPublicUsers.map((userProfile) => ( +
{userProfile.username || 'Unknown'}
- )) - )} - - )} + ))} +
+ )} +
+ )} - {activeTab === 'friends' && ( - <> - {filteredFriends.length === 0 ? ( -

- {friends.length === 0 - ? 'Add friends to see their collections' - : 'No friends match your search'} -

- ) : ( - filteredFriends.map((friend) => ( -
-
- + {/* ============ FRIENDS TAB ============ */} + {activeTab === 'friends' && ( +
+
+ {(['list', 'requests', 'search'] as FriendsSubTab[]).map((tab) => ( + + ))} +
+ + {friendsSubTab === 'list' && ( +
+ {friends.length === 0 ? ( +

No friends yet. Search for users to add them!

+ ) : ( + friends.map((friend) => ( +
{friend.username || 'Unknown'} +
+ + +
- + )) + )} +
+ )} + + {friendsSubTab === 'requests' && ( +
+
+

Received

+ {pendingRequests.length === 0 ? ( +

No pending requests

+ ) : ( +
+ {pendingRequests.map((req) => ( +
+ {req.username || 'Unknown'} +
+ + +
+
+ ))} +
+ )} +
+
+

Sent

+ {sentRequests.length === 0 ? ( +

No sent requests

+ ) : ( +
+ {sentRequests.map((req) => ( +
+
+ + {req.username || 'Unknown'} +
+ Pending +
+ ))} +
+ )} +
+
+ )} + + {friendsSubTab === 'search' && ( +
+
+ setFriendSearch(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearchFriends()} + placeholder="Search by username..." + className="flex-1 px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500" + /> + +
+ {friendSearchResults.length > 0 && ( +
+ {friendSearchResults.map((result) => ( +
+ {result.username || 'Unknown'} + {isAlreadyFriendOrPending(result.id) ? ( + Already connected + ) : ( + + )} +
+ ))}
- )) - )} - - )} -
+ )} +
+ )} +
+ )} + + {/* ============ TRADES TAB ============ */} + {activeTab === 'trades' && ( +
+
+ + +
+ + {(tradesSubTab === 'pending' ? pendingTrades : tradeHistory).length === 0 ? ( +

+ {tradesSubTab === 'pending' ? 'No pending trades' : 'No trade history'} +

+ ) : ( +
+ {(tradesSubTab === 'pending' ? pendingTrades : tradeHistory).map((trade) => { + const isSender = trade.sender_id === user?.id; + const otherUser = isSender ? trade.receiver : trade.sender; + const statusColor = trade.status === 'accepted' ? 'text-green-400' : trade.status === 'declined' ? 'text-red-400' : trade.status === 'cancelled' ? 'text-gray-400' : 'text-yellow-400'; + + return ( +
+
+
+ + {isSender ? `To: ${otherUser?.username}` : `From: ${otherUser?.username}`} +
+ {trade.status} +
+ +
+ {renderTradeItems(trade.items, trade.sender_id, isSender ? 'You give' : 'They give')} + {renderTradeItems(trade.items, trade.receiver_id, isSender ? 'You receive' : 'They receive')} +
+ + {trade.message &&
Message: {trade.message}
} + + {tradesSubTab === 'pending' && ( +
+ {isSender ? ( + + ) : ( + <> + + + + )} +
+ )} + +
{new Date(trade.created_at || '').toLocaleDateString()}
+
+ ); + })} +
+ )} +
+ )} + + {/* ============ PROFILE TAB ============ */} + {activeTab === 'profile' && ( +
+
+ + setUsername(e.target.value)} + className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500" + placeholder="Enter your username" + /> +
+ +
+ +
+ {VISIBILITY_OPTIONS.map((option) => ( + + ))} +
+
+ + +
+ )}
+ + setConfirmModal({ ...confirmModal, isOpen: false })} + onConfirm={confirmModal.onConfirm} + title={confirmModal.title} + message={confirmModal.message} + variant={confirmModal.variant} + />
); } diff --git a/src/components/Friends.tsx b/src/components/Friends.tsx deleted file mode 100644 index 0b3a585..0000000 --- a/src/components/Friends.tsx +++ /dev/null @@ -1,312 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { Search, UserPlus, UserMinus, Check, X, Users, Clock, Send } from 'lucide-react'; -import { useAuth } from '../contexts/AuthContext'; -import { - getFriends, - getPendingRequests, - getSentRequests, - searchUsers, - sendFriendRequest, - acceptFriendRequest, - declineFriendRequest, - removeFriend, - Friend, -} from '../services/friendsService'; - -type Tab = 'friends' | 'requests' | 'search'; - -export default function Friends() { - const { user } = useAuth(); - const [activeTab, setActiveTab] = useState('friends'); - const [friends, setFriends] = useState([]); - const [pendingRequests, setPendingRequests] = useState([]); - const [sentRequests, setSentRequests] = useState([]); - const [searchQuery, setSearchQuery] = useState(''); - const [searchResults, setSearchResults] = useState<{ id: string; username: string | null }[]>([]); - const [loading, setLoading] = useState(true); - const [searching, setSearching] = useState(false); - - useEffect(() => { - if (user) { - loadFriendsData(); - } - }, [user]); - - const loadFriendsData = async () => { - if (!user) return; - setLoading(true); - try { - const [friendsData, pendingData, sentData] = await Promise.all([ - getFriends(user.id), - getPendingRequests(user.id), - getSentRequests(user.id), - ]); - setFriends(friendsData); - setPendingRequests(pendingData); - setSentRequests(sentData); - } catch (error) { - console.error('Error loading friends:', error); - } finally { - setLoading(false); - } - }; - - const handleSearch = async () => { - if (!user || searchQuery.trim().length < 2) return; - setSearching(true); - try { - const results = await searchUsers(searchQuery, user.id); - setSearchResults(results || []); - } catch (error) { - console.error('Error searching users:', error); - } finally { - setSearching(false); - } - }; - - const handleSendRequest = async (addresseeId: string) => { - if (!user) return; - try { - await sendFriendRequest(user.id, addresseeId); - setSearchResults((prev) => prev.filter((u) => u.id !== addresseeId)); - await loadFriendsData(); - } catch (error) { - console.error('Error sending friend request:', error); - alert('Failed to send friend request'); - } - }; - - const handleAcceptRequest = async (friendshipId: string) => { - try { - await acceptFriendRequest(friendshipId); - await loadFriendsData(); - } catch (error) { - console.error('Error accepting request:', error); - } - }; - - const handleDeclineRequest = async (friendshipId: string) => { - try { - await declineFriendRequest(friendshipId); - await loadFriendsData(); - } catch (error) { - console.error('Error declining request:', error); - } - }; - - const handleRemoveFriend = async (friendshipId: string) => { - if (!confirm('Remove this friend?')) return; - try { - await removeFriend(friendshipId); - await loadFriendsData(); - } catch (error) { - console.error('Error removing friend:', error); - } - }; - - const isAlreadyFriendOrPending = (userId: string) => { - return ( - friends.some((f) => f.id === userId) || - pendingRequests.some((f) => f.id === userId) || - sentRequests.some((f) => f.id === userId) - ); - }; - - if (loading) { - return ( -
-
-
- ); - } - - return ( -
-
-

Friends

- - {/* Tabs */} -
- - - -
- - {/* Friends List */} - {activeTab === 'friends' && ( -
- {friends.length === 0 ? ( -

- No friends yet. Search for users to add them! -

- ) : ( - friends.map((friend) => ( -
- {friend.username || 'Unknown'} - -
- )) - )} -
- )} - - {/* Requests Tab */} - {activeTab === 'requests' && ( -
- {/* Received Requests */} -
-

Received Requests

- {pendingRequests.length === 0 ? ( -

No pending requests

- ) : ( -
- {pendingRequests.map((request) => ( -
- {request.username || 'Unknown'} -
- - -
-
- ))} -
- )} -
- - {/* Sent Requests */} -
-

Sent Requests

- {sentRequests.length === 0 ? ( -

No sent requests

- ) : ( -
- {sentRequests.map((request) => ( -
-
- - {request.username || 'Unknown'} -
- Pending -
- ))} -
- )} -
-
- )} - - {/* Search Tab */} - {activeTab === 'search' && ( -
-
- setSearchQuery(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleSearch()} - placeholder="Search by username..." - className="flex-1 px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" - /> - -
- - {searchResults.length > 0 && ( -
- {searchResults.map((result) => ( -
- {result.username || 'Unknown'} - {isAlreadyFriendOrPending(result.id) ? ( - Already connected - ) : ( - - )} -
- ))} -
- )} - - {searchQuery.length >= 2 && searchResults.length === 0 && !searching && ( -

No users found

- )} -
- )} -
-
- ); -} diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx index 1c5b5c7..0502059 100644 --- a/src/components/Navigation.tsx +++ b/src/components/Navigation.tsx @@ -1,205 +1,157 @@ import React, { useState, useRef, useEffect } from 'react'; - import { Home, PlusSquare, Library, LogOut, Settings, ChevronDown, Search, Heart, Menu, Users, ArrowLeftRight, Globe } from 'lucide-react'; - import { useAuth } from '../contexts/AuthContext'; - import { supabase } from '../lib/supabase'; +import { Library, LogOut, ChevronDown, Search, Heart, Users } from 'lucide-react'; +import { useAuth } from '../contexts/AuthContext'; +import { supabase } from '../lib/supabase'; - type Page = 'home' | 'deck' | 'login' | 'collection' | 'profile' | 'search' | 'life-counter' | 'friends' | 'trades' | 'community'; +type Page = 'home' | 'deck' | 'login' | 'collection' | 'search' | 'life-counter' | 'community'; - interface NavigationProps { - currentPage: Page; - setCurrentPage: (page: Page) => void; - } +interface NavigationProps { + currentPage: Page; + setCurrentPage: (page: Page) => void; +} - export default function Navigation({ currentPage, setCurrentPage }: NavigationProps) { - const { user, signOut } = useAuth(); - const [showDropdown, setShowDropdown] = useState(false); - const [showMobileMenu, setShowMobileMenu] = useState(false); - const dropdownRef = useRef(null); - const mobileMenuRef = useRef(null); - const [username, setUsername] = useState(null); +export default function Navigation({ currentPage, setCurrentPage }: NavigationProps) { + const { user, signOut } = useAuth(); + const [showDropdown, setShowDropdown] = useState(false); + const dropdownRef = useRef(null); + const [username, setUsername] = useState(null); - useEffect(() => { - const fetchProfile = async () => { - if (user) { - const { data } = await supabase - .from('profiles') - .select('username') - .eq('id', user.id) - .single(); - - if (data) { - setUsername(data.username); - } - } - }; + useEffect(() => { + const fetchProfile = async () => { + if (user) { + const { data } = await supabase + .from('profiles') + .select('username') + .eq('id', user.id) + .single(); - fetchProfile(); - }, [user]); - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setShowDropdown(false); - } - if (mobileMenuRef.current && !mobileMenuRef.current.contains(event.target as Node)) { - setShowMobileMenu(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - - const navItems = [ - { id: 'home' as const, label: 'Decks', icon: Library }, - { id: 'collection' as const, label: 'Collection', icon: Library }, - { id: 'community' as const, label: 'Community', icon: Globe }, - { id: 'friends' as const, label: 'Friends', icon: Users }, - { id: 'trades' as const, label: 'Trades', icon: ArrowLeftRight }, - { id: 'search' as const, label: 'Search', icon: Search }, - { id: 'life-counter' as const, label: 'Life', icon: Heart }, - ]; - - const handleSignOut = async () => { - try { - await signOut(); - setCurrentPage('login'); - } catch (error) { - console.error('Error signing out:', error); + if (data) { + setUsername(data.username); } - }; + } + }; - const getAvatarUrl = (userId: string) => { - return `https://api.dicebear.com/7.x/avataaars/svg?seed=${userId}&backgroundColor=b6e3f4,c0aede,d1d4f9`; - }; + fetchProfile(); + }, [user]); - return ( - <> - {/* Desktop Navigation - Top */} - + const navItems = [ + { id: 'home' as const, label: 'Decks', icon: Library }, + { id: 'collection' as const, label: 'Collection', icon: Library }, + { id: 'community' as const, label: 'Community', icon: Users }, + { id: 'search' as const, label: 'Search', icon: Search }, + { id: 'life-counter' as const, label: 'Life', icon: Heart }, + ]; - {/* Mobile Navigation - Bottom */} -
+ + + {/* Mobile Navigation - Bottom */} + + + ); +} diff --git a/src/components/Profile.tsx b/src/components/Profile.tsx deleted file mode 100644 index 3b29f0e..0000000 --- a/src/components/Profile.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { Save, Globe, Users, Lock } from 'lucide-react'; -import { useAuth } from '../contexts/AuthContext'; -import { supabase } from '../lib/supabase'; - -const THEME_COLORS = ['red', 'green', 'blue', 'yellow', 'grey', 'purple']; - -const VISIBILITY_OPTIONS = [ - { value: 'public', label: 'Public', icon: Globe, description: 'Anyone can view your collection' }, - { value: 'friends', label: 'Friends Only', icon: Users, description: 'Only friends can view your collection' }, - { value: 'private', label: 'Private', icon: Lock, description: 'Only you can view your collection' }, -] as const; - -type CollectionVisibility = 'public' | 'friends' | 'private'; - -export default function Profile() { - const { user } = useAuth(); - const [username, setUsername] = useState(''); - const [themeColor, setThemeColor] = useState('blue'); - const [collectionVisibility, setCollectionVisibility] = useState('private'); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - - useEffect(() => { - const loadProfile = async () => { - if (user) { - const { data, error } = await supabase - .from('profiles') - .select('username, theme_color, collection_visibility') - .eq('id', user.id) - .single(); - - if (data) { - setUsername(data.username || ''); - setThemeColor(data.theme_color || 'blue'); - setCollectionVisibility((data.collection_visibility as CollectionVisibility) || 'private'); - } - setLoading(false); - } - }; - - loadProfile(); - }, [user]); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!user) return; - - setSaving(true); - try { - const { error } = await supabase - .from('profiles') - .upsert({ - id: user.id, - username, - theme_color: themeColor, - collection_visibility: collectionVisibility, - updated_at: new Date() - }); - - if (error) throw error; - alert('Profile updated successfully!'); - } catch (error) { - console.error('Error updating profile:', error); - alert('Failed to update profile'); - } finally { - setSaving(false); - } - }; - - if (loading) { - return ( -
-
-
- ); - } - - return ( -
-
-

Profile Settings

- -
-
- - setUsername(e.target.value)} - className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" - placeholder="Enter your username" - /> -
- -
- -
- {THEME_COLORS.map((color) => ( - - ))} -
-
- -
- -
- {VISIBILITY_OPTIONS.map((option) => { - const Icon = option.icon; - return ( - - ); - })} -
-
- - -
-
-
- ); -} diff --git a/src/components/TradeCreator.tsx b/src/components/TradeCreator.tsx index c1c9dd0..5fa20e8 100644 --- a/src/components/TradeCreator.tsx +++ b/src/components/TradeCreator.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; -import { X, ArrowLeftRight, Plus, Minus, Send, Gift } from 'lucide-react'; +import { X, ArrowLeftRight, Plus, Minus, Send, Gift, Loader2 } from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; +import { useToast } from '../contexts/ToastContext'; import { getUserCollection, getCardsByIds } from '../services/api'; import { createTrade } from '../services/tradesService'; import { Card } from '../types'; @@ -32,6 +33,7 @@ export default function TradeCreator({ onTradeCreated, }: TradeCreatorProps) { const { user } = useAuth(); + const toast = useToast(); const [myCollection, setMyCollection] = useState([]); const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); @@ -133,7 +135,7 @@ export default function TradeCreator({ // At least one side should have cards (allowing gifts) if (myOfferedCards.size === 0 && wantedCards.size === 0) { - alert('Please select at least one card to trade or gift'); + toast.warning('Please select at least one card to trade or gift'); return; } @@ -160,7 +162,7 @@ export default function TradeCreator({ onTradeCreated(); } catch (error) { console.error('Error creating trade:', error); - alert('Failed to create trade'); + toast.error('Failed to create trade'); } finally { setSubmitting(false); } diff --git a/src/components/Trades.tsx b/src/components/Trades.tsx deleted file mode 100644 index 7f6ca43..0000000 --- a/src/components/Trades.tsx +++ /dev/null @@ -1,326 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { ArrowLeftRight, Check, X, Clock, History, Plus, Package } from 'lucide-react'; -import { useAuth } from '../contexts/AuthContext'; -import { - getTrades, - getTradeHistory, - acceptTrade, - declineTrade, - cancelTrade, - Trade, - TradeItem, -} from '../services/tradesService'; -import { getCardsByIds } from '../services/api'; -import { Card } from '../types'; - -type Tab = 'pending' | 'history'; - -interface TradeWithCards extends Trade { - cardDetails?: Map; -} - -export default function Trades() { - const { user } = useAuth(); - const [activeTab, setActiveTab] = useState('pending'); - const [pendingTrades, setPendingTrades] = useState([]); - const [tradeHistory, setTradeHistory] = useState([]); - const [loading, setLoading] = useState(true); - const [processingTradeId, setProcessingTradeId] = useState(null); - - useEffect(() => { - if (user) { - loadTrades(); - } - }, [user]); - - const loadTrades = async () => { - if (!user) return; - setLoading(true); - try { - const [pending, history] = await Promise.all([ - getTrades(user.id).then((trades) => trades.filter((t) => t.status === 'pending')), - getTradeHistory(user.id), - ]); - - // Load card details for all trades - const allCardIds = new Set(); - [...pending, ...history].forEach((trade) => { - trade.items?.forEach((item) => allCardIds.add(item.card_id)); - }); - - let cardDetails = new Map(); - if (allCardIds.size > 0) { - const cards = await getCardsByIds(Array.from(allCardIds)); - cards.forEach((card) => cardDetails.set(card.id, card)); - } - - setPendingTrades(pending.map((t) => ({ ...t, cardDetails }))); - setTradeHistory(history.map((t) => ({ ...t, cardDetails }))); - } catch (error) { - console.error('Error loading trades:', error); - } finally { - setLoading(false); - } - }; - - const handleAccept = async (tradeId: string) => { - setProcessingTradeId(tradeId); - try { - const success = await acceptTrade(tradeId); - if (success) { - await loadTrades(); - } else { - alert('Failed to execute trade. Please check your collection.'); - } - } catch (error) { - console.error('Error accepting trade:', error); - alert('Error accepting trade'); - } finally { - setProcessingTradeId(null); - } - }; - - const handleDecline = async (tradeId: string) => { - setProcessingTradeId(tradeId); - try { - await declineTrade(tradeId); - await loadTrades(); - } catch (error) { - console.error('Error declining trade:', error); - } finally { - setProcessingTradeId(null); - } - }; - - const handleCancel = async (tradeId: string) => { - if (!confirm('Cancel this trade offer?')) return; - setProcessingTradeId(tradeId); - try { - await cancelTrade(tradeId); - await loadTrades(); - } catch (error) { - console.error('Error cancelling trade:', error); - } finally { - setProcessingTradeId(null); - } - }; - - const getStatusColor = (status: Trade['status']) => { - switch (status) { - case 'accepted': - return 'text-green-400'; - case 'declined': - return 'text-red-400'; - case 'cancelled': - return 'text-gray-400'; - default: - return 'text-yellow-400'; - } - }; - - const renderTradeItems = ( - items: TradeItem[] | undefined, - ownerId: string, - cardDetails: Map | undefined, - label: string - ) => { - const ownerItems = items?.filter((i) => i.owner_id === ownerId) || []; - if (ownerItems.length === 0) { - return ( -
- {label}: Nothing (gift) -
- ); - } - - return ( -
-
{label}:
-
- {ownerItems.map((item) => { - const card = cardDetails?.get(item.card_id); - return ( -
- {card?.image_uris?.small && ( - {card.name} - )} - {card?.name || item.card_id} - {item.quantity > 1 && ( - x{item.quantity} - )} -
- ); - })} -
-
- ); - }; - - const renderTrade = (trade: TradeWithCards, showActions: boolean) => { - const isSender = trade.sender_id === user?.id; - const otherUser = isSender ? trade.receiver : trade.sender; - - return ( -
- {/* Header */} -
-
- - - {isSender ? `To: ${otherUser?.username}` : `From: ${otherUser?.username}`} - -
- - {trade.status} - -
- - {/* Trade Items */} -
- {renderTradeItems( - trade.items, - trade.sender_id, - trade.cardDetails, - isSender ? 'You give' : 'They give' - )} - {renderTradeItems( - trade.items, - trade.receiver_id, - trade.cardDetails, - isSender ? 'You receive' : 'They receive' - )} -
- - {/* Message */} - {trade.message && ( -
- Message: {trade.message} -
- )} - - {/* Actions */} - {showActions && trade.status === 'pending' && ( -
- {isSender ? ( - - ) : ( - <> - - - - )} -
- )} - - {/* Timestamp */} -
- {new Date(trade.created_at || '').toLocaleDateString()} -
-
- ); - }; - - if (loading) { - return ( -
-
-
- ); - } - - return ( -
-
-
-

Trades

-
- - {/* Tabs */} -
- - -
- - {/* Pending Trades */} - {activeTab === 'pending' && ( -
- {pendingTrades.length === 0 ? ( -
- -

No pending trades

-

- Visit a friend's collection to propose a trade -

-
- ) : ( - pendingTrades.map((trade) => renderTrade(trade, true)) - )} -
- )} - - {/* Trade History */} - {activeTab === 'history' && ( -
- {tradeHistory.length === 0 ? ( -

No trade history yet

- ) : ( - tradeHistory.map((trade) => renderTrade(trade, false)) - )} -
- )} -
-
- ); -} diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index c71eb95..fc60b4b 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -34,13 +34,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const { error } = await supabase .from('profiles') .upsert( - { - id: session.user.id, - theme_color: 'blue' // Default theme color + { + id: session.user.id }, { onConflict: 'id' } ); - + if (error) { console.error('Error creating profile:', error); } @@ -65,8 +64,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const { error: profileError } = await supabase .from('profiles') .insert({ - id: data.user!.id, - theme_color: 'blue' // Default theme color + id: data.user!.id }); if (profileError) { @@ -104,4 +102,4 @@ export function useAuth() { throw new Error('useAuth must be used within an AuthProvider'); } return context; -} +} diff --git a/src/contexts/ToastContext.tsx b/src/contexts/ToastContext.tsx new file mode 100644 index 0000000..6c1fccf --- /dev/null +++ b/src/contexts/ToastContext.tsx @@ -0,0 +1,100 @@ +import React, { createContext, useContext, useState, useCallback } from 'react'; +import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react'; + +type ToastType = 'success' | 'error' | 'warning' | 'info'; + +interface Toast { + id: string; + message: string; + type: ToastType; +} + +interface ToastContextType { + showToast: (message: string, type?: ToastType) => void; + success: (message: string) => void; + error: (message: string) => void; + warning: (message: string) => void; + info: (message: string) => void; +} + +const ToastContext = createContext(undefined); + +export function useToast() { + const context = useContext(ToastContext); + if (!context) { + throw new Error('useToast must be used within a ToastProvider'); + } + return context; +} + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]); + + const removeToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + const showToast = useCallback((message: string, type: ToastType = 'info') => { + const id = Math.random().toString(36).substring(7); + setToasts((prev) => [...prev, { id, message, type }]); + + // Auto remove after 4 seconds + setTimeout(() => removeToast(id), 4000); + }, [removeToast]); + + const success = useCallback((message: string) => showToast(message, 'success'), [showToast]); + const error = useCallback((message: string) => showToast(message, 'error'), [showToast]); + const warning = useCallback((message: string) => showToast(message, 'warning'), [showToast]); + const info = useCallback((message: string) => showToast(message, 'info'), [showToast]); + + const getIcon = (type: ToastType) => { + switch (type) { + case 'success': + return ; + case 'error': + return ; + case 'warning': + return ; + case 'info': + return ; + } + }; + + const getStyles = (type: ToastType) => { + switch (type) { + case 'success': + return 'bg-green-600'; + case 'error': + return 'bg-red-600'; + case 'warning': + return 'bg-yellow-600'; + case 'info': + return 'bg-blue-600'; + } + }; + + return ( + + {children} + + {/* Toast Container */} +
+ {toasts.map((toast) => ( +
+ {getIcon(toast.type)} + {toast.message} + +
+ ))} +
+
+ ); +} diff --git a/src/lib/Entities.ts b/src/lib/Entities.ts index a83c31f..c351d55 100644 --- a/src/lib/Entities.ts +++ b/src/lib/Entities.ts @@ -140,7 +140,6 @@ export type Database = { Row: { created_at: string | null id: string - theme_color: string | null updated_at: string | null username: string | null collection_visibility: 'public' | 'friends' | 'private' | null @@ -148,7 +147,6 @@ export type Database = { Insert: { created_at?: string | null id: string - theme_color?: string | null updated_at?: string | null username?: string | null collection_visibility?: 'public' | 'friends' | 'private' | null @@ -156,7 +154,6 @@ export type Database = { Update: { created_at?: string | null id?: string - theme_color?: string | null updated_at?: string | null username?: string | null collection_visibility?: 'public' | 'friends' | 'private' | null