feature/issue-10-deck-card-collection #12
@@ -1,483 +0,0 @@
|
|||||||
# Collection API Documentation
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This document describes the backend API implementation for managing user card collections in the Deckerr application. The implementation uses Supabase as the backend service with Row Level Security (RLS) enabled to ensure users can only access their own collection data.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Database Schema
|
|
||||||
|
|
||||||
The collection feature uses the existing `collections` table with the following structure:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE public.collections (
|
|
||||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id uuid REFERENCES public.profiles(id) NOT NULL,
|
|
||||||
card_id text NOT NULL,
|
|
||||||
quantity integer DEFAULT 1,
|
|
||||||
created_at timestamptz DEFAULT now(),
|
|
||||||
updated_at timestamptz DEFAULT now()
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Security
|
|
||||||
|
|
||||||
- **Authentication**: All endpoints require the user to be authenticated via Supabase Auth
|
|
||||||
- **Authorization**: Row Level Security (RLS) policies ensure users can only access their own collections
|
|
||||||
- **Validation**: Input validation is performed on all operations to prevent invalid data
|
|
||||||
|
|
||||||
## API Service
|
|
||||||
|
|
||||||
### Location
|
|
||||||
|
|
||||||
- **Service File**: `/home/node/projects/deckerr/src/services/collectionService.ts`
|
|
||||||
- **Hook File**: `/home/node/projects/deckerr/src/hooks/useCollection.ts`
|
|
||||||
|
|
||||||
### Core Functions
|
|
||||||
|
|
||||||
#### 1. getUserCollection()
|
|
||||||
|
|
||||||
Get all cards in the authenticated user's collection.
|
|
||||||
|
|
||||||
**Returns**: `Promise<CollectionCard[]>`
|
|
||||||
|
|
||||||
**Example**:
|
|
||||||
```typescript
|
|
||||||
import { getUserCollection } from '../services/collectionService';
|
|
||||||
|
|
||||||
const collection = await getUserCollection();
|
|
||||||
// Returns: [{ id, user_id, card_id, quantity, created_at, updated_at }, ...]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 2. getCardInCollection(cardId: string)
|
|
||||||
|
|
||||||
Check if a single card exists in the user's collection.
|
|
||||||
|
|
||||||
**Parameters**:
|
|
||||||
- `cardId` (string): The Scryfall card ID
|
|
||||||
|
|
||||||
**Returns**: `Promise<CollectionCard | null>`
|
|
||||||
|
|
||||||
**Example**:
|
|
||||||
```typescript
|
|
||||||
import { getCardInCollection } from '../services/collectionService';
|
|
||||||
|
|
||||||
const card = await getCardInCollection('card-uuid-123');
|
|
||||||
// Returns: { id, user_id, card_id, quantity, created_at, updated_at } or null
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 3. checkCardsOwnership(cardIds: string[])
|
|
||||||
|
|
||||||
Check ownership status for multiple cards at once.
|
|
||||||
|
|
||||||
**Parameters**:
|
|
||||||
- `cardIds` (string[]): Array of Scryfall card IDs
|
|
||||||
|
|
||||||
**Returns**: `Promise<Map<string, number>>`
|
|
||||||
|
|
||||||
**Example**:
|
|
||||||
```typescript
|
|
||||||
import { checkCardsOwnership } from '../services/collectionService';
|
|
||||||
|
|
||||||
const ownership = await checkCardsOwnership(['card-1', 'card-2', 'card-3']);
|
|
||||||
// Returns: Map { 'card-1' => 4, 'card-2' => 2, 'card-3' => 0 }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 4. getDeckCardOwnership(deckId: string)
|
|
||||||
|
|
||||||
Get detailed ownership information for all cards in a specific deck.
|
|
||||||
|
|
||||||
**Parameters**:
|
|
||||||
- `deckId` (string): The deck ID
|
|
||||||
|
|
||||||
**Returns**: `Promise<CardOwnershipInfo[]>`
|
|
||||||
|
|
||||||
**Response Type**:
|
|
||||||
```typescript
|
|
||||||
interface CardOwnershipInfo {
|
|
||||||
card_id: string;
|
|
||||||
owned: boolean; // true if user has enough copies
|
|
||||||
quantity_in_collection: number; // how many user owns
|
|
||||||
quantity_in_deck: number; // how many needed for deck
|
|
||||||
quantity_needed: number; // how many more needed
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Example**:
|
|
||||||
```typescript
|
|
||||||
import { getDeckCardOwnership } from '../services/collectionService';
|
|
||||||
|
|
||||||
const ownership = await getDeckCardOwnership('deck-uuid-123');
|
|
||||||
// Returns: [
|
|
||||||
// { card_id: 'card-1', owned: true, quantity_in_collection: 4, quantity_in_deck: 2, quantity_needed: 0 },
|
|
||||||
// { card_id: 'card-2', owned: false, quantity_in_collection: 1, quantity_in_deck: 3, quantity_needed: 2 }
|
|
||||||
// ]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Security**: Verifies that the user owns the deck before returning ownership info.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 5. getMissingCardsFromDeck(deckId: string)
|
|
||||||
|
|
||||||
Get only the cards that are missing from a deck (quantity needed > 0).
|
|
||||||
|
|
||||||
**Parameters**:
|
|
||||||
- `deckId` (string): The deck ID
|
|
||||||
|
|
||||||
**Returns**: `Promise<MissingCardInfo[]>`
|
|
||||||
|
|
||||||
**Response Type**:
|
|
||||||
```typescript
|
|
||||||
interface MissingCardInfo {
|
|
||||||
card_id: string;
|
|
||||||
quantity_needed: number;
|
|
||||||
quantity_in_collection: number;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Example**:
|
|
||||||
```typescript
|
|
||||||
import { getMissingCardsFromDeck } from '../services/collectionService';
|
|
||||||
|
|
||||||
const missingCards = await getMissingCardsFromDeck('deck-uuid-123');
|
|
||||||
// Returns: [
|
|
||||||
// { card_id: 'card-2', quantity_needed: 2, quantity_in_collection: 1 },
|
|
||||||
// { card_id: 'card-5', quantity_needed: 4, quantity_in_collection: 0 }
|
|
||||||
// ]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 6. addCardToCollection(cardId: string, quantity?: number)
|
|
||||||
|
|
||||||
Add a single card to the user's collection. If the card already exists, increments the quantity.
|
|
||||||
|
|
||||||
**Parameters**:
|
|
||||||
- `cardId` (string): The Scryfall card ID
|
|
||||||
- `quantity` (number, optional): Quantity to add (default: 1)
|
|
||||||
|
|
||||||
**Returns**: `Promise<CollectionCard>`
|
|
||||||
|
|
||||||
**Validation**:
|
|
||||||
- Card ID must be a non-empty string
|
|
||||||
- Quantity must be at least 1
|
|
||||||
|
|
||||||
**Example**:
|
|
||||||
```typescript
|
|
||||||
import { addCardToCollection } from '../services/collectionService';
|
|
||||||
|
|
||||||
const result = await addCardToCollection('card-uuid-123', 2);
|
|
||||||
// Returns: { id, user_id, card_id, quantity, created_at, updated_at }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 7. addCardsToCollectionBulk(cards: Array<{card_id: string, quantity: number}>)
|
|
||||||
|
|
||||||
Add multiple cards to the collection in a single operation. More efficient than multiple individual calls.
|
|
||||||
|
|
||||||
**Parameters**:
|
|
||||||
- `cards` (Array): Array of objects with `card_id` and `quantity`
|
|
||||||
|
|
||||||
**Returns**: `Promise<Array<{card_id: string, success: boolean, error?: string}>>`
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
- Automatically merges with existing collection entries
|
|
||||||
- Processes in batches of 1000 for optimal performance
|
|
||||||
- Returns individual success/failure status for each card
|
|
||||||
|
|
||||||
**Example**:
|
|
||||||
```typescript
|
|
||||||
import { addCardsToCollectionBulk } from '../services/collectionService';
|
|
||||||
|
|
||||||
const cards = [
|
|
||||||
{ card_id: 'card-1', quantity: 4 },
|
|
||||||
{ card_id: 'card-2', quantity: 2 },
|
|
||||||
{ card_id: 'card-3', quantity: 1 }
|
|
||||||
];
|
|
||||||
|
|
||||||
const results = await addCardsToCollectionBulk(cards);
|
|
||||||
// Returns: [
|
|
||||||
// { card_id: 'card-1', success: true },
|
|
||||||
// { card_id: 'card-2', success: true },
|
|
||||||
// { card_id: 'card-3', success: false, error: 'Database error' }
|
|
||||||
// ]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 8. addMissingDeckCardsToCollection(deckId: string)
|
|
||||||
|
|
||||||
Convenience function to add all missing cards from a deck to the collection in one operation.
|
|
||||||
|
|
||||||
**Parameters**:
|
|
||||||
- `deckId` (string): The deck ID
|
|
||||||
|
|
||||||
**Returns**: `Promise<Array<{card_id: string, success: boolean, error?: string}>>`
|
|
||||||
|
|
||||||
**Security**: Verifies deck ownership before adding cards.
|
|
||||||
|
|
||||||
**Example**:
|
|
||||||
```typescript
|
|
||||||
import { addMissingDeckCardsToCollection } from '../services/collectionService';
|
|
||||||
|
|
||||||
const results = await addMissingDeckCardsToCollection('deck-uuid-123');
|
|
||||||
// Returns: [
|
|
||||||
// { card_id: 'card-1', success: true },
|
|
||||||
// { card_id: 'card-2', success: true }
|
|
||||||
// ]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 9. removeCardFromCollection(cardId: string, quantity?: number)
|
|
||||||
|
|
||||||
Remove a card from the collection, or decrease its quantity.
|
|
||||||
|
|
||||||
**Parameters**:
|
|
||||||
- `cardId` (string): The Scryfall card ID
|
|
||||||
- `quantity` (number, optional): Quantity to remove. If not specified or >= existing quantity, removes the card entirely.
|
|
||||||
|
|
||||||
**Returns**: `Promise<boolean>`
|
|
||||||
|
|
||||||
**Example**:
|
|
||||||
```typescript
|
|
||||||
import { removeCardFromCollection } from '../services/collectionService';
|
|
||||||
|
|
||||||
// Remove 2 copies
|
|
||||||
await removeCardFromCollection('card-uuid-123', 2);
|
|
||||||
|
|
||||||
// Remove card entirely
|
|
||||||
await removeCardFromCollection('card-uuid-123');
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## React Hook: useCollection
|
|
||||||
|
|
||||||
A custom hook that wraps the collection service with state management, loading states, and error handling.
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useCollection } from '../hooks/useCollection';
|
|
||||||
|
|
||||||
function MyComponent() {
|
|
||||||
const {
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
clearError,
|
|
||||||
getCollection,
|
|
||||||
checkCardOwnership,
|
|
||||||
checkMultipleCardsOwnership,
|
|
||||||
getDeckOwnership,
|
|
||||||
getMissingCards,
|
|
||||||
addCard,
|
|
||||||
addCardsBulk,
|
|
||||||
addMissingDeckCards,
|
|
||||||
removeCard
|
|
||||||
} = useCollection();
|
|
||||||
|
|
||||||
// Use the functions
|
|
||||||
const handleAddCard = async (cardId: string) => {
|
|
||||||
const success = await addCard(cardId, 1);
|
|
||||||
if (success) {
|
|
||||||
console.log('Card added successfully!');
|
|
||||||
} else {
|
|
||||||
console.error('Error:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{loading && <p>Loading...</p>}
|
|
||||||
{error && <p>Error: {error}</p>}
|
|
||||||
{/* Your component UI */}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Hook API
|
|
||||||
|
|
||||||
All functions from the service are available with the same signatures, plus:
|
|
||||||
|
|
||||||
- `loading` (boolean): True when any operation is in progress
|
|
||||||
- `error` (string | null): Error message if an operation failed
|
|
||||||
- `clearError` (() => void): Function to clear the error state
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
All functions include comprehensive error handling:
|
|
||||||
|
|
||||||
1. **Authentication Errors**: Thrown if user is not authenticated
|
|
||||||
2. **Authorization Errors**: Thrown if user tries to access data they don't own
|
|
||||||
3. **Validation Errors**: Thrown if input parameters are invalid
|
|
||||||
4. **Database Errors**: Supabase errors are caught and wrapped with user-friendly messages
|
|
||||||
|
|
||||||
**Example Error Handling**:
|
|
||||||
```typescript
|
|
||||||
try {
|
|
||||||
await addCardToCollection('invalid-card', -5);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error.message); // "Quantity must be at least 1"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Performance Considerations
|
|
||||||
|
|
||||||
1. **Bulk Operations**: Use `addCardsToCollectionBulk()` for adding multiple cards instead of calling `addCardToCollection()` in a loop.
|
|
||||||
|
|
||||||
2. **Batch Size**: Bulk operations are automatically batched at 1000 cards to optimize database performance.
|
|
||||||
|
|
||||||
3. **Caching**: Consider implementing client-side caching for collection data that doesn't change frequently.
|
|
||||||
|
|
||||||
4. **RLS Performance**: Supabase RLS policies are indexed on `user_id` for optimal query performance.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Integration Examples
|
|
||||||
|
|
||||||
### Example 1: Show Missing Cards in Deck Editor
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useCollection } from '../hooks/useCollection';
|
|
||||||
import { CardOwnershipInfo } from '../services/collectionService';
|
|
||||||
|
|
||||||
function DeckEditorWithCollection({ deckId }: { deckId: string }) {
|
|
||||||
const { getDeckOwnership, loading } = useCollection();
|
|
||||||
const [ownership, setOwnership] = useState<CardOwnershipInfo[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchOwnership = async () => {
|
|
||||||
const data = await getDeckOwnership(deckId);
|
|
||||||
if (data) setOwnership(data);
|
|
||||||
};
|
|
||||||
fetchOwnership();
|
|
||||||
}, [deckId]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h2>Deck Cards</h2>
|
|
||||||
{loading && <p>Loading...</p>}
|
|
||||||
{ownership.map(card => (
|
|
||||||
<div key={card.card_id}>
|
|
||||||
<p>Card: {card.card_id}</p>
|
|
||||||
{!card.owned && (
|
|
||||||
<p className="text-red-500">
|
|
||||||
Missing {card.quantity_needed} copies
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example 2: Add All Missing Cards Button
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useCollection } from '../hooks/useCollection';
|
|
||||||
|
|
||||||
function AddAllMissingButton({ deckId }: { deckId: string }) {
|
|
||||||
const { addMissingDeckCards, loading, error } = useCollection();
|
|
||||||
|
|
||||||
const handleAddAll = async () => {
|
|
||||||
const results = await addMissingDeckCards(deckId);
|
|
||||||
if (results) {
|
|
||||||
const successCount = results.filter(r => r.success).length;
|
|
||||||
const failCount = results.filter(r => !r.success).length;
|
|
||||||
alert(`Added ${successCount} cards. ${failCount} failed.`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<button onClick={handleAddAll} disabled={loading}>
|
|
||||||
{loading ? 'Adding...' : 'Add All Missing Cards'}
|
|
||||||
</button>
|
|
||||||
{error && <p className="text-red-500">{error}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example 3: Add Individual Card Button
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useCollection } from '../hooks/useCollection';
|
|
||||||
|
|
||||||
function AddCardButton({ cardId, quantity }: { cardId: string; quantity: number }) {
|
|
||||||
const { addCard, loading } = useCollection();
|
|
||||||
|
|
||||||
const handleAdd = async () => {
|
|
||||||
const success = await addCard(cardId, quantity);
|
|
||||||
if (success) {
|
|
||||||
alert('Card added to collection!');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button onClick={handleAdd} disabled={loading}>
|
|
||||||
{loading ? 'Adding...' : `Add ${quantity} to Collection`}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
The project currently has no automated tests configured. To manually test the collection API:
|
|
||||||
|
|
||||||
1. **Build the project**: `npm run build` - Verifies TypeScript compilation
|
|
||||||
2. **Run the linter**: `npm run lint` - Checks code quality
|
|
||||||
3. **Manual testing**: Start the dev server with `npm run dev` and test through the UI
|
|
||||||
|
|
||||||
### Manual Test Checklist
|
|
||||||
|
|
||||||
- [ ] Add a single card to collection
|
|
||||||
- [ ] Add the same card again (should increment quantity)
|
|
||||||
- [ ] Add multiple cards in bulk
|
|
||||||
- [ ] View collection
|
|
||||||
- [ ] Check card ownership for a deck
|
|
||||||
- [ ] Add missing cards from a deck
|
|
||||||
- [ ] Remove a card from collection
|
|
||||||
- [ ] Verify RLS: User A cannot see User B's collection
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
1. **Caching**: Add client-side caching to reduce database queries
|
|
||||||
2. **Optimistic Updates**: Update UI immediately before server confirmation
|
|
||||||
3. **Websocket Updates**: Real-time collection updates using Supabase Realtime
|
|
||||||
4. **Import/Export**: Bulk import collection from CSV or other formats
|
|
||||||
5. **Statistics**: Track collection value, completion percentage, etc.
|
|
||||||
6. **Trade Lists**: Mark cards as available for trade
|
|
||||||
7. **Wishlists**: Separate table for cards user wants to acquire
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
For issues or questions:
|
|
||||||
- Check Supabase logs for backend errors
|
|
||||||
- Review browser console for client-side errors
|
|
||||||
- Verify authentication status
|
|
||||||
- Check RLS policies in Supabase dashboard
|
|
||||||
@@ -1,414 +0,0 @@
|
|||||||
# ISSUE-10 Implementation Summary
|
|
||||||
|
|
||||||
## Ticket: Add information if we have cards from deck we create in our cards collection
|
|
||||||
|
|
||||||
**Branch**: `feature/issue-10-deck-card-collection`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Implemented comprehensive backend services for checking card ownership in user collections and adding missing cards to collections. This provides the foundation for the frontend to display which cards from a deck are missing from the user's collection and allow users to add them individually or in bulk.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Files Created
|
|
||||||
|
|
||||||
### 1. `/home/node/projects/deckerr/src/services/collectionService.ts` (541 lines)
|
|
||||||
|
|
||||||
Complete backend service for collection management with the following functionality:
|
|
||||||
|
|
||||||
**Core Functions:**
|
|
||||||
- `getUserCollection()` - Get user's entire collection
|
|
||||||
- `getCardInCollection(cardId)` - Check if a single card exists
|
|
||||||
- `checkCardsOwnership(cardIds[])` - Batch check ownership for multiple cards
|
|
||||||
- `getDeckCardOwnership(deckId)` - Get detailed ownership info for all cards in a deck
|
|
||||||
- `getMissingCardsFromDeck(deckId)` - Get only missing cards from a deck
|
|
||||||
|
|
||||||
**Add Cards:**
|
|
||||||
- `addCardToCollection(cardId, quantity)` - Add single card (increments if exists)
|
|
||||||
- `addCardsToCollectionBulk(cards[])` - Bulk add multiple cards efficiently
|
|
||||||
- `addMissingDeckCardsToCollection(deckId)` - Add all missing deck cards at once
|
|
||||||
|
|
||||||
**Remove Cards:**
|
|
||||||
- `removeCardFromCollection(cardId, quantity?)` - Remove or decrease card quantity
|
|
||||||
|
|
||||||
**Key Features:**
|
|
||||||
- Full authentication and authorization checks
|
|
||||||
- Comprehensive input validation
|
|
||||||
- Automatic user ID resolution via Supabase Auth
|
|
||||||
- Batch processing for bulk operations (1000 cards per batch)
|
|
||||||
- Detailed error messages for debugging
|
|
||||||
- TypeScript interfaces for all data structures
|
|
||||||
|
|
||||||
### 2. `/home/node/projects/deckerr/src/hooks/useCollection.ts` (204 lines)
|
|
||||||
|
|
||||||
Custom React hook that wraps the collection service with state management:
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Loading state tracking
|
|
||||||
- Error state management with `clearError()` function
|
|
||||||
- All service functions exposed with consistent error handling
|
|
||||||
- Ready-to-use in React components
|
|
||||||
|
|
||||||
**Functions Exposed:**
|
|
||||||
- `getCollection()`
|
|
||||||
- `checkCardOwnership(cardId)`
|
|
||||||
- `checkMultipleCardsOwnership(cardIds[])`
|
|
||||||
- `getDeckOwnership(deckId)`
|
|
||||||
- `getMissingCards(deckId)`
|
|
||||||
- `addCard(cardId, quantity)`
|
|
||||||
- `addCardsBulk(cards[])`
|
|
||||||
- `addMissingDeckCards(deckId)`
|
|
||||||
- `removeCard(cardId, quantity?)`
|
|
||||||
|
|
||||||
### 3. `/home/node/projects/deckerr/COLLECTION_API.md` (486 lines)
|
|
||||||
|
|
||||||
Comprehensive API documentation including:
|
|
||||||
- Architecture overview
|
|
||||||
- Database schema documentation
|
|
||||||
- Security model explanation
|
|
||||||
- Detailed function documentation with examples
|
|
||||||
- React hook usage guide
|
|
||||||
- Integration examples for common use cases
|
|
||||||
- Manual testing checklist
|
|
||||||
- Future enhancement suggestions
|
|
||||||
|
|
||||||
### 4. `/home/node/projects/deckerr/IMPLEMENTATION_SUMMARY.md` (This file)
|
|
||||||
|
|
||||||
Summary of all changes made for this ticket.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Files Modified
|
|
||||||
|
|
||||||
### `/home/node/projects/deckerr/src/types/index.ts`
|
|
||||||
|
|
||||||
**Changes:**
|
|
||||||
- Added `prices` field to `Card` interface (for displaying card prices)
|
|
||||||
- Added `Collection` interface for typed collection data
|
|
||||||
|
|
||||||
**Before:**
|
|
||||||
```typescript
|
|
||||||
export interface Card {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
// ... other fields
|
|
||||||
colors?: string[];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
```typescript
|
|
||||||
export interface Card {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
// ... other fields
|
|
||||||
colors?: string[];
|
|
||||||
prices?: {
|
|
||||||
usd?: string;
|
|
||||||
usd_foil?: string;
|
|
||||||
eur?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Collection {
|
|
||||||
id: string;
|
|
||||||
user_id: string;
|
|
||||||
card_id: string;
|
|
||||||
quantity: number;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Technical Implementation Details
|
|
||||||
|
|
||||||
### Architecture Decisions
|
|
||||||
|
|
||||||
1. **Supabase Backend**: Leveraging Supabase's client-side SDK eliminates need for separate API server
|
|
||||||
2. **Row Level Security**: All data access is secured at the database level
|
|
||||||
3. **TypeScript First**: Full type safety throughout the codebase
|
|
||||||
4. **Service Layer Pattern**: Business logic separated from UI components
|
|
||||||
5. **Custom Hooks**: React patterns for clean component integration
|
|
||||||
|
|
||||||
### Security Implementation
|
|
||||||
|
|
||||||
**Authentication:**
|
|
||||||
- All service functions call `getCurrentUserId()` to verify user is authenticated
|
|
||||||
- Throws descriptive errors if authentication fails
|
|
||||||
|
|
||||||
**Authorization:**
|
|
||||||
- Supabase RLS policies ensure users can only access their own data
|
|
||||||
- Additional verification for deck ownership before operations
|
|
||||||
- No SQL injection vulnerabilities (using Supabase's query builder)
|
|
||||||
|
|
||||||
**Validation:**
|
|
||||||
- Card IDs validated as non-empty strings
|
|
||||||
- Quantities validated as positive integers
|
|
||||||
- Bulk operations validate all cards before processing
|
|
||||||
|
|
||||||
### Performance Optimizations
|
|
||||||
|
|
||||||
1. **Batch Processing**: Bulk operations process up to 1000 cards per batch
|
|
||||||
2. **Single Queries**: `checkCardsOwnership()` uses a single query with `IN` clause
|
|
||||||
3. **Efficient Updates**: Bulk add separates updates vs inserts for optimal performance
|
|
||||||
4. **No N+1 Queries**: All card checks done in single batch queries
|
|
||||||
|
|
||||||
### Error Handling Strategy
|
|
||||||
|
|
||||||
**Three-Layer Approach:**
|
|
||||||
1. **Input Validation**: Catches invalid parameters before database calls
|
|
||||||
2. **Service Layer**: Wraps Supabase errors with user-friendly messages
|
|
||||||
3. **Hook Layer**: Provides component-level error state management
|
|
||||||
|
|
||||||
**Example Error Flow:**
|
|
||||||
```
|
|
||||||
Invalid Input → Validation Error → Hook Error State → UI Display
|
|
||||||
Database Error → Service Error → Hook Error State → UI Display
|
|
||||||
Auth Error → Service Error → Hook Error State → UI Display
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Database Schema (Existing)
|
|
||||||
|
|
||||||
The implementation uses the existing `collections` table:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE public.collections (
|
|
||||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id uuid REFERENCES public.profiles(id) NOT NULL,
|
|
||||||
card_id text NOT NULL,
|
|
||||||
quantity integer DEFAULT 1,
|
|
||||||
created_at timestamptz DEFAULT now(),
|
|
||||||
updated_at timestamptz DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- RLS Enabled
|
|
||||||
ALTER TABLE public.collections ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
-- Policies
|
|
||||||
CREATE POLICY "Users can view their own collection"
|
|
||||||
ON public.collections FOR SELECT
|
|
||||||
TO authenticated
|
|
||||||
USING (user_id = auth.uid());
|
|
||||||
|
|
||||||
CREATE POLICY "Users can manage their own collection"
|
|
||||||
ON public.collections FOR ALL
|
|
||||||
TO authenticated
|
|
||||||
USING (user_id = auth.uid());
|
|
||||||
```
|
|
||||||
|
|
||||||
**No schema changes were required.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Results
|
|
||||||
|
|
||||||
### Build Test
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
**Result**: ✅ SUCCESS - Built in 5.94s with no TypeScript errors
|
|
||||||
|
|
||||||
### Lint Test
|
|
||||||
```bash
|
|
||||||
npm run lint
|
|
||||||
```
|
|
||||||
**Result**: ✅ SUCCESS - No linting errors in new files
|
|
||||||
|
|
||||||
**Pre-existing linting issues** (not related to this implementation):
|
|
||||||
- CardCarousel.tsx: unused 'index' variable
|
|
||||||
- DeckList.tsx: unused 'getCardById' import
|
|
||||||
- Profile.tsx: unused 'error' variable
|
|
||||||
- AuthContext.tsx: React Fast Refresh warning (common pattern)
|
|
||||||
|
|
||||||
### TypeScript Compilation
|
|
||||||
- All types properly defined with no `any` types
|
|
||||||
- Full IntelliSense support in IDEs
|
|
||||||
- No type errors in service or hook files
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Integration Guidelines for Frontend
|
|
||||||
|
|
||||||
### Step 1: Import the Hook
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useCollection } from '../hooks/useCollection';
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Use in Component
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function DeckEditor({ deckId }) {
|
|
||||||
const {
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
getDeckOwnership,
|
|
||||||
addCard,
|
|
||||||
addMissingDeckCards
|
|
||||||
} = useCollection();
|
|
||||||
|
|
||||||
// Your component logic
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Display Missing Cards
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Get ownership info
|
|
||||||
const ownership = await getDeckOwnership(deckId);
|
|
||||||
|
|
||||||
// Filter for missing cards
|
|
||||||
const missingCards = ownership.filter(card => !card.owned);
|
|
||||||
|
|
||||||
// Display in UI
|
|
||||||
missingCards.map(card => (
|
|
||||||
<div>
|
|
||||||
<p>Need {card.quantity_needed} more copies</p>
|
|
||||||
<button onClick={() => addCard(card.card_id, card.quantity_needed)}>
|
|
||||||
Add to Collection
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
));
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Bulk Add Button
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
<button onClick={async () => {
|
|
||||||
const results = await addMissingDeckCards(deckId);
|
|
||||||
console.log('Added', results?.filter(r => r.success).length, 'cards');
|
|
||||||
}}>
|
|
||||||
Add All Missing Cards
|
|
||||||
</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Endpoints Summary
|
|
||||||
|
|
||||||
| Function | Purpose | Returns |
|
|
||||||
|----------|---------|---------|
|
|
||||||
| `getUserCollection()` | Get full collection | `CollectionCard[]` |
|
|
||||||
| `getCardInCollection(id)` | Check single card | `CollectionCard \| null` |
|
|
||||||
| `checkCardsOwnership(ids[])` | Batch ownership check | `Map<id, quantity>` |
|
|
||||||
| `getDeckCardOwnership(deckId)` | Deck ownership details | `CardOwnershipInfo[]` |
|
|
||||||
| `getMissingCardsFromDeck(deckId)` | Missing cards only | `MissingCardInfo[]` |
|
|
||||||
| `addCardToCollection(id, qty)` | Add single card | `CollectionCard` |
|
|
||||||
| `addCardsToCollectionBulk(cards[])` | Bulk add cards | `Result[]` |
|
|
||||||
| `addMissingDeckCardsToCollection(deckId)` | Add all missing | `Result[]` |
|
|
||||||
| `removeCardFromCollection(id, qty?)` | Remove card | `boolean` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security Verification
|
|
||||||
|
|
||||||
✅ **Authentication Required**: All functions check user authentication
|
|
||||||
✅ **Authorization Enforced**: RLS policies prevent unauthorized access
|
|
||||||
✅ **Input Validation**: All inputs validated before database operations
|
|
||||||
✅ **No SQL Injection**: Using Supabase query builder (parameterized queries)
|
|
||||||
✅ **Error Messages Safe**: No sensitive data exposed in error messages
|
|
||||||
✅ **Deck Ownership**: Verified before allowing operations on deck data
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Next Steps / Frontend Integration Tasks
|
|
||||||
|
|
||||||
1. **Update DeckEditor Component**
|
|
||||||
- Import `useCollection` hook
|
|
||||||
- Call `getDeckOwnership(deckId)` on component mount
|
|
||||||
- Display ownership status for each card
|
|
||||||
- Show "Add to Collection" button for missing cards
|
|
||||||
- Show "Add All Missing Cards" button
|
|
||||||
|
|
||||||
2. **Update DeckManager Component**
|
|
||||||
- Add collection status indicators
|
|
||||||
- Show quantity needed vs quantity owned
|
|
||||||
- Implement individual card add buttons
|
|
||||||
- Implement bulk add button
|
|
||||||
|
|
||||||
3. **Update Collection Component**
|
|
||||||
- Use `getUserCollection()` to load actual collection data
|
|
||||||
- Replace local state with Supabase data
|
|
||||||
- Update `addToCollection` to use `addCard()` from hook
|
|
||||||
- Persist collection changes to database
|
|
||||||
|
|
||||||
4. **UI Enhancements**
|
|
||||||
- Add loading spinners during operations
|
|
||||||
- Display error messages from hook
|
|
||||||
- Show success notifications after adding cards
|
|
||||||
- Add visual indicators (icons/colors) for owned/missing cards
|
|
||||||
|
|
||||||
5. **Optional Enhancements**
|
|
||||||
- Add confirmation dialog for bulk operations
|
|
||||||
- Show total cost of missing cards
|
|
||||||
- Add "Preview" mode before bulk add
|
|
||||||
- Implement undo functionality
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
**No new dependencies added.** All functionality implemented using existing packages:
|
|
||||||
- `@supabase/supabase-js` (already installed)
|
|
||||||
- `react` (already installed)
|
|
||||||
- TypeScript types (already installed)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Known Limitations
|
|
||||||
|
|
||||||
1. **No Automated Tests**: Project has no test framework configured
|
|
||||||
2. **No Caching**: Each query hits the database (consider React Query for future)
|
|
||||||
3. **No Optimistic Updates**: UI waits for server confirmation
|
|
||||||
4. **No Real-time Updates**: Changes not reflected in other open tabs/devices
|
|
||||||
5. **Basic Error Messages**: Could be more user-friendly with specific guidance
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recommendations for Future Improvements
|
|
||||||
|
|
||||||
### High Priority
|
|
||||||
1. Add unit tests for service functions
|
|
||||||
2. Add integration tests for hook
|
|
||||||
3. Implement optimistic UI updates
|
|
||||||
4. Add caching layer (React Query or SWR)
|
|
||||||
|
|
||||||
### Medium Priority
|
|
||||||
1. Add Supabase Realtime for live collection updates
|
|
||||||
2. Implement collection statistics (total value, completion %)
|
|
||||||
3. Add import/export functionality for collections
|
|
||||||
4. Create collection sharing features
|
|
||||||
|
|
||||||
### Low Priority
|
|
||||||
1. Add collection analytics dashboard
|
|
||||||
2. Implement trade/wishlist features
|
|
||||||
3. Add collection version history
|
|
||||||
4. Create collection comparison tools
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
The backend implementation is **complete and production-ready** with:
|
|
||||||
- ✅ Full authentication and authorization
|
|
||||||
- ✅ Comprehensive error handling
|
|
||||||
- ✅ Input validation
|
|
||||||
- ✅ TypeScript type safety
|
|
||||||
- ✅ Efficient batch operations
|
|
||||||
- ✅ Clean separation of concerns
|
|
||||||
- ✅ Extensive documentation
|
|
||||||
- ✅ Build and lint passing
|
|
||||||
|
|
||||||
The frontend team can now integrate these services to display collection status and allow users to add cards to their collection.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Questions or Issues?
|
|
||||||
|
|
||||||
Refer to `/home/node/projects/deckerr/COLLECTION_API.md` for detailed API documentation and integration examples.
|
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { searchCards } from '../services/api';
|
import { RefreshCw, PackagePlus, Loader2, CheckCircle, XCircle, Trash2 } from 'lucide-react';
|
||||||
|
import { searchCards, getUserCollection, addCardToCollection } from '../services/api';
|
||||||
import { Card } from '../types';
|
import { Card } from '../types';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import MagicCard from './MagicCard';
|
import MagicCard from './MagicCard';
|
||||||
|
|
||||||
const CardSearch = () => {
|
const CardSearch = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
const [cardName, setCardName] = useState('');
|
const [cardName, setCardName] = useState('');
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
const [rulesText, setRulesText] = useState('');
|
const [rulesText, setRulesText] = useState('');
|
||||||
@@ -40,6 +43,85 @@ const CardSearch = () => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Collection state
|
||||||
|
const [userCollection, setUserCollection] = useState<Map<string, number>>(new Map());
|
||||||
|
const [addingCardId, setAddingCardId] = useState<string | null>(null);
|
||||||
|
const [cardFaceIndex, setCardFaceIndex] = useState<Map<string, number>>(new Map());
|
||||||
|
const [snackbar, setSnackbar] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
||||||
|
|
||||||
|
// Load user collection
|
||||||
|
useEffect(() => {
|
||||||
|
const loadUserCollection = async () => {
|
||||||
|
if (!user) return;
|
||||||
|
try {
|
||||||
|
const collection = await getUserCollection(user.id);
|
||||||
|
setUserCollection(collection);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading user collection:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadUserCollection();
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
// Helper function to check if a card has an actual back face
|
||||||
|
const isDoubleFaced = (card: Card) => {
|
||||||
|
const backFaceLayouts = ['transform', 'modal_dfc', 'double_faced_token', 'reversible_card'];
|
||||||
|
return card.card_faces && card.card_faces.length > 1 && backFaceLayouts.includes(card.layout);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get current face index for a card
|
||||||
|
const getCurrentFaceIndex = (cardId: string) => {
|
||||||
|
return cardFaceIndex.get(cardId) || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle card face
|
||||||
|
const toggleCardFace = (cardId: string, totalFaces: number) => {
|
||||||
|
setCardFaceIndex(prev => {
|
||||||
|
const newMap = new Map(prev);
|
||||||
|
const currentIndex = prev.get(cardId) || 0;
|
||||||
|
const nextIndex = (currentIndex + 1) % totalFaces;
|
||||||
|
newMap.set(cardId, nextIndex);
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get card image for current face
|
||||||
|
const getCardImageUri = (card: Card, faceIndex: number = 0) => {
|
||||||
|
if (isDoubleFaced(card) && card.card_faces) {
|
||||||
|
return card.card_faces[faceIndex]?.image_uris?.normal || card.card_faces[faceIndex]?.image_uris?.small;
|
||||||
|
}
|
||||||
|
return card.image_uris?.normal || card.image_uris?.small || card.card_faces?.[0]?.image_uris?.normal;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add card to collection
|
||||||
|
const handleAddCardToCollection = async (cardId: string) => {
|
||||||
|
if (!user) {
|
||||||
|
setSnackbar({ message: 'Please log in to add cards to your collection', type: 'error' });
|
||||||
|
setTimeout(() => setSnackbar(null), 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setAddingCardId(cardId);
|
||||||
|
await addCardToCollection(user.id, cardId, 1);
|
||||||
|
|
||||||
|
setUserCollection(prev => {
|
||||||
|
const newMap = new Map(prev);
|
||||||
|
const currentQty = newMap.get(cardId) || 0;
|
||||||
|
newMap.set(cardId, currentQty + 1);
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
|
|
||||||
|
setSnackbar({ message: 'Card added to collection!', type: 'success' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding card to collection:', error);
|
||||||
|
setSnackbar({ message: 'Failed to add card to collection', type: 'error' });
|
||||||
|
} finally {
|
||||||
|
setAddingCardId(null);
|
||||||
|
setTimeout(() => setSnackbar(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSearch = async (e: React.FormEvent) => {
|
const handleSearch = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -524,18 +606,107 @@ const CardSearch = () => {
|
|||||||
|
|
||||||
{searchResults && searchResults.length > 0 && (
|
{searchResults && searchResults.length > 0 && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
{searchResults.map((card) => (
|
{searchResults.map((card) => {
|
||||||
<div key={card.id} className="bg-gray-800 rounded-lg overflow-hidden">
|
const currentFaceIndex = getCurrentFaceIndex(card.id);
|
||||||
<MagicCard card={card} />
|
const isMultiFaced = isDoubleFaced(card);
|
||||||
<div className="p-4">
|
const inCollection = userCollection.get(card.id) || 0;
|
||||||
<h3 className="font-bold mb-2">{card.name}</h3>
|
const isAddingThisCard = addingCardId === card.id;
|
||||||
<p className="text-gray-400 text-sm">{card.type_line}</p>
|
|
||||||
|
const displayName = isMultiFaced && card.card_faces
|
||||||
|
? card.card_faces[currentFaceIndex]?.name || card.name
|
||||||
|
: card.name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={card.id} className="bg-gray-800 rounded-lg overflow-hidden hover:ring-2 hover:ring-blue-500 transition-all">
|
||||||
|
<div className="relative">
|
||||||
|
{getCardImageUri(card, currentFaceIndex) ? (
|
||||||
|
<img
|
||||||
|
src={getCardImageUri(card, currentFaceIndex)}
|
||||||
|
alt={displayName}
|
||||||
|
className="w-full h-auto"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MagicCard card={card} />
|
||||||
|
)}
|
||||||
|
{isMultiFaced && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleCardFace(card.id, card.card_faces!.length);
|
||||||
|
}}
|
||||||
|
className="absolute bottom-2 right-2 bg-purple-600 hover:bg-purple-700 text-white p-2 rounded-full shadow-lg transition-all"
|
||||||
|
title="Flip card"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="font-bold">{displayName}</h3>
|
||||||
|
{inCollection > 0 && (
|
||||||
|
<span className="text-xs bg-green-600 px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||||
|
<CheckCircle size={12} />
|
||||||
|
x{inCollection}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 text-sm mb-3">
|
||||||
|
{isMultiFaced && card.card_faces
|
||||||
|
? card.card_faces[currentFaceIndex]?.type_line || card.type_line
|
||||||
|
: card.type_line}
|
||||||
|
</p>
|
||||||
|
{card.prices?.usd && (
|
||||||
|
<div className="text-sm text-gray-400 mb-2">${card.prices.usd}</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleAddCardToCollection(card.id)}
|
||||||
|
disabled={isAddingThisCard}
|
||||||
|
className="w-full px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded-lg flex items-center justify-center gap-2"
|
||||||
|
title="Add to collection"
|
||||||
|
>
|
||||||
|
{isAddingThisCard ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="animate-spin" size={20} />
|
||||||
|
Adding...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PackagePlus size={20} />
|
||||||
|
Add to Collection
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Snackbar */}
|
||||||
|
{snackbar && (
|
||||||
|
<div
|
||||||
|
className={`fixed bottom-4 right-4 p-4 rounded-lg shadow-lg transition-all duration-300 ${
|
||||||
|
snackbar.type === 'success' ? 'bg-green-500' : 'bg-red-500'
|
||||||
|
} text-white z-50`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center">
|
||||||
|
{snackbar.type === 'success' ? (
|
||||||
|
<CheckCircle className="mr-2" size={20} />
|
||||||
|
) : (
|
||||||
|
<XCircle className="mr-2" size={20} />
|
||||||
|
)}
|
||||||
|
<span>{snackbar.message}</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setSnackbar(null)} className="ml-4 text-gray-200 hover:text-white focus:outline-none">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,37 +1,191 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Search, Plus } from 'lucide-react';
|
import { Search, Loader2, Trash2, CheckCircle, XCircle, RefreshCw, Plus, Minus, X } from 'lucide-react';
|
||||||
import { Card } from '../types';
|
import { Card } from '../types';
|
||||||
import { searchCards } from '../services/api';
|
import { getUserCollection, getCardsByIds, addCardToCollection } from '../services/api';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import ConfirmModal from './ConfirmModal';
|
||||||
|
|
||||||
export default function Collection() {
|
export default function Collection() {
|
||||||
|
const { user } = useAuth();
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchResults, setSearchResults] = useState<Card[]>([]);
|
|
||||||
const [collection, setCollection] = useState<{ card: Card; quantity: number }[]>([]);
|
const [collection, setCollection] = useState<{ card: Card; quantity: number }[]>([]);
|
||||||
|
const [filteredCollection, setFilteredCollection] = useState<{ card: Card; quantity: number }[]>([]);
|
||||||
|
const [isLoadingCollection, setIsLoadingCollection] = useState(true);
|
||||||
|
const [hoveredCard, setHoveredCard] = useState<Card | null>(null);
|
||||||
|
const [selectedCard, setSelectedCard] = useState<{ card: Card; quantity: number } | null>(null);
|
||||||
|
const [cardFaceIndex, setCardFaceIndex] = useState<Map<string, number>>(new Map());
|
||||||
|
const [snackbar, setSnackbar] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
||||||
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
|
const [confirmModal, setConfirmModal] = useState<{
|
||||||
|
isOpen: boolean;
|
||||||
|
cardId: string;
|
||||||
|
cardName: string;
|
||||||
|
}>({ isOpen: false, cardId: '', cardName: '' });
|
||||||
|
|
||||||
const handleSearch = async (e: React.FormEvent) => {
|
// Helper function to check if a card has an actual back face (not adventure/split/etc)
|
||||||
e.preventDefault();
|
const isDoubleFaced = (card: Card) => {
|
||||||
if (!searchQuery.trim()) return;
|
// Only show flip for cards with physical back sides
|
||||||
|
const backFaceLayouts = ['transform', 'modal_dfc', 'double_faced_token', 'reversible_card'];
|
||||||
|
return card.card_faces && card.card_faces.length > 1 && backFaceLayouts.includes(card.layout);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to get the current face index for a card
|
||||||
|
const getCurrentFaceIndex = (cardId: string) => {
|
||||||
|
return cardFaceIndex.get(cardId) || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to get the image URI for a card (handling both single and double-faced)
|
||||||
|
const getCardImageUri = (card: Card, faceIndex: number = 0) => {
|
||||||
|
if (isDoubleFaced(card) && card.card_faces) {
|
||||||
|
return card.card_faces[faceIndex]?.image_uris?.normal || card.card_faces[faceIndex]?.image_uris?.small;
|
||||||
|
}
|
||||||
|
return card.image_uris?.normal || card.image_uris?.small;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to get the large image URI for hover preview
|
||||||
|
const getCardLargeImageUri = (card: Card, faceIndex: number = 0) => {
|
||||||
|
if (isDoubleFaced(card) && card.card_faces) {
|
||||||
|
return card.card_faces[faceIndex]?.image_uris?.large || card.card_faces[faceIndex]?.image_uris?.normal;
|
||||||
|
}
|
||||||
|
return card.image_uris?.large || card.image_uris?.normal;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle card face
|
||||||
|
const toggleCardFace = (cardId: string, totalFaces: number) => {
|
||||||
|
setCardFaceIndex(prev => {
|
||||||
|
const newMap = new Map(prev);
|
||||||
|
const currentIndex = prev.get(cardId) || 0;
|
||||||
|
const nextIndex = (currentIndex + 1) % totalFaces;
|
||||||
|
newMap.set(cardId, nextIndex);
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load user's collection from Supabase on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadCollection = async () => {
|
||||||
|
if (!user) {
|
||||||
|
setIsLoadingCollection(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoadingCollection(true);
|
||||||
|
// Get collection from Supabase (returns Map<card_id, quantity>)
|
||||||
|
const collectionMap = await getUserCollection(user.id);
|
||||||
|
|
||||||
|
if (collectionMap.size === 0) {
|
||||||
|
setCollection([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the actual card data from Scryfall for all cards in collection
|
||||||
|
const cardIds = Array.from(collectionMap.keys());
|
||||||
|
const cards = await getCardsByIds(cardIds);
|
||||||
|
|
||||||
|
// Combine card data with quantities
|
||||||
|
const collectionWithCards = cards.map(card => ({
|
||||||
|
card,
|
||||||
|
quantity: collectionMap.get(card.id) || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setCollection(collectionWithCards);
|
||||||
|
setFilteredCollection(collectionWithCards);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading collection:', error);
|
||||||
|
setSnackbar({ message: 'Failed to load collection', type: 'error' });
|
||||||
|
} finally {
|
||||||
|
setIsLoadingCollection(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadCollection();
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
// Filter collection based on search query
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchQuery.trim()) {
|
||||||
|
setFilteredCollection(collection);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
const filtered = collection.filter(({ card }) => {
|
||||||
|
return (
|
||||||
|
card.name.toLowerCase().includes(query) ||
|
||||||
|
card.type_line?.toLowerCase().includes(query) ||
|
||||||
|
card.oracle_text?.toLowerCase().includes(query) ||
|
||||||
|
card.colors?.some(color => color.toLowerCase().includes(query))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
setFilteredCollection(filtered);
|
||||||
|
}, [searchQuery, collection]);
|
||||||
|
|
||||||
|
// Update card quantity in collection
|
||||||
|
const updateCardQuantity = async (cardId: string, newQuantity: number) => {
|
||||||
|
if (!user || newQuantity < 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cards = await searchCards(searchQuery);
|
setIsUpdating(true);
|
||||||
setSearchResults(cards);
|
|
||||||
|
if (newQuantity === 0) {
|
||||||
|
// Remove card from collection
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('collections')
|
||||||
|
.delete()
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.eq('card_id', cardId);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
setCollection(prev => prev.filter(item => item.card.id !== cardId));
|
||||||
|
setSelectedCard(null);
|
||||||
|
setSnackbar({ message: 'Card removed from collection', type: 'success' });
|
||||||
|
} else {
|
||||||
|
// Update quantity
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('collections')
|
||||||
|
.update({ quantity: newQuantity, updated_at: new Date().toISOString() })
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.eq('card_id', cardId);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
setCollection(prev =>
|
||||||
|
prev.map(item =>
|
||||||
|
item.card.id === cardId ? { ...item, quantity: newQuantity } : item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selectedCard && selectedCard.card.id === cardId) {
|
||||||
|
setSelectedCard({ ...selectedCard, quantity: newQuantity });
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnackbar({ message: 'Quantity updated', type: 'success' });
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to search cards:', error);
|
console.error('Error updating card quantity:', error);
|
||||||
|
setSnackbar({ message: 'Failed to update quantity', type: 'error' });
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false);
|
||||||
|
setTimeout(() => setSnackbar(null), 3000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const addToCollection = (card: Card) => {
|
// Add one to quantity
|
||||||
setCollection(prev => {
|
const incrementQuantity = async (cardId: string, currentQuantity: number) => {
|
||||||
const existing = prev.find(c => c.card.id === card.id);
|
await updateCardQuantity(cardId, currentQuantity + 1);
|
||||||
if (existing) {
|
};
|
||||||
return prev.map(c =>
|
|
||||||
c.card.id === card.id
|
// Remove one from quantity
|
||||||
? { ...c, quantity: c.quantity + 1 }
|
const decrementQuantity = async (cardId: string, currentQuantity: number) => {
|
||||||
: c
|
if (currentQuantity > 0) {
|
||||||
);
|
await updateCardQuantity(cardId, currentQuantity - 1);
|
||||||
}
|
}
|
||||||
return [...prev, { card, quantity: 1 }];
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -39,83 +193,306 @@ export default function Collection() {
|
|||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<h1 className="text-3xl font-bold mb-6">My Collection</h1>
|
<h1 className="text-3xl font-bold mb-6">My Collection</h1>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search within collection */}
|
||||||
<form onSubmit={handleSearch} className="flex gap-2 mb-8">
|
<div className="mb-8">
|
||||||
<div className="relative flex-1">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
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"
|
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"
|
||||||
placeholder="Search cards to add..."
|
placeholder="Search your collection by name, type, or text..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
</div>
|
||||||
type="submit"
|
|
||||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Search size={20} />
|
|
||||||
Search
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Search Results */}
|
|
||||||
{searchResults.length > 0 && (
|
|
||||||
<div className="mb-8">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Search Results</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
||||||
{searchResults.map(card => (
|
|
||||||
<div key={card.id} className="bg-gray-800 rounded-lg overflow-hidden">
|
|
||||||
{card.image_uris?.normal && (
|
|
||||||
<img
|
|
||||||
src={card.image_uris.normal}
|
|
||||||
alt={card.name}
|
|
||||||
className="w-full h-auto"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="p-4">
|
|
||||||
<h3 className="font-bold mb-2">{card.name}</h3>
|
|
||||||
<button
|
|
||||||
onClick={() => addToCollection(card)}
|
|
||||||
className="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<Plus size={20} />
|
|
||||||
Add to Collection
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Collection */}
|
{/* Collection */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold mb-4">My Cards</h2>
|
<h2 className="text-xl font-semibold mb-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
{searchQuery ? `Found ${filteredCollection.length} card(s)` : `My Cards (${collection.length} unique, ${collection.reduce((acc, c) => acc + c.quantity, 0)} total)`}
|
||||||
{collection.map(({ card, quantity }) => (
|
</h2>
|
||||||
<div key={card.id} className="bg-gray-800 rounded-lg overflow-hidden">
|
|
||||||
{card.image_uris?.normal && (
|
{isLoadingCollection ? (
|
||||||
<img
|
<div className="flex items-center justify-center py-12">
|
||||||
src={card.image_uris.normal}
|
<Loader2 className="animate-spin text-blue-500" size={48} />
|
||||||
alt={card.name}
|
</div>
|
||||||
className="w-full h-auto"
|
) : collection.length === 0 ? (
|
||||||
/>
|
<div className="text-center py-12 text-gray-400">
|
||||||
|
<p className="text-lg mb-2">Your collection is empty</p>
|
||||||
|
<p className="text-sm">Add cards from the Deck Manager to build your collection</p>
|
||||||
|
</div>
|
||||||
|
) : filteredCollection.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-gray-400">
|
||||||
|
<p className="text-lg mb-2">No cards found</p>
|
||||||
|
<p className="text-sm">Try a different search term</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10 gap-3">
|
||||||
|
{filteredCollection.map(({ card, quantity }) => {
|
||||||
|
const currentFaceIndex = getCurrentFaceIndex(card.id);
|
||||||
|
const isMultiFaced = isDoubleFaced(card);
|
||||||
|
const displayName = isMultiFaced && card.card_faces
|
||||||
|
? card.card_faces[currentFaceIndex]?.name || card.name
|
||||||
|
: card.name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={card.id}
|
||||||
|
className="relative group cursor-pointer"
|
||||||
|
onMouseEnter={() => setHoveredCard(card)}
|
||||||
|
onMouseLeave={() => setHoveredCard(null)}
|
||||||
|
onClick={() => setSelectedCard({ card, quantity })}
|
||||||
|
>
|
||||||
|
{/* Small card thumbnail */}
|
||||||
|
<div className="relative rounded-lg overflow-hidden shadow-lg transition-all group-hover:ring-2 group-hover:ring-blue-500">
|
||||||
|
<img
|
||||||
|
src={getCardImageUri(card, currentFaceIndex)}
|
||||||
|
alt={displayName}
|
||||||
|
className="w-full h-auto"
|
||||||
|
/>
|
||||||
|
{/* Quantity badge */}
|
||||||
|
<div className="absolute top-1 right-1 bg-blue-600 text-white text-xs font-bold px-2 py-1 rounded-full shadow-lg">
|
||||||
|
x{quantity}
|
||||||
|
</div>
|
||||||
|
{/* Flip button for double-faced cards */}
|
||||||
|
{isMultiFaced && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleCardFace(card.id, card.card_faces!.length);
|
||||||
|
}}
|
||||||
|
className="absolute bottom-1 right-1 bg-purple-600 hover:bg-purple-700 text-white p-1 rounded-full shadow-lg transition-all"
|
||||||
|
title="Flip card"
|
||||||
|
>
|
||||||
|
<RefreshCw size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card name below thumbnail */}
|
||||||
|
<div className="mt-1 text-xs text-center truncate px-1">
|
||||||
|
{displayName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hover Card Preview - only show if no card is selected */}
|
||||||
|
{hoveredCard && !selectedCard && (() => {
|
||||||
|
const currentFaceIndex = getCurrentFaceIndex(hoveredCard.id);
|
||||||
|
const isMultiFaced = isDoubleFaced(hoveredCard);
|
||||||
|
const currentFace = isMultiFaced && hoveredCard.card_faces
|
||||||
|
? hoveredCard.card_faces[currentFaceIndex]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const displayName = currentFace?.name || hoveredCard.name;
|
||||||
|
const displayTypeLine = currentFace?.type_line || hoveredCard.type_line;
|
||||||
|
const displayOracleText = currentFace?.oracle_text || hoveredCard.oracle_text;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed top-1/2 right-8 transform -translate-y-1/2 z-40 pointer-events-none">
|
||||||
|
<div className="bg-gray-800 rounded-lg shadow-2xl p-4 max-w-md">
|
||||||
|
<div className="relative">
|
||||||
|
<img
|
||||||
|
src={getCardLargeImageUri(hoveredCard, currentFaceIndex)}
|
||||||
|
alt={displayName}
|
||||||
|
className="w-full h-auto rounded-lg shadow-lg"
|
||||||
|
/>
|
||||||
|
{isMultiFaced && (
|
||||||
|
<div className="absolute top-2 right-2 bg-purple-600 text-white text-xs font-bold px-2 py-1 rounded-full shadow-lg">
|
||||||
|
Face {currentFaceIndex + 1}/{hoveredCard.card_faces!.length}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="p-4">
|
</div>
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="mt-3 space-y-2">
|
||||||
<h3 className="font-bold">{card.name}</h3>
|
<h3 className="text-xl font-bold">{displayName}</h3>
|
||||||
<span className="text-sm bg-blue-600 px-2 py-1 rounded">
|
<p className="text-sm text-gray-400">{displayTypeLine}</p>
|
||||||
x{quantity}
|
{displayOracleText && (
|
||||||
</span>
|
<p className="text-sm text-gray-300 border-t border-gray-700 pt-2">
|
||||||
|
{displayOracleText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{hoveredCard.prices?.usd && (
|
||||||
|
<div className="text-sm text-green-400 font-semibold border-t border-gray-700 pt-2">
|
||||||
|
${hoveredCard.prices.usd}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Card Detail Panel - slides in from right */}
|
||||||
|
{selectedCard && (() => {
|
||||||
|
const currentFaceIndex = getCurrentFaceIndex(selectedCard.card.id);
|
||||||
|
const isMultiFaced = isDoubleFaced(selectedCard.card);
|
||||||
|
const currentFace = isMultiFaced && selectedCard.card.card_faces
|
||||||
|
? selectedCard.card.card_faces[currentFaceIndex]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const displayName = currentFace?.name || selectedCard.card.name;
|
||||||
|
const displayTypeLine = currentFace?.type_line || selectedCard.card.type_line;
|
||||||
|
const displayOracleText = currentFace?.oracle_text || selectedCard.card.oracle_text;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity duration-300"
|
||||||
|
onClick={() => setSelectedCard(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Sliding Panel */}
|
||||||
|
<div className="fixed top-0 right-0 h-full w-full md:w-96 bg-gray-800 shadow-2xl z-50 overflow-y-auto animate-slide-in-right">
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedCard(null)}
|
||||||
|
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Card Image */}
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<img
|
||||||
|
src={getCardLargeImageUri(selectedCard.card, currentFaceIndex)}
|
||||||
|
alt={displayName}
|
||||||
|
className="w-full h-auto rounded-lg shadow-lg"
|
||||||
|
/>
|
||||||
|
{isMultiFaced && (
|
||||||
|
<>
|
||||||
|
<div className="absolute top-2 right-2 bg-purple-600 text-white text-xs font-bold px-2 py-1 rounded-full shadow-lg">
|
||||||
|
Face {currentFaceIndex + 1}/{selectedCard.card.card_faces!.length}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleCardFace(selectedCard.card.id, selectedCard.card.card_faces!.length)}
|
||||||
|
className="absolute bottom-2 right-2 bg-purple-600 hover:bg-purple-700 text-white p-2 rounded-full shadow-lg transition-all"
|
||||||
|
title="Flip card"
|
||||||
|
>
|
||||||
|
<RefreshCw size={20} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card Info */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-2">{displayName}</h2>
|
||||||
|
<p className="text-sm text-gray-400">{displayTypeLine}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{displayOracleText && (
|
||||||
|
<div className="border-t border-gray-700 pt-3">
|
||||||
|
<p className="text-sm text-gray-300">{displayOracleText}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedCard.card.prices?.usd && (
|
||||||
|
<div className="border-t border-gray-700 pt-3">
|
||||||
|
<div className="text-lg text-green-400 font-semibold">
|
||||||
|
${selectedCard.card.prices.usd} each
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
Total value: ${(parseFloat(selectedCard.card.prices.usd) * selectedCard.quantity).toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quantity Management */}
|
||||||
|
<div className="border-t border-gray-700 pt-3">
|
||||||
|
<h3 className="text-lg font-semibold mb-3">Quantity in Collection</h3>
|
||||||
|
<div className="flex items-center justify-between bg-gray-900 rounded-lg p-4">
|
||||||
|
<button
|
||||||
|
onClick={() => decrementQuantity(selectedCard.card.id, selectedCard.quantity)}
|
||||||
|
disabled={isUpdating || selectedCard.quantity === 0}
|
||||||
|
className="bg-red-600 hover:bg-red-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white p-2 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Minus size={20} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-3xl font-bold">{selectedCard.quantity}</div>
|
||||||
|
<div className="text-xs text-gray-400">copies</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => incrementQuantity(selectedCard.card.id, selectedCard.quantity)}
|
||||||
|
disabled={isUpdating}
|
||||||
|
className="bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white p-2 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Remove from collection button */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setConfirmModal({
|
||||||
|
isOpen: true,
|
||||||
|
cardId: selectedCard.card.id,
|
||||||
|
cardName: displayName,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isUpdating}
|
||||||
|
className="w-full mt-3 px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg flex items-center justify-center gap-2 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={20} />
|
||||||
|
Remove from Collection
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Confirm Modal */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={confirmModal.isOpen}
|
||||||
|
onClose={() => setConfirmModal({ isOpen: false, cardId: '', cardName: '' })}
|
||||||
|
onConfirm={() => {
|
||||||
|
updateCardQuantity(confirmModal.cardId, 0);
|
||||||
|
setConfirmModal({ isOpen: false, cardId: '', cardName: '' });
|
||||||
|
}}
|
||||||
|
title="Remove from Collection"
|
||||||
|
message={`Are you sure you want to remove "${confirmModal.cardName}" from your collection? This action cannot be undone.`}
|
||||||
|
confirmText="Remove"
|
||||||
|
cancelText="Cancel"
|
||||||
|
variant="danger"
|
||||||
|
isLoading={isUpdating}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Snackbar */}
|
||||||
|
{snackbar && (
|
||||||
|
<div
|
||||||
|
className={`fixed bottom-4 right-4 p-4 rounded-lg shadow-lg transition-all duration-300 ${
|
||||||
|
snackbar.type === 'success' ? 'bg-green-500' : 'bg-red-500'
|
||||||
|
} text-white z-50`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center">
|
||||||
|
{snackbar.type === 'success' ? (
|
||||||
|
<CheckCircle className="mr-2" size={20} />
|
||||||
|
) : (
|
||||||
|
<XCircle className="mr-2" size={20} />
|
||||||
|
)}
|
||||||
|
<span>{snackbar.message}</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setSnackbar(null)} className="ml-4 text-gray-200 hover:text-white focus:outline-none">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
105
src/components/ConfirmModal.tsx
Normal file
105
src/components/ConfirmModal.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { AlertCircle, CheckCircle, Trash2, AlertTriangle } from 'lucide-react';
|
||||||
|
import Modal from './Modal';
|
||||||
|
|
||||||
|
interface ConfirmModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
variant?: 'danger' | 'warning' | 'info' | 'success';
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfirmModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText = 'Confirm',
|
||||||
|
cancelText = 'Cancel',
|
||||||
|
variant = 'danger',
|
||||||
|
isLoading = false,
|
||||||
|
}: ConfirmModalProps) {
|
||||||
|
const handleConfirm = () => {
|
||||||
|
onConfirm();
|
||||||
|
if (!isLoading) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const variantConfig = {
|
||||||
|
danger: {
|
||||||
|
icon: Trash2,
|
||||||
|
iconColor: 'text-red-500',
|
||||||
|
iconBg: 'bg-red-500/10',
|
||||||
|
buttonColor: 'bg-red-600 hover:bg-red-700',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
icon: AlertTriangle,
|
||||||
|
iconColor: 'text-yellow-500',
|
||||||
|
iconBg: 'bg-yellow-500/10',
|
||||||
|
buttonColor: 'bg-yellow-600 hover:bg-yellow-700',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
icon: AlertCircle,
|
||||||
|
iconColor: 'text-blue-500',
|
||||||
|
iconBg: 'bg-blue-500/10',
|
||||||
|
buttonColor: 'bg-blue-600 hover:bg-blue-700',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
icon: CheckCircle,
|
||||||
|
iconColor: 'text-green-500',
|
||||||
|
iconBg: 'bg-green-500/10',
|
||||||
|
buttonColor: 'bg-green-600 hover:bg-green-700',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = variantConfig[variant];
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} size="sm" showCloseButton={false}>
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="flex items-center justify-center mb-4">
|
||||||
|
<div className={`${config.iconBg} p-3 rounded-full`}>
|
||||||
|
<Icon className={config.iconColor} size={32} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h2 className="text-xl font-bold text-white text-center mb-2">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
<p className="text-gray-400 text-center mb-6">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={`flex-1 px-4 py-2 ${config.buttonColor} disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg transition-colors`}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Loading...' : confirmText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Plus, Search, Save, Trash2, Loader2, CheckCircle, XCircle, AlertCircle, PackagePlus } from 'lucide-react';
|
import { Plus, Search, Save, Trash2, Loader2, CheckCircle, XCircle, AlertCircle, PackagePlus, RefreshCw } from 'lucide-react';
|
||||||
import { Card, Deck } from '../types';
|
import { Card, Deck } from '../types';
|
||||||
import { searchCards, getUserCollection, addCardToCollection, addMultipleCardsToCollection } from '../services/api';
|
import { searchCards, getUserCollection, addCardToCollection, addMultipleCardsToCollection } from '../services/api';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
@@ -120,6 +120,7 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
|||||||
const [isLoadingCollection, setIsLoadingCollection] = useState(true);
|
const [isLoadingCollection, setIsLoadingCollection] = useState(true);
|
||||||
const [addingCardId, setAddingCardId] = useState<string | null>(null);
|
const [addingCardId, setAddingCardId] = useState<string | null>(null);
|
||||||
const [isAddingAll, setIsAddingAll] = useState(false);
|
const [isAddingAll, setIsAddingAll] = useState(false);
|
||||||
|
const [cardFaceIndex, setCardFaceIndex] = useState<Map<string, number>>(new Map());
|
||||||
|
|
||||||
// Load user collection on component mount
|
// Load user collection on component mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -141,6 +142,33 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
|||||||
loadUserCollection();
|
loadUserCollection();
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
// Helper functions for double-faced cards
|
||||||
|
const isDoubleFaced = (card: Card) => {
|
||||||
|
const backFaceLayouts = ['transform', 'modal_dfc', 'double_faced_token', 'reversible_card'];
|
||||||
|
return card.card_faces && card.card_faces.length > 1 && backFaceLayouts.includes(card.layout);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentFaceIndex = (cardId: string) => {
|
||||||
|
return cardFaceIndex.get(cardId) || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCardFace = (cardId: string, totalFaces: number) => {
|
||||||
|
setCardFaceIndex(prev => {
|
||||||
|
const newMap = new Map(prev);
|
||||||
|
const currentIndex = prev.get(cardId) || 0;
|
||||||
|
const nextIndex = (currentIndex + 1) % totalFaces;
|
||||||
|
newMap.set(cardId, nextIndex);
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCardImageUri = (card: Card, faceIndex: number = 0) => {
|
||||||
|
if (isDoubleFaced(card) && card.card_faces) {
|
||||||
|
return card.card_faces[faceIndex]?.image_uris?.normal || card.card_faces[faceIndex]?.image_uris?.small;
|
||||||
|
}
|
||||||
|
return card.image_uris?.normal || card.image_uris?.small || card.card_faces?.[0]?.image_uris?.normal;
|
||||||
|
};
|
||||||
|
|
||||||
// Helper function to check if a card is in the collection
|
// Helper function to check if a card is in the collection
|
||||||
const isCardInCollection = (cardId: string, requiredQuantity: number = 1): boolean => {
|
const isCardInCollection = (cardId: string, requiredQuantity: number = 1): boolean => {
|
||||||
const ownedQuantity = userCollection.get(cardId) || 0;
|
const ownedQuantity = userCollection.get(cardId) || 0;
|
||||||
@@ -428,11 +456,11 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
|||||||
cardsToAdd.push({ card, quantity });
|
cardsToAdd.push({ card, quantity });
|
||||||
} else {
|
} else {
|
||||||
console.warn(`Card not found: ${cardName}`);
|
console.warn(`Card not found: ${cardName}`);
|
||||||
alert(`Card not found: ${cardName}`);
|
setSnackbar({ message: `Card not found: ${cardName}`, type: 'error' });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to search card ${cardName}:`, error);
|
console.error(`Failed to search card ${cardName}:`, error);
|
||||||
alert(`Failed to search card ${cardName}: ${error}`);
|
setSnackbar({ message: `Failed to import card: ${cardName}`, type: 'error' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,24 +519,82 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
{searchResults.map(card => (
|
{searchResults.map(card => {
|
||||||
<div
|
const currentFaceIndex = getCurrentFaceIndex(card.id);
|
||||||
key={card.id}
|
const isMultiFaced = isDoubleFaced(card);
|
||||||
className="bg-gray-800 rounded-lg overflow-hidden hover:ring-2 hover:ring-blue-500 transition-all"
|
const inCollection = userCollection.get(card.id) || 0;
|
||||||
>
|
const isAddingThisCard = addingCardId === card.id;
|
||||||
<MagicCard card={card} />
|
|
||||||
<div className="p-4">
|
const displayName = isMultiFaced && card.card_faces
|
||||||
<h3 className="font-bold mb-2">{card.name}</h3>
|
? card.card_faces[currentFaceIndex]?.name || card.name
|
||||||
<button
|
: card.name;
|
||||||
onClick={() => addCardToDeck(card)}
|
|
||||||
className="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center justify-center gap-2"
|
return (
|
||||||
>
|
<div
|
||||||
<Plus size={20} />
|
key={card.id}
|
||||||
Add to Deck
|
className="bg-gray-800 rounded-lg overflow-hidden hover:ring-2 hover:ring-blue-500 transition-all"
|
||||||
</button>
|
>
|
||||||
|
<div className="relative">
|
||||||
|
{getCardImageUri(card, currentFaceIndex) ? (
|
||||||
|
<img
|
||||||
|
src={getCardImageUri(card, currentFaceIndex)}
|
||||||
|
alt={displayName}
|
||||||
|
className="w-full h-auto"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MagicCard card={card} />
|
||||||
|
)}
|
||||||
|
{isMultiFaced && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleCardFace(card.id, card.card_faces!.length);
|
||||||
|
}}
|
||||||
|
className="absolute bottom-2 right-2 bg-purple-600 hover:bg-purple-700 text-white p-2 rounded-full shadow-lg transition-all"
|
||||||
|
title="Flip card"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="font-bold">{displayName}</h3>
|
||||||
|
{inCollection > 0 && (
|
||||||
|
<span className="text-xs bg-green-600 px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||||
|
<CheckCircle size={12} />
|
||||||
|
x{inCollection}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{card.prices?.usd && (
|
||||||
|
<div className="text-sm text-gray-400 mb-2">${card.prices.usd}</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => addCardToDeck(card)}
|
||||||
|
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus size={20} />
|
||||||
|
Add to Deck
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleAddCardToCollection(card.id, 1)}
|
||||||
|
disabled={isAddingThisCard}
|
||||||
|
className="px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded-lg flex items-center justify-center gap-2"
|
||||||
|
title="Add to collection"
|
||||||
|
>
|
||||||
|
{isAddingThisCard ? (
|
||||||
|
<Loader2 className="animate-spin" size={20} />
|
||||||
|
) : (
|
||||||
|
<PackagePlus size={20} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ interface MagicCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MagicCard = ({ card }: MagicCardProps) => {
|
const MagicCard = ({ card }: MagicCardProps) => {
|
||||||
|
// Handle both regular cards and double-faced cards (transform, modal_dfc, etc)
|
||||||
|
const imageUri = card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative card-hover animate-fade-in">
|
<div className="relative card-hover animate-fade-in">
|
||||||
{card.image_uris?.normal ? (
|
{imageUri ? (
|
||||||
<img
|
<img
|
||||||
src={card.image_uris.normal}
|
src={imageUri}
|
||||||
alt={card.name}
|
alt={card.name}
|
||||||
className="w-full h-auto rounded-lg transition-smooth"
|
className="w-full h-auto rounded-lg transition-smooth"
|
||||||
/>
|
/>
|
||||||
|
|||||||
80
src/components/Modal.tsx
Normal file
80
src/components/Modal.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Modal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
size = 'md',
|
||||||
|
showCloseButton = true
|
||||||
|
}: ModalProps) {
|
||||||
|
// Close modal on ESC key
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && isOpen) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
return () => document.removeEventListener('keydown', handleEscape);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
// Prevent body scroll when modal is open
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = 'unset';
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = 'unset';
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'max-w-md',
|
||||||
|
md: 'max-w-lg',
|
||||||
|
lg: 'max-w-2xl',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-50 z-50 transition-opacity duration-300 animate-fade-in"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
|
||||||
|
<div
|
||||||
|
className={`${sizeClasses[size]} w-full bg-gray-800 rounded-lg shadow-2xl pointer-events-auto animate-scale-in`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{showCloseButton && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors z-10"
|
||||||
|
>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user