Compare commits
36 Commits
6de32baefb
...
feature/tr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1183f0c7f6 | ||
| d1728546b1 | |||
| 9f5dab94af | |||
| 89fc4a782c | |||
|
|
abbe68888d | ||
|
|
8f064d4336 | ||
|
|
a005df9965 | ||
|
|
7eb893ac63 | ||
|
|
8d0ce534f8 | ||
| 8671745351 | |||
| 70e7db0bac | |||
| 4a28f5f1ec | |||
| 7e1cd5f9bd | |||
| b77cd48013 | |||
|
|
304676a06b | ||
|
|
a7681357b5 | ||
|
|
a0c5143103 | ||
|
|
bab6367181 | ||
|
|
0780976661 | ||
|
|
36482bc3d6 | ||
|
|
ea287efdbf | ||
|
|
459cc0eced | ||
|
|
e94952ad20 | ||
| 90b39481d1 | |||
| e83874162f | |||
| ebae5a82db | |||
|
|
1b8d4519e6 | ||
|
|
defcf2af9c | ||
|
|
e062a3a84f | ||
|
|
73b7735074 | ||
| 57f0e7efe7 | |||
|
|
b3fc8079c6 | ||
|
|
6d8e7b3224 | ||
|
|
8bb80dac2e | ||
|
|
247f2205b8 | ||
|
|
e96289de03 |
15
.claude/settings.local.json
Normal file
15
.claude/settings.local.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__supabase__apply_migration",
|
||||
"mcp__supabase__list_tables",
|
||||
"mcp__supabase__execute_sql",
|
||||
"Bash(npm run build:*)",
|
||||
"mcp__supabase__get_advisors"
|
||||
]
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
"enabledMcpjsonServers": [
|
||||
"supabase"
|
||||
]
|
||||
}
|
||||
21
.dockerignore
Normal file
21
.dockerignore
Normal file
@@ -0,0 +1,21 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.md
|
||||
!DOCKER.md
|
||||
.vscode
|
||||
.idea
|
||||
*.log
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
coverage
|
||||
.nyc_output
|
||||
*.test.ts
|
||||
*.test.tsx
|
||||
*.spec.ts
|
||||
*.spec.tsx
|
||||
98
.env.example
Normal file
98
.env.example
Normal file
@@ -0,0 +1,98 @@
|
||||
# =====================================================
|
||||
# DECKERR DOCKER CONFIGURATION
|
||||
# =====================================================
|
||||
# Copy this file to .env and configure your settings
|
||||
#
|
||||
# Two deployment modes available:
|
||||
# 1. External Supabase: Use docker-compose.yml (simpler)
|
||||
# 2. Self-hosted Supabase: Use docker-compose.selfhosted.yml (full stack)
|
||||
# =====================================================
|
||||
|
||||
# =====================================================
|
||||
# MODE 1: EXTERNAL SUPABASE (docker-compose.yml)
|
||||
# =====================================================
|
||||
# Use this if you have:
|
||||
# - A Supabase cloud account (supabase.com)
|
||||
# - A separately self-hosted Supabase instance
|
||||
# - Access to a paid hosted Supabase service
|
||||
|
||||
# Your Supabase project URL
|
||||
VITE_SUPABASE_URL=https://your-project.supabase.co
|
||||
|
||||
# Your Supabase anonymous/public key
|
||||
VITE_SUPABASE_ANON_KEY=your-anon-key-here
|
||||
|
||||
# Port to run Deckerr on (default: 3000)
|
||||
PORT=3000
|
||||
|
||||
|
||||
# =====================================================
|
||||
# MODE 2: SELF-HOSTED SUPABASE (docker-compose.selfhosted.yml)
|
||||
# =====================================================
|
||||
# Use this to run everything locally, including Supabase
|
||||
|
||||
# --- Site Configuration ---
|
||||
# Your domain or IP address (used for redirects)
|
||||
SITE_URL=http://localhost:3000
|
||||
|
||||
# External API URL (Kong gateway)
|
||||
API_EXTERNAL_URL=http://localhost:8000
|
||||
|
||||
# --- Port Configuration ---
|
||||
DECKERR_PORT=3000
|
||||
KONG_HTTP_PORT=8000
|
||||
KONG_HTTPS_PORT=8443
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# --- Security Keys ---
|
||||
# IMPORTANT: Generate secure random values for production!
|
||||
# You can use: openssl rand -base64 32
|
||||
|
||||
# PostgreSQL password
|
||||
POSTGRES_PASSWORD=your-super-secret-postgres-password
|
||||
|
||||
# JWT Secret (must be at least 32 characters)
|
||||
# Generate with: openssl rand -base64 32
|
||||
JWT_SECRET=your-super-secret-jwt-token-with-at-least-32-characters
|
||||
|
||||
# JWT Expiry in seconds (default: 3600 = 1 hour)
|
||||
JWT_EXPIRY=3600
|
||||
|
||||
# Supabase Anonymous Key
|
||||
# Generate at: https://supabase.com/docs/guides/self-hosting#api-keys
|
||||
# Or use: npx @supabase/cli@latest gen key --type anon --jwt-secret "YOUR_JWT_SECRET"
|
||||
ANON_KEY=your-anon-key
|
||||
|
||||
# Supabase Service Role Key (admin access)
|
||||
# Generate at: https://supabase.com/docs/guides/self-hosting#api-keys
|
||||
# Or use: npx @supabase/cli@latest gen key --type service_role --jwt-secret "YOUR_JWT_SECRET"
|
||||
SERVICE_ROLE_KEY=your-service-role-key
|
||||
|
||||
# --- Email Configuration (Optional) ---
|
||||
# Required for email verification and password reset
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-email@example.com
|
||||
SMTP_PASS=your-email-password
|
||||
SMTP_ADMIN_EMAIL=admin@example.com
|
||||
SMTP_SENDER_NAME=Deckerr
|
||||
|
||||
# Enable email auto-confirm (set to true to skip email verification)
|
||||
ENABLE_EMAIL_AUTOCONFIRM=true
|
||||
|
||||
# --- Feature Flags ---
|
||||
# Disable new user signups
|
||||
DISABLE_SIGNUP=false
|
||||
|
||||
# Enable email signup
|
||||
ENABLE_EMAIL_SIGNUP=true
|
||||
|
||||
# Enable anonymous users
|
||||
ENABLE_ANONYMOUS_USERS=false
|
||||
|
||||
# --- Advanced ---
|
||||
# Additional redirect URLs (comma-separated)
|
||||
ADDITIONAL_REDIRECT_URLS=
|
||||
|
||||
# PostgREST schemas
|
||||
PGRST_DB_SCHEMAS=public,graphql_public
|
||||
8
.mcp.json
Normal file
8
.mcp.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"supabase": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.supabase.com/mcp?project_ref=yedghjrpyxhxesnbtbip"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
191
DOCKER.md
Normal file
191
DOCKER.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# Deckerr Docker Deployment
|
||||
|
||||
Self-host Deckerr with two deployment options:
|
||||
|
||||
## Deployment Options
|
||||
|
||||
| Option | Use Case | Complexity |
|
||||
|--------|----------|------------|
|
||||
| **External Supabase** | Use hosted Supabase (cloud or paid) | Simple |
|
||||
| **Self-hosted Supabase** | Run everything locally | Advanced |
|
||||
|
||||
---
|
||||
|
||||
## Option 1: External Supabase (Recommended)
|
||||
|
||||
Use your own Supabase instance (cloud, paid hosted, or separately self-hosted).
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Copy environment template
|
||||
cp .env.example .env
|
||||
|
||||
# 2. Edit .env with your Supabase credentials
|
||||
VITE_SUPABASE_URL=https://your-project.supabase.co
|
||||
VITE_SUPABASE_ANON_KEY=your-anon-key
|
||||
PORT=3000
|
||||
|
||||
# 3. Run database migrations on your Supabase
|
||||
# Go to Supabase Dashboard > SQL Editor and run:
|
||||
# Contents of supabase/migrations/20250131132458_black_frost.sql
|
||||
|
||||
# 4. Start Deckerr
|
||||
docker-compose up -d
|
||||
|
||||
# 5. Access at http://localhost:3000
|
||||
```
|
||||
|
||||
### Using Hosted Supabase (Paid Service)
|
||||
|
||||
Contact the Deckerr team for access credentials to use the hosted backend.
|
||||
|
||||
---
|
||||
|
||||
## Option 2: Self-Hosted Supabase (Full Stack)
|
||||
|
||||
Run Deckerr with a complete self-hosted Supabase stack.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker & Docker Compose
|
||||
- 2GB+ RAM
|
||||
- Ports: 3000, 5432, 8000
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Copy environment template
|
||||
cp .env.example .env
|
||||
|
||||
# 2. Generate secure keys
|
||||
# JWT Secret (required)
|
||||
openssl rand -base64 32
|
||||
|
||||
# Generate Supabase API keys
|
||||
# Option A: Use online generator at https://supabase.com/docs/guides/self-hosting#api-keys
|
||||
# Option B: Use Supabase CLI
|
||||
npx @supabase/cli@latest gen key --type anon --jwt-secret "YOUR_JWT_SECRET"
|
||||
npx @supabase/cli@latest gen key --type service_role --jwt-secret "YOUR_JWT_SECRET"
|
||||
|
||||
# 3. Update .env with your generated values
|
||||
POSTGRES_PASSWORD=<generated-password>
|
||||
JWT_SECRET=<generated-jwt-secret>
|
||||
ANON_KEY=<generated-anon-key>
|
||||
SERVICE_ROLE_KEY=<generated-service-key>
|
||||
|
||||
# 4. Start all services
|
||||
docker-compose -f docker-compose.selfhosted.yml up -d
|
||||
|
||||
# 5. Access Deckerr at http://localhost:3000
|
||||
# API available at http://localhost:8000
|
||||
```
|
||||
|
||||
### Generate Keys Script
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
JWT_SECRET=$(openssl rand -base64 32)
|
||||
echo "JWT_SECRET=$JWT_SECRET"
|
||||
echo ""
|
||||
echo "Now generate API keys at:"
|
||||
echo "https://supabase.com/docs/guides/self-hosting#api-keys"
|
||||
echo "Use this JWT secret: $JWT_SECRET"
|
||||
```
|
||||
|
||||
### Self-Hosted Services
|
||||
|
||||
| Service | Port | Description |
|
||||
|---------|------|-------------|
|
||||
| Deckerr | 3000 | Frontend app |
|
||||
| Kong | 8000 | API Gateway |
|
||||
| PostgreSQL | 5432 | Database |
|
||||
| Auth | 9999 | Authentication (internal) |
|
||||
| REST | 3000 | PostgREST API (internal) |
|
||||
| Realtime | 4000 | WebSocket (internal) |
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### External Supabase Mode
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `VITE_SUPABASE_URL` | Yes | Supabase project URL |
|
||||
| `VITE_SUPABASE_ANON_KEY` | Yes | Supabase anonymous key |
|
||||
| `PORT` | No | App port (default: 3000) |
|
||||
|
||||
### Self-Hosted Mode
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `POSTGRES_PASSWORD` | Yes | PostgreSQL password |
|
||||
| `JWT_SECRET` | Yes | JWT signing secret (32+ chars) |
|
||||
| `ANON_KEY` | Yes | Supabase anonymous key |
|
||||
| `SERVICE_ROLE_KEY` | Yes | Supabase service role key |
|
||||
| `SITE_URL` | No | Your domain (default: localhost) |
|
||||
| `SMTP_*` | No | Email configuration |
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Start (external Supabase)
|
||||
docker-compose up -d
|
||||
|
||||
# Start (self-hosted)
|
||||
docker-compose -f docker-compose.selfhosted.yml up -d
|
||||
|
||||
# Stop
|
||||
docker-compose down
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Rebuild after code changes
|
||||
docker-compose build --no-cache
|
||||
docker-compose up -d
|
||||
|
||||
# Reset database (self-hosted only)
|
||||
docker-compose -f docker-compose.selfhosted.yml down -v
|
||||
docker-compose -f docker-compose.selfhosted.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Production Checklist
|
||||
|
||||
- [ ] Use strong passwords (generate with `openssl rand -base64 32`)
|
||||
- [ ] Configure HTTPS with reverse proxy (nginx, Traefik, Caddy)
|
||||
- [ ] Set up email (SMTP) for password reset
|
||||
- [ ] Configure firewall rules
|
||||
- [ ] Set up backups for PostgreSQL volume
|
||||
- [ ] Consider rate limiting at reverse proxy level
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Container won't start
|
||||
```bash
|
||||
docker-compose logs <service-name>
|
||||
```
|
||||
|
||||
### Database connection issues
|
||||
```bash
|
||||
# Check if database is healthy
|
||||
docker-compose exec db pg_isready -U postgres
|
||||
```
|
||||
|
||||
### Reset everything
|
||||
```bash
|
||||
docker-compose down -v
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
### Check service health
|
||||
```bash
|
||||
docker-compose ps
|
||||
```
|
||||
39
Dockerfile
Normal file
39
Dockerfile
Normal file
@@ -0,0 +1,39 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build arguments for Supabase configuration
|
||||
ARG VITE_SUPABASE_URL
|
||||
ARG VITE_SUPABASE_ANON_KEY
|
||||
|
||||
# Set environment variables for build
|
||||
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
|
||||
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy custom nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy built assets from builder
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -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
dev-dist/registerSW.js
Normal file
1
dev-dist/registerSW.js
Normal file
@@ -0,0 +1 @@
|
||||
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
|
||||
110
dev-dist/sw.js
Normal file
110
dev-dist/sw.js
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Copyright 2018 Google Inc. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// If the loader is already loaded, just stop.
|
||||
if (!self.define) {
|
||||
let registry = {};
|
||||
|
||||
// Used for `eval` and `importScripts` where we can't get script URL by other means.
|
||||
// In both cases, it's safe to use a global var because those functions are synchronous.
|
||||
let nextDefineUri;
|
||||
|
||||
const singleRequire = (uri, parentUri) => {
|
||||
uri = new URL(uri + ".js", parentUri).href;
|
||||
return registry[uri] || (
|
||||
|
||||
new Promise(resolve => {
|
||||
if ("document" in self) {
|
||||
const script = document.createElement("script");
|
||||
script.src = uri;
|
||||
script.onload = resolve;
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
nextDefineUri = uri;
|
||||
importScripts(uri);
|
||||
resolve();
|
||||
}
|
||||
})
|
||||
|
||||
.then(() => {
|
||||
let promise = registry[uri];
|
||||
if (!promise) {
|
||||
throw new Error(`Module ${uri} didn’t register its module`);
|
||||
}
|
||||
return promise;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
self.define = (depsNames, factory) => {
|
||||
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
|
||||
if (registry[uri]) {
|
||||
// Module is already loading or loaded.
|
||||
return;
|
||||
}
|
||||
let exports = {};
|
||||
const require = depUri => singleRequire(depUri, uri);
|
||||
const specialDeps = {
|
||||
module: { uri },
|
||||
exports,
|
||||
require
|
||||
};
|
||||
registry[uri] = Promise.all(depsNames.map(
|
||||
depName => specialDeps[depName] || require(depName)
|
||||
)).then(deps => {
|
||||
factory(...deps);
|
||||
return exports;
|
||||
});
|
||||
};
|
||||
}
|
||||
define(['./workbox-ca84f546'], (function (workbox) { 'use strict';
|
||||
|
||||
self.skipWaiting();
|
||||
workbox.clientsClaim();
|
||||
|
||||
/**
|
||||
* The precacheAndRoute() method efficiently caches and responds to
|
||||
* requests for URLs in the manifest.
|
||||
* See https://goo.gl/S9QRab
|
||||
*/
|
||||
workbox.precacheAndRoute([{
|
||||
"url": "registerSW.js",
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.vigoqq958cg"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
allowlist: [/^\/$/]
|
||||
}));
|
||||
workbox.registerRoute(/^https:\/\/api\.scryfall\.com\/.*/i, new workbox.CacheFirst({
|
||||
"cacheName": "scryfall-cache",
|
||||
plugins: [new workbox.ExpirationPlugin({
|
||||
maxEntries: 500,
|
||||
maxAgeSeconds: 604800
|
||||
}), new workbox.CacheableResponsePlugin({
|
||||
statuses: [0, 200]
|
||||
})]
|
||||
}), 'GET');
|
||||
workbox.registerRoute(/^https:\/\/cards\.scryfall\.io\/.*/i, new workbox.CacheFirst({
|
||||
"cacheName": "card-images-cache",
|
||||
plugins: [new workbox.ExpirationPlugin({
|
||||
maxEntries: 1000,
|
||||
maxAgeSeconds: 2592000
|
||||
}), new workbox.CacheableResponsePlugin({
|
||||
statuses: [0, 200]
|
||||
})]
|
||||
}), 'GET');
|
||||
|
||||
}));
|
||||
4556
dev-dist/workbox-ca84f546.js
Normal file
4556
dev-dist/workbox-ca84f546.js
Normal file
File diff suppressed because it is too large
Load Diff
201
docker-compose.selfhosted.yml
Normal file
201
docker-compose.selfhosted.yml
Normal file
@@ -0,0 +1,201 @@
|
||||
version: '3.8'
|
||||
|
||||
# Full self-hosted deployment with Supabase included
|
||||
# This includes PostgreSQL, Auth, REST API, and the Deckerr frontend
|
||||
|
||||
services:
|
||||
# ============================================
|
||||
# DECKERR FRONTEND
|
||||
# ============================================
|
||||
deckerr:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- VITE_SUPABASE_URL=http://${SITE_URL:-localhost}:${KONG_HTTP_PORT:-8000}
|
||||
- VITE_SUPABASE_ANON_KEY=${ANON_KEY}
|
||||
container_name: deckerr
|
||||
ports:
|
||||
- "${DECKERR_PORT:-3000}:80"
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
kong:
|
||||
condition: service_healthy
|
||||
|
||||
# ============================================
|
||||
# SUPABASE SERVICES
|
||||
# ============================================
|
||||
|
||||
# PostgreSQL Database
|
||||
db:
|
||||
image: supabase/postgres:15.1.1.78
|
||||
container_name: supabase-db
|
||||
healthcheck:
|
||||
test: pg_isready -U postgres -h localhost
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
environment:
|
||||
POSTGRES_HOST: /var/run/postgresql
|
||||
PGPORT: 5432
|
||||
POSTGRES_PORT: 5432
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
PGDATABASE: postgres
|
||||
POSTGRES_DB: postgres
|
||||
volumes:
|
||||
- supabase-db-data:/var/lib/postgresql/data
|
||||
- ./supabase/migrations:/docker-entrypoint-initdb.d/migrations
|
||||
restart: unless-stopped
|
||||
|
||||
# Supabase Kong API Gateway
|
||||
kong:
|
||||
image: kong:2.8.1
|
||||
container_name: supabase-kong
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${KONG_HTTP_PORT:-8000}:8000/tcp"
|
||||
- "${KONG_HTTPS_PORT:-8443}:8443/tcp"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
KONG_DATABASE: "off"
|
||||
KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml
|
||||
KONG_DNS_ORDER: LAST,A,CNAME
|
||||
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth
|
||||
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
|
||||
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
|
||||
SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
||||
volumes:
|
||||
- ./docker/kong.yml:/home/kong/kong.yml:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "kong", "health"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
# Supabase Auth (GoTrue)
|
||||
auth:
|
||||
image: supabase/gotrue:v2.143.0
|
||||
container_name: supabase-auth
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9999/health"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
GOTRUE_API_HOST: 0.0.0.0
|
||||
GOTRUE_API_PORT: 9999
|
||||
API_EXTERNAL_URL: ${API_EXTERNAL_URL:-http://localhost:8000}
|
||||
|
||||
GOTRUE_DB_DRIVER: postgres
|
||||
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@db:5432/postgres
|
||||
|
||||
GOTRUE_SITE_URL: ${SITE_URL:-http://localhost:3000}
|
||||
GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS:-}
|
||||
GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP:-false}
|
||||
|
||||
GOTRUE_JWT_ADMIN_ROLES: service_role
|
||||
GOTRUE_JWT_AUD: authenticated
|
||||
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
|
||||
GOTRUE_JWT_EXP: ${JWT_EXPIRY:-3600}
|
||||
GOTRUE_JWT_SECRET: ${JWT_SECRET}
|
||||
|
||||
GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP:-true}
|
||||
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${ENABLE_ANONYMOUS_USERS:-false}
|
||||
GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM:-false}
|
||||
|
||||
GOTRUE_SMTP_HOST: ${SMTP_HOST:-}
|
||||
GOTRUE_SMTP_PORT: ${SMTP_PORT:-587}
|
||||
GOTRUE_SMTP_USER: ${SMTP_USER:-}
|
||||
GOTRUE_SMTP_PASS: ${SMTP_PASS:-}
|
||||
GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL:-}
|
||||
GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME:-Deckerr}
|
||||
GOTRUE_MAILER_URLPATHS_INVITE: /auth/v1/verify
|
||||
GOTRUE_MAILER_URLPATHS_CONFIRMATION: /auth/v1/verify
|
||||
GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/v1/verify
|
||||
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/v1/verify
|
||||
|
||||
# Supabase REST API (PostgREST)
|
||||
rest:
|
||||
image: postgrest/postgrest:v12.0.1
|
||||
container_name: supabase-rest
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@db:5432/postgres
|
||||
PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS:-public,graphql_public}
|
||||
PGRST_DB_ANON_ROLE: anon
|
||||
PGRST_JWT_SECRET: ${JWT_SECRET}
|
||||
PGRST_DB_USE_LEGACY_GUCS: "false"
|
||||
PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
|
||||
PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY:-3600}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:3000/ready || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
# Supabase Realtime
|
||||
realtime:
|
||||
image: supabase/realtime:v2.28.32
|
||||
container_name: supabase-realtime
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sSfL", "--head", "-o", "/dev/null", "-H", "Authorization: Bearer ${ANON_KEY}", "http://localhost:4000/api/tenants/realtime-dev/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PORT: 4000
|
||||
DB_HOST: db
|
||||
DB_PORT: 5432
|
||||
DB_USER: supabase_admin
|
||||
DB_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
DB_NAME: postgres
|
||||
DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
|
||||
DB_ENC_KEY: supabaserealtime
|
||||
API_JWT_SECRET: ${JWT_SECRET}
|
||||
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq}
|
||||
ERL_AFLAGS: -proto_dist inet_tcp
|
||||
DNS_NODES: "''"
|
||||
RLIMIT_NOFILE: "10000"
|
||||
APP_NAME: realtime
|
||||
SEED_SELF_HOST: true
|
||||
REPLICATION_MODE: RLS
|
||||
REPLICATION_POLL_INTERVAL: 100
|
||||
SECURE_CHANNELS: "true"
|
||||
SLOT_NAME: supabase_realtime_rls
|
||||
TEMPORARY_SLOT: "true"
|
||||
|
||||
# Supabase Meta (for Studio - optional)
|
||||
meta:
|
||||
image: supabase/postgres-meta:v0.80.0
|
||||
container_name: supabase-meta
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PG_META_PORT: 8080
|
||||
PG_META_DB_HOST: db
|
||||
PG_META_DB_PORT: 5432
|
||||
PG_META_DB_NAME: postgres
|
||||
PG_META_DB_USER: supabase_admin
|
||||
PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
|
||||
volumes:
|
||||
supabase-db-data:
|
||||
22
docker-compose.yml
Normal file
22
docker-compose.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
version: '3.8'
|
||||
|
||||
# Simple deployment - Uses external Supabase (hosted or self-hosted separately)
|
||||
# For full self-hosted setup with Supabase included, use docker-compose.selfhosted.yml
|
||||
|
||||
services:
|
||||
deckerr:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- VITE_SUPABASE_URL=${VITE_SUPABASE_URL}
|
||||
- VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}
|
||||
container_name: deckerr
|
||||
ports:
|
||||
- "${PORT:-3000}:80"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
138
docker/kong.yml
Normal file
138
docker/kong.yml
Normal file
@@ -0,0 +1,138 @@
|
||||
_format_version: "2.1"
|
||||
_transform: true
|
||||
|
||||
###
|
||||
### Consumers / Users
|
||||
###
|
||||
consumers:
|
||||
- username: DASHBOARD
|
||||
- username: anon
|
||||
keyauth_credentials:
|
||||
- key: ${SUPABASE_ANON_KEY}
|
||||
- username: service_role
|
||||
keyauth_credentials:
|
||||
- key: ${SUPABASE_SERVICE_KEY}
|
||||
|
||||
###
|
||||
### Access Control Lists
|
||||
###
|
||||
acls:
|
||||
- consumer: anon
|
||||
group: anon
|
||||
- consumer: service_role
|
||||
group: admin
|
||||
|
||||
###
|
||||
### API Routes
|
||||
###
|
||||
services:
|
||||
## Open Auth routes
|
||||
- name: auth-v1-open
|
||||
url: http://auth:9999/verify
|
||||
routes:
|
||||
- name: auth-v1-open
|
||||
strip_path: true
|
||||
paths:
|
||||
- /auth/v1/verify
|
||||
plugins:
|
||||
- name: cors
|
||||
- name: auth-v1-open-callback
|
||||
url: http://auth:9999/callback
|
||||
routes:
|
||||
- name: auth-v1-open-callback
|
||||
strip_path: true
|
||||
paths:
|
||||
- /auth/v1/callback
|
||||
plugins:
|
||||
- name: cors
|
||||
- name: auth-v1-open-authorize
|
||||
url: http://auth:9999/authorize
|
||||
routes:
|
||||
- name: auth-v1-open-authorize
|
||||
strip_path: true
|
||||
paths:
|
||||
- /auth/v1/authorize
|
||||
plugins:
|
||||
- name: cors
|
||||
|
||||
## Secure Auth routes
|
||||
- name: auth-v1
|
||||
_comment: "GoTrue: /auth/v1/* -> http://auth:9999/*"
|
||||
url: http://auth:9999/
|
||||
routes:
|
||||
- name: auth-v1-all
|
||||
strip_path: true
|
||||
paths:
|
||||
- /auth/v1/
|
||||
plugins:
|
||||
- name: cors
|
||||
- name: key-auth
|
||||
config:
|
||||
hide_credentials: false
|
||||
- name: acl
|
||||
config:
|
||||
hide_groups_header: true
|
||||
allow:
|
||||
- admin
|
||||
- anon
|
||||
|
||||
## Secure REST routes
|
||||
- name: rest-v1
|
||||
_comment: "PostgREST: /rest/v1/* -> http://rest:3000/*"
|
||||
url: http://rest:3000/
|
||||
routes:
|
||||
- name: rest-v1-all
|
||||
strip_path: true
|
||||
paths:
|
||||
- /rest/v1/
|
||||
plugins:
|
||||
- name: cors
|
||||
- name: key-auth
|
||||
config:
|
||||
hide_credentials: false
|
||||
- name: acl
|
||||
config:
|
||||
hide_groups_header: true
|
||||
allow:
|
||||
- admin
|
||||
- anon
|
||||
|
||||
## Realtime routes
|
||||
- name: realtime-v1
|
||||
_comment: "Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*"
|
||||
url: http://realtime:4000/socket/
|
||||
routes:
|
||||
- name: realtime-v1-all
|
||||
strip_path: true
|
||||
paths:
|
||||
- /realtime/v1/
|
||||
plugins:
|
||||
- name: cors
|
||||
- name: key-auth
|
||||
config:
|
||||
hide_credentials: false
|
||||
- name: acl
|
||||
config:
|
||||
hide_groups_header: true
|
||||
allow:
|
||||
- admin
|
||||
- anon
|
||||
|
||||
## Meta routes (for Supabase Studio)
|
||||
- name: meta
|
||||
_comment: "pg-meta: /pg/* -> http://meta:8080/*"
|
||||
url: http://meta:8080/
|
||||
routes:
|
||||
- name: meta-all
|
||||
strip_path: true
|
||||
paths:
|
||||
- /pg/
|
||||
plugins:
|
||||
- name: key-auth
|
||||
config:
|
||||
hide_credentials: false
|
||||
- name: acl
|
||||
config:
|
||||
hide_groups_header: true
|
||||
allow:
|
||||
- admin
|
||||
25
index.html
25
index.html
@@ -2,9 +2,28 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>Deckerr - Card Deck Manager</title>
|
||||
<meta name="title" content="Deckerr - Card Deck Manager" />
|
||||
<meta name="description" content="Manage your trading card game decks on the go. Build, organize, and track your card collection." />
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Deckerr" />
|
||||
|
||||
<!-- Apple Touch Icons -->
|
||||
<link rel="apple-touch-icon" href="/icon.svg" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/icon.svg" />
|
||||
|
||||
<!-- MS Application -->
|
||||
<meta name="msapplication-TileColor" content="#0f172a" />
|
||||
<meta name="msapplication-config" content="/browserconfig.xml" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
29
nginx.conf
Normal file
29
nginx.conf
Normal file
@@ -0,0 +1,29 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript application/json;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Handle SPA routing - serve index.html for all routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
}
|
||||
5889
package-lock.json
generated
5889
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "vite-react-typescript-starter",
|
||||
"name": "deckerr",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -29,6 +29,7 @@
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.3.0",
|
||||
"vite": "^5.4.2"
|
||||
"vite": "^5.4.2",
|
||||
"vite-plugin-pwa": "^1.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
9
public/browserconfig.xml
Normal file
9
public/browserconfig.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/icon.svg"/>
|
||||
<TileColor>#0f172a</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
23
public/icon.svg
Normal file
23
public/icon.svg
Normal file
@@ -0,0 +1,23 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Background -->
|
||||
<rect width="512" height="512" fill="#0f172a" rx="128"/>
|
||||
|
||||
<!-- Card stack effect - back card -->
|
||||
<rect x="120" y="130" width="240" height="320" rx="16" fill="#1e293b" transform="rotate(-8 240 290)"/>
|
||||
|
||||
<!-- Card stack effect - middle card -->
|
||||
<rect x="120" y="130" width="240" height="320" rx="16" fill="#334155" transform="rotate(-4 240 290)"/>
|
||||
|
||||
<!-- Front card -->
|
||||
<rect x="120" y="130" width="240" height="320" rx="16" fill="#475569"/>
|
||||
<rect x="135" y="145" width="210" height="290" rx="12" fill="#1e293b"/>
|
||||
|
||||
<!-- Card details/design -->
|
||||
<circle cx="240" cy="200" r="40" fill="#3b82f6" opacity="0.6"/>
|
||||
<rect x="160" y="280" width="160" height="12" rx="6" fill="#3b82f6" opacity="0.8"/>
|
||||
<rect x="160" y="310" width="120" height="12" rx="6" fill="#3b82f6" opacity="0.6"/>
|
||||
<rect x="160" y="340" width="140" height="12" rx="6" fill="#3b82f6" opacity="0.4"/>
|
||||
|
||||
<!-- "D" Letter overlay -->
|
||||
<text x="256" y="310" font-family="Arial, sans-serif" font-size="160" font-weight="bold" fill="#3b82f6" text-anchor="middle" opacity="0.15">D</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/mana-color/forest.png
Normal file
BIN
public/mana-color/forest.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
BIN
public/mana-color/island.png
Normal file
BIN
public/mana-color/island.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
BIN
public/mana-color/moutain.png
Normal file
BIN
public/mana-color/moutain.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
BIN
public/mana-color/plains.png
Normal file
BIN
public/mana-color/plains.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 179 KiB |
BIN
public/mana-color/swamp.png
Normal file
BIN
public/mana-color/swamp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 153 KiB |
111
public/manifest.json
Normal file
111
public/manifest.json
Normal file
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"name": "Deckerr - Card Deck Manager",
|
||||
"short_name": "Deckerr",
|
||||
"description": "Manage your trading card game decks on the go",
|
||||
"theme_color": "#0f172a",
|
||||
"background_color": "#0f172a",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-maskable-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-maskable-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "/screenshot-mobile-1.png",
|
||||
"sizes": "540x720",
|
||||
"type": "image/png",
|
||||
"form_factor": "narrow"
|
||||
},
|
||||
{
|
||||
"src": "/screenshot-desktop-1.png",
|
||||
"sizes": "1280x720",
|
||||
"type": "image/png",
|
||||
"form_factor": "wide"
|
||||
}
|
||||
],
|
||||
"categories": ["games", "utilities"],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "My Decks",
|
||||
"short_name": "Decks",
|
||||
"description": "View your deck collection",
|
||||
"url": "/?page=home",
|
||||
"icons": [{ "src": "/icon-192x192.png", "sizes": "192x192" }]
|
||||
},
|
||||
{
|
||||
"name": "Search Cards",
|
||||
"short_name": "Search",
|
||||
"description": "Search for cards",
|
||||
"url": "/?page=search",
|
||||
"icons": [{ "src": "/icon-192x192.png", "sizes": "192x192" }]
|
||||
},
|
||||
{
|
||||
"name": "Life Counter",
|
||||
"short_name": "Life",
|
||||
"description": "Track life totals",
|
||||
"url": "/?page=life-counter",
|
||||
"icons": [{ "src": "/icon-192x192.png", "sizes": "192x192" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
168
src/App.tsx
168
src/App.tsx
@@ -1,91 +1,103 @@
|
||||
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 { 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';
|
||||
type Page = 'home' | 'deck' | 'login' | 'collection' | 'edit-deck' | 'search' | 'life-counter' | 'community';
|
||||
|
||||
function AppContent() {
|
||||
const [currentPage, setCurrentPage] = useState<Page>('home');
|
||||
const [selectedDeckId, setSelectedDeckId] = useState<string | null>(null);
|
||||
const { user, loading } = useAuth();
|
||||
function AppContent() {
|
||||
const [currentPage, setCurrentPage] = useState<Page>('home');
|
||||
const [selectedDeckId, setSelectedDeckId] = useState<string | null>(null);
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
|
||||
<div className="loading-spinner h-32 w-32"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user && currentPage !== 'login') {
|
||||
return <LoginForm />;
|
||||
}
|
||||
|
||||
const handleDeckEdit = (deckId: string) => {
|
||||
setSelectedDeckId(deckId);
|
||||
setCurrentPage('edit-deck');
|
||||
};
|
||||
|
||||
const renderPage = () => {
|
||||
switch (currentPage) {
|
||||
case 'home':
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
|
||||
<div className="loading-spinner h-32 w-32"></div>
|
||||
<div className="relative bg-gray-900 text-white p-3 sm:p-6 animate-fade-in md:min-h-screen">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h1 className="text-2xl md:text-3xl font-bold mb-4 md:mb-6 animate-slide-in-left">My Decks</h1>
|
||||
<DeckList
|
||||
onDeckEdit={handleDeckEdit}
|
||||
onCreateDeck={() => setCurrentPage('deck')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user && currentPage !== 'login') {
|
||||
case 'deck':
|
||||
return <DeckManager />;
|
||||
case 'edit-deck':
|
||||
return selectedDeckId ? (
|
||||
<DeckEditor
|
||||
deckId={selectedDeckId}
|
||||
onClose={() => {
|
||||
setSelectedDeckId(null);
|
||||
setCurrentPage('home');
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
case 'collection':
|
||||
return <Collection />;
|
||||
case 'search':
|
||||
return <CardSearch />;
|
||||
case 'life-counter':
|
||||
return <LifeCounter />;
|
||||
case 'community':
|
||||
return <Community />;
|
||||
case 'login':
|
||||
return <LoginForm />;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeckEdit = (deckId: string) => {
|
||||
setSelectedDeckId(deckId);
|
||||
setCurrentPage('edit-deck');
|
||||
};
|
||||
|
||||
const renderPage = () => {
|
||||
switch (currentPage) {
|
||||
case 'home':
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white p-6 animate-fade-in">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-6 animate-slide-in-left">My Decks</h1>
|
||||
<DeckList onDeckEdit={handleDeckEdit} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'deck':
|
||||
return <DeckManager />;
|
||||
case 'edit-deck':
|
||||
return selectedDeckId ? (
|
||||
<DeckEditor
|
||||
deckId={selectedDeckId}
|
||||
onClose={() => {
|
||||
setSelectedDeckId(null);
|
||||
setCurrentPage('home');
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
case 'collection':
|
||||
return <Collection />;
|
||||
case 'profile':
|
||||
return <Profile />;
|
||||
case 'search':
|
||||
return <CardSearch />;
|
||||
case 'life-counter':
|
||||
return <LifeCounter />;
|
||||
case 'login':
|
||||
return <LoginForm />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900">
|
||||
<Navigation currentPage={currentPage} setCurrentPage={setCurrentPage} />
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 flex flex-col">
|
||||
<Navigation currentPage={currentPage} setCurrentPage={setCurrentPage} />
|
||||
<main className="relative flex-1 overflow-y-auto">
|
||||
<div className="relative min-h-full md:min-h-0 pt-0 md:pt-16 pb-20 md:pb-0">
|
||||
{renderPage()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</main>
|
||||
<PWAInstallPrompt />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<AppContent />
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import React, { useState } from 'react';
|
||||
import { searchCards } from '../services/api';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { RefreshCw, PackagePlus, Loader2, CheckCircle, XCircle, Trash2 } from 'lucide-react';
|
||||
import { searchCards, getUserCollection, addCardToCollection } from '../services/api';
|
||||
import { Card } from '../types';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import MagicCard from './MagicCard';
|
||||
import { getManaIconPath } from './ManaCost';
|
||||
|
||||
const CardSearch = () => {
|
||||
const { user } = useAuth();
|
||||
const [cardName, setCardName] = useState('');
|
||||
const [text, setText] = useState('');
|
||||
const [rulesText, setRulesText] = useState('');
|
||||
@@ -40,6 +44,93 @@ const CardSearch = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
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;
|
||||
};
|
||||
|
||||
// Get card art crop for current face
|
||||
const getCardArtCrop = (card: Card, faceIndex: number = 0) => {
|
||||
if (isDoubleFaced(card) && card.card_faces) {
|
||||
return card.card_faces[faceIndex]?.image_uris?.art_crop || card.card_faces[faceIndex]?.image_uris?.normal;
|
||||
}
|
||||
return card.image_uris?.art_crop || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.art_crop;
|
||||
};
|
||||
|
||||
// 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) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
@@ -110,9 +201,9 @@ const CardSearch = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white p-6">
|
||||
<div className="relative bg-gray-900 text-white p-3 sm:p-6 md:min-h-screen">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-6">Card Search</h1>
|
||||
<h1 className="text-2xl md:text-3xl font-bold mb-4 md:mb-6">Card Search</h1>
|
||||
<form onSubmit={handleSearch} className="mb-8 space-y-4">
|
||||
{/* Card Details */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@@ -137,7 +228,7 @@ const CardSearch = () => {
|
||||
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 text-white"
|
||||
placeholder="Rules Text (~ for card name)"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={typeLine}
|
||||
@@ -177,9 +268,11 @@ const CardSearch = () => {
|
||||
onChange={() => setColors({ ...colors, [color]: !active })}
|
||||
className="rounded border-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<span style={{ fontSize: '1.5em' }}>
|
||||
{color === 'W' ? '⚪' : color === 'U' ? '🔵' : color === 'B' ? '⚫' : color === 'R' ? '🔴' : color === 'G' ? '🟢' : '🟤'}
|
||||
</span>
|
||||
{getManaIconPath(color) ? (
|
||||
<img src={getManaIconPath(color)!} alt={color} className="w-6 h-6" />
|
||||
) : (
|
||||
<span className="w-6 h-6 flex items-center justify-center bg-gray-500 text-white font-bold rounded-full text-sm">{color}</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@@ -203,9 +296,11 @@ const CardSearch = () => {
|
||||
onChange={() => setCommanderColors({ ...commanderColors, [color]: !active })}
|
||||
className="rounded border-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<span style={{ fontSize: '1.5em' }}>
|
||||
{color === 'W' ? '⚪' : color === 'U' ? '🔵' : color === 'B' ? '⚫' : color === 'R' ? '🔴' : color === 'G' ? '🟢' : '🟤'}
|
||||
</span>
|
||||
{getManaIconPath(color) ? (
|
||||
<img src={getManaIconPath(color)!} alt={color} className="w-6 h-6" />
|
||||
) : (
|
||||
<span className="w-6 h-6 flex items-center justify-center bg-gray-500 text-white font-bold rounded-full text-sm">{color}</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@@ -213,25 +308,32 @@ const CardSearch = () => {
|
||||
</div>
|
||||
|
||||
{/* Mana Cost */}
|
||||
<div className="grid grid-cols-3 md:grid-cols-6 gap-2">
|
||||
{Object.entries(manaCost).map(([color, count]) => (
|
||||
<div key={color} className="flex items-center space-x-2">
|
||||
<span style={{ fontSize: '1.5em' }}>
|
||||
{color === 'W' ? '⚪' : color === 'U' ? '🔵' : color === 'B' ? '⚫' : color === 'R' ? '🔴' : color === 'G' ? '🟢' : '🟤'}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={count}
|
||||
onChange={(e) => setManaCost({ ...manaCost, [color]: parseInt(e.target.value) })}
|
||||
className="w-16 px-2 py-1 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-2">
|
||||
{Object.entries(manaCost).map(([color, count]) => {
|
||||
const iconPath = getManaIconPath(color);
|
||||
return (
|
||||
<div key={color} className="flex items-center space-x-2">
|
||||
{iconPath ? (
|
||||
<img src={iconPath} alt={color} className="w-6 h-6 md:w-8 md:h-8" />
|
||||
) : (
|
||||
<span className="w-6 h-6 md:w-8 md:h-8 flex items-center justify-center bg-gray-500 text-white font-bold rounded-full text-sm">
|
||||
{color}
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
type="number"
|
||||
value={count}
|
||||
onChange={(e) => setManaCost({ ...manaCost, [color]: parseInt(e.target.value) })}
|
||||
className="w-14 sm:w-16 px-2 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<select
|
||||
value={manaValueComparison}
|
||||
onChange={(e) => setManaValueComparison(e.target.value)}
|
||||
@@ -272,7 +374,7 @@ const CardSearch = () => {
|
||||
</div>
|
||||
|
||||
{/* Formats */}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<select
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e.target.value)}
|
||||
@@ -314,7 +416,7 @@ const CardSearch = () => {
|
||||
</div>
|
||||
|
||||
{/* Sets */}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={set}
|
||||
@@ -350,7 +452,7 @@ const CardSearch = () => {
|
||||
</div>
|
||||
|
||||
{/* Criteria */}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={criteria}
|
||||
@@ -377,7 +479,7 @@ const CardSearch = () => {
|
||||
</div>
|
||||
|
||||
{/* Prices */}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<select
|
||||
value={currency}
|
||||
onChange={(e) => setCurrency(e.target.value)}
|
||||
@@ -504,7 +606,7 @@ const CardSearch = () => {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="mt-4 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg"
|
||||
className="mt-4 w-full sm:w-auto min-h-[44px] px-6 py-3 bg-blue-600 hover:bg-blue-700 rounded-lg font-medium text-base"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
@@ -523,19 +625,172 @@ const CardSearch = () => {
|
||||
)}
|
||||
|
||||
{searchResults && searchResults.length > 0 && (
|
||||
<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">
|
||||
<MagicCard card={card} />
|
||||
<div className="p-4">
|
||||
<h3 className="font-bold mb-2">{card.name}</h3>
|
||||
<p className="text-gray-400 text-sm">{card.type_line}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
{/* Mobile: Horizontal list layout */}
|
||||
<div className="flex flex-col gap-2 sm:hidden">
|
||||
{searchResults.map((card) => {
|
||||
const currentFaceIndex = getCurrentFaceIndex(card.id);
|
||||
const isMultiFaced = isDoubleFaced(card);
|
||||
const inCollection = userCollection.get(card.id) || 0;
|
||||
const isAddingThisCard = addingCardId === card.id;
|
||||
|
||||
const displayName = isMultiFaced && card.card_faces
|
||||
? card.card_faces[currentFaceIndex]?.name || card.name
|
||||
: card.name;
|
||||
|
||||
return (
|
||||
<div key={card.id} className="flex bg-gray-800 rounded-lg overflow-hidden">
|
||||
{/* Card art crop */}
|
||||
<div className="relative w-16 h-16 flex-shrink-0">
|
||||
<img
|
||||
src={getCardArtCrop(card, currentFaceIndex)}
|
||||
alt={displayName}
|
||||
className="w-full h-full object-cover rounded-l-lg"
|
||||
/>
|
||||
{isMultiFaced && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleCardFace(card.id, card.card_faces!.length);
|
||||
}}
|
||||
className="absolute bottom-0.5 right-0.5 bg-purple-600 text-white p-0.5 rounded-full"
|
||||
>
|
||||
<RefreshCw size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Info */}
|
||||
<div className="flex-1 p-2 flex flex-col justify-center min-w-0">
|
||||
<h3 className="font-bold text-sm truncate">{displayName}</h3>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
{card.prices?.usd && <span>${card.prices.usd}</span>}
|
||||
{inCollection > 0 && (
|
||||
<span className="text-green-400 flex items-center gap-0.5">
|
||||
<CheckCircle size={10} />
|
||||
x{inCollection}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Action button */}
|
||||
<div className="flex items-center p-2">
|
||||
<button
|
||||
onClick={() => handleAddCardToCollection(card.id)}
|
||||
disabled={isAddingThisCard}
|
||||
className="p-2.5 bg-green-600 active:bg-green-700 disabled:bg-gray-600 rounded-lg"
|
||||
title="Add to collection"
|
||||
>
|
||||
{isAddingThisCard ? (
|
||||
<Loader2 className="animate-spin" size={18} />
|
||||
) : (
|
||||
<PackagePlus size={18} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Desktop: Grid layout */}
|
||||
<div className="hidden sm:grid sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
|
||||
{searchResults.map((card) => {
|
||||
const currentFaceIndex = getCurrentFaceIndex(card.id);
|
||||
const isMultiFaced = isDoubleFaced(card);
|
||||
const inCollection = userCollection.get(card.id) || 0;
|
||||
const isAddingThisCard = addingCardId === card.id;
|
||||
|
||||
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>
|
||||
)}
|
||||
{inCollection > 0 && (
|
||||
<span className="absolute top-1 right-1 text-xs bg-green-600 px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<CheckCircle size={12} />
|
||||
x{inCollection}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<h3 className="font-bold text-sm truncate mb-1">{displayName}</h3>
|
||||
<p className="text-gray-400 text-xs truncate mb-2">
|
||||
{isMultiFaced && card.card_faces
|
||||
? card.card_faces[currentFaceIndex]?.type_line || card.type_line
|
||||
: card.type_line}
|
||||
</p>
|
||||
{card.prices?.usd && (
|
||||
<div className="text-xs text-gray-400 mb-2">${card.prices.usd}</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleAddCardToCollection(card.id)}
|
||||
disabled={isAddingThisCard}
|
||||
className="w-full px-3 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 text-sm"
|
||||
title="Add to collection"
|
||||
>
|
||||
{isAddingThisCard ? (
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
) : (
|
||||
<>
|
||||
<PackagePlus size={16} />
|
||||
Add
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</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-[140]`}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,121 +1,500 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Search, Plus } from 'lucide-react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Search, Loader2, Trash2, CheckCircle, XCircle, RefreshCw, Plus, Minus, X } from 'lucide-react';
|
||||
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() {
|
||||
const { user } = useAuth();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<Card[]>([]);
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
if (!searchQuery.trim()) return;
|
||||
|
||||
try {
|
||||
const cards = await searchCards(searchQuery);
|
||||
setSearchResults(cards);
|
||||
} catch (error) {
|
||||
console.error('Failed to search cards:', error);
|
||||
}
|
||||
// Helper function to check if a card has an actual back face (not adventure/split/etc)
|
||||
const isDoubleFaced = (card: Card) => {
|
||||
// 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);
|
||||
};
|
||||
|
||||
const addToCollection = (card: Card) => {
|
||||
setCollection(prev => {
|
||||
const existing = prev.find(c => c.card.id === card.id);
|
||||
if (existing) {
|
||||
return prev.map(c =>
|
||||
c.card.id === card.id
|
||||
? { ...c, quantity: c.quantity + 1 }
|
||||
: c
|
||||
);
|
||||
}
|
||||
return [...prev, { card, quantity: 1 }];
|
||||
// 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;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-6">My Collection</h1>
|
||||
// Load user's collection from Supabase on mount
|
||||
useEffect(() => {
|
||||
const loadCollection = async () => {
|
||||
if (!user) {
|
||||
setIsLoadingCollection(false);
|
||||
return;
|
||||
}
|
||||
|
||||
{/* Search */}
|
||||
<form onSubmit={handleSearch} className="flex gap-2 mb-8">
|
||||
<div className="relative flex-1">
|
||||
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 {
|
||||
setIsUpdating(true);
|
||||
|
||||
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) {
|
||||
console.error('Error updating card quantity:', error);
|
||||
setSnackbar({ message: 'Failed to update quantity', type: 'error' });
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
setTimeout(() => setSnackbar(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
// Add one to quantity
|
||||
const incrementQuantity = async (cardId: string, currentQuantity: number) => {
|
||||
await updateCardQuantity(cardId, currentQuantity + 1);
|
||||
};
|
||||
|
||||
// Remove one from quantity
|
||||
const decrementQuantity = async (cardId: string, currentQuantity: number) => {
|
||||
if (currentQuantity > 0) {
|
||||
await updateCardQuantity(cardId, currentQuantity - 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative bg-gray-900 text-white p-3 sm:p-6 md:min-h-screen">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h1 className="text-2xl md:text-3xl font-bold mb-4 md:mb-6">My Collection</h1>
|
||||
|
||||
{/* Search within collection */}
|
||||
<div className="mb-8">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
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"
|
||||
placeholder="Search cards to add..."
|
||||
placeholder="Search your collection by name, type, or text..."
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Collection */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">My Cards</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{collection.map(({ card, quantity }) => (
|
||||
<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"
|
||||
/>
|
||||
<h2 className="text-xl font-semibold mb-4">
|
||||
{searchQuery ? `Found ${filteredCollection.length} card(s)` : `My Cards (${collection.length} unique, ${collection.reduce((acc, c) => acc + c.quantity, 0)} total)`}
|
||||
</h2>
|
||||
|
||||
{isLoadingCollection ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="animate-spin text-blue-500" size={48} />
|
||||
</div>
|
||||
) : 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-1.5 sm:gap-2">
|
||||
{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 sm:text-sm 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="hidden lg:block fixed top-1/2 right-8 transform -translate-y-1/2 z-30 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 className="flex justify-between items-center mb-2">
|
||||
<h3 className="font-bold">{card.name}</h3>
|
||||
<span className="text-sm bg-blue-600 px-2 py-1 rounded">
|
||||
x{quantity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
<h3 className="text-xl font-bold">{displayName}</h3>
|
||||
<p className="text-sm text-gray-400">{displayTypeLine}</p>
|
||||
{displayOracleText && (
|
||||
<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-[110] 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-[120] overflow-y-auto animate-slide-in-right">
|
||||
{/* Close button - fixed position, stays visible when scrolling */}
|
||||
<button
|
||||
onClick={() => setSelectedCard(null)}
|
||||
className="fixed top-4 right-4 bg-gray-700 hover:bg-gray-600 text-white p-2 md:p-1.5 rounded-full transition-colors z-[130] shadow-lg"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={24} className="md:w-5 md:h-5" />
|
||||
</button>
|
||||
|
||||
<div className="p-4 sm:p-6">
|
||||
|
||||
{/* Card Image */}
|
||||
<div className="relative mb-4 max-w-sm mx-auto">
|
||||
<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-xl md:text-2xl font-bold text-white mb-2">{displayName}</h2>
|
||||
<p className="text-xs sm: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 min-h-[44px] 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>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 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-[140]`}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
1122
src/components/Community.tsx
Normal file
1122
src/components/Community.tsx
Normal file
File diff suppressed because it is too large
Load Diff
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>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export default function DeckBuilder({
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: isOpen ? "0%" : "100%" }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
className="fixed top-0 right-0 w-4/5 h-full bg-gray-800 p-6 shadow-lg md:static md:w-full md:h-auto md:p-6 md:shadow-none z-50"
|
||||
className="fixed top-0 right-0 w-4/5 h-full bg-gray-800 p-6 shadow-lg md:static md:w-full md:h-auto md:p-6 md:shadow-none z-[110]"
|
||||
>
|
||||
{/* Bouton de fermeture */}
|
||||
<button
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle, Check, Edit } from 'lucide-react';
|
||||
import { Deck } from '../types';
|
||||
import { validateDeck } from '../utils/deckValidation';
|
||||
|
||||
interface DeckCardProps {
|
||||
deck: Deck;
|
||||
@@ -9,68 +8,67 @@ interface DeckCardProps {
|
||||
}
|
||||
|
||||
export default function DeckCard({ deck, onEdit }: DeckCardProps) {
|
||||
// Use pre-calculated validation data
|
||||
const isValid = deck.isValid ?? true;
|
||||
const validationErrors = deck.validationErrors || [];
|
||||
|
||||
if(deck.id === "410ed539-a8f4-4bc4-91f1-6c113b9b7e25"){
|
||||
console.log("deck", deck.name);
|
||||
console.log("cardEntities", deck.cards);
|
||||
}
|
||||
|
||||
const validation = validateDeck(deck);
|
||||
const commander = deck.format === 'commander' ? deck.cards.find(card =>
|
||||
card.is_commander
|
||||
)?.card : null;
|
||||
// Use cover card (already loaded)
|
||||
const coverImage = deck.coverCard?.image_uris?.normal;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-gray-800 rounded-xl overflow-hidden shadow-lg card-hover cursor-pointer animate-scale-in"
|
||||
className="bg-gray-800 rounded-lg overflow-hidden shadow-lg hover:shadow-xl transition-all cursor-pointer group"
|
||||
onClick={() => onEdit?.(deck.id)}
|
||||
>
|
||||
<div className="relative h-48 overflow-hidden">
|
||||
<img
|
||||
src={commander?.image_uris?.normal || deck.cards[0]?.card.image_uris?.normal}
|
||||
alt={commander?.name || deck.cards[0]?.card.name}
|
||||
className="w-full object-cover object-top transform translate-y-[-12%]"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-gray-900 to-transparent" />
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xl font-bold text-white">{deck.name}</h3>
|
||||
{validation.isValid ? (
|
||||
<div className="flex items-center text-green-400">
|
||||
<Check size={16} className="mr-1" />
|
||||
<span className="text-sm">Legal</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center text-yellow-400" title={validation.errors.join(', ')}>
|
||||
<AlertTriangle size={16} className="mr-1" />
|
||||
<span className="text-sm">Issues</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-gray-400">
|
||||
<span className="capitalize">{deck.format}</span>
|
||||
<span>{deck.cards.reduce((acc, curr) => acc + curr.quantity, 0)} cards</span>
|
||||
</div>
|
||||
|
||||
{commander && (
|
||||
<div className="mt-2 text-sm text-gray-300">
|
||||
<span className="text-blue-400">Commander:</span> {commander.name}
|
||||
{/* Full Card Art */}
|
||||
<div className="relative aspect-[5/7] overflow-hidden">
|
||||
{coverImage ? (
|
||||
<img
|
||||
src={coverImage}
|
||||
alt={deck.coverCard?.name || deck.name}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-700 flex items-center justify-center text-gray-500">
|
||||
No Cover
|
||||
</div>
|
||||
)}
|
||||
{/* Overlay for text readability */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-gray-900 via-gray-900/60 to-transparent" />
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit?.(deck.id);
|
||||
}}
|
||||
className="mt-4 w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center justify-center gap-2 text-white btn-ripple transition-smooth glow-on-hover"
|
||||
>
|
||||
<Edit size={20} />
|
||||
Edit Deck
|
||||
</button>
|
||||
{/* Deck info overlay */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3">
|
||||
<div className="flex items-start justify-between mb-1">
|
||||
<h3 className="text-base sm:text-lg font-bold text-white line-clamp-2 flex-1">{deck.name}</h3>
|
||||
{isValid ? (
|
||||
<Check size={16} className="text-green-400 ml-2 flex-shrink-0" />
|
||||
) : (
|
||||
<AlertTriangle size={16} className="text-yellow-400 ml-2 flex-shrink-0" title={validationErrors.join(', ')} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-gray-300 mb-2">
|
||||
<span className="capitalize">{deck.format}</span>
|
||||
<span>{deck.cardCount || 0} cards</span>
|
||||
</div>
|
||||
|
||||
{deck.format === 'commander' && deck.coverCard && (
|
||||
<div className="text-xs text-blue-300 mb-2 truncate">
|
||||
<span className="font-semibold">Commander:</span> {deck.coverCard.name}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit?.(deck.id);
|
||||
}}
|
||||
className="w-full min-h-[36px] px-3 py-2 bg-blue-600/90 hover:bg-blue-600 rounded-md flex items-center justify-center gap-2 text-white text-sm font-medium transition-colors backdrop-blur-sm"
|
||||
>
|
||||
<Edit size={16} />
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -42,6 +42,7 @@ export default function DeckEditor({ deckId, onClose }: DeckEditorProps) {
|
||||
const cards = cardEntities.map(entity => ({
|
||||
card: scryfallCards.find(c => c.id === entity.card_id) as Card,
|
||||
quantity: entity.quantity,
|
||||
is_commander: entity.is_commander,
|
||||
}));
|
||||
|
||||
setDeck({
|
||||
@@ -62,7 +63,7 @@ export default function DeckEditor({ deckId, onClose }: DeckEditorProps) {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white p-6 flex items-center justify-center">
|
||||
<div className="relative md:min-h-screen bg-gray-900 text-white p-6 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
@@ -70,7 +71,7 @@ export default function DeckEditor({ deckId, onClose }: DeckEditorProps) {
|
||||
|
||||
if (!deck) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white p-6">
|
||||
<div className="relative md:min-h-screen bg-gray-900 text-white p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="bg-red-500/10 border border-red-500 rounded-lg p-4">
|
||||
<h2 className="text-xl font-bold text-red-500">Error</h2>
|
||||
|
||||
@@ -3,12 +3,15 @@ import { getCardById, getCardsByIds } from '../services/api';
|
||||
import { Deck } from '../types';
|
||||
import { supabase } from "../lib/supabase";
|
||||
import DeckCard from "./DeckCard";
|
||||
import { PlusCircle } from 'lucide-react';
|
||||
import MigrateDeckButton from "./MigrateDeckButton.tsx";
|
||||
|
||||
interface DeckListProps {
|
||||
onDeckEdit?: (deckId: string) => void;
|
||||
onCreateDeck?: () => void;
|
||||
}
|
||||
|
||||
const DeckList = ({ onDeckEdit }: DeckListProps) => {
|
||||
const DeckList = ({ onDeckEdit, onCreateDeck }: DeckListProps) => {
|
||||
const [decks, setDecks] = useState<Deck[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -21,58 +24,36 @@ const DeckList = ({ onDeckEdit }: DeckListProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const decksWithCards = await Promise.all(decksData.map(async (deck) => {
|
||||
const { data: cardEntities, error: cardsError } = await supabase
|
||||
.from('deck_cards')
|
||||
.select('*')
|
||||
.eq('deck_id', deck.id);
|
||||
// Get all unique cover card IDs
|
||||
const coverCardIds = decksData
|
||||
.map(deck => deck.cover_card_id)
|
||||
.filter(Boolean);
|
||||
|
||||
// Fetch only cover cards (much lighter!)
|
||||
const coverCards = coverCardIds.length > 0
|
||||
? await getCardsByIds(coverCardIds)
|
||||
: [];
|
||||
|
||||
// Map decks with their cover cards
|
||||
const decksWithCoverCards = decksData.map(deck => {
|
||||
const coverCard = deck.cover_card_id
|
||||
? coverCards.find(c => c.id === deck.cover_card_id)
|
||||
: null;
|
||||
|
||||
if (cardsError) {
|
||||
console.error(`Error fetching cards for deck ${deck.id}:`, cardsError);
|
||||
return { ...deck, cards: [] };
|
||||
}
|
||||
return {
|
||||
...deck,
|
||||
cards: [], // Empty array, we don't load all cards here
|
||||
coverCard: coverCard || null,
|
||||
createdAt: new Date(deck.created_at),
|
||||
updatedAt: new Date(deck.updated_at),
|
||||
validationErrors: deck.validation_errors || [],
|
||||
isValid: deck.is_valid ?? true,
|
||||
cardCount: deck.card_count || 0,
|
||||
coverCardId: deck.cover_card_id,
|
||||
};
|
||||
});
|
||||
|
||||
const cardIds = cardEntities.map((entity) => entity.card_id);
|
||||
const uniqueCardIds = [...new Set(cardIds)];
|
||||
|
||||
if(deck.id === "410ed539-a8f4-4bc4-91f1-6c113b9b7e25"){
|
||||
console.log("uniqueCardIds", uniqueCardIds);
|
||||
}
|
||||
|
||||
|
||||
|
||||
try {
|
||||
const scryfallCards = await getCardsByIds(uniqueCardIds);
|
||||
|
||||
if (!scryfallCards) {
|
||||
console.error("scryfallCards is undefined after getCardsByIds");
|
||||
return { ...deck, cards: [] };
|
||||
}
|
||||
|
||||
const cards = cardEntities.map((entity) => {
|
||||
const card = scryfallCards.find((c) => c.id === entity.card_id);
|
||||
return {
|
||||
card,
|
||||
quantity: entity.quantity,
|
||||
is_commander: entity.is_commander,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...deck,
|
||||
cards,
|
||||
createdAt: new Date(deck.created_at),
|
||||
updatedAt: new Date(deck.updated_at),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching cards from Scryfall:", error);
|
||||
return { ...deck, cards: [] };
|
||||
}
|
||||
}));
|
||||
|
||||
setDecks(decksWithCards);
|
||||
setDecks(decksWithCoverCards);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
@@ -88,10 +69,26 @@ const DeckList = ({ onDeckEdit }: DeckListProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3 sm:gap-4">
|
||||
{decks.map((deck) => (
|
||||
<DeckCard key={deck.id} deck={deck} onEdit={onDeckEdit} />
|
||||
))}
|
||||
|
||||
{/* Create New Deck Card */}
|
||||
<button
|
||||
onClick={onCreateDeck}
|
||||
className="bg-gray-800 rounded-lg overflow-hidden shadow-lg hover:shadow-xl border-2 border-dashed border-gray-600 hover:border-blue-500 transition-all duration-300 hover:scale-105 cursor-pointer group aspect-[5/7] flex flex-col items-center justify-center gap-3 p-4"
|
||||
>
|
||||
<PlusCircle size={48} className="text-gray-600 group-hover:text-blue-500 transition-colors" />
|
||||
<div className="text-center">
|
||||
<h3 className="text-sm sm:text-base font-bold text-gray-400 group-hover:text-blue-400 transition-colors">
|
||||
Create New Deck
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mt-1 hidden sm:block">
|
||||
Start building
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, Search, Save, Trash2, Loader2, CheckCircle, XCircle, AlertCircle, PackagePlus } from 'lucide-react';
|
||||
import { Plus, Minus, Search, Save, Trash2, Loader2, CheckCircle, XCircle, AlertCircle, PackagePlus, RefreshCw, X } from 'lucide-react';
|
||||
import { Card, Deck } from '../types';
|
||||
import { searchCards, getUserCollection, addCardToCollection, addMultipleCardsToCollection } from '../services/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { validateDeck } from '../utils/deckValidation';
|
||||
import MagicCard from './MagicCard';
|
||||
import { ManaCost, ManaSymbol } from './ManaCost';
|
||||
|
||||
interface DeckManagerProps {
|
||||
initialDeck?: Deck;
|
||||
@@ -25,7 +26,8 @@ interface DeckManagerProps {
|
||||
|
||||
const suggestLandCountAndDistribution = (
|
||||
cards: { card; quantity: number }[],
|
||||
format: string
|
||||
format: string,
|
||||
commanderColors: string[] = []
|
||||
) => {
|
||||
const formatRules = {
|
||||
standard: { minCards: 60 },
|
||||
@@ -63,6 +65,16 @@ const suggestLandCountAndDistribution = (
|
||||
}
|
||||
});
|
||||
|
||||
// For commander, filter out colors not in commander's color identity
|
||||
if (format === 'commander' && commanderColors.length > 0) {
|
||||
for (const color in colorCounts) {
|
||||
if (!commanderColors.includes(color)) {
|
||||
totalColorSymbols -= colorCounts[color as keyof typeof colorCounts];
|
||||
colorCounts[color as keyof typeof colorCounts] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const landDistribution: { [key: string]: number } = {};
|
||||
for (const color in colorCounts) {
|
||||
const proportion =
|
||||
@@ -95,9 +107,25 @@ const suggestLandCountAndDistribution = (
|
||||
return { landCount: landsToAdd, landDistribution };
|
||||
};
|
||||
|
||||
// Get commander color identity
|
||||
const getCommanderColors = (commander: Card | null): string[] => {
|
||||
if (!commander) return [];
|
||||
return commander.colors || [];
|
||||
};
|
||||
|
||||
// Check if a card's colors are valid for the commander
|
||||
const isCardValidForCommander = (card: Card, commanderColors: string[]): boolean => {
|
||||
if (commanderColors.length === 0) return true; // No commander restriction
|
||||
const cardColors = card.colors || [];
|
||||
// Every color in the card must be in the commander's colors
|
||||
return cardColors.every(color => commanderColors.includes(color));
|
||||
};
|
||||
|
||||
export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
||||
const [currentDeckId, setCurrentDeckId] = useState<string | null>(initialDeck?.id || null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<Card[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [selectedCards, setSelectedCards] = useState<{
|
||||
card: Card;
|
||||
quantity: number;
|
||||
@@ -120,6 +148,10 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
||||
const [isLoadingCollection, setIsLoadingCollection] = useState(true);
|
||||
const [addingCardId, setAddingCardId] = useState<string | null>(null);
|
||||
const [isAddingAll, setIsAddingAll] = useState(false);
|
||||
const [cardFaceIndex, setCardFaceIndex] = useState<Map<string, number>>(new Map());
|
||||
const [hoveredCard, setHoveredCard] = useState<Card | null>(null);
|
||||
const [hoverSource, setHoverSource] = useState<'search' | 'deck' | null>(null);
|
||||
const [selectedCard, setSelectedCard] = useState<Card | null>(null);
|
||||
|
||||
// Load user collection on component mount
|
||||
useEffect(() => {
|
||||
@@ -141,6 +173,40 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
||||
loadUserCollection();
|
||||
}, [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;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
// Helper function to check if a card is in the collection
|
||||
const isCardInCollection = (cardId: string, requiredQuantity: number = 1): boolean => {
|
||||
const ownedQuantity = userCollection.get(cardId) || 0;
|
||||
@@ -232,11 +298,16 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
||||
e.preventDefault();
|
||||
if (!searchQuery.trim()) return;
|
||||
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const cards = await searchCards(searchQuery);
|
||||
setSearchResults(cards);
|
||||
setSearchResults(cards || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to search cards:', error);
|
||||
setSearchResults([]);
|
||||
setSnackbar({ message: 'Failed to search cards', type: 'error' });
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -282,8 +353,9 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const deckId = currentDeckId || crypto.randomUUID();
|
||||
const deckToSave: Deck = {
|
||||
id: initialDeck?.id || crypto.randomUUID(),
|
||||
id: deckId,
|
||||
name: deckName,
|
||||
format: deckFormat,
|
||||
cards: selectedCards,
|
||||
@@ -292,6 +364,17 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Calculate validation for storage
|
||||
const validation = validateDeck(deckToSave);
|
||||
|
||||
// Determine cover card (commander or first card)
|
||||
const commanderCard = deckFormat === 'commander' ? selectedCards.find(c => c.card.id === commander?.id) : null;
|
||||
const coverCard = commanderCard?.card || selectedCards[0]?.card;
|
||||
const coverCardId = coverCard?.id || null;
|
||||
|
||||
// Calculate total card count
|
||||
const totalCardCount = selectedCards.reduce((acc, curr) => acc + curr.quantity, 0);
|
||||
|
||||
const deckData = {
|
||||
id: deckToSave.id,
|
||||
name: deckToSave.name,
|
||||
@@ -299,6 +382,10 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
||||
user_id: deckToSave.userId,
|
||||
created_at: deckToSave.createdAt,
|
||||
updated_at: deckToSave.updatedAt,
|
||||
cover_card_id: coverCardId,
|
||||
validation_errors: validation.errors,
|
||||
is_valid: validation.isValid,
|
||||
card_count: totalCardCount,
|
||||
};
|
||||
|
||||
// Save or update the deck
|
||||
@@ -309,9 +396,14 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
||||
|
||||
if (deckError) throw deckError;
|
||||
|
||||
// Update current deck ID if this was a new deck
|
||||
if (!currentDeckId) {
|
||||
setCurrentDeckId(deckId);
|
||||
}
|
||||
|
||||
// Delete existing cards if updating
|
||||
if (initialDeck) {
|
||||
await supabase.from('deck_cards').delete().eq('deck_id', initialDeck.id);
|
||||
if (currentDeckId) {
|
||||
await supabase.from('deck_cards').delete().eq('deck_id', currentDeckId);
|
||||
}
|
||||
|
||||
// Save the deck cards
|
||||
@@ -351,11 +443,14 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
||||
|
||||
const validation = validateDeck(currentDeck);
|
||||
|
||||
// Commander color identity validation (for land suggestions)
|
||||
const commanderColors = deckFormat === 'commander' ? getCommanderColors(commander) : [];
|
||||
|
||||
const deckSize = selectedCards.reduce((acc, curr) => acc + curr.quantity, 0);
|
||||
const {
|
||||
landCount: suggestedLandCountValue,
|
||||
landDistribution: suggestedLands,
|
||||
} = suggestLandCountAndDistribution(selectedCards, deckFormat);
|
||||
} = suggestLandCountAndDistribution(selectedCards, deckFormat, commanderColors);
|
||||
|
||||
const totalPrice = selectedCards.reduce((acc, { card, quantity }) => {
|
||||
const isBasicLand =
|
||||
@@ -428,11 +523,11 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
||||
cardsToAdd.push({ card, quantity });
|
||||
} else {
|
||||
console.warn(`Card not found: ${cardName}`);
|
||||
alert(`Card not found: ${cardName}`);
|
||||
setSnackbar({ message: `Card not found: ${cardName}`, type: 'error' });
|
||||
}
|
||||
} catch (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' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,53 +557,188 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white p-6">
|
||||
<div className="relative bg-gray-900 text-white p-3 sm:p-6 pt-6 pb-44 md:pt-20 md:pb-6 md:min-h-screen">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||
{/* Card Search Section */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<form onSubmit={handleSearch} className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
||||
size={20}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
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 text-white"
|
||||
placeholder="Search for cards..."
|
||||
/>
|
||||
</div>
|
||||
{/* Mobile-First Search Bar */}
|
||||
<form onSubmit={handleSearch} className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-24 py-3 bg-gray-800 border border-gray-700 rounded-lg focus:ring-2 focus:ring-blue-500 text-white"
|
||||
placeholder="Rechercher une carte..."
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
}}
|
||||
className="absolute right-14 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-white"
|
||||
>
|
||||
<XCircle size={20} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center gap-2"
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-2 bg-blue-600 hover:bg-blue-700 rounded-md"
|
||||
>
|
||||
<Search size={20} />
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{searchResults.map(card => (
|
||||
<div
|
||||
key={card.id}
|
||||
className="bg-gray-800 rounded-lg overflow-hidden hover:ring-2 hover:ring-blue-500 transition-all"
|
||||
>
|
||||
<MagicCard card={card} />
|
||||
<div className="p-4">
|
||||
<h3 className="font-bold mb-2">{card.name}</h3>
|
||||
{/* Vertical Card List for Mobile */}
|
||||
<div className="space-y-2">
|
||||
{isSearching ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="animate-spin text-blue-500" size={48} />
|
||||
</div>
|
||||
) : searchResults.length === 0 && searchQuery ? (
|
||||
<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>
|
||||
) : (
|
||||
searchResults.map(card => {
|
||||
const currentFaceIndex = getCurrentFaceIndex(card.id);
|
||||
const isMultiFaced = isDoubleFaced(card);
|
||||
const inCollection = userCollection.get(card.id) || 0;
|
||||
const isAddingThisCard = addingCardId === card.id;
|
||||
const cardInDeck = selectedCards.find(c => c.card.id === card.id);
|
||||
const quantityInDeck = cardInDeck?.quantity || 0;
|
||||
|
||||
const displayName = isMultiFaced && card.card_faces
|
||||
? card.card_faces[currentFaceIndex]?.name || card.name
|
||||
: card.name;
|
||||
|
||||
const isValidForCommander = deckFormat !== 'commander' || !commander || isCardValidForCommander(card, commanderColors);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={card.id}
|
||||
className={`bg-gray-800 rounded-lg p-3 flex items-center gap-3 hover:bg-gray-750 transition-colors cursor-pointer ${
|
||||
!isValidForCommander ? 'border border-yellow-500/50' : ''
|
||||
}`}
|
||||
onMouseEnter={() => {
|
||||
setHoveredCard(card);
|
||||
setHoverSource('search');
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoveredCard(null);
|
||||
setHoverSource(null);
|
||||
}}
|
||||
onClick={() => setSelectedCard(card)}
|
||||
>
|
||||
{/* Card Thumbnail */}
|
||||
<div className="relative flex-shrink-0 w-16 h-22 rounded overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
{getCardImageUri(card, currentFaceIndex) ? (
|
||||
<img
|
||||
src={getCardImageUri(card, currentFaceIndex)}
|
||||
alt={displayName}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-700" />
|
||||
)}
|
||||
{isMultiFaced && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleCardFace(card.id, card.card_faces!.length);
|
||||
}}
|
||||
className="absolute bottom-0 right-0 bg-purple-600 text-white p-1 rounded-tl"
|
||||
>
|
||||
<RefreshCw size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-sm truncate">{displayName}</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{card.mana_cost && (
|
||||
<ManaCost cost={card.mana_cost} size={14} />
|
||||
)}
|
||||
{card.prices?.usd && (
|
||||
<div className="text-xs text-gray-400">${card.prices.usd}</div>
|
||||
)}
|
||||
</div>
|
||||
{inCollection > 0 && (
|
||||
<div className="text-xs text-green-400 mt-1">
|
||||
<CheckCircle size={12} className="inline mr-1" />
|
||||
x{inCollection} in collection
|
||||
</div>
|
||||
)}
|
||||
{!isValidForCommander && (
|
||||
<div className="text-xs text-yellow-400 mt-1 flex items-center gap-1">
|
||||
<AlertCircle size={12} />
|
||||
Not in commander colors
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add/Quantity Controls */}
|
||||
{quantityInDeck > 0 ? (
|
||||
<div className="flex-shrink-0 flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (quantityInDeck === 1) {
|
||||
removeCardFromDeck(card.id);
|
||||
} else {
|
||||
updateCardQuantity(card.id, quantityInDeck - 1);
|
||||
}
|
||||
}}
|
||||
className="w-8 h-8 bg-red-600 hover:bg-red-700 rounded-full flex items-center justify-center transition-colors"
|
||||
>
|
||||
<Minus size={16} />
|
||||
</button>
|
||||
<span className="w-6 text-center text-sm font-medium">{quantityInDeck}</span>
|
||||
<button
|
||||
onClick={() => addCardToDeck(card)}
|
||||
className="w-8 h-8 bg-blue-600 hover:bg-blue-700 rounded-full flex items-center justify-center transition-colors"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
addCardToDeck(card);
|
||||
}}
|
||||
className="flex-shrink-0 w-10 h-10 bg-blue-600 hover:bg-blue-700 rounded-full flex items-center justify-center transition-colors"
|
||||
>
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Add to Collection Button (hidden on mobile by default) */}
|
||||
<button
|
||||
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"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAddCardToCollection(card.id, 1);
|
||||
}}
|
||||
disabled={isAddingThisCard}
|
||||
className="hidden sm:flex flex-shrink-0 w-10 h-10 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded-full items-center justify-center transition-colors"
|
||||
title="Add to collection"
|
||||
>
|
||||
<Plus size={20} />
|
||||
Add to Deck
|
||||
{isAddingThisCard ? (
|
||||
<Loader2 className="animate-spin" size={20} />
|
||||
) : (
|
||||
<PackagePlus size={20} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -537,27 +767,39 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
||||
</select>
|
||||
|
||||
{deckFormat === 'commander' && (
|
||||
<select
|
||||
value={commander?.id || ''}
|
||||
onChange={e => {
|
||||
const card =
|
||||
selectedCards.find(c => c.card.id === e.target.value)?.card ||
|
||||
null;
|
||||
setCommander(card);
|
||||
}}
|
||||
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
|
||||
>
|
||||
<option value="">Select Commander</option>
|
||||
{selectedCards
|
||||
.filter(c =>
|
||||
c.card.type_line?.toLowerCase().includes('legendary')
|
||||
)
|
||||
.map(({ card }) => (
|
||||
<option key={card.id} value={card.id}>
|
||||
{card.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
value={commander?.id || ''}
|
||||
onChange={e => {
|
||||
const card =
|
||||
selectedCards.find(c => c.card.id === e.target.value)?.card ||
|
||||
null;
|
||||
setCommander(card);
|
||||
}}
|
||||
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white"
|
||||
>
|
||||
<option value="">Select Commander</option>
|
||||
{selectedCards
|
||||
.filter(c =>
|
||||
c.card.type_line?.toLowerCase().includes('legendary')
|
||||
)
|
||||
.map(({ card }) => (
|
||||
<option key={card.id} value={card.id}>
|
||||
{card.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{commander && commanderColors.length > 0 && (
|
||||
<div className="bg-gray-700 rounded px-3 py-2 flex items-center gap-2">
|
||||
<span className="text-xs text-gray-400">Commander Colors:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{commanderColors.map(color => (
|
||||
<ManaSymbol key={color} symbol={color} size={18} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
@@ -596,91 +838,45 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
||||
<h3 className="font-bold text-xl">
|
||||
Cards ({selectedCards.reduce((acc, curr) => acc + curr.quantity, 0)})
|
||||
</h3>
|
||||
{!isLoadingCollection && getMissingCards().length > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm text-yellow-500">
|
||||
<AlertCircle size={16} />
|
||||
<span>{getMissingCards().length} missing</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isLoadingCollection && getMissingCards().length > 0 && (
|
||||
<button
|
||||
onClick={handleAddAllMissingCards}
|
||||
disabled={isAddingAll}
|
||||
className="w-full px-4 py-2 bg-yellow-600 hover:bg-yellow-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded-lg flex items-center justify-center gap-2 mb-3 relative"
|
||||
>
|
||||
{isAddingAll ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" size={20} />
|
||||
<span>Adding to collection...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PackagePlus size={20} />
|
||||
<span>Add All Missing Cards to Collection</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{selectedCards.map(({ card, quantity }) => {
|
||||
const ownedQuantity = userCollection.get(card.id) || 0;
|
||||
const isMissing = !isCardInCollection(card.id, quantity);
|
||||
const neededQuantity = Math.max(0, quantity - ownedQuantity);
|
||||
const isValidForCommander = deckFormat !== 'commander' || !commander || isCardValidForCommander(card, commanderColors);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={card.id}
|
||||
className={`flex items-center gap-4 p-2 rounded-lg ${
|
||||
isMissing
|
||||
? 'bg-yellow-900/20 border border-yellow-700/50'
|
||||
: 'bg-gray-700'
|
||||
className={`flex items-center gap-3 p-2 rounded-lg bg-gray-700 cursor-pointer hover:bg-gray-650 transition-colors ${
|
||||
!isValidForCommander ? 'border border-yellow-500/50' : ''
|
||||
}`}
|
||||
onMouseEnter={() => {
|
||||
setHoveredCard(card);
|
||||
setHoverSource('deck');
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoveredCard(null);
|
||||
setHoverSource(null);
|
||||
}}
|
||||
onClick={() => setSelectedCard(card)}
|
||||
>
|
||||
<img
|
||||
src={card.image_uris?.art_crop}
|
||||
alt={card.name}
|
||||
className="w-12 h-12 rounded"
|
||||
className="w-10 h-10 rounded"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium flex items-center gap-2">
|
||||
{card.name}
|
||||
{isMissing && (
|
||||
<span className="text-xs bg-yellow-600 px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<AlertCircle size={12} />
|
||||
Missing {neededQuantity}
|
||||
</span>
|
||||
)}
|
||||
{!isMissing && ownedQuantity > 0 && (
|
||||
<span className="text-xs bg-green-600 px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<CheckCircle size={12} />
|
||||
Owned ({ownedQuantity})
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-sm truncate">{card.name}</h4>
|
||||
{card.prices?.usd && (
|
||||
<div className="text-sm text-gray-400">${card.prices.usd}</div>
|
||||
<div className="text-xs text-gray-400">${card.prices.usd}</div>
|
||||
)}
|
||||
{!isValidForCommander && (
|
||||
<div className="text-xs text-yellow-400 flex items-center gap-1 mt-0.5">
|
||||
<AlertCircle size={10} />
|
||||
<span>Not in commander colors</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isMissing && (
|
||||
<button
|
||||
onClick={() => handleAddCardToCollection(card.id, neededQuantity)}
|
||||
disabled={addingCardId === card.id}
|
||||
className="px-3 py-1 bg-yellow-600 hover:bg-yellow-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded text-sm flex items-center gap-1"
|
||||
title={`Add ${neededQuantity} to collection`}
|
||||
>
|
||||
{addingCardId === card.id ? (
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
) : (
|
||||
<>
|
||||
<Plus size={16} />
|
||||
<span className="hidden sm:inline">Add</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="number"
|
||||
value={quantity}
|
||||
@@ -688,13 +884,13 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
||||
updateCardQuantity(card.id, parseInt(e.target.value))
|
||||
}
|
||||
min="1"
|
||||
className="w-16 px-2 py-1 bg-gray-600 border border-gray-500 rounded text-center"
|
||||
className="w-14 px-2 py-1 bg-gray-600 border border-gray-500 rounded text-center text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeCardFromDeck(card.id)}
|
||||
className="text-red-500 hover:text-red-400"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -702,57 +898,268 @@ export default function DeckManager({ initialDeck, onSave }: DeckManagerProps) {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="font-bold text-xl">
|
||||
Total Price: ${totalPrice.toFixed(2)}
|
||||
</div>
|
||||
|
||||
{deckSize > 0 && (
|
||||
<div className="text-gray-400">
|
||||
Suggested Land Count: {suggestedLandCountValue}
|
||||
{Object.entries(suggestedLands).map(([landType, count]) => (
|
||||
<div key={landType}>
|
||||
{landType}: {count}
|
||||
</div>
|
||||
))}
|
||||
{deckSize > 0 && suggestedLandCountValue > 0 && (
|
||||
<div className="bg-gray-700 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-semibold text-gray-300">Suggested Lands</span>
|
||||
<span className="text-xs text-gray-400">{suggestedLandCountValue} total</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{Object.entries(suggestedLands).map(([landType, count]) =>
|
||||
count > 0 ? (
|
||||
<div key={landType} className="flex items-center gap-1.5 bg-gray-800 px-2 py-1 rounded">
|
||||
<ManaSymbol symbol={landType} size={20} />
|
||||
<span className="text-sm font-medium text-white">{count}</span>
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={addSuggestedLandsToDeck}
|
||||
className="w-full mt-3 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
<Plus size={20} />
|
||||
Add Suggested Lands
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deckSize > 0 && (
|
||||
<button
|
||||
onClick={addSuggestedLandsToDeck}
|
||||
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 Suggested Lands
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={saveDeck}
|
||||
disabled={
|
||||
!deckName.trim() || selectedCards.length === 0 || isSaving
|
||||
}
|
||||
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 relative"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin text-white absolute left-2 top-1/2 -translate-y-1/2" size={20} />
|
||||
<span className="opacity-0">Save Deck</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save size={20} />
|
||||
<span>{initialDeck ? 'Update Deck' : 'Save Deck'}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fixed Footer with Price and Actions - Mobile First */}
|
||||
<div className="fixed bottom-16 left-0 right-0 md:left-auto md:right-4 md:bottom-4 md:w-80 z-20 bg-gray-800 border-t border-gray-700 md:border md:rounded-lg shadow-2xl">
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Total Price */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-gray-300">Total Price</span>
|
||||
<span className="text-xl font-bold text-green-400">${totalPrice.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2">
|
||||
{!isLoadingCollection && getMissingCards().length > 0 && (
|
||||
<button
|
||||
onClick={handleAddAllMissingCards}
|
||||
disabled={isAddingAll}
|
||||
className="flex-1 px-3 py-2 bg-yellow-600 hover:bg-yellow-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded-lg flex items-center justify-center gap-2 text-sm font-medium transition-colors"
|
||||
title="Add missing cards to collection"
|
||||
>
|
||||
{isAddingAll ? (
|
||||
<Loader2 className="animate-spin" size={18} />
|
||||
) : (
|
||||
<>
|
||||
<PackagePlus size={18} />
|
||||
<span className="hidden sm:inline">Add Missing</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={saveDeck}
|
||||
disabled={
|
||||
!deckName.trim() || selectedCards.length === 0 || isSaving
|
||||
}
|
||||
className="flex-1 px-3 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 text-sm font-medium relative transition-colors"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin text-white" size={18} />
|
||||
<span>Saving...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save size={18} />
|
||||
<span>{initialDeck ? 'Update' : 'Save'}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</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;
|
||||
|
||||
// Position preview based on hover source
|
||||
const positionClass = hoverSource === 'deck' ? 'left-8' : 'right-8';
|
||||
|
||||
return (
|
||||
<div className={`hidden lg:block fixed top-1/2 ${positionClass} transform -translate-y-1/2 z-30 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>
|
||||
<div className="mt-3 space-y-2">
|
||||
<h3 className="text-xl font-bold">{displayName}</h3>
|
||||
<p className="text-sm text-gray-400">{displayTypeLine}</p>
|
||||
{displayOracleText && (
|
||||
<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.id);
|
||||
const isMultiFaced = isDoubleFaced(selectedCard);
|
||||
const currentFace = isMultiFaced && selectedCard.card_faces
|
||||
? selectedCard.card_faces[currentFaceIndex]
|
||||
: null;
|
||||
|
||||
const displayName = currentFace?.name || selectedCard.name;
|
||||
const displayTypeLine = currentFace?.type_line || selectedCard.type_line;
|
||||
const displayOracleText = currentFace?.oracle_text || selectedCard.oracle_text;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-[110] 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-[120] overflow-y-auto animate-slide-in-right">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={() => setSelectedCard(null)}
|
||||
className="fixed top-4 right-4 bg-gray-700 hover:bg-gray-600 text-white p-2 md:p-1.5 rounded-full transition-colors z-[130] shadow-lg"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={24} className="md:w-5 md:h-5" />
|
||||
</button>
|
||||
|
||||
<div className="p-4 sm:p-6">
|
||||
{/* Card Image */}
|
||||
<div className="relative mb-4 max-w-sm mx-auto">
|
||||
<img
|
||||
src={getCardLargeImageUri(selectedCard, 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_faces!.length}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleCardFace(selectedCard.id, selectedCard.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-xl md:text-2xl font-bold text-white mb-2">{displayName}</h2>
|
||||
<p className="text-xs sm: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.prices?.usd && (
|
||||
<div className="border-t border-gray-700 pt-3">
|
||||
<div className="text-lg text-green-400 font-semibold">
|
||||
${selectedCard.prices.usd} each
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collection Status */}
|
||||
{userCollection.has(selectedCard.id) && (
|
||||
<div className="border-t border-gray-700 pt-3">
|
||||
<div className="text-sm text-green-400">
|
||||
<CheckCircle size={16} className="inline mr-1" />
|
||||
x{userCollection.get(selectedCard.id)} in your collection
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deck Quantity Management */}
|
||||
<div className="border-t border-gray-700 pt-3">
|
||||
<h3 className="text-lg font-semibold mb-3">Quantity in Deck</h3>
|
||||
<div className="flex items-center justify-between bg-gray-900 rounded-lg p-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
const cardInDeck = selectedCards.find(c => c.card.id === selectedCard.id);
|
||||
const currentQuantity = cardInDeck?.quantity || 0;
|
||||
if (currentQuantity === 1) {
|
||||
removeCardFromDeck(selectedCard.id);
|
||||
} else if (currentQuantity > 1) {
|
||||
updateCardQuantity(selectedCard.id, currentQuantity - 1);
|
||||
}
|
||||
}}
|
||||
disabled={!selectedCards.find(c => c.card.id === selectedCard.id)}
|
||||
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">
|
||||
{selectedCards.find(c => c.card.id === selectedCard.id)?.quantity || 0}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">copies</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => addCardToDeck(selectedCard)}
|
||||
className="bg-green-600 hover:bg-green-700 text-white p-2 rounded-lg transition-colors"
|
||||
>
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{snackbar && (
|
||||
<div
|
||||
className={`fixed bottom-4 right-4 bg-green-500 text-white p-4 rounded-lg shadow-lg transition-all duration-300 ${
|
||||
className={`fixed bottom-4 right-4 text-white p-4 rounded-lg shadow-lg transition-all duration-300 z-[140] ${
|
||||
snackbar.type === 'success' ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -157,7 +157,7 @@ import React, { useState, useEffect } from 'react';
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white p-6">
|
||||
<div className="relative md:min-h-screen bg-gray-900 text-white p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-6">Life Counter</h1>
|
||||
{!setupComplete ? renderSetupForm() : renderLifeCounters()}
|
||||
|
||||
@@ -6,11 +6,14 @@ interface 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 (
|
||||
<div className="relative card-hover animate-fade-in">
|
||||
{card.image_uris?.normal ? (
|
||||
{imageUri ? (
|
||||
<img
|
||||
src={card.image_uris.normal}
|
||||
src={imageUri}
|
||||
alt={card.name}
|
||||
className="w-full h-auto rounded-lg transition-smooth"
|
||||
/>
|
||||
|
||||
71
src/components/ManaCost.tsx
Normal file
71
src/components/ManaCost.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
|
||||
// Map mana symbols to their icon paths
|
||||
const MANA_ICONS: Record<string, string> = {
|
||||
W: '/mana-color/plains.png',
|
||||
U: '/mana-color/island.png',
|
||||
B: '/mana-color/swamp.png',
|
||||
R: '/mana-color/moutain.png', // Note: filename has typo "moutain"
|
||||
G: '/mana-color/forest.png',
|
||||
};
|
||||
|
||||
interface ManaSymbolProps {
|
||||
symbol: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
// Renders a single mana symbol (either as an icon or as text for numbers/other)
|
||||
export function ManaSymbol({ symbol, size = 16 }: ManaSymbolProps) {
|
||||
const iconPath = MANA_ICONS[symbol];
|
||||
|
||||
if (iconPath) {
|
||||
return (
|
||||
<img
|
||||
src={iconPath}
|
||||
alt={symbol}
|
||||
className="inline-block"
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// For numbers and other symbols, show as a circle with the symbol
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center justify-center bg-gray-500 text-white font-bold rounded-full"
|
||||
style={{ width: size, height: size, fontSize: size * 0.6 }}
|
||||
>
|
||||
{symbol}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface ManaCostProps {
|
||||
cost: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
// Parses and renders a full mana cost string like "{2}{W}{U}"
|
||||
export function ManaCost({ cost, size = 16 }: ManaCostProps) {
|
||||
if (!cost) return null;
|
||||
|
||||
// Parse mana cost string: {2}{W}{U} -> ['2', 'W', 'U']
|
||||
const symbols = cost.match(/\{([^}]+)\}/g)?.map(s => s.slice(1, -1)) || [];
|
||||
|
||||
if (symbols.length === 0) return null;
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
{symbols.map((symbol, index) => (
|
||||
<ManaSymbol key={index} symbol={symbol} size={size} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to get icon path for a color (for use in filters, etc.)
|
||||
export function getManaIconPath(color: string): string | null {
|
||||
return MANA_ICONS[color] || null;
|
||||
}
|
||||
|
||||
export default ManaCost;
|
||||
64
src/components/MigrateDeckButton.tsx
Normal file
64
src/components/MigrateDeckButton.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Database, Loader2 } from 'lucide-react';
|
||||
import { migrateExistingDecks } from '../utils/migrateDeckData';
|
||||
|
||||
export default function MigrateDeckButton() {
|
||||
const [isMigrating, setIsMigrating] = useState(false);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
|
||||
const handleMigrate = async () => {
|
||||
if (!confirm('This will update all existing decks with optimization data. Continue?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsMigrating(true);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
await migrateExistingDecks();
|
||||
setResult('Migration completed successfully!');
|
||||
} catch (error) {
|
||||
console.error('Migration error:', error);
|
||||
setResult('Migration failed. Check console for details.');
|
||||
} finally {
|
||||
setIsMigrating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
|
||||
<h3 className="text-lg font-semibold mb-2 flex items-center gap-2">
|
||||
<Database size={20} />
|
||||
Deck Migration Tool
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
Update existing decks with optimization fields (cover image, validation cache, card count).
|
||||
Run this once after the database migration.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={handleMigrate}
|
||||
disabled={isMigrating}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg flex items-center gap-2 transition-colors"
|
||||
>
|
||||
{isMigrating ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" size={20} />
|
||||
Migrating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Database size={20} />
|
||||
Migrate Decks
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{result && (
|
||||
<p className={`mt-3 text-sm ${result.includes('success') ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{result}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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-[110] transition-opacity duration-300 animate-fade-in"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="fixed inset-0 z-[120] 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,199 +1,157 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Home, PlusSquare, Library, LogOut, Settings, ChevronDown, Search, Heart, Menu } 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';
|
||||
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<HTMLDivElement>(null);
|
||||
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
export default function Navigation({ currentPage, setCurrentPage }: NavigationProps) {
|
||||
const { user, signOut } = useAuth();
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProfile = async () => {
|
||||
if (user) {
|
||||
const { data } = await supabase
|
||||
.from('profiles')
|
||||
.select('username')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
useEffect(() => {
|
||||
const fetchProfile = async () => {
|
||||
if (user) {
|
||||
const { data } = await supabase
|
||||
.from('profiles')
|
||||
.select('username')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (data) {
|
||||
setUsername(data.username);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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: 'Home', icon: Home },
|
||||
{ id: 'deck' as const, label: 'New Deck', icon: PlusSquare },
|
||||
{ id: 'collection' as const, label: 'Collection', icon: Library },
|
||||
{ id: 'search' as const, label: 'Search', icon: Search },
|
||||
{ id: 'life-counter' as const, label: 'Life Counter', 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 */}
|
||||
<nav className="hidden md:block fixed top-0 left-0 right-0 bg-gray-800 border-b border-gray-700 z-50 animate-slide-in-left">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center space-x-8">
|
||||
<span className="text-2xl font-bold text-orange-500 animate-bounce-in">Deckerr</span>
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setCurrentPage(item.id)}
|
||||
className={`flex items-center space-x-2 px-3 py-2 rounded-md text-sm font-medium transition-smooth
|
||||
${currentPage === item.id
|
||||
? 'text-white bg-gray-900 animate-pulse-glow'
|
||||
: 'text-gray-300 hover:text-white hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<item.icon size={20} />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
|
||||
{user && (
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className="flex items-center space-x-3 px-3 py-2 rounded-md hover:bg-gray-700 transition-smooth"
|
||||
>
|
||||
<img
|
||||
src={getAvatarUrl(user.id)}
|
||||
alt="User avatar"
|
||||
className="w-8 h-8 rounded-full bg-gray-700 transition-smooth hover:scale-110"
|
||||
/>
|
||||
<span className="text-gray-300 text-sm">{username || user.email}</span>
|
||||
<ChevronDown size={16} className="text-gray-400" />
|
||||
</button>
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
{showDropdown && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-gray-800 rounded-md shadow-lg py-1 border border-gray-700 animate-scale-in glass-effect">
|
||||
<button
|
||||
onClick={() => {
|
||||
setCurrentPage('profile');
|
||||
setShowDropdown(false);
|
||||
}}
|
||||
className="flex items-center space-x-2 w-full px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 transition-smooth"
|
||||
>
|
||||
<Settings size={16} />
|
||||
<span>Profile Settings</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="flex items-center space-x-2 w-full px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 transition-smooth"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span>Sign Out</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
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 */}
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-gray-800 border-t border-gray-700 z-50 animate-slide-in-right">
|
||||
<div className="flex justify-between items-center h-16 px-4">
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
await signOut();
|
||||
setCurrentPage('login');
|
||||
} catch (error) {
|
||||
console.error('Error signing out:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getAvatarUrl = (userId: string) => {
|
||||
return `https://api.dicebear.com/7.x/avataaars/svg?seed=${userId}&backgroundColor=b6e3f4,c0aede,d1d4f9`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop Navigation - Top */}
|
||||
<nav className="hidden md:block fixed top-0 left-0 right-0 bg-gray-800 border-b border-gray-700 z-[100] animate-slide-in-left">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center space-x-8">
|
||||
<span className="text-2xl font-bold text-orange-500 animate-bounce-in">Deckerr</span>
|
||||
<div className="relative" ref={mobileMenuRef}>
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
onClick={() => setShowMobileMenu(!showMobileMenu)}
|
||||
className="text-gray-300 hover:text-white"
|
||||
key={item.id}
|
||||
onClick={() => setCurrentPage(item.id)}
|
||||
className={`flex items-center space-x-2 px-3 py-2 rounded-md text-sm font-medium transition-smooth
|
||||
${currentPage === item.id
|
||||
? 'text-white bg-gray-900 animate-pulse-glow'
|
||||
: 'text-gray-300 hover:text-white hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Menu size={24} />
|
||||
<item.icon size={20} />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
{showMobileMenu && (
|
||||
<div className="absolute right-0 bottom-16 w-48 bg-gray-800 rounded-md shadow-lg py-1 border border-gray-700 animate-scale-in glass-effect">
|
||||
{navItems.map((item) => (
|
||||
))}
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className="flex items-center space-x-3 px-3 py-2 rounded-md hover:bg-gray-700 transition-smooth"
|
||||
>
|
||||
<img
|
||||
src={getAvatarUrl(user.id)}
|
||||
alt="User avatar"
|
||||
className="w-8 h-8 rounded-full bg-gray-700 transition-smooth hover:scale-110"
|
||||
/>
|
||||
<span className="text-gray-300 text-sm">{username || user.email}</span>
|
||||
<ChevronDown size={16} className="text-gray-400" />
|
||||
</button>
|
||||
|
||||
{showDropdown && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-gray-800 rounded-md shadow-lg py-1 border border-gray-700 animate-scale-in glass-effect z-[110]">
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
setCurrentPage(item.id);
|
||||
setShowMobileMenu(false);
|
||||
}}
|
||||
onClick={handleSignOut}
|
||||
className="flex items-center space-x-2 w-full px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 transition-smooth"
|
||||
>
|
||||
<item.icon size={16} />
|
||||
<span>{item.label}</span>
|
||||
<LogOut size={16} />
|
||||
<span>Sign Out</span>
|
||||
</button>
|
||||
))}
|
||||
{user && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setCurrentPage('profile');
|
||||
setShowMobileMenu(false);
|
||||
}}
|
||||
className="flex items-center space-x-2 w-full px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 transition-smooth"
|
||||
>
|
||||
<Settings size={16} />
|
||||
<span>Profile Settings</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="flex items-center space-x-2 w-full px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 transition-smooth"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span>Sign Out</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Content Padding */}
|
||||
<div className="md:pt-16 pb-16 md:pb-0" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
{/* Mobile Navigation - Bottom */}
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-gray-800 border-t border-gray-700 z-50 safe-area-bottom">
|
||||
<div className="flex justify-around items-center h-16 px-2">
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setCurrentPage(item.id)}
|
||||
className={`flex flex-col items-center justify-center flex-1 h-full transition-colors ${
|
||||
currentPage === item.id
|
||||
? 'text-blue-500'
|
||||
: 'text-gray-400 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
<item.icon size={20} />
|
||||
<span className="text-xs mt-1">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Sign Out button for mobile */}
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="flex flex-col items-center justify-center flex-1 h-full text-gray-400 hover:text-gray-200 transition-colors"
|
||||
>
|
||||
<LogOut size={20} />
|
||||
<span className="text-xs mt-1">Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
128
src/components/PWAInstallPrompt.tsx
Normal file
128
src/components/PWAInstallPrompt.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Download, X } from 'lucide-react';
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||
}
|
||||
|
||||
export default function PWAInstallPrompt() {
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [showPrompt, setShowPrompt] = useState(false);
|
||||
const [isInstalled, setIsInstalled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if already installed
|
||||
const isStandalone = window.matchMedia('(display-mode: standalone)').matches;
|
||||
if (isStandalone || (window.navigator as any).standalone) {
|
||||
setIsInstalled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user dismissed the prompt before
|
||||
const dismissed = localStorage.getItem('pwa-install-dismissed');
|
||||
const dismissedTime = dismissed ? parseInt(dismissed) : 0;
|
||||
const daysSinceDismissed = (Date.now() - dismissedTime) / (1000 * 60 * 60 * 24);
|
||||
|
||||
// Show prompt again after 7 days
|
||||
if (daysSinceDismissed > 7) {
|
||||
localStorage.removeItem('pwa-install-dismissed');
|
||||
}
|
||||
|
||||
const handler = (e: Event) => {
|
||||
e.preventDefault();
|
||||
const promptEvent = e as BeforeInstallPromptEvent;
|
||||
setDeferredPrompt(promptEvent);
|
||||
|
||||
// Show prompt if not dismissed recently
|
||||
if (!dismissed || daysSinceDismissed > 7) {
|
||||
setShowPrompt(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeinstallprompt', handler);
|
||||
|
||||
// Detect if app was installed
|
||||
window.addEventListener('appinstalled', () => {
|
||||
setIsInstalled(true);
|
||||
setShowPrompt(false);
|
||||
setDeferredPrompt(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeinstallprompt', handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleInstallClick = async () => {
|
||||
if (!deferredPrompt) return;
|
||||
|
||||
try {
|
||||
await deferredPrompt.prompt();
|
||||
const choiceResult = await deferredPrompt.userChoice;
|
||||
|
||||
if (choiceResult.outcome === 'accepted') {
|
||||
console.log('User accepted the install prompt');
|
||||
setShowPrompt(false);
|
||||
} else {
|
||||
console.log('User dismissed the install prompt');
|
||||
handleDismiss();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during installation:', error);
|
||||
} finally {
|
||||
setDeferredPrompt(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
setShowPrompt(false);
|
||||
localStorage.setItem('pwa-install-dismissed', Date.now().toString());
|
||||
};
|
||||
|
||||
if (isInstalled || !showPrompt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-20 md:bottom-4 left-4 right-4 md:left-auto md:right-4 md:max-w-sm z-[105] animate-slide-in-bottom">
|
||||
<div className="bg-gradient-to-r from-blue-600 to-purple-600 rounded-lg shadow-2xl p-4 text-white">
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="absolute top-2 right-2 text-white/80 hover:text-white transition-colors"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-3 pr-6">
|
||||
<div className="bg-white/20 rounded-lg p-2 flex-shrink-0">
|
||||
<Download size={24} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-lg mb-1">Install Deckerr</h3>
|
||||
<p className="text-sm text-white/90 mb-3">
|
||||
Install our app for quick access and offline support!
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleInstallClick}
|
||||
className="flex-1 bg-white text-blue-600 font-semibold py-2 px-4 rounded-lg hover:bg-blue-50 transition-colors min-h-[44px]"
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="flex-1 bg-white/20 font-semibold py-2 px-4 rounded-lg hover:bg-white/30 transition-colors min-h-[44px]"
|
||||
>
|
||||
Not Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Save } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
const THEME_COLORS = ['red', 'green', 'blue', 'yellow', 'grey', 'purple'];
|
||||
|
||||
export default function Profile() {
|
||||
const { user } = useAuth();
|
||||
const [username, setUsername] = useState('');
|
||||
const [themeColor, setThemeColor] = useState('blue');
|
||||
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')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (data) {
|
||||
setUsername(data.username || '');
|
||||
setThemeColor(data.theme_color || 'blue');
|
||||
}
|
||||
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,
|
||||
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 (
|
||||
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white p-6">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-8">Profile Settings</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Theme Color
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{THEME_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => setThemeColor(color)}
|
||||
className={`h-12 rounded-lg border-2 transition-all capitalize
|
||||
${themeColor === color
|
||||
? 'border-white scale-105'
|
||||
: 'border-transparent hover:border-gray-600'
|
||||
}`}
|
||||
style={{ backgroundColor: `var(--color-${color}-primary)` }}
|
||||
>
|
||||
{color}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 text-white font-semibold py-2 px-4 rounded-lg transition duration-200"
|
||||
>
|
||||
{saving ? (
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-white"></div>
|
||||
) : (
|
||||
<>
|
||||
<Save size={20} />
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
754
src/components/TradeCreator.tsx
Normal file
754
src/components/TradeCreator.tsx
Normal file
@@ -0,0 +1,754 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, ArrowLeftRight, ArrowRight, ArrowLeft, Minus, Send, Gift, Loader2, Search } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { getUserCollection, getCardsByIds } from '../services/api';
|
||||
import { createTrade, updateTrade } from '../services/tradesService';
|
||||
import { Card } from '../types';
|
||||
|
||||
interface CollectionItem {
|
||||
card: Card;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
interface SelectedCard {
|
||||
card: Card;
|
||||
quantity: number;
|
||||
maxQuantity: number;
|
||||
}
|
||||
|
||||
// ============ MOVED OUTSIDE TO PREVENT RE-RENDER ============
|
||||
|
||||
interface CollectionGridProps {
|
||||
items: CollectionItem[];
|
||||
selectedCards: Map<string, SelectedCard>;
|
||||
onAdd: (card: Card, maxQty: number) => void;
|
||||
onRemove: (cardId: string) => void;
|
||||
emptyMessage: string;
|
||||
selectionColor: 'green' | 'blue';
|
||||
searchValue: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
searchPlaceholder: string;
|
||||
}
|
||||
|
||||
function CollectionGrid({
|
||||
items,
|
||||
selectedCards,
|
||||
onAdd,
|
||||
onRemove,
|
||||
emptyMessage,
|
||||
selectionColor,
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
searchPlaceholder,
|
||||
}: CollectionGridProps) {
|
||||
const ringColor = selectionColor === 'green' ? 'ring-green-500' : 'ring-blue-500';
|
||||
const badgeColor = selectionColor === 'green' ? 'bg-green-600' : 'bg-blue-500';
|
||||
|
||||
const filteredItems = items.filter(({ card }) =>
|
||||
card.name.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="w-full pl-9 pr-8 py-2 bg-gray-700 border border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
{searchValue && (
|
||||
<button
|
||||
onClick={() => onSearchChange('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="text-gray-400 text-center py-8">{emptyMessage}</p>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<p className="text-gray-400 text-center py-8">No cards match "{searchValue}"</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-2">
|
||||
{filteredItems.map(({ card, quantity }) => {
|
||||
const selected = selectedCards.get(card.id);
|
||||
const remainingQty = quantity - (selected?.quantity || 0);
|
||||
return (
|
||||
<div
|
||||
key={card.id}
|
||||
className={`relative cursor-pointer rounded-lg overflow-hidden transition active:scale-95 ${
|
||||
selected ? `ring-2 ${ringColor}` : 'active:ring-2 active:ring-gray-500'
|
||||
}`}
|
||||
onClick={() => remainingQty > 0 && onAdd(card, quantity)}
|
||||
>
|
||||
<img
|
||||
src={card.image_uris?.small || card.image_uris?.normal}
|
||||
alt={card.name}
|
||||
className={`w-full h-auto ${remainingQty === 0 ? 'opacity-50' : ''}`}
|
||||
/>
|
||||
<div className="absolute top-1 right-1 bg-gray-900/80 text-white text-[10px] px-1 py-0.5 rounded">
|
||||
{remainingQty}/{quantity}
|
||||
</div>
|
||||
{card.prices?.usd && (
|
||||
<div className="absolute top-1 left-1 bg-gray-900/80 text-green-400 text-[10px] px-1 py-0.5 rounded font-semibold">
|
||||
${card.prices.usd}
|
||||
</div>
|
||||
)}
|
||||
{selected && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(card.id);
|
||||
}}
|
||||
className={`absolute bottom-1 left-1 ${badgeColor} text-white text-[10px] px-1.5 py-0.5 rounded flex items-center gap-0.5`}
|
||||
>
|
||||
+{selected.quantity}
|
||||
<Minus size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectedCardsSummaryProps {
|
||||
cards: Map<string, SelectedCard>;
|
||||
onRemove: (cardId: string) => void;
|
||||
label: string;
|
||||
emptyLabel: string;
|
||||
color: 'green' | 'blue';
|
||||
}
|
||||
|
||||
function SelectedCardsSummary({ cards, onRemove, label, emptyLabel, color }: SelectedCardsSummaryProps) {
|
||||
const bgColor = color === 'green' ? 'bg-green-900/50' : 'bg-blue-900/50';
|
||||
const textColor = color === 'green' ? 'text-green-400' : 'text-blue-400';
|
||||
|
||||
// Calculate total price
|
||||
const totalPrice = Array.from(cards.values()).reduce((total, item) => {
|
||||
const price = item.card.prices?.usd ? parseFloat(item.card.prices.usd) : 0;
|
||||
return total + (price * item.quantity);
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h4 className={`text-xs font-semibold ${textColor}`}>{label}:</h4>
|
||||
{cards.size > 0 && (
|
||||
<span className={`text-xs font-semibold ${textColor}`}>
|
||||
${totalPrice.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{cards.size === 0 ? (
|
||||
<p className="text-gray-500 text-xs">{emptyLabel}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Array.from(cards.values()).map((item) => (
|
||||
<div
|
||||
key={item.card.id}
|
||||
className={`flex items-center gap-1 ${bgColor} px-1.5 py-0.5 rounded text-xs`}
|
||||
>
|
||||
<span className="truncate max-w-[80px]">{item.card.name}</span>
|
||||
<span className={textColor}>x{item.quantity}</span>
|
||||
<button
|
||||
onClick={() => onRemove(item.card.id)}
|
||||
className="text-red-400 active:text-red-300"
|
||||
>
|
||||
<Minus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ MAIN COMPONENT ============
|
||||
|
||||
interface TradeCreatorProps {
|
||||
receiverId: string;
|
||||
receiverUsername: string;
|
||||
receiverCollection: CollectionItem[];
|
||||
onClose: () => void;
|
||||
onTradeCreated: () => void;
|
||||
editMode?: boolean;
|
||||
existingTradeId?: string;
|
||||
initialSenderCards?: Card[];
|
||||
initialReceiverCards?: Card[];
|
||||
initialMessage?: string;
|
||||
}
|
||||
|
||||
type MobileStep = 'want' | 'give' | 'review';
|
||||
|
||||
export default function TradeCreator({
|
||||
receiverId,
|
||||
receiverUsername,
|
||||
receiverCollection,
|
||||
onClose,
|
||||
onTradeCreated,
|
||||
editMode = false,
|
||||
existingTradeId,
|
||||
initialSenderCards = [],
|
||||
initialReceiverCards = [],
|
||||
initialMessage = '',
|
||||
}: TradeCreatorProps) {
|
||||
const { user } = useAuth();
|
||||
const toast = useToast();
|
||||
const [myCollection, setMyCollection] = useState<CollectionItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [message, setMessage] = useState(initialMessage);
|
||||
|
||||
const [isGiftMode, setIsGiftMode] = useState(false);
|
||||
const [mobileStep, setMobileStep] = useState<MobileStep>('want');
|
||||
|
||||
const [myOfferedCards, setMyOfferedCards] = useState<Map<string, SelectedCard>>(new Map());
|
||||
const [wantedCards, setWantedCards] = useState<Map<string, SelectedCard>>(new Map());
|
||||
|
||||
const [myCollectionSearch, setMyCollectionSearch] = useState('');
|
||||
const [theirCollectionSearch, setTheirCollectionSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadMyCollection();
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isGiftMode) {
|
||||
setWantedCards(new Map());
|
||||
setMobileStep('give');
|
||||
} else {
|
||||
setMobileStep('want');
|
||||
}
|
||||
}, [isGiftMode]);
|
||||
|
||||
// Pre-populate cards in edit mode
|
||||
useEffect(() => {
|
||||
if (!editMode || !myCollection.length || !receiverCollection.length) return;
|
||||
if (initialSenderCards.length === 0 && initialReceiverCards.length === 0) return;
|
||||
|
||||
console.log('Pre-populating cards', {
|
||||
initialSenderCards: initialSenderCards.length,
|
||||
initialReceiverCards: initialReceiverCards.length,
|
||||
myCollection: myCollection.length,
|
||||
receiverCollection: receiverCollection.length
|
||||
});
|
||||
|
||||
// Pre-populate sender cards with their quantities
|
||||
const senderMap = new Map<string, SelectedCard>();
|
||||
initialSenderCards.forEach(card => {
|
||||
const collectionItem = myCollection.find(c => c.card.id === card.id);
|
||||
if (collectionItem) {
|
||||
// Find the quantity from trade items if card has quantity property
|
||||
const quantity = (card as any).quantity || 1;
|
||||
console.log('Adding sender card:', card.name, 'qty:', quantity);
|
||||
senderMap.set(card.id, {
|
||||
card: card,
|
||||
quantity: quantity,
|
||||
maxQuantity: collectionItem.quantity,
|
||||
});
|
||||
} else {
|
||||
console.log('Card not found in my collection:', card.name, card.id);
|
||||
}
|
||||
});
|
||||
setMyOfferedCards(senderMap);
|
||||
|
||||
// Pre-populate receiver cards with their quantities
|
||||
const receiverMap = new Map<string, SelectedCard>();
|
||||
initialReceiverCards.forEach(card => {
|
||||
const collectionItem = receiverCollection.find(c => c.card.id === card.id);
|
||||
if (collectionItem) {
|
||||
// Find the quantity from trade items if card has quantity property
|
||||
const quantity = (card as any).quantity || 1;
|
||||
console.log('Adding receiver card:', card.name, 'qty:', quantity);
|
||||
receiverMap.set(card.id, {
|
||||
card: card,
|
||||
quantity: quantity,
|
||||
maxQuantity: collectionItem.quantity,
|
||||
});
|
||||
} else {
|
||||
console.log('Card not found in their collection:', card.name, card.id);
|
||||
}
|
||||
});
|
||||
setWantedCards(receiverMap);
|
||||
}, [editMode, myCollection, receiverCollection, initialSenderCards, initialReceiverCards]);
|
||||
|
||||
const loadMyCollection = async () => {
|
||||
if (!user) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const collectionMap = await getUserCollection(user.id);
|
||||
if (collectionMap.size === 0) {
|
||||
setMyCollection([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const cardIds = Array.from(collectionMap.keys());
|
||||
const cards = await getCardsByIds(cardIds);
|
||||
|
||||
const collectionWithCards = cards.map((card) => ({
|
||||
card,
|
||||
quantity: collectionMap.get(card.id) || 0,
|
||||
}));
|
||||
|
||||
setMyCollection(collectionWithCards);
|
||||
} catch (error) {
|
||||
console.error('Error loading my collection:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addToOffer = (card: Card, maxQuantity: number) => {
|
||||
setMyOfferedCards((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
const existing = newMap.get(card.id);
|
||||
if (existing) {
|
||||
if (existing.quantity < existing.maxQuantity) {
|
||||
newMap.set(card.id, { ...existing, quantity: existing.quantity + 1 });
|
||||
}
|
||||
} else {
|
||||
newMap.set(card.id, { card, quantity: 1, maxQuantity });
|
||||
}
|
||||
return newMap;
|
||||
});
|
||||
};
|
||||
|
||||
const removeFromOffer = (cardId: string) => {
|
||||
setMyOfferedCards((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
const existing = newMap.get(cardId);
|
||||
if (existing && existing.quantity > 1) {
|
||||
newMap.set(cardId, { ...existing, quantity: existing.quantity - 1 });
|
||||
} else {
|
||||
newMap.delete(cardId);
|
||||
}
|
||||
return newMap;
|
||||
});
|
||||
};
|
||||
|
||||
const addToWanted = (card: Card, maxQuantity: number) => {
|
||||
setWantedCards((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
const existing = newMap.get(card.id);
|
||||
if (existing) {
|
||||
if (existing.quantity < existing.maxQuantity) {
|
||||
newMap.set(card.id, { ...existing, quantity: existing.quantity + 1 });
|
||||
}
|
||||
} else {
|
||||
newMap.set(card.id, { card, quantity: 1, maxQuantity });
|
||||
}
|
||||
return newMap;
|
||||
});
|
||||
};
|
||||
|
||||
const removeFromWanted = (cardId: string) => {
|
||||
setWantedCards((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
const existing = newMap.get(cardId);
|
||||
if (existing && existing.quantity > 1) {
|
||||
newMap.set(cardId, { ...existing, quantity: existing.quantity - 1 });
|
||||
} else {
|
||||
newMap.delete(cardId);
|
||||
}
|
||||
return newMap;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!user) return;
|
||||
|
||||
if (myOfferedCards.size === 0 && wantedCards.size === 0) {
|
||||
toast.warning('Please select at least one card to trade or gift');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const myCards = Array.from(myOfferedCards.values()).map((item) => ({
|
||||
cardId: item.card.id,
|
||||
quantity: item.quantity,
|
||||
}));
|
||||
|
||||
const theirCards = Array.from(wantedCards.values()).map((item) => ({
|
||||
cardId: item.card.id,
|
||||
quantity: item.quantity,
|
||||
}));
|
||||
|
||||
if (editMode && existingTradeId) {
|
||||
// Update existing trade
|
||||
await updateTrade({
|
||||
tradeId: existingTradeId,
|
||||
editorId: user.id,
|
||||
message: message || undefined,
|
||||
myCards,
|
||||
theirCards,
|
||||
});
|
||||
toast.success('Trade updated!');
|
||||
} else {
|
||||
// Create new trade
|
||||
await createTrade({
|
||||
user1Id: user.id,
|
||||
user2Id: receiverId,
|
||||
message: message || undefined,
|
||||
user1Cards: myCards,
|
||||
user2Cards: theirCards,
|
||||
});
|
||||
toast.success('Trade offer sent!');
|
||||
}
|
||||
|
||||
onTradeCreated();
|
||||
} catch (error) {
|
||||
console.error('Error with trade:', error);
|
||||
toast.error(editMode ? 'Failed to update trade' : 'Failed to create trade');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isGift = myOfferedCards.size > 0 && wantedCards.size === 0;
|
||||
const isRequest = myOfferedCards.size === 0 && wantedCards.size > 0;
|
||||
|
||||
const goToNextStep = () => {
|
||||
if (mobileStep === 'want') setMobileStep('give');
|
||||
else if (mobileStep === 'give') setMobileStep('review');
|
||||
};
|
||||
|
||||
const goToPrevStep = () => {
|
||||
if (mobileStep === 'review') setMobileStep('give');
|
||||
else if (mobileStep === 'give' && !isGiftMode) setMobileStep('want');
|
||||
};
|
||||
|
||||
const canSubmit = myOfferedCards.size > 0 || wantedCards.size > 0;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/80 z-[110] flex items-center justify-center">
|
||||
<Loader2 className="animate-spin text-blue-500" size={48} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/80 z-[110] flex items-center justify-center p-0 md:p-4">
|
||||
<div className="bg-gray-800 w-full h-full md:rounded-lg md:w-full md:max-w-6xl md:max-h-[90vh] overflow-hidden flex flex-col">
|
||||
|
||||
{/* ============ MOBILE VIEW ============ */}
|
||||
<div className="flex flex-col h-full md:hidden">
|
||||
<div className="flex items-center justify-between p-3 border-b border-gray-700">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<ArrowLeftRight size={20} className="text-blue-400 flex-shrink-0" />
|
||||
<h2 className="font-bold truncate">Trade with {receiverUsername}</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 -mr-2 active:bg-gray-700 rounded-lg">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-3 border-b border-gray-700">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<div
|
||||
className={`relative w-12 h-6 rounded-full transition-colors ${
|
||||
isGiftMode ? 'bg-purple-600' : 'bg-gray-600'
|
||||
}`}
|
||||
onClick={() => setIsGiftMode(!isGiftMode)}
|
||||
>
|
||||
<div
|
||||
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
isGiftMode ? 'translate-x-7' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Gift size={18} className={isGiftMode ? 'text-purple-400' : 'text-gray-400'} />
|
||||
<span className={`text-sm ${isGiftMode ? 'text-purple-400' : 'text-gray-400'}`}>
|
||||
Gift (I don't want anything back)
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-2 p-2 bg-gray-900/50">
|
||||
{!isGiftMode && (
|
||||
<>
|
||||
<div className={`w-2 h-2 rounded-full ${mobileStep === 'want' ? 'bg-blue-500' : 'bg-gray-600'}`} />
|
||||
<span className={`text-xs ${mobileStep === 'want' ? 'text-blue-400' : 'text-gray-500'}`}>I Want</span>
|
||||
<ArrowRight size={14} className="text-gray-500" />
|
||||
</>
|
||||
)}
|
||||
<div className={`w-2 h-2 rounded-full ${mobileStep === 'give' ? 'bg-green-500' : 'bg-gray-600'}`} />
|
||||
<span className={`text-xs ${mobileStep === 'give' ? 'text-green-400' : 'text-gray-500'}`}>I Give</span>
|
||||
<ArrowRight size={14} className="text-gray-500" />
|
||||
<div className={`w-2 h-2 rounded-full ${mobileStep === 'review' ? 'bg-purple-500' : 'bg-gray-600'}`} />
|
||||
<span className={`text-xs ${mobileStep === 'review' ? 'text-purple-400' : 'text-gray-500'}`}>Review</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
{mobileStep === 'want' && !isGiftMode && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-blue-400 mb-3">
|
||||
Select cards from {receiverUsername}'s collection
|
||||
</h3>
|
||||
<CollectionGrid
|
||||
items={receiverCollection}
|
||||
selectedCards={wantedCards}
|
||||
onAdd={addToWanted}
|
||||
onRemove={removeFromWanted}
|
||||
emptyMessage="Their collection is empty"
|
||||
selectionColor="blue"
|
||||
searchValue={theirCollectionSearch}
|
||||
onSearchChange={setTheirCollectionSearch}
|
||||
searchPlaceholder="Search their cards..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mobileStep === 'give' && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-green-400 mb-3">
|
||||
Select cards to {isGiftMode ? 'gift' : 'offer'}
|
||||
</h3>
|
||||
<CollectionGrid
|
||||
items={myCollection}
|
||||
selectedCards={myOfferedCards}
|
||||
onAdd={addToOffer}
|
||||
onRemove={removeFromOffer}
|
||||
emptyMessage="Your collection is empty"
|
||||
selectionColor="green"
|
||||
searchValue={myCollectionSearch}
|
||||
onSearchChange={setMyCollectionSearch}
|
||||
searchPlaceholder="Search my cards..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mobileStep === 'review' && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-purple-400">Review Trade</h3>
|
||||
<div className="bg-gray-900/50 rounded-lg p-3 space-y-3">
|
||||
<SelectedCardsSummary
|
||||
cards={myOfferedCards}
|
||||
onRemove={removeFromOffer}
|
||||
label="I Give"
|
||||
emptyLabel="Nothing (requesting cards)"
|
||||
color="green"
|
||||
/>
|
||||
{!isGiftMode && (
|
||||
<SelectedCardsSummary
|
||||
cards={wantedCards}
|
||||
onRemove={removeFromWanted}
|
||||
label="I Want"
|
||||
emptyLabel="Nothing (sending gift)"
|
||||
color="blue"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Message (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Add a message..."
|
||||
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-700 p-3 flex gap-2">
|
||||
{(mobileStep !== 'want' && !isGiftMode) || (mobileStep !== 'give' && isGiftMode) ? (
|
||||
<button
|
||||
onClick={goToPrevStep}
|
||||
disabled={mobileStep === 'give' && isGiftMode}
|
||||
className="flex items-center justify-center gap-1 px-4 py-2.5 bg-gray-700 active:bg-gray-600 disabled:opacity-50 rounded-lg flex-1"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
Back
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center justify-center gap-1 px-4 py-2.5 bg-gray-700 active:bg-gray-600 rounded-lg flex-1"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
|
||||
{mobileStep === 'review' ? (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || !canSubmit}
|
||||
className="flex items-center justify-center gap-2 px-4 py-2.5 bg-blue-600 active:bg-blue-700 disabled:bg-gray-600 rounded-lg flex-1"
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2 className="animate-spin" size={18} />
|
||||
) : isGift ? (
|
||||
<>
|
||||
<Gift size={18} />
|
||||
Send Gift
|
||||
</>
|
||||
) : isRequest ? (
|
||||
<>
|
||||
<Send size={18} />
|
||||
Request
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send size={18} />
|
||||
Send Trade
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={goToNextStep}
|
||||
className="flex items-center justify-center gap-1 px-4 py-2.5 bg-blue-600 active:bg-blue-700 rounded-lg flex-1"
|
||||
>
|
||||
Next
|
||||
<ArrowRight size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ============ DESKTOP VIEW ============ */}
|
||||
<div className="hidden md:flex md:flex-col h-full">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<ArrowLeftRight size={24} className="text-blue-400" />
|
||||
<h2 className="text-xl font-bold">{editMode ? 'Edit Trade' : `Trade with ${receiverUsername}`}</h2>
|
||||
<label className="flex items-center gap-2 ml-4 cursor-pointer">
|
||||
<div
|
||||
className={`relative w-10 h-5 rounded-full transition-colors ${
|
||||
isGiftMode ? 'bg-purple-600' : 'bg-gray-600'
|
||||
}`}
|
||||
onClick={() => setIsGiftMode(!isGiftMode)}
|
||||
>
|
||||
<div
|
||||
className={`absolute top-0.5 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
isGiftMode ? 'translate-x-5' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<Gift size={16} className={isGiftMode ? 'text-purple-400' : 'text-gray-400'} />
|
||||
<span className={`text-sm ${isGiftMode ? 'text-purple-400' : 'text-gray-400'}`}>Gift mode</span>
|
||||
</label>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-700 rounded-lg transition">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden flex">
|
||||
<div className="flex-1 p-4 border-r border-gray-700 overflow-y-auto">
|
||||
<h3 className="text-lg font-semibold mb-3 text-green-400">My Collection (I give)</h3>
|
||||
<CollectionGrid
|
||||
items={myCollection}
|
||||
selectedCards={myOfferedCards}
|
||||
onAdd={addToOffer}
|
||||
onRemove={removeFromOffer}
|
||||
emptyMessage="Your collection is empty"
|
||||
selectionColor="green"
|
||||
searchValue={myCollectionSearch}
|
||||
onSearchChange={setMyCollectionSearch}
|
||||
searchPlaceholder="Search my cards..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isGiftMode && (
|
||||
<div className="flex-1 p-4 overflow-y-auto">
|
||||
<h3 className="text-lg font-semibold mb-3 text-blue-400">
|
||||
{receiverUsername}'s Collection (I want)
|
||||
</h3>
|
||||
<CollectionGrid
|
||||
items={receiverCollection}
|
||||
selectedCards={wantedCards}
|
||||
onAdd={addToWanted}
|
||||
onRemove={removeFromWanted}
|
||||
emptyMessage="Their collection is empty"
|
||||
selectionColor="blue"
|
||||
searchValue={theirCollectionSearch}
|
||||
onSearchChange={setTheirCollectionSearch}
|
||||
searchPlaceholder="Search their cards..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-700 p-4">
|
||||
<div className="flex gap-6 mb-4">
|
||||
<SelectedCardsSummary
|
||||
cards={myOfferedCards}
|
||||
onRemove={removeFromOffer}
|
||||
label="I Give"
|
||||
emptyLabel="Nothing selected (gift request)"
|
||||
color="green"
|
||||
/>
|
||||
{!isGiftMode && (
|
||||
<SelectedCardsSummary
|
||||
cards={wantedCards}
|
||||
onRemove={removeFromWanted}
|
||||
label="I Want"
|
||||
emptyLabel="Nothing selected (gift)"
|
||||
color="blue"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Add a message (optional)"
|
||||
className="flex-1 px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || !canSubmit}
|
||||
className="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 rounded-lg transition"
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2 className="animate-spin" size={20} />
|
||||
) : isGift ? (
|
||||
<>
|
||||
<Gift size={20} />
|
||||
Send Gift
|
||||
</>
|
||||
) : isRequest ? (
|
||||
<>
|
||||
<Send size={20} />
|
||||
Request Cards
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send size={20} />
|
||||
Propose Trade
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
472
src/components/TradeDetail.tsx
Normal file
472
src/components/TradeDetail.tsx
Normal file
@@ -0,0 +1,472 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Check, ArrowLeftRight, DollarSign, Loader2, Edit, RefreshCcw, History } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { Trade, TradeHistoryEntry, getTradeVersionHistory } from '../services/tradesService';
|
||||
import { getUserCollection, getCardsByIds } from '../services/api';
|
||||
import { Card } from '../types';
|
||||
import TradeCreator from './TradeCreator';
|
||||
|
||||
interface TradeDetailProps {
|
||||
trade: Trade;
|
||||
onClose: () => void;
|
||||
onAccept: (tradeId: string) => Promise<void>;
|
||||
onDecline: (tradeId: string) => Promise<void>;
|
||||
onTradeUpdated: () => void;
|
||||
}
|
||||
|
||||
interface TradeCardItem {
|
||||
card: Card;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
interface CollectionItem {
|
||||
card: Card;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
function calculateTotalPrice(items: TradeCardItem[]): number {
|
||||
return items.reduce((total, { card, quantity }) => {
|
||||
const price = card.prices?.usd ? parseFloat(card.prices.usd) : 0;
|
||||
return total + (price * quantity);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export default function TradeDetail({
|
||||
trade,
|
||||
onClose,
|
||||
onAccept,
|
||||
onDecline,
|
||||
onTradeUpdated,
|
||||
}: TradeDetailProps) {
|
||||
const { user } = useAuth();
|
||||
const toast = useToast();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [senderCards, setSenderCards] = useState<TradeCardItem[]>([]);
|
||||
const [receiverCards, setReceiverCards] = useState<TradeCardItem[]>([]);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [history, setHistory] = useState<TradeHistoryEntry[]>([]);
|
||||
const [showEditMode, setShowEditMode] = useState(false);
|
||||
const [editReceiverCollection, setEditReceiverCollection] = useState<CollectionItem[]>([]);
|
||||
|
||||
const isUser1 = trade.user1_id === user?.id;
|
||||
const isUser2 = trade.user2_id === user?.id;
|
||||
const otherUser = isUser1 ? trade.user2 : trade.user1;
|
||||
const myUserId = user?.id || '';
|
||||
const otherUserId = isUser1 ? trade.user2_id : trade.user1_id;
|
||||
|
||||
useEffect(() => {
|
||||
loadTradeCards();
|
||||
loadTradeHistory();
|
||||
}, [trade]);
|
||||
|
||||
const loadTradeCards = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const allCardIds = trade.items?.map(item => item.card_id) || [];
|
||||
if (allCardIds.length === 0) {
|
||||
setSenderCards([]);
|
||||
setReceiverCards([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const cards = await getCardsByIds(allCardIds);
|
||||
const cardMap = new Map<string, Card>();
|
||||
cards.forEach(card => cardMap.set(card.id, card));
|
||||
|
||||
const myItems: TradeCardItem[] = [];
|
||||
const theirItems: TradeCardItem[] = [];
|
||||
|
||||
trade.items?.forEach(item => {
|
||||
const card = cardMap.get(item.card_id);
|
||||
if (!card) return;
|
||||
|
||||
if (item.owner_id === myUserId) {
|
||||
myItems.push({ card, quantity: item.quantity });
|
||||
} else {
|
||||
theirItems.push({ card, quantity: item.quantity });
|
||||
}
|
||||
});
|
||||
|
||||
setSenderCards(myItems);
|
||||
setReceiverCards(theirItems);
|
||||
} catch (error) {
|
||||
console.error('Error loading trade cards:', error);
|
||||
toast.error('Failed to load trade details');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTradeHistory = async () => {
|
||||
try {
|
||||
const historyData = await getTradeVersionHistory(trade.id);
|
||||
setHistory(historyData);
|
||||
} catch (error) {
|
||||
console.error('Error loading trade history:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccept = async () => {
|
||||
setProcessing(true);
|
||||
try {
|
||||
await onAccept(trade.id);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error accepting trade:', error);
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDecline = async () => {
|
||||
setProcessing(true);
|
||||
try {
|
||||
await onDecline(trade.id);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error declining trade:', error);
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = async () => {
|
||||
try {
|
||||
// Load the other user's collection for editing
|
||||
const collectionMap = await getUserCollection(otherUserId);
|
||||
const cardIds = Array.from(collectionMap.keys());
|
||||
const cards = await getCardsByIds(cardIds);
|
||||
const collection = cards.map((card) => ({
|
||||
card,
|
||||
quantity: collectionMap.get(card.id) || 0,
|
||||
}));
|
||||
|
||||
setEditReceiverCollection(collection);
|
||||
setShowEditMode(true);
|
||||
} catch (error) {
|
||||
console.error('Error loading collection for edit:', error);
|
||||
toast.error('Failed to load collection');
|
||||
}
|
||||
};
|
||||
|
||||
// In the symmetric model, counter-offer is the same as edit
|
||||
const handleCounterOffer = handleEdit;
|
||||
|
||||
// senderCards = myCards, receiverCards = theirCards (already calculated correctly)
|
||||
const yourCards = senderCards;
|
||||
const theirCards = receiverCards;
|
||||
const yourPrice = calculateTotalPrice(yourCards);
|
||||
const theirPrice = calculateTotalPrice(theirCards);
|
||||
|
||||
// For edit mode, pre-populate with current cards
|
||||
// In the symmetric model, both edit and counter-offer use the same perspective:
|
||||
// - Your cards (what you're offering)
|
||||
// - Their cards (what you want)
|
||||
// Include quantity in the card object so TradeCreator can preserve it
|
||||
const editInitialSenderCards = yourCards.map(c => ({ ...c.card, quantity: c.quantity }));
|
||||
const editInitialReceiverCards = theirCards.map(c => ({ ...c.card, quantity: c.quantity }));
|
||||
|
||||
if (showEditMode) {
|
||||
return (
|
||||
<TradeCreator
|
||||
receiverId={otherUserId}
|
||||
receiverUsername={otherUser?.username || 'User'}
|
||||
receiverCollection={editReceiverCollection}
|
||||
onClose={() => {
|
||||
setShowEditMode(false);
|
||||
onClose();
|
||||
}}
|
||||
onTradeCreated={() => {
|
||||
setShowEditMode(false);
|
||||
onTradeUpdated();
|
||||
onClose();
|
||||
}}
|
||||
editMode={true}
|
||||
existingTradeId={trade.id}
|
||||
initialSenderCards={editInitialSenderCards}
|
||||
initialReceiverCards={editInitialReceiverCards}
|
||||
initialMessage={trade.message || ''}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<div className="bg-gray-900 w-full md:max-w-4xl md:rounded-2xl flex flex-col max-h-screen md:max-h-[90vh]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowLeftRight size={20} className="text-blue-400" />
|
||||
<div>
|
||||
<h2 className="text-lg font-bold">Trade Details {trade.version > 1 && `(v${trade.version})`}</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
With: {otherUser?.username}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-800 rounded-lg transition"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="animate-spin text-blue-500" size={48} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Your Side */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-green-400">
|
||||
You Give
|
||||
</h3>
|
||||
<div className="flex items-center gap-1 text-green-400 text-sm">
|
||||
<DollarSign size={14} />
|
||||
{yourPrice.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{yourCards.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">Gift (no cards)</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
|
||||
{yourCards.map((item, idx) => (
|
||||
<div key={idx} className="relative rounded-lg overflow-hidden">
|
||||
<img
|
||||
src={item.card.image_uris?.small || item.card.image_uris?.normal}
|
||||
alt={item.card.name}
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
{item.quantity > 1 && (
|
||||
<div className="absolute top-1 right-1 bg-green-600 text-white text-xs px-1.5 py-0.5 rounded font-semibold">
|
||||
x{item.quantity}
|
||||
</div>
|
||||
)}
|
||||
{item.card.prices?.usd && (
|
||||
<div className="absolute bottom-1 left-1 bg-gray-900/90 text-white text-[10px] px-1 py-0.5 rounded">
|
||||
${item.card.prices.usd}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Their Side */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-blue-400">
|
||||
You Receive
|
||||
</h3>
|
||||
<div className="flex items-center gap-1 text-blue-400 text-sm">
|
||||
<DollarSign size={14} />
|
||||
{theirPrice.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{theirCards.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">Gift (no cards)</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
|
||||
{theirCards.map((item, idx) => (
|
||||
<div key={idx} className="relative rounded-lg overflow-hidden">
|
||||
<img
|
||||
src={item.card.image_uris?.small || item.card.image_uris?.normal}
|
||||
alt={item.card.name}
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
{item.quantity > 1 && (
|
||||
<div className="absolute top-1 right-1 bg-blue-600 text-white text-xs px-1.5 py-0.5 rounded font-semibold">
|
||||
x{item.quantity}
|
||||
</div>
|
||||
)}
|
||||
{item.card.prices?.usd && (
|
||||
<div className="absolute bottom-1 left-1 bg-gray-900/90 text-white text-[10px] px-1 py-0.5 rounded">
|
||||
${item.card.prices.usd}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
{trade.message && (
|
||||
<div className="p-3 bg-gray-800 rounded-lg">
|
||||
<p className="text-sm text-gray-400 mb-1">Message:</p>
|
||||
<p className="text-sm">{trade.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Price Difference */}
|
||||
{!loading && (yourPrice > 0 || theirPrice > 0) && (
|
||||
<div className="p-3 bg-gray-800 rounded-lg">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">Value Difference:</span>
|
||||
<span className={Math.abs(yourPrice - theirPrice) > 5 ? 'text-yellow-400' : 'text-gray-300'}>
|
||||
${Math.abs(yourPrice - theirPrice).toFixed(2)}
|
||||
{yourPrice > theirPrice ? ' in your favor' : yourPrice < theirPrice ? ' in their favor' : ' (balanced)'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History */}
|
||||
{history.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
className="flex items-center gap-2 text-sm text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
<History size={16} />
|
||||
{showHistory ? 'Hide' : 'Show'} History ({history.length} {history.length === 1 ? 'version' : 'versions'})
|
||||
</button>
|
||||
|
||||
{showHistory && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{history.map((entry) => (
|
||||
<div key={entry.id} className="p-3 bg-gray-800 rounded-lg text-sm">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold text-purple-400">Version {entry.version}</span>
|
||||
<span className="text-gray-400 text-xs">
|
||||
Edited by {entry.editor?.username} • {new Date(entry.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{entry.message && (
|
||||
<p className="text-gray-300 text-xs">{entry.message}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions - Only for pending trades */}
|
||||
{trade.status === 'pending' && !loading && (
|
||||
<div className="border-t border-gray-800 p-4 space-y-2">
|
||||
{/* Only the user who DIDN'T make the last edit can respond */}
|
||||
{trade.editor_id && trade.editor_id !== user?.id ? (
|
||||
/* User receives the last edit - can accept/decline/counter */
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleAccept}
|
||||
disabled={processing}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 rounded-lg font-medium transition"
|
||||
>
|
||||
{processing ? (
|
||||
<Loader2 className="animate-spin" size={18} />
|
||||
) : (
|
||||
<>
|
||||
<Check size={18} />
|
||||
Accept Trade
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDecline}
|
||||
disabled={processing}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-red-600 hover:bg-red-700 disabled:bg-gray-600 rounded-lg font-medium transition"
|
||||
>
|
||||
<X size={18} />
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCounterOffer}
|
||||
disabled={processing}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-purple-600 hover:bg-purple-700 disabled:bg-gray-600 rounded-lg font-medium transition"
|
||||
>
|
||||
<RefreshCcw size={18} />
|
||||
Make Counter Offer
|
||||
</button>
|
||||
</>
|
||||
) : trade.editor_id === user?.id ? (
|
||||
/* User made the last edit - can still edit while waiting for response */
|
||||
<>
|
||||
<p className="text-center text-gray-400 text-sm py-2">
|
||||
Waiting for {otherUser?.username} to respond...
|
||||
</p>
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
disabled={processing}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 rounded-lg font-medium transition"
|
||||
>
|
||||
<Edit size={18} />
|
||||
Modify Your Offer
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
/* No editor yet (initial trade) */
|
||||
<>
|
||||
{isUser1 ? (
|
||||
/* User1 (initiator) can edit their initial offer */
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
disabled={processing}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 rounded-lg font-medium transition"
|
||||
>
|
||||
<Edit size={18} />
|
||||
Edit Trade Offer
|
||||
</button>
|
||||
) : (
|
||||
/* User2 (partner) can accept/decline/counter */
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleAccept}
|
||||
disabled={processing}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 rounded-lg font-medium transition"
|
||||
>
|
||||
{processing ? (
|
||||
<Loader2 className="animate-spin" size={18} />
|
||||
) : (
|
||||
<>
|
||||
<Check size={18} />
|
||||
Accept Trade
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDecline}
|
||||
disabled={processing}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-red-600 hover:bg-red-700 disabled:bg-gray-600 rounded-lg font-medium transition"
|
||||
>
|
||||
<X size={18} />
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCounterOffer}
|
||||
disabled={processing}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-purple-600 hover:bg-purple-700 disabled:bg-gray-600 rounded-lg font-medium transition"
|
||||
>
|
||||
<RefreshCcw size={18} />
|
||||
Make Counter Offer
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,8 +35,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
.from('profiles')
|
||||
.upsert(
|
||||
{
|
||||
id: session.user.id,
|
||||
theme_color: 'blue' // Default theme color
|
||||
id: session.user.id
|
||||
},
|
||||
{ onConflict: 'id' }
|
||||
);
|
||||
@@ -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) {
|
||||
|
||||
100
src/contexts/ToastContext.tsx
Normal file
100
src/contexts/ToastContext.tsx
Normal file
@@ -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<ToastContextType | undefined>(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<Toast[]>([]);
|
||||
|
||||
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 <CheckCircle size={20} />;
|
||||
case 'error':
|
||||
return <XCircle size={20} />;
|
||||
case 'warning':
|
||||
return <AlertCircle size={20} />;
|
||||
case 'info':
|
||||
return <Info size={20} />;
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<ToastContext.Provider value={{ showToast, success, error, warning, info }}>
|
||||
{children}
|
||||
|
||||
{/* Toast Container */}
|
||||
<div className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`${getStyles(toast.type)} text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 min-w-[280px] animate-slide-in-right`}
|
||||
>
|
||||
{getIcon(toast.type)}
|
||||
<span className="flex-1">{toast.message}</span>
|
||||
<button
|
||||
onClick={() => removeToast(toast.id)}
|
||||
className="text-white/80 hover:text-white"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -140,26 +140,168 @@ 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
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string | null
|
||||
id: string
|
||||
theme_color?: string | null
|
||||
updated_at?: string | null
|
||||
username?: string | null
|
||||
collection_visibility?: 'public' | 'friends' | 'private' | null
|
||||
}
|
||||
Update: {
|
||||
created_at?: string | null
|
||||
id?: string
|
||||
theme_color?: string | null
|
||||
updated_at?: string | null
|
||||
username?: string | null
|
||||
collection_visibility?: 'public' | 'friends' | 'private' | null
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
friendships: {
|
||||
Row: {
|
||||
id: string
|
||||
requester_id: string
|
||||
addressee_id: string
|
||||
status: 'pending' | 'accepted' | 'declined'
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
requester_id: string
|
||||
addressee_id: string
|
||||
status?: 'pending' | 'accepted' | 'declined'
|
||||
created_at?: string | null
|
||||
updated_at?: string | null
|
||||
}
|
||||
Update: {
|
||||
id?: string
|
||||
requester_id?: string
|
||||
addressee_id?: string
|
||||
status?: 'pending' | 'accepted' | 'declined'
|
||||
created_at?: string | null
|
||||
updated_at?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "friendships_requester_id_fkey"
|
||||
columns: ["requester_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "friendships_addressee_id_fkey"
|
||||
columns: ["addressee_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
}
|
||||
]
|
||||
}
|
||||
trades: {
|
||||
Row: {
|
||||
id: string
|
||||
user1_id: string
|
||||
user2_id: string
|
||||
status: 'pending' | 'accepted' | 'declined' | 'cancelled'
|
||||
message: string | null
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
version: number
|
||||
editor_id: string | null
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
user1_id: string
|
||||
user2_id: string
|
||||
status?: 'pending' | 'accepted' | 'declined' | 'cancelled'
|
||||
message?: string | null
|
||||
created_at?: string | null
|
||||
updated_at?: string | null
|
||||
version?: number
|
||||
editor_id?: string | null
|
||||
}
|
||||
Update: {
|
||||
id?: string
|
||||
user1_id?: string
|
||||
user2_id?: string
|
||||
status?: 'pending' | 'accepted' | 'declined' | 'cancelled'
|
||||
message?: string | null
|
||||
created_at?: string | null
|
||||
updated_at?: string | null
|
||||
version?: number
|
||||
editor_id?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "trades_user1_id_fkey"
|
||||
columns: ["user1_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "trades_user2_id_fkey"
|
||||
columns: ["user2_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "trades_editor_id_fkey"
|
||||
columns: ["editor_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
}
|
||||
]
|
||||
}
|
||||
trade_items: {
|
||||
Row: {
|
||||
id: string
|
||||
trade_id: string
|
||||
owner_id: string
|
||||
card_id: string
|
||||
quantity: number
|
||||
created_at: string | null
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
trade_id: string
|
||||
owner_id: string
|
||||
card_id: string
|
||||
quantity?: number
|
||||
created_at?: string | null
|
||||
}
|
||||
Update: {
|
||||
id?: string
|
||||
trade_id?: string
|
||||
owner_id?: string
|
||||
card_id?: string
|
||||
quantity?: number
|
||||
created_at?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "trade_items_trade_id_fkey"
|
||||
columns: ["trade_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "trades"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "trade_items_owner_id_fkey"
|
||||
columns: ["owner_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Views: {
|
||||
[_ in never]: never
|
||||
|
||||
202
src/services/friendsService.ts
Normal file
202
src/services/friendsService.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
export interface Friend {
|
||||
id: string;
|
||||
odship_id: string;
|
||||
username: string | null;
|
||||
status: 'pending' | 'accepted' | 'declined';
|
||||
isRequester: boolean;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface FriendshipWithProfile {
|
||||
id: string;
|
||||
requester_id: string;
|
||||
addressee_id: string;
|
||||
status: 'pending' | 'accepted' | 'declined';
|
||||
created_at: string | null;
|
||||
requester: { username: string | null };
|
||||
addressee: { username: string | null };
|
||||
}
|
||||
|
||||
// Get all friends (accepted friendships)
|
||||
export async function getFriends(userId: string): Promise<Friend[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('friendships')
|
||||
.select(`
|
||||
id,
|
||||
requester_id,
|
||||
addressee_id,
|
||||
status,
|
||||
created_at,
|
||||
requester:profiles!friendships_requester_id_fkey(username),
|
||||
addressee:profiles!friendships_addressee_id_fkey(username)
|
||||
`)
|
||||
.eq('status', 'accepted')
|
||||
.or(`requester_id.eq.${userId},addressee_id.eq.${userId}`);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return (data as unknown as FriendshipWithProfile[]).map((f) => {
|
||||
const isRequester = f.requester_id === userId;
|
||||
return {
|
||||
id: isRequester ? f.addressee_id : f.requester_id,
|
||||
friendshipId: f.id,
|
||||
username: isRequester ? f.addressee?.username : f.requester?.username,
|
||||
status: f.status,
|
||||
isRequester,
|
||||
created_at: f.created_at,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Get pending friend requests (received)
|
||||
export async function getPendingRequests(userId: string): Promise<Friend[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('friendships')
|
||||
.select(`
|
||||
id,
|
||||
requester_id,
|
||||
addressee_id,
|
||||
status,
|
||||
created_at,
|
||||
requester:profiles!friendships_requester_id_fkey(username)
|
||||
`)
|
||||
.eq('status', 'pending')
|
||||
.eq('addressee_id', userId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return (data as any[]).map((f) => ({
|
||||
id: f.requester_id,
|
||||
friendshipId: f.id,
|
||||
username: f.requester?.username,
|
||||
status: f.status,
|
||||
isRequester: false,
|
||||
created_at: f.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
// Get sent friend requests (pending)
|
||||
export async function getSentRequests(userId: string): Promise<Friend[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('friendships')
|
||||
.select(`
|
||||
id,
|
||||
requester_id,
|
||||
addressee_id,
|
||||
status,
|
||||
created_at,
|
||||
addressee:profiles!friendships_addressee_id_fkey(username)
|
||||
`)
|
||||
.eq('status', 'pending')
|
||||
.eq('requester_id', userId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return (data as any[]).map((f) => ({
|
||||
id: f.addressee_id,
|
||||
friendshipId: f.id,
|
||||
username: f.addressee?.username,
|
||||
status: f.status,
|
||||
isRequester: true,
|
||||
created_at: f.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
// Search users by username
|
||||
export async function searchUsers(query: string, currentUserId: string) {
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, username')
|
||||
.ilike('username', `%${query}%`)
|
||||
.neq('id', currentUserId)
|
||||
.limit(10);
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
// Send friend request
|
||||
export async function sendFriendRequest(requesterId: string, addresseeId: string) {
|
||||
const { data, error } = await supabase
|
||||
.from('friendships')
|
||||
.insert({
|
||||
requester_id: requesterId,
|
||||
addressee_id: addresseeId,
|
||||
status: 'pending',
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
// Accept friend request
|
||||
export async function acceptFriendRequest(friendshipId: string) {
|
||||
const { data, error } = await supabase
|
||||
.from('friendships')
|
||||
.update({ status: 'accepted', updated_at: new Date().toISOString() })
|
||||
.eq('id', friendshipId)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
// Decline friend request
|
||||
export async function declineFriendRequest(friendshipId: string) {
|
||||
const { data, error } = await supabase
|
||||
.from('friendships')
|
||||
.update({ status: 'declined', updated_at: new Date().toISOString() })
|
||||
.eq('id', friendshipId)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
// Remove friend (delete friendship)
|
||||
export async function removeFriend(friendshipId: string) {
|
||||
const { error } = await supabase
|
||||
.from('friendships')
|
||||
.delete()
|
||||
.eq('id', friendshipId);
|
||||
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
// Check if two users are friends
|
||||
export async function areFriends(userId1: string, userId2: string): Promise<boolean> {
|
||||
const { data, error } = await supabase
|
||||
.from('friendships')
|
||||
.select('id')
|
||||
.eq('status', 'accepted')
|
||||
.or(`and(requester_id.eq.${userId1},addressee_id.eq.${userId2}),and(requester_id.eq.${userId2},addressee_id.eq.${userId1})`)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
return data !== null;
|
||||
}
|
||||
|
||||
// Get friendship status between two users
|
||||
export async function getFriendshipStatus(userId1: string, userId2: string) {
|
||||
const { data, error } = await supabase
|
||||
.from('friendships')
|
||||
.select('id, status, requester_id')
|
||||
.or(`and(requester_id.eq.${userId1},addressee_id.eq.${userId2}),and(requester_id.eq.${userId2},addressee_id.eq.${userId1})`)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
if (!data) return { status: 'none' as const, friendshipId: null, isRequester: false };
|
||||
|
||||
return {
|
||||
status: data.status as 'pending' | 'accepted' | 'declined',
|
||||
friendshipId: data.id,
|
||||
isRequester: data.requester_id === userId1,
|
||||
};
|
||||
}
|
||||
327
src/services/tradesService.ts
Normal file
327
src/services/tradesService.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
export interface TradeItem {
|
||||
id: string;
|
||||
trade_id: string;
|
||||
owner_id: string;
|
||||
card_id: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface Trade {
|
||||
id: string;
|
||||
user1_id: string;
|
||||
user2_id: string;
|
||||
status: 'pending' | 'accepted' | 'declined' | 'cancelled';
|
||||
message: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
version: number;
|
||||
editor_id: string | null;
|
||||
user1?: { username: string | null };
|
||||
user2?: { username: string | null };
|
||||
items?: TradeItem[];
|
||||
}
|
||||
|
||||
export interface TradeHistoryEntry {
|
||||
id: string;
|
||||
trade_id: string;
|
||||
version: number;
|
||||
editor_id: string;
|
||||
message: string | null;
|
||||
created_at: string;
|
||||
editor?: { username: string | null };
|
||||
items?: TradeHistoryItem[];
|
||||
}
|
||||
|
||||
export interface TradeHistoryItem {
|
||||
id: string;
|
||||
history_id: string;
|
||||
owner_id: string;
|
||||
card_id: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface CreateTradeParams {
|
||||
user1Id: string;
|
||||
user2Id: string;
|
||||
message?: string;
|
||||
user1Cards: { cardId: string; quantity: number }[];
|
||||
user2Cards: { cardId: string; quantity: number }[];
|
||||
}
|
||||
|
||||
export interface UpdateTradeParams {
|
||||
tradeId: string;
|
||||
editorId: string;
|
||||
message?: string;
|
||||
myCards: { cardId: string; quantity: number }[];
|
||||
theirCards: { cardId: string; quantity: number }[];
|
||||
}
|
||||
|
||||
// Get all trades for a user
|
||||
export async function getTrades(userId: string): Promise<Trade[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('trades')
|
||||
.select(`
|
||||
*,
|
||||
user1:profiles!trades_user1_id_fkey(username),
|
||||
user2:profiles!trades_user2_id_fkey(username),
|
||||
items:trade_items(*)
|
||||
`)
|
||||
.or(`user1_id.eq.${userId},user2_id.eq.${userId}`)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
return data as Trade[];
|
||||
}
|
||||
|
||||
// Get pending trades for a user
|
||||
export async function getPendingTrades(userId: string): Promise<Trade[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('trades')
|
||||
.select(`
|
||||
*,
|
||||
user1:profiles!trades_user1_id_fkey(username),
|
||||
user2:profiles!trades_user2_id_fkey(username),
|
||||
items:trade_items(*)
|
||||
`)
|
||||
.eq('status', 'pending')
|
||||
.or(`user1_id.eq.${userId},user2_id.eq.${userId}`)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
return data as Trade[];
|
||||
}
|
||||
|
||||
// Get trade by ID
|
||||
export async function getTradeById(tradeId: string): Promise<Trade | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('trades')
|
||||
.select(`
|
||||
*,
|
||||
user1:profiles!trades_user1_id_fkey(username),
|
||||
user2:profiles!trades_user2_id_fkey(username),
|
||||
items:trade_items(*)
|
||||
`)
|
||||
.eq('id', tradeId)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data as Trade;
|
||||
}
|
||||
|
||||
// Create a new trade with items
|
||||
export async function createTrade(params: CreateTradeParams): Promise<Trade> {
|
||||
const { user1Id, user2Id, message, user1Cards, user2Cards } = params;
|
||||
|
||||
// Create the trade
|
||||
const { data: trade, error: tradeError } = await supabase
|
||||
.from('trades')
|
||||
.insert({
|
||||
user1_id: user1Id,
|
||||
user2_id: user2Id,
|
||||
message,
|
||||
status: 'pending',
|
||||
// editor_id starts as null - gets set when someone edits the trade
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (tradeError) throw tradeError;
|
||||
|
||||
// Add user1's cards
|
||||
const user1Items = user1Cards.map((card) => ({
|
||||
trade_id: trade.id,
|
||||
owner_id: user1Id,
|
||||
card_id: card.cardId,
|
||||
quantity: card.quantity,
|
||||
}));
|
||||
|
||||
// Add user2's cards
|
||||
const user2Items = user2Cards.map((card) => ({
|
||||
trade_id: trade.id,
|
||||
owner_id: user2Id,
|
||||
card_id: card.cardId,
|
||||
quantity: card.quantity,
|
||||
}));
|
||||
|
||||
const allItems = [...user1Items, ...user2Items];
|
||||
|
||||
if (allItems.length > 0) {
|
||||
const { error: itemsError } = await supabase
|
||||
.from('trade_items')
|
||||
.insert(allItems);
|
||||
|
||||
if (itemsError) throw itemsError;
|
||||
}
|
||||
|
||||
return trade;
|
||||
}
|
||||
|
||||
// Accept a trade (executes the card transfer)
|
||||
export async function acceptTrade(tradeId: string): Promise<boolean> {
|
||||
const { data, error } = await supabase.rpc('execute_trade', {
|
||||
trade_id: tradeId,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return data as boolean;
|
||||
}
|
||||
|
||||
// Decline a trade
|
||||
export async function declineTrade(tradeId: string): Promise<Trade> {
|
||||
const { data, error } = await supabase
|
||||
.from('trades')
|
||||
.update({ status: 'declined', updated_at: new Date().toISOString() })
|
||||
.eq('id', tradeId)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
// Cancel a trade (sender only)
|
||||
export async function cancelTrade(tradeId: string): Promise<Trade> {
|
||||
const { data, error } = await supabase
|
||||
.from('trades')
|
||||
.update({ status: 'cancelled', updated_at: new Date().toISOString() })
|
||||
.eq('id', tradeId)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
// Get trade history (completed/cancelled/declined trades)
|
||||
export async function getTradeHistory(userId: string): Promise<Trade[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('trades')
|
||||
.select(`
|
||||
*,
|
||||
user1:profiles!trades_user1_id_fkey(username),
|
||||
user2:profiles!trades_user2_id_fkey(username),
|
||||
items:trade_items(*)
|
||||
`)
|
||||
.or(`user1_id.eq.${userId},user2_id.eq.${userId}`)
|
||||
.in('status', ['accepted', 'declined', 'cancelled'])
|
||||
.order('updated_at', { ascending: false })
|
||||
.limit(50);
|
||||
|
||||
if (error) throw error;
|
||||
return data as Trade[];
|
||||
}
|
||||
|
||||
// Update an existing trade (for edits and counter-offers)
|
||||
export async function updateTrade(params: UpdateTradeParams): Promise<Trade> {
|
||||
const { tradeId, editorId, message, myCards, theirCards } = params;
|
||||
|
||||
// Get current trade info
|
||||
const { data: currentTrade, error: tradeError } = await supabase
|
||||
.from('trades')
|
||||
.select('version, user1_id, user2_id')
|
||||
.eq('id', tradeId)
|
||||
.single();
|
||||
|
||||
if (tradeError) throw tradeError;
|
||||
|
||||
const newVersion = (currentTrade.version || 1) + 1;
|
||||
|
||||
// Determine the other user's ID
|
||||
const otherUserId = currentTrade.user1_id === editorId
|
||||
? currentTrade.user2_id
|
||||
: currentTrade.user1_id;
|
||||
|
||||
// Save current state to history before updating
|
||||
const { data: historyEntry, error: historyError } = await supabase
|
||||
.from('trade_history')
|
||||
.insert({
|
||||
trade_id: tradeId,
|
||||
version: currentTrade.version || 1,
|
||||
editor_id: editorId,
|
||||
message: message || null,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (historyError) throw historyError;
|
||||
|
||||
// Save current items to history
|
||||
const { data: currentItems } = await supabase
|
||||
.from('trade_items')
|
||||
.select('*')
|
||||
.eq('trade_id', tradeId);
|
||||
|
||||
if (currentItems && currentItems.length > 0) {
|
||||
const historyItems = currentItems.map(item => ({
|
||||
history_id: historyEntry.id,
|
||||
owner_id: item.owner_id,
|
||||
card_id: item.card_id,
|
||||
quantity: item.quantity,
|
||||
}));
|
||||
|
||||
await supabase.from('trade_history_items').insert(historyItems);
|
||||
}
|
||||
|
||||
// Update the trade
|
||||
const { data: updatedTrade, error: updateError } = await supabase
|
||||
.from('trades')
|
||||
.update({
|
||||
message,
|
||||
version: newVersion,
|
||||
editor_id: editorId,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', tradeId)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (updateError) throw updateError;
|
||||
|
||||
// Delete existing items
|
||||
await supabase.from('trade_items').delete().eq('trade_id', tradeId);
|
||||
|
||||
// Add new items (myCards belong to editor, theirCards belong to other user)
|
||||
const myItems = myCards.map((card) => ({
|
||||
trade_id: tradeId,
|
||||
owner_id: editorId,
|
||||
card_id: card.cardId,
|
||||
quantity: card.quantity,
|
||||
}));
|
||||
|
||||
const theirItems = theirCards.map((card) => ({
|
||||
trade_id: tradeId,
|
||||
owner_id: otherUserId,
|
||||
card_id: card.cardId,
|
||||
quantity: card.quantity,
|
||||
}));
|
||||
|
||||
const allItems = [...myItems, ...theirItems];
|
||||
|
||||
if (allItems.length > 0) {
|
||||
const { error: itemsError } = await supabase
|
||||
.from('trade_items')
|
||||
.insert(allItems);
|
||||
|
||||
if (itemsError) throw itemsError;
|
||||
}
|
||||
|
||||
return updatedTrade;
|
||||
}
|
||||
|
||||
// Get version history for a trade
|
||||
export async function getTradeVersionHistory(tradeId: string): Promise<TradeHistoryEntry[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('trade_history')
|
||||
.select(`
|
||||
*,
|
||||
editor:profiles!trade_history_editor_id_fkey(username),
|
||||
items:trade_history_items(*)
|
||||
`)
|
||||
.eq('trade_id', tradeId)
|
||||
.order('version', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
return data as TradeHistoryEntry[];
|
||||
}
|
||||
@@ -5,13 +5,28 @@ export interface User {
|
||||
themeColor: 'red' | 'green' | 'blue' | 'yellow' | 'grey' | 'purple';
|
||||
}
|
||||
|
||||
export interface CardImageUris {
|
||||
small?: string;
|
||||
normal?: string;
|
||||
large?: string;
|
||||
art_crop?: string;
|
||||
border_crop?: string;
|
||||
png?: string;
|
||||
}
|
||||
|
||||
export interface CardFace {
|
||||
name?: string;
|
||||
mana_cost?: string;
|
||||
type_line?: string;
|
||||
oracle_text?: string;
|
||||
image_uris?: CardImageUris;
|
||||
}
|
||||
|
||||
export interface Card {
|
||||
id: string;
|
||||
name: string;
|
||||
image_uris?: {
|
||||
normal: string;
|
||||
art_crop: string;
|
||||
};
|
||||
image_uris?: CardImageUris;
|
||||
card_faces?: CardFace[];
|
||||
mana_cost?: string;
|
||||
type_line?: string;
|
||||
oracle_text?: string;
|
||||
@@ -40,6 +55,11 @@ export interface Deck {
|
||||
userId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
coverCardId?: string;
|
||||
coverCard?: Card | null;
|
||||
validationErrors?: string[];
|
||||
isValid?: boolean;
|
||||
cardCount?: number;
|
||||
}
|
||||
|
||||
export interface CardEntity {
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import { Deck } from '../types';
|
||||
import { Card, Deck } from '../types';
|
||||
|
||||
interface DeckValidation {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
// Helper function to get commander color identity
|
||||
function getCommanderColors(commander: Card | null): string[] {
|
||||
if (!commander) return [];
|
||||
return commander.colors || [];
|
||||
}
|
||||
|
||||
// Helper function to check if a card's colors are valid for the commander
|
||||
function isCardValidForCommander(card: Card, commanderColors: string[]): boolean {
|
||||
if (commanderColors.length === 0) return true;
|
||||
const cardColors = card.colors || [];
|
||||
return cardColors.every(color => commanderColors.includes(color));
|
||||
}
|
||||
|
||||
const FORMAT_RULES = {
|
||||
standard: {
|
||||
minCards: 60,
|
||||
@@ -74,6 +87,25 @@ export function validateDeck(deck: Deck): DeckValidation {
|
||||
}
|
||||
});
|
||||
|
||||
// Commander-specific validations
|
||||
if (deck.format === 'commander') {
|
||||
const commander = deck.cards.find(card => card.is_commander)?.card;
|
||||
|
||||
if (!commander) {
|
||||
errors.push('Commander deck must have a commander');
|
||||
} else {
|
||||
// Check commander color identity
|
||||
const commanderColors = getCommanderColors(commander);
|
||||
const invalidCards = deck.cards.filter(({ card, is_commander }) =>
|
||||
!is_commander && !isCardValidForCommander(card, commanderColors)
|
||||
);
|
||||
|
||||
if (invalidCards.length > 0) {
|
||||
errors.push(`Some cards don't match commander's color identity`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
|
||||
116
src/utils/migrateDeckData.ts
Normal file
116
src/utils/migrateDeckData.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { getCardsByIds } from '../services/api';
|
||||
import { validateDeck } from './deckValidation';
|
||||
import { Deck } from '../types';
|
||||
|
||||
/**
|
||||
* Migrate existing decks to include optimization fields
|
||||
* This should be run once to update all existing decks
|
||||
*/
|
||||
export async function migrateExistingDecks() {
|
||||
console.log('Starting deck migration...');
|
||||
|
||||
// Get all decks
|
||||
const { data: decksData, error: decksError } = await supabase
|
||||
.from('decks')
|
||||
.select('*');
|
||||
|
||||
if (decksError) {
|
||||
console.error('Error fetching decks:', decksError);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found ${decksData.length} decks to migrate`);
|
||||
|
||||
for (const deck of decksData) {
|
||||
// Skip if already migrated
|
||||
if (deck.cover_card_id && deck.card_count !== null) {
|
||||
console.log(`Deck ${deck.name} already migrated, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`Migrating deck: ${deck.name}`);
|
||||
|
||||
// Get deck cards
|
||||
const { data: cardEntities, error: cardsError } = await supabase
|
||||
.from('deck_cards')
|
||||
.select('*')
|
||||
.eq('deck_id', deck.id);
|
||||
|
||||
if (cardsError || !cardEntities || cardEntities.length === 0) {
|
||||
console.error(`Error fetching cards for deck ${deck.id}:`, cardsError);
|
||||
continue;
|
||||
}
|
||||
|
||||
const cardIds = cardEntities.map(entity => entity.card_id);
|
||||
const uniqueCardIds = [...new Set(cardIds)];
|
||||
|
||||
try {
|
||||
// Fetch cards from Scryfall
|
||||
const scryfallCards = await getCardsByIds(uniqueCardIds);
|
||||
|
||||
if (!scryfallCards) {
|
||||
console.error(`Failed to fetch cards for deck ${deck.id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const cards = cardEntities.map(entity => {
|
||||
const card = scryfallCards.find(c => c.id === entity.card_id);
|
||||
return {
|
||||
card,
|
||||
quantity: entity.quantity,
|
||||
is_commander: entity.is_commander,
|
||||
};
|
||||
});
|
||||
|
||||
// Create deck object for validation
|
||||
const deckToValidate: Deck = {
|
||||
id: deck.id,
|
||||
name: deck.name,
|
||||
format: deck.format,
|
||||
cards,
|
||||
userId: deck.user_id,
|
||||
createdAt: new Date(deck.created_at),
|
||||
updatedAt: new Date(deck.updated_at),
|
||||
};
|
||||
|
||||
// Calculate validation
|
||||
const validation = validateDeck(deckToValidate);
|
||||
|
||||
// Determine cover card (commander or first card)
|
||||
const commanderCard = deck.format === 'commander'
|
||||
? cardEntities.find(c => c.is_commander)
|
||||
: null;
|
||||
const coverCardId = commanderCard
|
||||
? commanderCard.card_id
|
||||
: cardEntities[0]?.card_id || null;
|
||||
|
||||
// Calculate total card count
|
||||
const totalCardCount = cardEntities.reduce((acc, curr) => acc + curr.quantity, 0);
|
||||
|
||||
// Update deck with optimization fields
|
||||
const { error: updateError } = await supabase
|
||||
.from('decks')
|
||||
.update({
|
||||
cover_card_id: coverCardId,
|
||||
validation_errors: validation.errors,
|
||||
is_valid: validation.isValid,
|
||||
card_count: totalCardCount,
|
||||
})
|
||||
.eq('id', deck.id);
|
||||
|
||||
if (updateError) {
|
||||
console.error(`Error updating deck ${deck.id}:`, updateError);
|
||||
} else {
|
||||
console.log(`✓ Migrated deck: ${deck.name}`);
|
||||
}
|
||||
|
||||
// Small delay to avoid rate limiting
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
} catch (error) {
|
||||
console.error(`Error processing deck ${deck.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Migration complete!');
|
||||
}
|
||||
273
supabase/migrations/20250124000000_friends_trades_visibility.sql
Normal file
273
supabase/migrations/20250124000000_friends_trades_visibility.sql
Normal file
@@ -0,0 +1,273 @@
|
||||
/*
|
||||
# Friends, Trades, and Collection Visibility
|
||||
|
||||
1. Changes to profiles
|
||||
- Add `collection_visibility` column (public, friends, private)
|
||||
|
||||
2. New Tables
|
||||
- `friendships` - Friend relationships between users
|
||||
- `trades` - Trade offers between users
|
||||
- `trade_items` - Cards included in trades
|
||||
|
||||
3. Security
|
||||
- RLS policies for all new tables
|
||||
- Updated collection policies for visibility
|
||||
*/
|
||||
|
||||
-- Add collection visibility to profiles
|
||||
ALTER TABLE public.profiles
|
||||
ADD COLUMN collection_visibility text DEFAULT 'private'
|
||||
CHECK (collection_visibility IN ('public', 'friends', 'private'));
|
||||
|
||||
-- =============================================
|
||||
-- FRIENDSHIPS TABLE
|
||||
-- =============================================
|
||||
CREATE TABLE public.friendships (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
requester_id uuid REFERENCES public.profiles(id) ON DELETE CASCADE NOT NULL,
|
||||
addressee_id uuid REFERENCES public.profiles(id) ON DELETE CASCADE NOT NULL,
|
||||
status text DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'declined')),
|
||||
created_at timestamptz DEFAULT now(),
|
||||
updated_at timestamptz DEFAULT now(),
|
||||
UNIQUE(requester_id, addressee_id),
|
||||
CHECK (requester_id != addressee_id)
|
||||
);
|
||||
|
||||
ALTER TABLE public.friendships ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Users can see friendships they're involved in
|
||||
CREATE POLICY "Users can view their friendships"
|
||||
ON public.friendships
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (requester_id = auth.uid() OR addressee_id = auth.uid());
|
||||
|
||||
-- Users can create friend requests
|
||||
CREATE POLICY "Users can send friend requests"
|
||||
ON public.friendships
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (requester_id = auth.uid());
|
||||
|
||||
-- Users can update friendships they received (accept/decline)
|
||||
CREATE POLICY "Users can respond to friend requests"
|
||||
ON public.friendships
|
||||
FOR UPDATE
|
||||
TO authenticated
|
||||
USING (addressee_id = auth.uid());
|
||||
|
||||
-- Users can delete their own friendships
|
||||
CREATE POLICY "Users can delete their friendships"
|
||||
ON public.friendships
|
||||
FOR DELETE
|
||||
TO authenticated
|
||||
USING (requester_id = auth.uid() OR addressee_id = auth.uid());
|
||||
|
||||
-- =============================================
|
||||
-- TRADES TABLE
|
||||
-- =============================================
|
||||
CREATE TABLE public.trades (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
sender_id uuid REFERENCES public.profiles(id) ON DELETE CASCADE NOT NULL,
|
||||
receiver_id uuid REFERENCES public.profiles(id) ON DELETE CASCADE NOT NULL,
|
||||
status text DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'declined', 'cancelled')),
|
||||
message text,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
updated_at timestamptz DEFAULT now(),
|
||||
CHECK (sender_id != receiver_id)
|
||||
);
|
||||
|
||||
ALTER TABLE public.trades ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Users can see trades they're involved in
|
||||
CREATE POLICY "Users can view their trades"
|
||||
ON public.trades
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (sender_id = auth.uid() OR receiver_id = auth.uid());
|
||||
|
||||
-- Users can create trades
|
||||
CREATE POLICY "Users can create trades"
|
||||
ON public.trades
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (sender_id = auth.uid());
|
||||
|
||||
-- Sender can cancel, receiver can accept/decline
|
||||
CREATE POLICY "Users can update their trades"
|
||||
ON public.trades
|
||||
FOR UPDATE
|
||||
TO authenticated
|
||||
USING (sender_id = auth.uid() OR receiver_id = auth.uid());
|
||||
|
||||
-- =============================================
|
||||
-- TRADE ITEMS TABLE
|
||||
-- =============================================
|
||||
CREATE TABLE public.trade_items (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
trade_id uuid REFERENCES public.trades(id) ON DELETE CASCADE NOT NULL,
|
||||
owner_id uuid REFERENCES public.profiles(id) ON DELETE CASCADE NOT NULL,
|
||||
card_id text NOT NULL,
|
||||
quantity integer DEFAULT 1 CHECK (quantity > 0),
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE public.trade_items ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Users can see items in their trades
|
||||
CREATE POLICY "Users can view trade items"
|
||||
ON public.trade_items
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.trades
|
||||
WHERE trades.id = trade_items.trade_id
|
||||
AND (trades.sender_id = auth.uid() OR trades.receiver_id = auth.uid())
|
||||
)
|
||||
);
|
||||
|
||||
-- Users can add items to trades they created
|
||||
CREATE POLICY "Users can add trade items"
|
||||
ON public.trade_items
|
||||
FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.trades
|
||||
WHERE trades.id = trade_items.trade_id
|
||||
AND trades.sender_id = auth.uid()
|
||||
AND trades.status = 'pending'
|
||||
)
|
||||
);
|
||||
|
||||
-- =============================================
|
||||
-- UPDATE COLLECTION POLICIES FOR VISIBILITY
|
||||
-- =============================================
|
||||
|
||||
-- Drop old restrictive policy
|
||||
DROP POLICY IF EXISTS "Users can view their own collection" ON public.collections;
|
||||
|
||||
-- New policy: view own collection OR public collections OR friend's collections (if friends visibility)
|
||||
CREATE POLICY "Users can view collections based on visibility"
|
||||
ON public.collections
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
user_id = auth.uid()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.profiles
|
||||
WHERE profiles.id = collections.user_id
|
||||
AND profiles.collection_visibility = 'public'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.profiles p
|
||||
JOIN public.friendships f ON (
|
||||
(f.requester_id = p.id AND f.addressee_id = auth.uid())
|
||||
OR (f.addressee_id = p.id AND f.requester_id = auth.uid())
|
||||
)
|
||||
WHERE p.id = collections.user_id
|
||||
AND p.collection_visibility = 'friends'
|
||||
AND f.status = 'accepted'
|
||||
)
|
||||
);
|
||||
|
||||
-- =============================================
|
||||
-- UPDATE PROFILES POLICY FOR PUBLIC VIEWING
|
||||
-- =============================================
|
||||
|
||||
-- Drop old restrictive policy
|
||||
DROP POLICY IF EXISTS "Users can view their own profile" ON public.profiles;
|
||||
|
||||
-- New policy: users can view all profiles (needed for friend search and public collections)
|
||||
CREATE POLICY "Users can view profiles"
|
||||
ON public.profiles
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (true);
|
||||
|
||||
-- =============================================
|
||||
-- FUNCTION: Execute trade (transfer cards)
|
||||
-- =============================================
|
||||
CREATE OR REPLACE FUNCTION public.execute_trade(trade_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_trade RECORD;
|
||||
v_item RECORD;
|
||||
BEGIN
|
||||
-- Get the trade
|
||||
SELECT * INTO v_trade FROM public.trades WHERE id = trade_id;
|
||||
|
||||
-- Check trade exists and is pending
|
||||
IF v_trade IS NULL OR v_trade.status != 'pending' THEN
|
||||
RETURN false;
|
||||
END IF;
|
||||
|
||||
-- Check caller is the receiver
|
||||
IF v_trade.receiver_id != auth.uid() THEN
|
||||
RETURN false;
|
||||
END IF;
|
||||
|
||||
-- Process each trade item
|
||||
FOR v_item IN SELECT * FROM public.trade_items WHERE trade_items.trade_id = execute_trade.trade_id
|
||||
LOOP
|
||||
-- Determine new owner
|
||||
DECLARE
|
||||
v_new_owner uuid;
|
||||
BEGIN
|
||||
IF v_item.owner_id = v_trade.sender_id THEN
|
||||
v_new_owner := v_trade.receiver_id;
|
||||
ELSE
|
||||
v_new_owner := v_trade.sender_id;
|
||||
END IF;
|
||||
|
||||
-- Remove from old owner's collection
|
||||
UPDATE public.collections
|
||||
SET quantity = quantity - v_item.quantity,
|
||||
updated_at = now()
|
||||
WHERE user_id = v_item.owner_id
|
||||
AND card_id = v_item.card_id;
|
||||
|
||||
-- Delete if quantity is 0 or less
|
||||
DELETE FROM public.collections
|
||||
WHERE user_id = v_item.owner_id
|
||||
AND card_id = v_item.card_id
|
||||
AND quantity <= 0;
|
||||
|
||||
-- Add to new owner's collection
|
||||
INSERT INTO public.collections (user_id, card_id, quantity)
|
||||
VALUES (v_new_owner, v_item.card_id, v_item.quantity)
|
||||
ON CONFLICT (user_id, card_id)
|
||||
DO UPDATE SET
|
||||
quantity = collections.quantity + v_item.quantity,
|
||||
updated_at = now();
|
||||
END;
|
||||
END LOOP;
|
||||
|
||||
-- Mark trade as accepted
|
||||
UPDATE public.trades
|
||||
SET status = 'accepted', updated_at = now()
|
||||
WHERE id = trade_id;
|
||||
|
||||
RETURN true;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Add unique constraint on collections for upsert
|
||||
ALTER TABLE public.collections
|
||||
ADD CONSTRAINT collections_user_card_unique UNIQUE (user_id, card_id);
|
||||
|
||||
-- =============================================
|
||||
-- INDEXES FOR PERFORMANCE
|
||||
-- =============================================
|
||||
CREATE INDEX idx_friendships_requester ON public.friendships(requester_id);
|
||||
CREATE INDEX idx_friendships_addressee ON public.friendships(addressee_id);
|
||||
CREATE INDEX idx_friendships_status ON public.friendships(status);
|
||||
CREATE INDEX idx_trades_sender ON public.trades(sender_id);
|
||||
CREATE INDEX idx_trades_receiver ON public.trades(receiver_id);
|
||||
CREATE INDEX idx_trades_status ON public.trades(status);
|
||||
CREATE INDEX idx_trade_items_trade ON public.trade_items(trade_id);
|
||||
CREATE INDEX idx_profiles_visibility ON public.profiles(collection_visibility);
|
||||
78
supabase/migrations/20250126000000_trade_history.sql
Normal file
78
supabase/migrations/20250126000000_trade_history.sql
Normal file
@@ -0,0 +1,78 @@
|
||||
-- Create trade_history table to track all versions of a trade
|
||||
CREATE TABLE IF NOT EXISTS public.trade_history (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
trade_id uuid REFERENCES public.trades(id) ON DELETE CASCADE NOT NULL,
|
||||
version integer NOT NULL,
|
||||
editor_id uuid REFERENCES public.profiles(id) NOT NULL,
|
||||
message text,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
UNIQUE(trade_id, version)
|
||||
);
|
||||
|
||||
-- Create trade_history_items table to store cards for each version
|
||||
CREATE TABLE IF NOT EXISTS public.trade_history_items (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
history_id uuid REFERENCES public.trade_history(id) ON DELETE CASCADE NOT NULL,
|
||||
owner_id uuid REFERENCES public.profiles(id) NOT NULL,
|
||||
card_id text NOT NULL,
|
||||
quantity integer DEFAULT 1,
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
|
||||
-- Add version column to trades table to track current version
|
||||
ALTER TABLE public.trades ADD COLUMN IF NOT EXISTS version integer DEFAULT 1;
|
||||
|
||||
-- Add editor_id to track who last edited the trade
|
||||
ALTER TABLE public.trades ADD COLUMN IF NOT EXISTS editor_id uuid REFERENCES public.profiles(id);
|
||||
|
||||
-- Create indexes for better performance
|
||||
CREATE INDEX IF NOT EXISTS idx_trade_history_trade_id ON public.trade_history(trade_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_trade_history_items_history_id ON public.trade_history_items(history_id);
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE public.trade_history ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.trade_history_items ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS policies for trade_history
|
||||
CREATE POLICY "Users can view history of their trades"
|
||||
ON public.trade_history FOR SELECT
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.trades
|
||||
WHERE trades.id = trade_history.trade_id
|
||||
AND (trades.sender_id = auth.uid() OR trades.receiver_id = auth.uid())
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "Users can create history for their trades"
|
||||
ON public.trade_history FOR INSERT
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.trades
|
||||
WHERE trades.id = trade_history.trade_id
|
||||
AND (trades.sender_id = auth.uid() OR trades.receiver_id = auth.uid())
|
||||
)
|
||||
);
|
||||
|
||||
-- RLS policies for trade_history_items
|
||||
CREATE POLICY "Users can view history items of their trades"
|
||||
ON public.trade_history_items FOR SELECT
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.trade_history th
|
||||
JOIN public.trades t ON t.id = th.trade_id
|
||||
WHERE th.id = trade_history_items.history_id
|
||||
AND (t.sender_id = auth.uid() OR t.receiver_id = auth.uid())
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "Users can create history items for their trades"
|
||||
ON public.trade_history_items FOR INSERT
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.trade_history th
|
||||
JOIN public.trades t ON t.id = th.trade_id
|
||||
WHERE th.id = trade_history_items.history_id
|
||||
AND (t.sender_id = auth.uid() OR t.receiver_id = auth.uid())
|
||||
)
|
||||
);
|
||||
@@ -1,9 +1,98 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['icon.svg'],
|
||||
manifest: {
|
||||
name: 'Deckerr - Card Deck Manager',
|
||||
short_name: 'Deckerr',
|
||||
description: 'Manage your trading card game decks on the go',
|
||||
theme_color: '#0f172a',
|
||||
background_color: '#0f172a',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait',
|
||||
scope: '/',
|
||||
start_url: '/',
|
||||
icons: [
|
||||
{
|
||||
src: 'icon.svg',
|
||||
sizes: '512x512',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'any'
|
||||
},
|
||||
{
|
||||
src: 'icon.svg',
|
||||
sizes: '512x512',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'maskable'
|
||||
}
|
||||
],
|
||||
categories: ['games', 'utilities'],
|
||||
shortcuts: [
|
||||
{
|
||||
name: 'My Decks',
|
||||
short_name: 'Decks',
|
||||
description: 'View your deck collection',
|
||||
url: '/?page=home'
|
||||
},
|
||||
{
|
||||
name: 'Search Cards',
|
||||
short_name: 'Search',
|
||||
description: 'Search for cards',
|
||||
url: '/?page=search'
|
||||
},
|
||||
{
|
||||
name: 'Life Counter',
|
||||
short_name: 'Life',
|
||||
description: 'Track life totals',
|
||||
url: '/?page=life-counter'
|
||||
}
|
||||
]
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https:\/\/api\.scryfall\.com\/.*/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'scryfall-cache',
|
||||
expiration: {
|
||||
maxEntries: 500,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 7 // 7 days
|
||||
},
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
urlPattern: /^https:\/\/cards\.scryfall\.io\/.*/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'card-images-cache',
|
||||
expiration: {
|
||||
maxEntries: 1000,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days
|
||||
},
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true
|
||||
}
|
||||
})
|
||||
],
|
||||
optimizeDeps: {
|
||||
exclude: ['lucide-react'],
|
||||
},
|
||||
|
||||
104
vite.config.ts.timestamp-1764157043179-658c718c76681.mjs
Normal file
104
vite.config.ts.timestamp-1764157043179-658c718c76681.mjs
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user