Major Scanner Improvements

🔧 Gemini AI Integration:
- Added Google Gemini API as default OCR service
- Auto-configures from GEMINI_AI_API_KEY environment variable
- Fixed Puter.js authentication issues
- Enhanced OCR settings with connection testing

🎨 Redesigned Scanner Queue:
- New thumbnail + content layout with checkbox overlay
- Smart quantity management (duplicates increment quantity)
- Complete card information display from database
- Two-row action layout (primary/secondary actions)
- Floating bottom toolbar for bulk actions
- Real card images from database

�� Enhanced User Experience:
- Fixed Canvas2D performance warnings
- Better error handling and fallbacks
- Improved responsive design
- Database confirmation indicators
- Professional card scanning workflow

📱 Mobile Ready:
- Optimized layouts for mobile scanning
- Touch-friendly controls and interactions
- Improved visual feedback and status indicators
This commit is contained in:
Randall Stillwell 2025-07-29 14:19:48 -05:00
parent b3240dbb3c
commit afb79c57d9
35 changed files with 7380 additions and 443 deletions

988
components/CameraScanner.js Normal file
View file

@ -0,0 +1,988 @@
import { useState, useEffect, useRef } from 'react';
import { aiCardOCR, ollamaCardOCR, puterCardOCR, geminiCardOCR } from '../lib/ai-ocr';
export default function CameraScanner({ onCardScanned, onError }) {
const [isStreaming, setIsStreaming] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [scanResult, setScanResult] = useState(null);
const [capturedImage, setCapturedImage] = useState(null);
const [isAutoScanning, setIsAutoScanning] = useState(true);
const [detectedCard, setDetectedCard] = useState(null);
const [scanningAnimation, setScanningAnimation] = useState(false);
const [toast, setToast] = useState(null);
const [isDetecting, setIsDetecting] = useState(false);
const [ocrSettings, setOcrSettings] = useState({
service: 'gemini', // Default to Gemini
openaiApiKey: '',
geminiApiKey: '',
ollamaUrl: 'http://localhost:11434'
});
const videoRef = useRef(null);
const canvasRef = useRef(null);
const detectionCanvasRef = useRef(null); // Separate canvas for computer vision
const streamRef = useRef(null);
const autoScanIntervalRef = useRef(null);
const detectionIntervalRef = useRef(null);
const lastScanTimeRef = useRef(0);
const lastDetectionTimeRef = useRef(0);
const detectionHistoryRef = useRef([]); // Track detection stability
const stableDetectionRef = useRef(null); // Current stable detection
// Load OCR settings from localStorage
useEffect(() => {
const loadOcrSettings = async () => {
let settings = {
service: 'gemini', // Default to Gemini
openaiApiKey: '',
geminiApiKey: '',
ollamaUrl: 'http://localhost:11434'
};
// Load saved settings
const savedSettings = localStorage.getItem('ocrSettings');
if (savedSettings) {
try {
const parsed = JSON.parse(savedSettings);
settings = { ...settings, ...parsed };
} catch (error) {
console.error('Failed to load OCR settings:', error);
}
}
// Try to auto-load Gemini API key from environment if not already set
if (!settings.geminiApiKey) {
try {
const response = await fetch('/api/config/gemini');
if (response.ok) {
const data = await response.json();
if (data.hasKey && data.apiKey) {
settings.geminiApiKey = data.apiKey;
settings.service = 'gemini'; // Ensure Gemini is selected
console.log('✅ Auto-configured Gemini API key from environment');
}
}
} catch (error) {
console.log('Could not auto-load Gemini API key:', error);
}
}
setOcrSettings(settings);
// Configure AI services
if (settings.openaiApiKey) {
aiCardOCR.setApiKey(settings.openaiApiKey);
}
if (settings.geminiApiKey) {
geminiCardOCR.setApiKey(settings.geminiApiKey);
}
if (settings.ollamaUrl) {
ollamaCardOCR.setBaseUrl(settings.ollamaUrl);
}
};
loadOcrSettings();
}, []);
// Configure canvas contexts for optimal performance
useEffect(() => {
if (canvasRef.current) {
const ctx = canvasRef.current.getContext('2d', { willReadFrequently: true });
}
if (detectionCanvasRef.current) {
const ctx = detectionCanvasRef.current.getContext('2d', { willReadFrequently: true });
}
}, []);
// Computer vision-based card detection (fast, no API calls)
const detectCardWithComputerVision = () => {
if (!videoRef.current || !detectionCanvasRef.current) return null;
const video = videoRef.current;
const canvas = detectionCanvasRef.current;
const ctx = canvas.getContext('2d');
// Set canvas size for detection (smaller for performance)
canvas.width = 320;
canvas.height = 240;
// Draw current video frame
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// Get image data for analysis
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
// Card detection algorithm using computer vision
const cardDetection = analyzeImageForCard(data, canvas.width, canvas.height);
return cardDetection;
};
// Computer vision algorithm to detect rectangular card-like objects
const analyzeImageForCard = (imageData, width, height) => {
// Convert to grayscale and detect edges
const grayscale = [];
const edges = [];
// Convert to grayscale
for (let i = 0; i < imageData.length; i += 4) {
const gray = Math.round(0.299 * imageData[i] + 0.587 * imageData[i + 1] + 0.114 * imageData[i + 2]);
grayscale.push(gray);
}
// Enhanced edge detection with stronger thresholds
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const idx = y * width + x;
// Horizontal gradient
const gx = -grayscale[idx - width - 1] - 2 * grayscale[idx - 1] - grayscale[idx + width - 1] +
grayscale[idx - width + 1] + 2 * grayscale[idx + 1] + grayscale[idx + width + 1];
// Vertical gradient
const gy = -grayscale[idx - width - 1] - 2 * grayscale[idx - width] - grayscale[idx - width + 1] +
grayscale[idx + width - 1] + 2 * grayscale[idx + width] + grayscale[idx + width + 1];
// Edge magnitude
const magnitude = Math.sqrt(gx * gx + gy * gy);
edges[idx] = magnitude > 80 ? 255 : 0; // Increased threshold from 50 to 80
}
}
// Look for rectangular regions with stricter criteria
const cardCandidates = findRectangularRegions(edges, width, height);
// Score candidates based on card-like properties with higher standards
const bestCandidate = scoreCardCandidates(cardCandidates, width, height);
return bestCandidate;
};
// Find rectangular regions that could be cards with stricter criteria
const findRectangularRegions = (edges, width, height) => {
const candidates = [];
const minCardWidth = Math.floor(width * 0.2); // Increased from 15% to 20%
const maxCardWidth = Math.floor(width * 0.7); // Decreased from 80% to 70%
const minCardHeight = Math.floor(height * 0.25); // Increased from 20% to 25%
const maxCardHeight = Math.floor(height * 0.8); // Decreased from 90% to 80%
// Scan for edge-dense rectangular regions with larger steps for performance
for (let y = 0; y < height - minCardHeight; y += 15) { // Increased step from 10 to 15
for (let x = 0; x < width - minCardWidth; x += 15) { // Increased step from 10 to 15
for (let w = minCardWidth; w <= maxCardWidth && x + w < width; w += 25) { // Increased step from 20 to 25
for (let h = minCardHeight; h <= maxCardHeight && y + h < height; h += 25) { // Increased step from 20 to 25
// Stricter card-like aspect ratio check
const aspectRatio = w / h;
if (aspectRatio < 0.65 || aspectRatio > 0.77) continue; // Narrowed from 0.6-0.8 to 0.65-0.77
// Count edges in this region with more selective sampling
let edgeCount = 0;
let totalPixels = 0;
let cornerEdges = 0; // Count edges near corners (cards have defined corners)
// Sample the region (not every pixel for performance)
for (let sy = y; sy < y + h; sy += 4) { // Increased step from 3 to 4
for (let sx = x; sx < x + w; sx += 4) { // Increased step from 3 to 4
const idx = sy * width + sx;
if (edges[idx] === 255) {
edgeCount++;
// Check if this edge is near a corner (cards have distinct corners)
const isNearCorner = (
(sx < x + w * 0.2 && sy < y + h * 0.2) || // Top-left
(sx > x + w * 0.8 && sy < y + h * 0.2) || // Top-right
(sx < x + w * 0.2 && sy > y + h * 0.8) || // Bottom-left
(sx > x + w * 0.8 && sy > y + h * 0.8) // Bottom-right
);
if (isNearCorner) cornerEdges++;
}
totalPixels++;
}
}
const edgeDensity = edgeCount / totalPixels;
const cornerEdgeDensity = cornerEdges / (edgeCount || 1);
// Stricter criteria: cards should have moderate edge density AND corner definition
if (edgeDensity > 0.08 && edgeDensity < 0.25 && cornerEdgeDensity > 0.1) { // Increased min from 0.05 to 0.08, added corner requirement
// Additional check: look for rectangular perimeter (cards have clear borders)
const perimeterStrength = checkRectangularPerimeter(edges, x, y, w, h, width);
if (perimeterStrength > 0.3) { // Only accept if perimeter is well-defined
candidates.push({
x: x,
y: y,
width: w,
height: h,
edgeDensity: edgeDensity,
aspectRatio: aspectRatio,
cornerEdgeDensity: cornerEdgeDensity,
perimeterStrength: perimeterStrength
});
}
}
}
}
}
}
return candidates;
};
// Check if the region has a well-defined rectangular perimeter (like a card border)
const checkRectangularPerimeter = (edges, x, y, w, h, frameWidth) => {
let perimeterEdges = 0;
let perimeterPixels = 0;
// Check top and bottom edges
for (let sx = x; sx < x + w; sx += 2) {
// Top edge
const topIdx = y * frameWidth + sx;
if (topIdx >= 0 && topIdx < edges.length) {
if (edges[topIdx] === 255) perimeterEdges++;
perimeterPixels++;
}
// Bottom edge
const bottomIdx = (y + h - 1) * frameWidth + sx;
if (bottomIdx >= 0 && bottomIdx < edges.length) {
if (edges[bottomIdx] === 255) perimeterEdges++;
perimeterPixels++;
}
}
// Check left and right edges
for (let sy = y; sy < y + h; sy += 2) {
// Left edge
const leftIdx = sy * frameWidth + x;
if (leftIdx >= 0 && leftIdx < edges.length) {
if (edges[leftIdx] === 255) perimeterEdges++;
perimeterPixels++;
}
// Right edge
const rightIdx = sy * frameWidth + (x + w - 1);
if (rightIdx >= 0 && rightIdx < edges.length) {
if (edges[rightIdx] === 255) perimeterEdges++;
perimeterPixels++;
}
}
return perimeterPixels > 0 ? perimeterEdges / perimeterPixels : 0;
};
// Score and select the best card candidate with higher standards
const scoreCardCandidates = (candidates, frameWidth, frameHeight) => {
if (candidates.length === 0) return null;
let bestCandidate = null;
let bestScore = 0;
for (const candidate of candidates) {
let score = 0;
// Prefer candidates with good aspect ratio (closer to 0.71 - typical card ratio)
const aspectRatioScore = 1 - Math.abs(candidate.aspectRatio - 0.71);
score += aspectRatioScore * 35; // Increased weight from 30 to 35
// Prefer moderate edge density (not too sparse, not too dense)
const edgeDensityScore = Math.min(candidate.edgeDensity * 100, 25);
score += edgeDensityScore;
// Reward corner definition (cards have clear corners)
const cornerScore = candidate.cornerEdgeDensity * 25;
score += cornerScore;
// Reward strong perimeter (cards have borders)
const perimeterScore = candidate.perimeterStrength * 20;
score += perimeterScore;
// Prefer cards that are reasonably sized
const cardArea = (candidate.width / frameWidth) * (candidate.height / frameHeight);
const sizeScore = cardArea > 0.05 && cardArea < 0.4 ? 15 : 0; // Reward reasonable size
score += sizeScore;
// Prefer cards closer to center (people usually center cards when scanning)
const centerX = frameWidth / 2;
const centerY = frameHeight / 2;
const cardCenterX = candidate.x + candidate.width / 2;
const cardCenterY = candidate.y + candidate.height / 2;
const distanceFromCenter = Math.sqrt(
Math.pow(cardCenterX - centerX, 2) + Math.pow(cardCenterY - centerY, 2)
);
const maxDistance = Math.sqrt(Math.pow(centerX, 2) + Math.pow(centerY, 2));
const centerScore = (1 - distanceFromCenter / maxDistance) * 10; // Reduced weight from 15 to 10
score += centerScore;
if (score > bestScore) {
bestScore = score;
bestCandidate = candidate;
}
}
// Much higher threshold - only return candidate if it's very likely to be a card
return bestScore > 70 ? bestCandidate : null; // Increased from 40 to 70
};
// Convert canvas coordinates to video coordinates (percentage)
const convertToVideoCoordinates = (candidate) => {
if (!candidate || !detectionCanvasRef.current || !videoRef.current) return null;
const canvas = detectionCanvasRef.current;
const video = videoRef.current;
// Convert from detection canvas coordinates to video percentage
return {
x: (candidate.x / canvas.width) * 100,
y: (candidate.y / canvas.height) * 100,
width: (candidate.width / canvas.width) * 100,
height: (candidate.height / canvas.height) * 100
};
};
// Fast computer vision detection effect
useEffect(() => {
if (isStreaming && isAutoScanning && !isProcessing) {
// Run computer vision detection every 500ms (fast, no API calls)
detectionIntervalRef.current = setInterval(() => {
const candidate = detectCardWithComputerVision();
// Add to detection history for stability tracking
const now = Date.now();
detectionHistoryRef.current.push({
timestamp: now,
detected: !!candidate,
candidate: candidate
});
// Keep only last 6 detections (3 seconds of history at 500ms intervals)
detectionHistoryRef.current = detectionHistoryRef.current.filter(
detection => now - detection.timestamp < 3000
);
// Check for stable detection (at least 4 out of last 6 detections must be positive)
const recentDetections = detectionHistoryRef.current.slice(-6);
const positiveDetections = recentDetections.filter(d => d.detected).length;
const isStableDetection = recentDetections.length >= 4 && positiveDetections >= 4;
if (isStableDetection && candidate) {
// Only update if this is a new stable detection or significantly different position
const shouldUpdate = !stableDetectionRef.current ||
Math.abs(candidate.x - stableDetectionRef.current.x) > 10 ||
Math.abs(candidate.y - stableDetectionRef.current.y) > 10;
if (shouldUpdate) {
const videoCoords = convertToVideoCoordinates(candidate);
if (videoCoords) {
setDetectedCard(videoCoords);
stableDetectionRef.current = candidate;
}
}
} else if (!isStableDetection) {
// Clear detection if not stable
setDetectedCard(null);
stableDetectionRef.current = null;
}
}, 500);
// Run AI analysis only when card is detected and stable for 3 seconds
autoScanIntervalRef.current = setInterval(() => {
const now = Date.now();
if (detectedCard && now - lastScanTimeRef.current > 3000) {
processDetectedCard();
}
}, 1000);
} else {
if (detectionIntervalRef.current) {
clearInterval(detectionIntervalRef.current);
detectionIntervalRef.current = null;
}
if (autoScanIntervalRef.current) {
clearInterval(autoScanIntervalRef.current);
autoScanIntervalRef.current = null;
}
// Clear detection history when not scanning
detectionHistoryRef.current = [];
stableDetectionRef.current = null;
}
return () => {
if (detectionIntervalRef.current) {
clearInterval(detectionIntervalRef.current);
}
if (autoScanIntervalRef.current) {
clearInterval(autoScanIntervalRef.current);
}
};
}, [isStreaming, isAutoScanning, isProcessing, detectedCard]);
// Show toast notification
const showToast = (message, type = 'success') => {
setToast({ message, type });
setTimeout(() => setToast(null), 3000);
};
// Start camera stream with card aspect ratio
const startCamera = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment', // Use back camera on mobile
width: { ideal: 1280 },
height: { ideal: 720 }, // 16:9 aspect ratio, good for cards
aspectRatio: { ideal: 16/9 }
}
});
if (videoRef.current) {
videoRef.current.srcObject = stream;
streamRef.current = stream;
// Wait for video to be ready
videoRef.current.onloadedmetadata = () => {
videoRef.current?.play().then(() => {
setIsStreaming(true);
}).catch((err) => {
onError(`Video playback failed: ${err.message}`);
});
};
videoRef.current.onerror = (err) => {
onError('Video element error occurred');
};
} else {
onError('Video element not available');
}
} catch (err) {
console.error('Camera access error:', err);
onError(`Unable to access camera: ${err.message}`);
}
};
// Stop camera stream
const stopCamera = () => {
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
if (autoScanIntervalRef.current) {
clearInterval(autoScanIntervalRef.current);
autoScanIntervalRef.current = null;
}
if (detectionIntervalRef.current) {
clearInterval(detectionIntervalRef.current);
detectionIntervalRef.current = null;
}
setIsStreaming(false);
setCapturedImage(null);
setScanResult(null);
setDetectedCard(null);
setIsDetecting(false);
// Clear detection state
detectionHistoryRef.current = [];
stableDetectionRef.current = null;
};
// Process the detected card with full AI analysis
const processDetectedCard = async () => {
if (!detectedCard || isProcessing) return;
lastScanTimeRef.current = Date.now();
setScanningAnimation(true);
// Capture image
const imageDataUrl = captureImageData();
if (imageDataUrl) {
await processImage(imageDataUrl, true); // true for auto-scan
}
setTimeout(() => setScanningAnimation(false), 2000);
};
// Capture image from video stream
const captureImageData = () => {
if (!videoRef.current || !canvasRef.current) return null;
const video = videoRef.current;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
// Set canvas dimensions to match video
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// Draw current video frame to canvas
ctx?.drawImage(video, 0, 0, canvas.width, canvas.height);
// Get image data URL
return canvas.toDataURL('image/jpeg', 0.8);
};
// Manual capture for testing
const captureImage = () => {
const imageDataUrl = captureImageData();
if (imageDataUrl) {
setCapturedImage(imageDataUrl);
processImage(imageDataUrl, false);
}
};
// Process image with AI OCR (only called when computer vision detects a card)
const processImage = async (imageData, isAutoScan = false) => {
setIsProcessing(true);
setScanResult(null);
try {
let ocrResult;
console.log('🔍 Current OCR settings:', ocrSettings);
if (ocrSettings.service === 'puter') {
console.log('🎯 Using Puter.js (Free AI Vision)...');
ocrResult = await puterCardOCR.analyzeCard(imageData);
console.log('✅ Puter.js Vision result:', ocrResult);
} else if (ocrSettings.service === 'openai') {
console.log('🤖 Using OpenAI Vision API...');
ocrResult = await aiCardOCR.analyzeCard(imageData);
console.log('✅ OpenAI Vision result:', ocrResult);
} else if (ocrSettings.service === 'ollama') {
console.log('🦙 Using Ollama Vision...');
ocrResult = await ollamaCardOCR.analyzeCard(imageData);
console.log('✅ Ollama Vision result:', ocrResult);
} else if (ocrSettings.service === 'gemini') {
console.log('🤖 Using Gemini Vision API...');
ocrResult = await geminiCardOCR.analyzeCard(imageData);
console.log('✅ Gemini Vision result:', ocrResult);
} else {
throw new Error('No OCR service configured');
}
// First check: Is this actually a trading card?
if (!ocrResult.isCard) {
if (isAutoScan) {
console.log('❌ No trading card detected in auto-scan, continuing...');
setDetectedCard(null); // Remove highlight
return;
} else {
// Show error for manual scans
onError(ocrResult.reason || 'No trading card detected in image');
return;
}
}
// Second check: Do we have a card name?
if (!ocrResult.cardName || ocrResult.cardName.trim().length < 2) {
if (isAutoScan) {
console.log('❌ Card name not clear in auto-scan, continuing...');
setDetectedCard(null);
return;
} else {
onError('Could not read card name clearly. Please try again with better lighting.');
return;
}
}
// Third check: Cross-reference with database
console.log('🔍 Cross-referencing with database...');
const dbResponse = await fetch('/api/cards/find-or-create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
},
body: JSON.stringify({
name: ocrResult.cardName.trim(),
set: ocrResult.setName,
setCode: ocrResult.setCode,
cardNumber: ocrResult.cardNumber,
game: ocrResult.game,
cardType: ocrResult.cardType,
rarity: ocrResult.rarity,
hp: ocrResult.hp,
manaCost: ocrResult.manaCost,
ocrData: {
confidence: ocrResult.confidence,
rawText: ocrResult.rawText,
abilities: ocrResult.abilities,
flavorText: ocrResult.flavorText,
artist: ocrResult.artist
}
})
});
if (!dbResponse.ok) {
throw new Error(`Database lookup failed: ${dbResponse.status}`);
}
const dbResult = await dbResponse.json();
// Handle different database response scenarios
if (dbResult.needsUserSelection) {
// Multiple matches found - let user choose
if (isAutoScan) {
showToast(`⚠️ Multiple matches for "${ocrResult.cardName}" - use manual mode to select`, 'warning');
setDetectedCard(null);
return;
} else {
// Show selection modal for manual scans
setScanResult({
type: 'multipleMatches',
cardName: ocrResult.cardName,
matches: dbResult.matches,
confidence: ocrResult.confidence
});
return;
}
}
if (dbResult.needsUserInput) {
// Low confidence or unclear card
if (isAutoScan) {
console.log('❌ Low confidence match, skipping auto-scan');
setDetectedCard(null);
return;
} else {
onError(dbResult.message || 'Card not found in database and confidence is too low');
return;
}
}
// Success! We have a confirmed card match
const finalCard = dbResult.card;
const cleanCardName = finalCard.name;
setScanResult({
type: 'success',
cardName: cleanCardName,
setName: finalCard.set_name,
game: finalCard.game,
rarity: finalCard.rarity,
confidence: ocrResult.confidence,
isExisting: dbResult.isExisting,
message: dbResult.message
});
// Show success toast for auto-scan
if (isAutoScan) {
showToast(`📸 Captured: ${cleanCardName}`, 'success');
setDetectedCard(null); // Remove highlight after successful scan
}
// Send the confirmed card data to parent
onCardScanned({
name: cleanCardName,
set: finalCard.set_name,
setCode: finalCard.set_code,
cardNumber: finalCard.card_number,
game: finalCard.game,
cardType: finalCard.card_type,
rarity: finalCard.rarity,
hp: finalCard.hp || ocrResult.hp,
manaCost: finalCard.mana_cost || ocrResult.manaCost,
abilities: ocrResult.abilities,
ocrText: ocrResult.rawText,
confidence: ocrResult.confidence,
capturedImage: imageData,
image_url: finalCard.image_url, // Add the database card image
databaseId: finalCard.id,
isExisting: dbResult.isExisting
});
} catch (error) {
console.error('Card processing error:', error);
if (!isAutoScan) {
onError(`Card processing failed: ${error.message}`);
} else {
// For auto-scans, just continue silently
setDetectedCard(null);
}
} finally {
setIsProcessing(false);
}
};
// Cleanup on unmount
useEffect(() => {
return () => {
stopCamera();
};
}, []);
return (
<div className="camera-scanner">
{/* Camera Controls */}
<div className="flex gap-4 mb-4 flex-wrap">
{!isStreaming ? (
<button
onClick={startCamera}
className="px-6 py-3 rounded-xl font-medium transition-all duration-200 flex items-center gap-2 hover:opacity-90"
style={{
backgroundColor: 'var(--accent-ember)',
color: 'white'
}}
>
<span>📹</span> Start Camera
</button>
) : (
<>
<button
onClick={() => setIsAutoScanning(!isAutoScanning)}
className={`px-4 py-2 rounded-xl font-medium transition-all duration-200 flex items-center gap-2 hover:opacity-90`}
style={{
backgroundColor: isAutoScanning ? 'var(--accent-flame)' : 'var(--bg-tertiary)',
color: isAutoScanning ? 'white' : 'var(--text-primary)',
border: isAutoScanning ? 'none' : '1px solid var(--border)'
}}
>
<span>{isAutoScanning ? '🔄' : '⏸️'}</span>
{isAutoScanning ? 'Auto Scanning' : 'Manual Mode'}
</button>
<button
onClick={captureImage}
disabled={isProcessing}
className="px-4 py-2 rounded-xl font-medium transition-all duration-200 flex items-center gap-2 hover:opacity-90 disabled:opacity-50"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-primary)',
border: '1px solid var(--border)'
}}
>
<span>📸</span>
Manual Capture
</button>
<button
onClick={stopCamera}
className="px-4 py-2 rounded-xl font-medium transition-all duration-200 flex items-center gap-2 hover:opacity-90"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-primary)',
border: '1px solid var(--border)'
}}
>
<span></span> Stop
</button>
</>
)}
</div>
{/* Camera Preview with Card-like Aspect Ratio */}
<div
className="relative rounded-xl overflow-hidden mb-4"
style={{
backgroundColor: 'var(--bg-secondary)',
aspectRatio: '16/9', // Card-friendly aspect ratio
maxHeight: '400px'
}}
>
<video
ref={videoRef}
autoPlay
playsInline
muted
className="w-full h-full object-cover"
style={{
minHeight: isStreaming ? 'auto' : '300px',
aspectRatio: '16/9'
}}
/>
{/* Card Detection Highlight - Only show when card is actually detected */}
{detectedCard && isStreaming && (
<div
className="absolute border-4 rounded-xl transition-all duration-500 ease-in-out"
style={{
borderColor: 'var(--accent-ember)',
left: `${detectedCard.x}%`,
top: `${detectedCard.y}%`,
width: `${detectedCard.width}%`,
height: `${detectedCard.height}%`,
backgroundColor: 'rgba(var(--accent-ember-rgb), 0.1)',
boxShadow: '0 0 30px rgba(var(--accent-ember-rgb), 0.6)',
borderRadius: '12px', // Card-like rounded corners
animation: 'pulse 2s infinite'
}}
>
{/* Card Detected Label */}
<div
className="absolute -top-8 left-0 px-3 py-1 rounded-lg text-sm font-medium"
style={{
backgroundColor: 'var(--accent-ember)',
color: 'white',
fontSize: '12px'
}}
>
🃏 Card Detected
</div>
{/* Scanning Animation */}
{scanningAnimation && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
</div>
)}
</div>
)}
{/* Detection Status Indicator */}
{isStreaming && (
<div className="absolute top-4 left-4 flex items-center gap-2">
<div
className={`w-3 h-3 rounded-full transition-all duration-300 ${
detectedCard ? 'animate-pulse' : ''
}`}
style={{
backgroundColor: detectedCard ? '#10b981' : '#6b7280'
}}
/>
<span className="text-sm font-medium" style={{ color: 'white', textShadow: '0 1px 2px rgba(0,0,0,0.8)' }}>
{detectedCard ? '🃏 Card Found' : '👁️ Watching'}
</span>
</div>
)}
{/* Show overlay when streaming but no card detected */}
{isStreaming && !detectedCard && (
<div className="absolute inset-0 pointer-events-none">
<div className="absolute inset-8 border-2 border-dashed rounded-xl flex items-center justify-center" style={{ borderColor: 'rgba(var(--accent-ember-rgb), 0.5)' }}>
<div className="px-6 py-4 rounded-xl text-center" style={{ backgroundColor: 'rgba(0,0,0,0.7)', color: 'white' }}>
<div className="font-medium text-lg mb-2">🃏 Position Trading Card</div>
<div className="text-sm opacity-75">
{isAutoScanning ? 'Computer vision will detect rectangular cards' : 'Position card and tap capture'}
</div>
<div className="text-xs opacity-60 mt-1">No API calls until card is detected</div>
</div>
</div>
</div>
)}
{/* Show placeholder when not streaming */}
{!isStreaming && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center" style={{ color: 'var(--text-secondary)' }}>
<div className="text-4xl mb-2">📹</div>
<div className="font-medium">Camera Preview</div>
<div className="text-sm opacity-75">Click "Start Camera" to begin</div>
<div className="text-xs opacity-60 mt-2">AI-optimized card detection</div>
</div>
</div>
)}
</div>
{/* Toast Notification */}
{toast && (
<div
className={`fixed top-4 right-4 z-50 px-4 py-3 rounded-xl shadow-lg transition-all duration-300 ${
toast.type === 'success' ? 'border-green-500' : toast.type === 'warning' ? 'border-yellow-500' : 'border-red-500'
}`}
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: toast.type === 'success' ? '#10b981' : toast.type === 'warning' ? '#f59e0b' : '#ef4444',
borderWidth: '2px',
color: 'var(--text-primary)'
}}
>
<div className="flex items-center gap-2">
<span>{toast.type === 'success' ? '✅' : toast.type === 'warning' ? '⚠️' : '❌'}</span>
<span className="font-medium">{toast.message}</span>
</div>
</div>
)}
{/* Hidden canvases for image processing */}
<canvas ref={canvasRef} className="hidden" />
<canvas ref={detectionCanvasRef} className="hidden" />
{/* Processing Status */}
{isProcessing && !scanningAnimation && (
<div className="rounded-xl p-4 mb-4" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2" style={{ borderColor: 'var(--accent-ember)' }}></div>
<div>
<div className="font-medium" style={{ color: 'var(--text-primary)' }}>Processing with AI...</div>
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>Card detected by computer vision</div>
</div>
</div>
</div>
)}
{/* Manual Capture Results (only show for manual captures) */}
{scanResult && capturedImage && (
<div className="rounded-xl border p-4 mb-4" style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}>
<div className="font-medium mb-2" style={{ color: 'var(--text-primary)' }}>Manual Scan Results</div>
{scanResult.type === 'multipleMatches' ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-yellow-600"></span>
<div className="font-medium" style={{ color: 'var(--text-primary)' }}>Multiple matches found for "{scanResult.cardName}":</div>
</div>
{scanResult.matches.map((match, index) => (
<div key={index} className="flex items-center gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
<span>{match.name}</span>
<span>({match.set_name})</span>
</div>
))}
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Confidence: {Math.round(scanResult.confidence)}%
</div>
</div>
) : scanResult.type === 'success' ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-green-600"></span>
<div>
<div className="font-medium" style={{ color: 'var(--text-primary)' }}>Card Found: {scanResult.cardName}</div>
{scanResult.setName && (
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>Set: {scanResult.setName}</div>
)}
{scanResult.game && (
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>Game: {scanResult.game}</div>
)}
{scanResult.rarity && (
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>Rarity: {scanResult.rarity}</div>
)}
{scanResult.message && (
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>{scanResult.message}</div>
)}
</div>
</div>
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Confidence: {Math.round(scanResult.confidence)}%
</div>
</div>
) : (
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-yellow-600"></span>
<div className="font-medium" style={{ color: 'var(--text-primary)' }}>Card not clearly recognized</div>
</div>
<details className="text-sm" style={{ color: 'var(--text-secondary)' }}>
<summary className="cursor-pointer">View raw OCR response</summary>
<pre className="mt-2 whitespace-pre-wrap p-2 rounded text-xs" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
{scanResult.rawText || 'No raw text available'}
</pre>
</details>
</div>
)}
</div>
)}
{/* Instructions */}
<div className="rounded-xl p-4 mt-4" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
<div className="font-medium mb-2" style={{ color: 'var(--text-primary)' }}>💡 Efficient Card Detection</div>
<ul className="text-sm space-y-1" style={{ color: 'var(--text-secondary)' }}>
<li> 👁 Computer vision detects rectangular objects (no API calls)</li>
<li> 🎯 AI analysis only when card-like shape is found</li>
<li> Fast detection every 500ms, AI scan every 3 seconds</li>
<li> 💰 Dramatically reduces API usage and costs</li>
<li> 🃏 Still maintains high accuracy for card recognition</li>
</ul>
</div>
</div>
);
}

View file

@ -138,18 +138,38 @@ export default function Layout({ children, user = { email: 'me@randallstillwell.
} }
}, [router.pathname]); }, [router.pathname]);
const navigation = [ // Navigation structure for authenticated users
{ name: 'Dashboard', href: '/dashboard', icon: 'grid', active: router.pathname === '/dashboard' }, const authenticatedNavigation = user ? [
{ name: 'My Collection', href: '/collections', icon: 'collection', active: router.pathname === '/collections', badge: '247' }, { name: 'Activity', href: '/activity', icon: 'activity', active: router.pathname === '/activity', isPlaceholder: true }
] : [];
// My Collection section (only for authenticated users)
const myCollectionNavigation = user ? {
name: 'My Collection',
href: '/dashboard', // My Collection itself links to dashboard
icon: 'collection',
active: router.pathname === '/dashboard' || router.pathname === '/collections' || router.pathname === '/my-cards' || router.pathname === '/decks' || router.pathname === '/analytics',
expanded: true, // Always expanded for now
items: [
{ name: 'Collections', href: '/collections', active: router.pathname === '/collections' },
{ name: 'Cards', href: '/my-cards', active: router.pathname === '/my-cards' },
{ name: 'Decks', href: '/decks', active: router.pathname === '/decks' },
{ name: 'Analytics', href: '/analytics', active: router.pathname === '/analytics', isPlaceholder: true }
]
} : null;
// Always visible navigation (public + authenticated)
const publicNavigation = [
{ name: 'Cards', href: '/cards', icon: 'card', active: router.pathname === '/cards' }, { name: 'Cards', href: '/cards', icon: 'card', active: router.pathname === '/cards' },
{ name: 'Decks', href: '/decks', icon: 'deck', active: router.pathname === '/decks', badge: '12' }, { name: 'Scanner', href: '/scanner', icon: 'scanner', active: router.pathname === '/scanner' },
{ name: 'Analytics', href: '/analytics', icon: 'analytics', active: router.pathname === '/analytics' }, { name: 'Deck Builder', href: '/deck-builder', icon: 'deck', active: router.pathname === '/deck-builder', isPlaceholder: true }
{ name: 'Settings', href: '/settings', icon: 'settings', active: router.pathname === '/settings' },
...(user?.role === 'admin' ? [
{ name: 'Admin Tools', href: '/admin/card-editor', icon: 'admin', active: router.pathname.startsWith('/admin'), badge: 'ADMIN' }
] : [])
]; ];
// Admin navigation (only for admin users)
const adminNavigation = (user?.role === 'admin') ? [
{ name: 'Admin Tools', href: '/admin/card-editor', icon: 'admin', active: router.pathname.startsWith('/admin'), badge: 'ADMIN' }
] : [];
const communityNavigation = { const communityNavigation = {
name: 'Community', name: 'Community',
icon: 'community', icon: 'community',
@ -221,6 +241,17 @@ export default function Layout({ children, user = { email: 'me@randallstillwell.
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-5 5v-5zM4.5 19.5L9 15m0 0l-4.5-4.5M9 15v5" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-5 5v-5zM4.5 19.5L9 15m0 0l-4.5-4.5M9 15v5" />
</svg> </svg>
),
activity: (
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
),
scanner: (
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
) )
}; };
return icons[iconName] || icons.grid; return icons[iconName] || icons.grid;
@ -293,53 +324,184 @@ export default function Layout({ children, user = { email: 'me@randallstillwell.
</div> </div>
<nav className="space-y-2 flex-1" role="navigation" aria-label="Main navigation"> <nav className="space-y-2 flex-1" role="navigation" aria-label="Main navigation">
{navigation.map((item) => ( {/* Authenticated User Navigation - Activity */}
<Link key={item.name} href={item.href}> {authenticatedNavigation.map((item) => (
<div <div key={item.name}>
className={` {item.isPlaceholder ? (
nav-item flex items-center justify-between px-4 py-3 rounded-2xl <div
transition-all duration-200 cursor-pointer className="nav-item flex items-center justify-between px-4 py-3 rounded-2xl opacity-50 cursor-not-allowed"
focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 style={{
${item.active backgroundColor: 'transparent',
? 'shadow-lg nav-item-active' color: 'var(--text-secondary)'
: 'hover:shadow-md nav-item-hover' }}
} >
`} <div className="flex items-center">
style={{ <span className="mr-3" aria-hidden="true">{getIcon(item.icon)}</span>
backgroundColor: item.active ? 'var(--bg-tertiary)' : 'transparent', <span className="font-medium">{item.name}</span>
color: item.active ? 'var(--text-primary)' : 'var(--text-secondary)', </div>
'--tw-ring-color': 'var(--accent-ember)', <span className="text-xs px-2 py-1 rounded-full" style={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-secondary)' }}>
'--tw-ring-offset-color': 'var(--bg-secondary)' Coming Soon
}}
onClick={() => setIsMobileMenuOpen(false)}
role="menuitem"
aria-current={item.active ? 'page' : undefined}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsMobileMenuOpen(false);
}
}}
>
<div className="flex items-center">
<span className="mr-3" aria-hidden="true">{getIcon(item.icon)}</span>
<span className="font-medium">{item.name}</span>
</div>
{item.badge && (
<span
className="px-2 py-1 text-xs rounded-full font-medium"
style={{
backgroundColor: item.badge === 'ADMIN' ? 'var(--accent-ember)' : 'var(--accent-flame)',
color: 'white'
}}
aria-label={`${item.badge} items`}
>
{item.badge}
</span> </span>
)} </div>
</div> ) : (
</Link> <Link href={item.href}>
<div
className={`
nav-item flex items-center justify-between px-4 py-3 rounded-2xl
transition-all duration-200 cursor-pointer
focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2
${item.active
? 'shadow-lg nav-item-active'
: 'hover:shadow-md nav-item-hover'
}
`}
style={{
backgroundColor: item.active ? 'var(--bg-tertiary)' : 'transparent',
color: item.active ? 'var(--text-primary)' : 'var(--text-secondary)',
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'var(--bg-secondary)'
}}
onClick={() => setIsMobileMenuOpen(false)}
>
<div className="flex items-center">
<span className="mr-3" aria-hidden="true">{getIcon(item.icon)}</span>
<span className="font-medium">{item.name}</span>
</div>
</div>
</Link>
)}
</div>
))}
{/* My Collection Section (only for authenticated users) */}
{myCollectionNavigation && (
<div className="space-y-1">
{/* My Collection Header - Clickable */}
<Link href={myCollectionNavigation.href}>
<div
className={`
nav-item flex items-center justify-between px-4 py-3 rounded-2xl
transition-all duration-200 cursor-pointer
focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2
${router.pathname === '/dashboard'
? 'shadow-lg nav-item-active'
: 'hover:shadow-md nav-item-hover'
}
`}
style={{
backgroundColor: router.pathname === '/dashboard' ? 'var(--bg-tertiary)' : 'transparent',
color: router.pathname === '/dashboard' ? 'var(--text-primary)' : 'var(--text-secondary)',
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'var(--bg-secondary)'
}}
onClick={() => setIsMobileMenuOpen(false)}
>
<div className="flex items-center">
<span className="mr-3" aria-hidden="true">{getIcon(myCollectionNavigation.icon)}</span>
<span className="font-medium">{myCollectionNavigation.name}</span>
</div>
</div>
</Link>
{/* My Collection Sub-items */}
{myCollectionNavigation.expanded && (
<div className="ml-4 space-y-1 border-l-2 pl-4" style={{ borderColor: 'var(--border)' }}>
{myCollectionNavigation.items.map((subItem) => (
<div key={subItem.name}>
{subItem.isPlaceholder ? (
<div
className="nav-item flex items-center justify-between px-4 py-2 rounded-xl opacity-50 cursor-not-allowed text-sm"
style={{
backgroundColor: 'transparent',
color: 'var(--text-secondary)'
}}
>
<span className="font-medium">{subItem.name}</span>
<span className="text-xs px-2 py-1 rounded-full" style={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-secondary)' }}>
Soon
</span>
</div>
) : (
<Link href={subItem.href}>
<div
className={`
nav-item flex items-center px-4 py-2 rounded-xl
transition-all duration-200 cursor-pointer text-sm
focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2
${subItem.active
? 'shadow-md nav-item-active'
: 'hover:shadow-sm nav-item-hover'
}
`}
style={{
backgroundColor: subItem.active ? 'var(--bg-tertiary)' : 'transparent',
color: subItem.active ? 'var(--text-primary)' : 'var(--text-secondary)',
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'var(--bg-secondary)'
}}
onClick={() => setIsMobileMenuOpen(false)}
>
<span className="font-medium">{subItem.name}</span>
</div>
</Link>
)}
</div>
))}
</div>
)}
</div>
)}
{/* Separator */}
<div className="border-t pt-2 mt-4" style={{ borderColor: 'var(--border)' }}></div>
{/* Public Navigation (always visible) */}
{publicNavigation.map((item) => (
<div key={item.name}>
{item.isPlaceholder ? (
<div
className="nav-item flex items-center justify-between px-4 py-3 rounded-2xl opacity-50 cursor-not-allowed"
style={{
backgroundColor: 'transparent',
color: 'var(--text-secondary)'
}}
>
<div className="flex items-center">
<span className="mr-3" aria-hidden="true">{getIcon(item.icon)}</span>
<span className="font-medium">{item.name}</span>
</div>
<span className="text-xs px-2 py-1 rounded-full" style={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-secondary)' }}>
Coming Soon
</span>
</div>
) : (
<Link href={item.href}>
<div
className={`
nav-item flex items-center justify-between px-4 py-3 rounded-2xl
transition-all duration-200 cursor-pointer
focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2
${item.active
? 'shadow-lg nav-item-active'
: 'hover:shadow-md nav-item-hover'
}
`}
style={{
backgroundColor: item.active ? 'var(--bg-tertiary)' : 'transparent',
color: item.active ? 'var(--text-primary)' : 'var(--text-secondary)',
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'var(--bg-secondary)'
}}
onClick={() => setIsMobileMenuOpen(false)}
>
<div className="flex items-center">
<span className="mr-3" aria-hidden="true">{getIcon(item.icon)}</span>
<span className="font-medium">{item.name}</span>
</div>
</div>
</Link>
)}
</div>
))} ))}
{/* Community Section with Sub-items */} {/* Community Section with Sub-items */}
@ -430,38 +592,98 @@ export default function Layout({ children, user = { email: 'me@randallstillwell.
{/* Bottom Section */} {/* Bottom Section */}
<div className="mt-auto space-y-2"> <div className="mt-auto space-y-2">
{/* Theme Toggle */} {/* Admin Navigation (if admin user) */}
<button {adminNavigation.map((item) => (
onClick={toggleTheme} <Link key={item.name} href={item.href}>
className="w-full flex items-center px-4 py-3 rounded-2xl transition-all duration-200 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 nav-item-hover" <div
style={{ className={`
backgroundColor: 'transparent', nav-item flex items-center justify-between px-4 py-3 rounded-2xl
color: 'var(--text-secondary)', transition-all duration-200 cursor-pointer
'--tw-ring-color': 'var(--accent-ember)', focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2
'--tw-ring-offset-color': 'var(--bg-secondary)' ${item.active
}} ? 'shadow-lg nav-item-active'
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`} : 'hover:shadow-md nav-item-hover'
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`} }
> `}
<span className="mr-3" aria-hidden="true"> style={{
{theme === 'light' ? ( backgroundColor: item.active ? 'var(--bg-tertiary)' : 'transparent',
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> color: item.active ? 'var(--text-primary)' : 'var(--text-secondary)',
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /> '--tw-ring-color': 'var(--accent-ember)',
</svg> '--tw-ring-offset-color': 'var(--bg-secondary)'
) : ( }}
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> onClick={() => setIsMobileMenuOpen(false)}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /> >
</svg> <div className="flex items-center">
)} <span className="mr-3" aria-hidden="true">{getIcon(item.icon)}</span>
</span> <span className="font-medium">{item.name}</span>
<span className="font-medium">{theme === 'light' ? 'Dark Mode' : 'Light Mode'}</span> </div>
</button> {item.badge && (
<span
className="px-2 py-1 text-xs rounded-full font-medium"
style={{
backgroundColor: item.badge === 'ADMIN' ? 'var(--accent-ember)' : 'var(--accent-flame)',
color: 'white'
}}
>
{item.badge}
</span>
)}
</div>
</Link>
))}
{/* User Profile Dropdown */} {/* User Profile Dropdown */}
<UserProfileDropdown <UserProfileDropdown
user={user} user={user}
onMobileMenuClose={() => setIsMobileMenuOpen(false)} onMobileMenuClose={() => setIsMobileMenuOpen(false)}
/> />
{/* Icon Buttons Row - Support and Dark Mode */}
<div className="flex justify-center space-x-4 pt-2">
{/* Support Icon Button */}
<Link href="/support">
<button
className="p-3 rounded-xl transition-all duration-200 hover:shadow-md cursor-pointer nav-item-hover focus:outline-none focus:ring-2 focus:ring-offset-2"
style={{
backgroundColor: 'transparent',
color: 'var(--text-secondary)',
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'var(--bg-secondary)'
}}
onClick={() => setIsMobileMenuOpen(false)}
aria-label="Support"
title="Support"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
</Link>
{/* Theme Toggle Icon Button */}
<button
onClick={toggleTheme}
className="p-3 rounded-xl transition-all duration-200 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 nav-item-hover"
style={{
backgroundColor: 'transparent',
color: 'var(--text-secondary)',
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'var(--bg-secondary)'
}}
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
>
{theme === 'light' ? (
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
) : (
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
)}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>

53
components/LoginCTA.js Normal file
View file

@ -0,0 +1,53 @@
import Link from 'next/link';
export default function LoginCTA({ message = "Sign up to create your own collections and more!" }) {
return (
<div className="fixed bottom-6 right-6 z-50">
<div
className="rounded-2xl p-4 shadow-2xl border max-w-sm"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border)',
backdropFilter: 'blur(10px)'
}}
>
<div className="flex items-start space-x-3">
<div className="flex-shrink-0">
<div className="w-8 h-8 rounded-full flex items-center justify-center" style={{ backgroundColor: 'var(--accent-ember)' }}>
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
</svg>
</div>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
{message}
</p>
<div className="flex space-x-2">
<Link
href="/signup"
className="px-3 py-1.5 text-xs font-medium rounded-lg transition-all duration-200 hover:opacity-90"
style={{
backgroundColor: 'var(--accent-ember)',
color: 'white'
}}
>
Sign Up Free
</Link>
<Link
href="/login"
className="px-3 py-1.5 text-xs font-medium rounded-lg border transition-all duration-200 hover:opacity-80"
style={{
color: 'var(--text-primary)',
borderColor: 'var(--border)'
}}
>
Sign In
</Link>
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,53 @@
import { useState, useEffect } from 'react';
/**
* Mana Symbol Settings Component
* Allows users to toggle between custom circular symbols and Scryfall SVG symbols
*/
export default function ManaSymbolSettings({ onSettingsChange }) {
const [useSVG, setUseSVG] = useState(false);
useEffect(() => {
// Load setting from localStorage
const savedSetting = localStorage.getItem('mana-symbol-svg');
if (savedSetting !== null) {
const shouldUseSVG = savedSetting === 'true';
setUseSVG(shouldUseSVG);
if (onSettingsChange) {
onSettingsChange({ useSVG: shouldUseSVG });
}
}
}, [onSettingsChange]);
const handleToggle = () => {
const newUseSVG = !useSVG;
setUseSVG(newUseSVG);
localStorage.setItem('mana-symbol-svg', newUseSVG.toString());
if (onSettingsChange) {
onSettingsChange({ useSVG: newUseSVG });
}
};
return (
<div className="flex items-center space-x-3 p-3 bg-bg-secondary rounded-lg">
<div className="flex-1">
<h4 className="text-sm font-medium text-text-primary">Mana Symbol Style</h4>
<p className="text-xs text-text-secondary">
{useSVG ? 'Using official Scryfall SVG symbols' : 'Using custom circular symbols'}
</p>
</div>
<button
onClick={handleToggle}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
useSVG ? 'bg-accent-ember' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
useSVG ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
);
}

233
components/ManaSymbols.js Normal file
View file

@ -0,0 +1,233 @@
import { useState, useEffect } from 'react';
import { parseManaSymbols, getColorSymbol } from '../lib/mana-symbols';
/**
* Individual mana symbol component
*/
function ManaSymbol({ symbol, color, name, scryfall_uri, size = 'md', useSVG = false }) {
const sizeClasses = {
xs: 'w-3 h-3 text-xs',
sm: 'w-4 h-4 text-xs',
md: 'w-5 h-5 text-sm',
lg: 'w-6 h-6 text-base',
xl: 'w-8 h-8 text-lg'
};
const isGradient = color.includes('linear-gradient');
// If using SVG and we have a Scryfall URI, render the SVG
if (useSVG && scryfall_uri) {
return (
<img
src={scryfall_uri}
alt={name}
title={name}
className={`${sizeClasses[size]} flex-shrink-0`}
style={{ filter: 'drop-shadow(0 0 2px rgba(0,0,0,0.3))' }}
/>
);
}
// Default circular symbol rendering
return (
<span
className={`inline-flex items-center justify-center rounded-full border border-gray-400 font-bold text-white ${sizeClasses[size]} flex-shrink-0`}
style={{
background: isGradient ? color : color,
color: color === '#FFFBD5' ? '#000' : '#fff',
textShadow: color === '#FFFBD5' ? 'none' : '0 0 2px rgba(0,0,0,0.8)'
}}
title={name}
>
{symbol}
</span>
);
}
/**
* Mana cost display component
* Renders a mana cost string as individual mana symbols
*/
export function ManaCost({ cost, size = 'md', className = '', useSVG = false }) {
const [symbols, setSymbols] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadSymbols() {
if (!cost) {
setSymbols([]);
setLoading(false);
return;
}
try {
const parsedSymbols = await parseManaSymbols(cost);
setSymbols(parsedSymbols);
} catch (error) {
console.error('Error parsing mana symbols:', error);
setSymbols([]);
} finally {
setLoading(false);
}
}
loadSymbols();
}, [cost]);
if (!cost) return null;
if (loading) {
return (
<div className={`flex items-center space-x-1 ${className}`}>
<div className="w-4 h-4 bg-gray-300 rounded-full animate-pulse"></div>
</div>
);
}
if (symbols.length === 0) return null;
return (
<div className={`flex items-center space-x-1 ${className}`}>
{symbols.map((symbolData, index) => (
<ManaSymbol
key={`${symbolData.raw}-${index}`}
symbol={symbolData.symbol}
color={symbolData.color}
name={symbolData.name}
scryfall_uri={symbolData.scryfall_uri}
size={size}
useSVG={useSVG}
/>
))}
</div>
);
}
/**
* Color identity display component
* Shows the color identity of a card using mana symbols
*/
export function ColorIdentity({ colors, size = 'sm', className = '', useSVG = false }) {
if (!colors || colors.length === 0) {
// Show colorless symbol for cards with no color identity
const colorlessSymbol = getColorSymbol('C');
return (
<div className={`flex items-center space-x-1 ${className}`}>
<ManaSymbol
symbol={colorlessSymbol.symbol}
color={colorlessSymbol.color}
name={colorlessSymbol.name}
scryfall_uri={colorlessSymbol.scryfall_uri}
size={size}
useSVG={useSVG}
/>
</div>
);
}
return (
<div className={`flex items-center space-x-1 ${className}`}>
{colors.map((color) => {
const symbolData = getColorSymbol(color);
return (
<ManaSymbol
key={color}
symbol={symbolData.symbol}
color={symbolData.color}
name={symbolData.name}
scryfall_uri={symbolData.scryfall_uri}
size={size}
useSVG={useSVG}
/>
);
})}
</div>
);
}
/**
* Single color symbol component for filters
*/
export function ColorFilterSymbol({ color, isActive, onClick, size = 'md', useSVG = false }) {
const symbolData = getColorSymbol(color);
return (
<button
onClick={() => onClick(color)}
className={`transition-all duration-200 ${
isActive
? 'ring-2 ring-accent-ember scale-110'
: 'opacity-70 hover:opacity-100 hover:scale-105'
}`}
title={symbolData.name}
>
<ManaSymbol
symbol={symbolData.symbol}
color={symbolData.color}
name={symbolData.name}
scryfall_uri={symbolData.scryfall_uri}
size={size}
useSVG={useSVG}
/>
</button>
);
}
/**
* Advanced mana cost component with Scryfall analysis
*/
export function AdvancedManaCost({ cost, showAnalysis = false, useSVG = false }) {
const [analysis, setAnalysis] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadAnalysis() {
if (!cost) {
setAnalysis(null);
setLoading(false);
return;
}
try {
const { getManaCostAnalysis } = await import('../lib/mana-symbols');
const analysisData = await getManaCostAnalysis(cost);
setAnalysis(analysisData);
} catch (error) {
console.error('Error analyzing mana cost:', error);
setAnalysis(null);
} finally {
setLoading(false);
}
}
loadAnalysis();
}, [cost]);
if (!cost) return null;
if (loading) {
return <div className="w-4 h-4 bg-gray-300 rounded-full animate-pulse"></div>;
}
if (!analysis) return <ManaCost cost={cost} useSVG={useSVG} />;
return (
<div className="space-y-2">
<ManaCost cost={analysis.cost} useSVG={useSVG} />
{showAnalysis && (
<div className="text-xs text-text-secondary space-y-1">
<div>CMC: {analysis.cmc}</div>
{analysis.colors.length > 0 && (
<div className="flex items-center space-x-1">
<span>Colors:</span>
<ColorIdentity colors={analysis.colors} size="xs" useSVG={useSVG} />
</div>
)}
<div className="flex space-x-2">
{analysis.colorless && <span className="bg-gray-100 text-gray-800 px-1 rounded text-xs">Colorless</span>}
{analysis.monocolored && <span className="bg-blue-100 text-blue-800 px-1 rounded text-xs">Mono</span>}
{analysis.multicolored && <span className="bg-purple-100 text-purple-800 px-1 rounded text-xs">Multi</span>}
</div>
</div>
)}
</div>
);
}
export default ManaCost;

464
components/OCRSettings.js Normal file
View file

@ -0,0 +1,464 @@
import { useState, useEffect } from 'react';
import { aiCardOCR, ollamaCardOCR, geminiCardOCR } from '../lib/ai-ocr';
export default function OCRSettings({ isOpen, onClose }) {
const [settings, setSettings] = useState({
service: 'gemini', // Default to free Gemini
openaiApiKey: '',
geminiApiKey: '',
ollamaUrl: 'http://localhost:11434'
});
const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState(null);
// Load settings from localStorage on mount
useEffect(() => {
const loadSettings = async () => {
const savedSettings = localStorage.getItem('ocrSettings');
let currentSettings = {
service: 'gemini',
openaiApiKey: '',
geminiApiKey: '',
ollamaUrl: 'http://localhost:11434'
};
if (savedSettings) {
try {
const parsed = JSON.parse(savedSettings);
currentSettings = { ...currentSettings, ...parsed };
} catch (error) {
console.error('Error loading OCR settings:', error);
}
}
// Try to auto-load Gemini API key from environment if not already set
if (!currentSettings.geminiApiKey) {
try {
const response = await fetch('/api/config/gemini');
if (response.ok) {
const data = await response.json();
if (data.hasKey && data.apiKey) {
currentSettings.geminiApiKey = data.apiKey;
currentSettings.service = 'gemini'; // Default to Gemini if key is available
}
}
} catch (error) {
console.log('Could not auto-load Gemini API key:', error);
}
}
setSettings(currentSettings);
};
loadSettings();
}, []);
const saveSettings = () => {
try {
localStorage.setItem('ocrSettings', JSON.stringify(settings));
// Configure AI services
if (settings.openaiApiKey) {
aiCardOCR.setApiKey(settings.openaiApiKey);
}
if (settings.geminiApiKey) {
geminiCardOCR.setApiKey(settings.geminiApiKey);
}
if (settings.ollamaUrl) {
ollamaCardOCR.setBaseUrl(settings.ollamaUrl);
}
setTestResult({ type: 'success', message: 'Settings saved successfully!' });
setTimeout(() => setTestResult(null), 3000);
} catch (error) {
console.error('Error saving OCR settings:', error);
setTestResult({ type: 'error', message: 'Failed to save settings' });
}
};
const testConnection = async () => {
setIsTesting(true);
setTestResult(null);
try {
if (settings.service === 'puter') {
// Test Puter.js connection
try {
// Try to load Puter.js if not already loaded
if (typeof window !== 'undefined' && !window.puter) {
const script = document.createElement('script');
script.src = 'https://js.puter.com/v2/';
await new Promise((resolve, reject) => {
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
if (window.puter && window.puter.ai) {
setTestResult({ type: 'success', message: 'Puter.js loaded successfully! Ready for free AI vision.' });
} else {
setTestResult({ type: 'error', message: 'Failed to load Puter.js AI capabilities' });
}
} catch (error) {
setTestResult({ type: 'error', message: 'Could not connect to Puter.js service' });
}
} else if (settings.service === 'openai') {
if (!settings.openaiApiKey) {
setTestResult({ type: 'error', message: 'Please enter your OpenAI API key' });
return;
}
// Test with a simple request
const response = await fetch('https://api.openai.com/v1/models', {
headers: {
'Authorization': `Bearer ${settings.openaiApiKey}`,
}
});
if (response.ok) {
setTestResult({ type: 'success', message: 'OpenAI API connection successful!' });
} else {
const error = await response.json().catch(() => ({}));
setTestResult({
type: 'error',
message: `OpenAI API error: ${error.error?.message || response.statusText}`
});
}
} else if (settings.service === 'gemini') {
if (!settings.geminiApiKey) {
setTestResult({ type: 'error', message: 'Please enter your Gemini API key' });
return;
}
// Test with a simple request
const response = await fetch('https://generativelanguage.googleapis.com/v1beta/models', {
headers: {
'x-goog-api-key': settings.geminiApiKey,
}
});
if (response.ok) {
const data = await response.json();
const hasVisionModel = data.models?.some(model =>
model.name.includes('gemini') && model.supportedGenerationMethods?.includes('generateContent')
);
if (hasVisionModel) {
setTestResult({ type: 'success', message: 'Gemini API connection successful with vision support!' });
} else {
setTestResult({
type: 'warning',
message: 'Gemini connected but vision capabilities unclear.'
});
}
} else {
const error = await response.json().catch(() => ({}));
setTestResult({
type: 'error',
message: `Gemini API error: ${error.error?.message || response.statusText}`
});
}
} else if (settings.service === 'ollama') {
// Test Ollama connection
const response = await fetch(`${settings.ollamaUrl}/api/tags`);
if (response.ok) {
const data = await response.json();
const hasVisionModel = data.models?.some(model =>
model.name.includes('llava') || model.name.includes('vision')
);
if (hasVisionModel) {
setTestResult({ type: 'success', message: 'Ollama connection successful with vision models!' });
} else {
setTestResult({
type: 'warning',
message: 'Ollama connected but no vision models found. Install llava:latest for card scanning.'
});
}
} else {
setTestResult({ type: 'error', message: 'Could not connect to Ollama server' });
}
}
} catch (error) {
console.error('Connection test error:', error);
setTestResult({
type: 'error',
message: `Connection failed: ${error.message}`
});
} finally {
setIsTesting(false);
}
};
const handleInputChange = (field, value) => {
setSettings(prev => ({
...prev,
[field]: value
}));
setTestResult(null);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="rounded-xl border max-w-md w-full max-h-[90vh] overflow-y-auto" style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b" style={{ borderColor: 'var(--border)' }}>
<h2 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>
🤖 OCR Settings
</h2>
<button
onClick={onClose}
className="p-2 rounded-lg hover:opacity-70 transition-opacity"
style={{ color: 'var(--text-secondary)' }}
>
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Service Selection */}
<div>
<label className="block text-sm font-medium mb-3" style={{ color: 'var(--text-primary)' }}>
OCR Service
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
name="service"
value="puter"
checked={settings.service === 'puter'}
onChange={(e) => handleInputChange('service', e.target.value)}
className="mr-3"
style={{ accentColor: 'var(--accent-ember)' }}
/>
<div>
<div className="font-medium" style={{ color: 'var(--text-primary)' }}>Puter.js</div>
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Free GPT-4o vision - requires signing in to puter.com first
</div>
</div>
</label>
<label className="flex items-center">
<input
type="radio"
name="service"
value="openai"
checked={settings.service === 'openai'}
onChange={(e) => handleInputChange('service', e.target.value)}
className="mr-3"
style={{ accentColor: 'var(--accent-ember)' }}
/>
<div>
<div className="font-medium" style={{ color: 'var(--text-primary)' }}>OpenAI Vision API</div>
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
High accuracy, requires API key (~$0.01 per scan)
</div>
</div>
</label>
<label className="flex items-center">
<input
type="radio"
name="service"
value="gemini"
checked={settings.service === 'gemini'}
onChange={(e) => handleInputChange('service', e.target.value)}
className="mr-3"
style={{ accentColor: 'var(--accent-ember)' }}
/>
<div>
<div className="font-medium" style={{ color: 'var(--text-primary)' }}>Google Gemini (Recommended)</div>
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Free tier available, high accuracy, requires API key
</div>
</div>
</label>
<label className="flex items-center">
<input
type="radio"
name="service"
value="ollama"
checked={settings.service === 'ollama'}
onChange={(e) => handleInputChange('service', e.target.value)}
className="mr-3"
style={{ accentColor: 'var(--accent-ember)' }}
/>
<div>
<div className="font-medium" style={{ color: 'var(--text-primary)' }}>Ollama Local</div>
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Private & free, requires local Ollama + LLaVA model
</div>
</div>
</label>
</div>
</div>
{/* OpenAI Settings */}
{settings.service === 'openai' && (
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
OpenAI API Key
</label>
<input
type="password"
placeholder="sk-..."
value={settings.openaiApiKey}
onChange={(e) => handleInputChange('openaiApiKey', e.target.value)}
className="w-full px-3 py-2 rounded-lg border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
/>
<div className="mt-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
Get your API key from{' '}
<a
href="https://platform.openai.com/api-keys"
target="_blank"
rel="noopener noreferrer"
className="underline hover:opacity-70"
style={{ color: 'var(--accent-ember)' }}
>
OpenAI Platform
</a>
</div>
</div>
)}
{/* Gemini Settings */}
{settings.service === 'gemini' && (
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Gemini API Key
</label>
<input
type="password"
placeholder="AIza..."
value={settings.geminiApiKey}
onChange={(e) => handleInputChange('geminiApiKey', e.target.value)}
className="w-full px-3 py-2 rounded-lg border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
/>
<div className="mt-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
Get your free API key from{' '}
<a
href="https://ai.google.dev/gemini-api/docs/api-key"
target="_blank"
rel="noopener noreferrer"
className="underline hover:opacity-70"
style={{ color: 'var(--accent-ember)' }}
>
Google AI Studio
</a>
</div>
</div>
)}
{/* Ollama Settings */}
{settings.service === 'ollama' && (
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Ollama Server URL
</label>
<input
type="url"
placeholder="http://localhost:11434"
value={settings.ollamaUrl}
onChange={(e) => handleInputChange('ollamaUrl', e.target.value)}
className="w-full px-3 py-2 rounded-lg border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
/>
<div className="mt-2 text-sm space-y-1" style={{ color: 'var(--text-secondary)' }}>
<div>Install Ollama and run: <code className="px-1 py-0.5 rounded text-xs" style={{ backgroundColor: 'var(--bg-tertiary)' }}>ollama pull llava:latest</code></div>
<div>
Setup guide:{' '}
<a
href="https://ollama.ai"
target="_blank"
rel="noopener noreferrer"
className="underline hover:opacity-70"
style={{ color: 'var(--accent-ember)' }}
>
ollama.ai
</a>
</div>
</div>
</div>
)}
{/* Test Result */}
{testResult && (
<div className={`p-3 rounded-lg border ${
testResult.type === 'success' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' :
testResult.type === 'warning' ? 'border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20' :
'border-red-500 bg-red-50 dark:bg-red-900/20'
}`}>
<div className={`text-sm ${
testResult.type === 'success' ? 'text-green-700 dark:text-green-300' :
testResult.type === 'warning' ? 'text-yellow-700 dark:text-yellow-300' :
'text-red-700 dark:text-red-300'
}`}>
{testResult.message}
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-3">
<button
onClick={testConnection}
disabled={isTesting}
className="flex-1 px-4 py-2 rounded-lg border font-medium transition-all duration-200 hover:opacity-80 disabled:opacity-50"
style={{
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
>
{isTesting ? 'Testing...' : 'Test Connection'}
</button>
<button
onClick={saveSettings}
className="flex-1 px-4 py-2 rounded-lg font-medium transition-all duration-200 hover:opacity-90"
style={{
backgroundColor: 'var(--accent-ember)',
color: 'white'
}}
>
Save Settings
</button>
</div>
{/* Usage Tips */}
<div className="rounded-lg p-4" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
<div className="font-medium mb-2" style={{ color: 'var(--text-primary)' }}>💡 Tips</div>
<ul className="text-sm space-y-1" style={{ color: 'var(--text-secondary)' }}>
<li> Puter.js offers free GPT-4o vision with no setup required</li>
<li> OpenAI Vision API offers highest accuracy for card recognition</li>
<li> Ollama is free and private but requires local setup</li>
<li> Test your connection before scanning cards</li>
<li> Settings are saved locally in your browser</li>
</ul>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,57 @@
import { useEffect } from 'react';
import { useRouter } from 'next/router';
import { useAuth } from '../lib/use-auth';
import Layout from './Layout';
export default function ProtectedRoute({ children, adminOnly = false, allowPublic = false, publicFallback = null }) {
const { user, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!loading) {
// If authentication is required and user is not logged in
if (!allowPublic && !user) {
router.push('/login');
return;
}
// If admin access is required and user is not admin
if (adminOnly && (!user || user.role !== 'admin')) {
router.push('/login');
return;
}
}
}, [user, loading, router, adminOnly, allowPublic]);
// Show loading while checking authentication
if (loading) {
return (
<Layout user={null}>
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 mx-auto mb-4" style={{ borderColor: 'var(--accent-ember)' }}></div>
<p style={{ color: 'var(--text-secondary)' }}>Loading...</p>
</div>
</div>
</Layout>
);
}
// If public access is allowed but user is not logged in, show public fallback
if (allowPublic && !user && publicFallback) {
return publicFallback;
}
// If authentication is required but user is not logged in, don't render anything (redirect will happen)
if (!allowPublic && !user) {
return null;
}
// If admin access is required but user is not admin, don't render anything (redirect will happen)
if (adminOnly && (!user || user.role !== 'admin')) {
return null;
}
// Render the protected content
return children;
}

527
lib/ai-ocr.js Normal file
View file

@ -0,0 +1,527 @@
// AI OCR Service for Trading Card Recognition
export class AICardOCR {
constructor() {
this.apiKey = null;
this.baseUrl = 'https://api.openai.com/v1';
}
setApiKey(apiKey) {
this.apiKey = apiKey;
}
async analyzeCard(imageDataUrl) {
if (!this.apiKey) {
throw new Error('OpenAI API key not configured');
}
const prompt = `You are a specialized trading card recognition system. Analyze this image and determine if it contains a trading card (Magic: The Gathering, Pokemon, Yu-Gi-Oh, Lorcana, etc.).
IMPORTANT: Only respond with card data if you can clearly identify a TRADING CARD in the image. Ignore:
- Random objects, books, papers
- Screenshots of websites or apps
- Blurry or unclear images
- Non-card gaming items
If you detect a trading card, extract the following information in JSON format:
{
"isCard": true,
"cardName": "exact card name as printed",
"setName": "set name if visible",
"setCode": "set code/symbol if visible",
"cardNumber": "collector number if visible",
"game": "MTG, Pokemon, YuGiOh, Lorcana, etc.",
"cardType": "creature, instant, trainer, etc.",
"rarity": "common, uncommon, rare, mythic, etc.",
"manaCost": "mana cost if visible",
"hp": "HP or power if visible",
"abilities": ["list of abilities/attacks if visible"],
"flavorText": "flavor text if clearly readable",
"artist": "artist name if visible",
"confidence": 85,
"rawText": "all visible text on the card"
}
If NO trading card is detected, respond with:
{
"isCard": false,
"confidence": 0,
"reason": "No trading card detected in image"
}
Focus on accuracy over speed. Only extract data you can clearly read.`;
try {
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: prompt
},
{
type: 'image_url',
image_url: {
url: imageDataUrl,
detail: 'high'
}
}
]
}
],
max_tokens: 1000,
temperature: 0.1
})
});
if (!response.ok) {
throw new Error(`OpenAI API error: ${response.status}`);
}
const data = await response.json();
const content = data.choices[0]?.message?.content;
if (!content) {
throw new Error('No response from OpenAI');
}
// Parse JSON response
let result;
try {
// Clean up the response - remove markdown code blocks if present
const cleanContent = content.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
result = JSON.parse(cleanContent);
} catch (parseError) {
console.error('Failed to parse OpenAI JSON response:', content);
// Fallback: try to extract card name from raw text
const cardNameMatch = content.match(/card.*?name.*?[:"]\s*([^"'\n]+)/i);
result = {
isCard: !!cardNameMatch,
cardName: cardNameMatch ? cardNameMatch[1].trim() : null,
confidence: 30,
rawText: content,
reason: 'Failed to parse structured response'
};
}
// Ensure we have the required structure
return {
isCard: result.isCard || false,
cardName: result.cardName || null,
setName: result.setName || null,
setCode: result.setCode || null,
cardNumber: result.cardNumber || null,
game: result.game || null,
cardType: result.cardType || null,
rarity: result.rarity || null,
manaCost: result.manaCost || null,
hp: result.hp || null,
abilities: result.abilities || [],
flavorText: result.flavorText || null,
artist: result.artist || null,
confidence: result.confidence || 0,
rawText: result.rawText || content,
reason: result.reason || null
};
} catch (error) {
console.error('OpenAI Vision API error:', error);
throw error;
}
}
}
export class OllamaVisionOCR {
constructor() {
this.baseUrl = 'http://localhost:11434';
}
setBaseUrl(url) {
this.baseUrl = url;
}
async analyzeCard(imageDataUrl) {
const prompt = `You are a specialized trading card recognition system. Analyze this image and determine if it contains a trading card (Magic: The Gathering, Pokemon, Yu-Gi-Oh, Lorcana, etc.).
IMPORTANT: Only respond with card data if you can clearly identify a TRADING CARD in the image. Ignore random objects, books, papers, screenshots, or blurry images.
If you detect a trading card, extract this information in JSON format:
{
"isCard": true,
"cardName": "exact card name as printed",
"setName": "set name if visible",
"setCode": "set code if visible",
"cardNumber": "collector number if visible",
"game": "MTG, Pokemon, YuGiOh, Lorcana, etc.",
"cardType": "creature, instant, trainer, etc.",
"rarity": "common, uncommon, rare, mythic, etc.",
"confidence": 85,
"rawText": "all visible text"
}
If NO trading card detected, respond: {"isCard": false, "confidence": 0, "reason": "No trading card detected"}`;
try {
// Convert data URL to base64
const base64Data = imageDataUrl.split(',')[1];
const response = await fetch(`${this.baseUrl}/api/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'llava:latest',
prompt: prompt,
images: [base64Data],
stream: false,
options: {
temperature: 0.1,
top_p: 0.9
}
})
});
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status}`);
}
const data = await response.json();
const content = data.response;
if (!content) {
throw new Error('No response from Ollama');
}
// Parse JSON response
let result;
try {
const cleanContent = content.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
result = JSON.parse(cleanContent);
} catch (parseError) {
console.error('Failed to parse Ollama JSON response:', content);
result = {
isCard: false,
confidence: 0,
rawText: content,
reason: 'Failed to parse response'
};
}
return {
isCard: result.isCard || false,
cardName: result.cardName || null,
setName: result.setName || null,
setCode: result.setCode || null,
cardNumber: result.cardNumber || null,
game: result.game || null,
cardType: result.cardType || null,
rarity: result.rarity || null,
manaCost: result.manaCost || null,
hp: result.hp || null,
abilities: result.abilities || [],
flavorText: result.flavorText || null,
artist: result.artist || null,
confidence: result.confidence || 0,
rawText: result.rawText || content,
reason: result.reason || null
};
} catch (error) {
console.error('Ollama Vision API error:', error);
throw error;
}
}
}
export class PuterVisionOCR {
constructor() {
this.puterLoaded = false;
this.authFailed = false;
}
async loadPuterJS() {
if (this.puterLoaded || typeof window === 'undefined') return;
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://js.puter.com/v2/';
script.onload = () => {
this.puterLoaded = true;
resolve();
};
script.onerror = reject;
document.head.appendChild(script);
});
}
async analyzeCard(imageDataUrl) {
// If we've already failed auth, don't try again
if (this.authFailed) {
throw new Error('Puter.js authentication failed. Please use OpenAI or Ollama instead.');
}
try {
await this.loadPuterJS();
if (!window.puter) {
throw new Error('Puter.js not loaded');
}
// Check if user is authenticated with Puter
try {
await window.puter.auth.getUser();
} catch (authError) {
console.warn('Puter.js authentication required. Please sign in to Puter.com first.');
this.authFailed = true;
throw new Error('Puter.js requires authentication. Please use OpenAI or Ollama instead, or sign in to Puter.com first.');
}
const prompt = `You are a specialized trading card recognition system. Analyze this image and determine if it contains a trading card (Magic: The Gathering, Pokemon, Yu-Gi-Oh, Lorcana, etc.).
CRITICAL: Only respond with card data if you can clearly identify a TRADING CARD in the image. Ignore:
- Random objects, books, papers, phone screens
- Screenshots of websites or digital interfaces
- Blurry, unclear, or dark images
- Non-card gaming items or accessories
If you detect a trading card, extract information in this JSON format:
{
"isCard": true,
"cardName": "exact card name as printed on the card",
"setName": "set name if visible",
"setCode": "set code/symbol if visible",
"cardNumber": "collector number if visible",
"game": "MTG, Pokemon, YuGiOh, Lorcana, etc.",
"cardType": "creature, instant, sorcery, trainer, etc.",
"rarity": "common, uncommon, rare, mythic, etc.",
"manaCost": "mana cost if visible",
"hp": "HP or power if visible",
"abilities": ["list of abilities or attacks if clearly readable"],
"confidence": 85,
"rawText": "all text visible on the card"
}
If NO trading card is clearly visible, respond with:
{
"isCard": false,
"confidence": 0,
"reason": "No trading card detected in image"
}
Be conservative - only extract data you can clearly read. Quality over quantity.`;
const response = await window.puter.ai.chat(prompt, imageDataUrl, {
model: "gpt-4o"
});
if (!response) {
throw new Error('No response from Puter.js');
}
// Parse JSON response
let result;
try {
// Clean up the response - remove markdown code blocks if present
const cleanContent = response.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
result = JSON.parse(cleanContent);
} catch (parseError) {
console.error('Failed to parse Puter JSON response:', response);
// Try to extract card name from raw response
const cardNameMatch = response.match(/card.*?name.*?[:"]\s*([^"'\n,}]+)/i);
result = {
isCard: !!cardNameMatch,
cardName: cardNameMatch ? cardNameMatch[1].trim() : null,
confidence: 30,
rawText: response,
reason: 'Failed to parse structured response'
};
}
// Ensure we have the required structure
return {
isCard: result.isCard || false,
cardName: result.cardName || null,
setName: result.setName || null,
setCode: result.setCode || null,
cardNumber: result.cardNumber || null,
game: result.game || null,
cardType: result.cardType || null,
rarity: result.rarity || null,
manaCost: result.manaCost || null,
hp: result.hp || null,
abilities: result.abilities || [],
flavorText: result.flavorText || null,
artist: result.artist || null,
confidence: result.confidence || 0,
rawText: result.rawText || response,
reason: result.reason || null
};
} catch (error) {
console.error('Puter.js Vision API error:', error);
// Mark auth as failed if it's an auth-related error
if (error.message.includes('authentication') || error.message.includes('auth') || error.message.includes('401')) {
this.authFailed = true;
}
throw error;
}
}
}
// Gemini Vision OCR using Google's Gemini API
export class GeminiVisionOCR {
constructor() {
this.apiKey = null;
}
setApiKey(apiKey) {
this.apiKey = apiKey;
}
async analyzeCard(imageDataUrl) {
if (!this.apiKey) {
throw new Error('Gemini API key not configured');
}
// Convert data URL to base64
const base64Data = imageDataUrl.split(',')[1];
if (!base64Data) {
throw new Error('Invalid image data format');
}
const prompt = `You are a specialized trading card recognition system. Analyze this image and determine if it contains a trading card (Magic: The Gathering, Pokemon, Yu-Gi-Oh, Lorcana, etc.).
CRITICAL: Only respond with card data if you can clearly identify a TRADING CARD in the image. Ignore:
- Random objects, books, papers, phone screens
- Screenshots of websites or digital interfaces
- Blurry, unclear, or dark images
- Non-card gaming items or accessories
If you detect a trading card, extract information in this JSON format:
{
"isCard": true,
"cardName": "exact card name as printed on the card",
"setName": "set name if visible",
"setCode": "set code/symbol if visible",
"cardNumber": "collector number if visible",
"game": "MTG, Pokemon, YuGiOh, Lorcana, etc.",
"cardType": "creature, instant, sorcery, trainer, etc.",
"rarity": "common, uncommon, rare, mythic, etc.",
"manaCost": "mana cost if visible",
"hp": "HP or power if visible",
"abilities": ["list of abilities or attacks if clearly readable"],
"confidence": 85,
"rawText": "all text visible on the card"
}
If NO trading card is clearly visible, respond with:
{
"isCard": false,
"confidence": 0,
"reason": "No trading card detected in image"
}
Be conservative - only extract data you can clearly read. Quality over quantity.`;
try {
const response = await fetch('https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': this.apiKey
},
body: JSON.stringify({
contents: [{
parts: [
{ text: prompt },
{
inline_data: {
mime_type: 'image/jpeg',
data: base64Data
}
}
]
}],
generationConfig: {
thinkingConfig: {
thinkingBudget: 0 // Disable thinking for faster response
}
}
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(`Gemini API error: ${response.status} - ${errorData.error?.message || 'Unknown error'}`);
}
const data = await response.json();
const content = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (!content) {
throw new Error('No response from Gemini API');
}
// Parse JSON response
let result;
try {
// Clean up the response - remove markdown code blocks if present
const cleanContent = content.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
result = JSON.parse(cleanContent);
} catch (parseError) {
console.error('Failed to parse Gemini JSON response:', content);
// Try to extract card name from raw response
const cardNameMatch = content.match(/card.*?name.*?[:"]\s*([^"'\n,}]+)/i);
result = {
isCard: !!cardNameMatch,
cardName: cardNameMatch ? cardNameMatch[1].trim() : null,
confidence: 30,
rawText: content,
reason: 'Failed to parse structured response'
};
}
// Ensure we have the required structure
return {
isCard: result.isCard || false,
cardName: result.cardName || null,
setName: result.setName || null,
setCode: result.setCode || null,
cardNumber: result.cardNumber || null,
game: result.game || null,
cardType: result.cardType || null,
rarity: result.rarity || null,
manaCost: result.manaCost || null,
hp: result.hp || null,
abilities: result.abilities || [],
flavorText: result.flavorText || null,
artist: result.artist || null,
confidence: result.confidence || 0,
rawText: result.rawText || content,
reason: result.reason || null
};
} catch (error) {
console.error('Gemini Vision API error:', error);
throw error;
}
}
}
// Export singleton instances
export const aiCardOCR = new AICardOCR();
export const ollamaCardOCR = new OllamaVisionOCR();
export const puterCardOCR = new PuterVisionOCR();
export const geminiCardOCR = new GeminiVisionOCR();

View file

@ -8,7 +8,7 @@ export function AuthProvider({ children }) {
useEffect(() => { useEffect(() => {
// Check for existing token on app load // Check for existing token on app load
const token = localStorage.getItem('token'); const token = localStorage.getItem('auth_token');
if (token) { if (token) {
// Verify token and set user // Verify token and set user
verifyToken(token); verifyToken(token);
@ -27,13 +27,13 @@ export function AuthProvider({ children }) {
if (response.ok) { if (response.ok) {
const userData = await response.json(); const userData = await response.json();
setUser(userData.user); setUser(userData); // API returns user data directly, not wrapped in .user
} else { } else {
localStorage.removeItem('token'); localStorage.removeItem('auth_token');
} }
} catch (error) { } catch (error) {
console.error('Token verification failed:', error); console.error('Token verification failed:', error);
localStorage.removeItem('token'); localStorage.removeItem('auth_token');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -52,7 +52,7 @@ export function AuthProvider({ children }) {
const data = await response.json(); const data = await response.json();
if (response.ok) { if (response.ok) {
localStorage.setItem('token', data.token); localStorage.setItem('auth_token', data.token);
setUser(data.user); setUser(data.user);
return { success: true }; return { success: true };
} else { } else {
@ -76,7 +76,7 @@ export function AuthProvider({ children }) {
const data = await response.json(); const data = await response.json();
if (response.ok) { if (response.ok) {
localStorage.setItem('token', data.token); localStorage.setItem('auth_token', data.token);
setUser(data.user); setUser(data.user);
return { success: true }; return { success: true };
} else { } else {
@ -88,7 +88,7 @@ export function AuthProvider({ children }) {
}; };
const logout = () => { const logout = () => {
localStorage.removeItem('token'); localStorage.removeItem('auth_token');
setUser(null); setUser(null);
}; };

290
lib/mana-symbols.js Normal file
View file

@ -0,0 +1,290 @@
// Mana symbol utility for Magic: The Gathering cards
// Using Scryfall's symbology API: https://scryfall.com/docs/api/card-symbols/parse-mana
// Cache for parsed mana costs to avoid repeated API calls
const manaCache = new Map();
// Fallback symbol mapping for offline/error cases
const FALLBACK_SYMBOLS = {
// Basic mana costs
'{W}': { symbol: 'W', color: '#FFFBD5', name: 'White' },
'{U}': { symbol: 'U', color: '#0E68AB', name: 'Blue' },
'{B}': { symbol: 'B', color: '#150B00', name: 'Black' },
'{R}': { symbol: 'R', color: '#D3202A', name: 'Red' },
'{G}': { symbol: 'G', color: '#00733E', name: 'Green' },
'{C}': { symbol: 'C', color: '#BEB9B2', name: 'Colorless' },
// Generic mana costs
'{0}': { symbol: '0', color: '#BEB9B2', name: 'Zero' },
'{1}': { symbol: '1', color: '#BEB9B2', name: 'One' },
'{2}': { symbol: '2', color: '#BEB9B2', name: 'Two' },
'{3}': { symbol: '3', color: '#BEB9B2', name: 'Three' },
'{4}': { symbol: '4', color: '#BEB9B2', name: 'Four' },
'{5}': { symbol: '5', color: '#BEB9B2', name: 'Five' },
'{6}': { symbol: '6', color: '#BEB9B2', name: 'Six' },
'{7}': { symbol: '7', color: '#BEB9B2', name: 'Seven' },
'{8}': { symbol: '8', color: '#BEB9B2', name: 'Eight' },
'{9}': { symbol: '9', color: '#BEB9B2', name: 'Nine' },
'{10}': { symbol: '10', color: '#BEB9B2', name: 'Ten' },
'{X}': { symbol: 'X', color: '#BEB9B2', name: 'X' },
// Hybrid mana
'{W/U}': { symbol: 'W/U', color: 'linear-gradient(135deg, #FFFBD5 50%, #0E68AB 50%)', name: 'White or Blue' },
'{W/B}': { symbol: 'W/B', color: 'linear-gradient(135deg, #FFFBD5 50%, #150B00 50%)', name: 'White or Black' },
'{U/B}': { symbol: 'U/B', color: 'linear-gradient(135deg, #0E68AB 50%, #150B00 50%)', name: 'Blue or Black' },
'{U/R}': { symbol: 'U/R', color: 'linear-gradient(135deg, #0E68AB 50%, #D3202A 50%)', name: 'Blue or Red' },
'{B/R}': { symbol: 'B/R', color: 'linear-gradient(135deg, #150B00 50%, #D3202A 50%)', name: 'Black or Red' },
'{B/G}': { symbol: 'B/G', color: 'linear-gradient(135deg, #150B00 50%, #00733E 50%)', name: 'Black or Green' },
'{R/G}': { symbol: 'R/G', color: 'linear-gradient(135deg, #D3202A 50%, #00733E 50%)', name: 'Red or Green' },
'{R/W}': { symbol: 'R/W', color: 'linear-gradient(135deg, #D3202A 50%, #FFFBD5 50%)', name: 'Red or White' },
'{G/W}': { symbol: 'G/W', color: 'linear-gradient(135deg, #00733E 50%, #FFFBD5 50%)', name: 'Green or White' },
'{G/U}': { symbol: 'G/U', color: 'linear-gradient(135deg, #00733E 50%, #0E68AB 50%)', name: 'Green or Blue' },
};
/**
* Parse mana cost using Scryfall's API
* @param {string} manaCost - Raw mana cost string (can be shorthand like "2WW" or formal like "{2}{W}{W}")
* @returns {Promise<Object>} Parsed mana cost data from Scryfall
*/
export async function parseManaCostWithScryfall(manaCost) {
if (!manaCost) return null;
// Check cache first
if (manaCache.has(manaCost)) {
return manaCache.get(manaCost);
}
try {
const response = await fetch(
`https://api.scryfall.com/symbology/parse-mana?cost=${encodeURIComponent(manaCost)}`
);
if (response.ok) {
const data = await response.json();
manaCache.set(manaCost, data);
return data;
} else {
console.warn('Scryfall API error:', response.status);
return null;
}
} catch (error) {
console.warn('Error parsing mana cost with Scryfall:', error);
return null;
}
}
/**
* Parse mana cost string and return array of symbol components
* Uses Scryfall API when possible, falls back to local parsing
* @param {string} manaCost - Mana cost string
* @returns {Promise<Array>} Array of mana symbol objects
*/
export async function parseManaSymbols(manaCost) {
if (!manaCost) return [];
// Try Scryfall API first
const scryfallData = await parseManaCostWithScryfall(manaCost);
if (scryfallData && scryfallData.cost) {
// Parse the normalized cost from Scryfall
const matches = scryfallData.cost.match(/\{[^}]+\}/g);
if (matches) {
return matches.map(match => {
const symbol = FALLBACK_SYMBOLS[match];
if (symbol) {
return {
raw: match,
symbol: symbol.symbol,
color: symbol.color,
name: symbol.name,
scryfall_uri: `https://svgs.scryfall.io/card-symbols/${match.replace(/[{}]/g, '')}.svg`
};
}
// Fallback for unknown symbols
const cleanSymbol = match.replace(/[{}]/g, '');
return {
raw: match,
symbol: cleanSymbol,
color: '#BEB9B2',
name: cleanSymbol,
scryfall_uri: `https://svgs.scryfall.io/card-symbols/${cleanSymbol}.svg`
};
});
}
}
// Fallback to local parsing if Scryfall fails
return parseManaSymbolsLocal(manaCost);
}
/**
* Local fallback mana cost parsing
* @param {string} manaCost - Mana cost string
* @returns {Array} Array of mana symbol objects
*/
function parseManaSymbolsLocal(manaCost) {
if (!manaCost) return [];
// Handle shorthand notation (like "2WW" -> "{2}{W}{W}")
let normalizedCost = manaCost;
// If it doesn't start with {, try to normalize it
if (!normalizedCost.startsWith('{')) {
normalizedCost = normalizedCost
.replace(/(\d+)/g, '{$1}') // Numbers: 2 -> {2}
.replace(/([WUBRG])/gi, '{$1}') // Colors: W -> {W}
.replace(/([XYZ])/gi, '{$1}') // Variables: X -> {X}
.toUpperCase();
}
// Match all {symbol} patterns
const matches = normalizedCost.match(/\{[^}]+\}/g);
if (!matches) return [];
return matches.map(match => {
const symbol = FALLBACK_SYMBOLS[match];
if (symbol) {
return {
raw: match,
symbol: symbol.symbol,
color: symbol.color,
name: symbol.name,
scryfall_uri: `https://svgs.scryfall.io/card-symbols/${match.replace(/[{}]/g, '')}.svg`
};
}
// Fallback for unknown symbols
const cleanSymbol = match.replace(/[{}]/g, '');
return {
raw: match,
symbol: cleanSymbol,
color: '#BEB9B2',
name: cleanSymbol,
scryfall_uri: `https://svgs.scryfall.io/card-symbols/${cleanSymbol}.svg`
};
});
}
/**
* Get color identity from mana cost using Scryfall data
* @param {string} manaCost - Mana cost string
* @returns {Promise<Array>} Array of color letters
*/
export async function getColorIdentity(manaCost) {
if (!manaCost) return [];
// Try Scryfall API first
const scryfallData = await parseManaCostWithScryfall(manaCost);
if (scryfallData && scryfallData.colors) {
return scryfallData.colors;
}
// Fallback to local parsing
const colors = new Set();
const symbols = await parseManaSymbols(manaCost);
symbols.forEach(({ raw }) => {
if (raw.includes('W')) colors.add('W');
if (raw.includes('U')) colors.add('U');
if (raw.includes('B')) colors.add('B');
if (raw.includes('R')) colors.add('R');
if (raw.includes('G')) colors.add('G');
});
return Array.from(colors);
}
/**
* Convert single color letter to display symbol
* @param {string} color - Single color letter (W, U, B, R, G)
* @returns {Object} Symbol object with display properties
*/
export function getColorSymbol(color) {
const symbolKey = `{${color}}`;
const symbol = FALLBACK_SYMBOLS[symbolKey];
return symbol ? {
...symbol,
scryfall_uri: `https://svgs.scryfall.io/card-symbols/${color}.svg`
} : {
symbol: color,
color: '#BEB9B2',
name: color,
scryfall_uri: `https://svgs.scryfall.io/card-symbols/${color}.svg`
};
}
/**
* Calculate converted mana cost using Scryfall data
* @param {string} manaCost - Mana cost string
* @returns {Promise<number>} Converted mana cost
*/
export async function calculateCMC(manaCost) {
if (!manaCost) return 0;
// Try Scryfall API first for accurate CMC
const scryfallData = await parseManaCostWithScryfall(manaCost);
if (scryfallData && typeof scryfallData.cmc === 'number') {
return scryfallData.cmc;
}
// Fallback to local calculation
const symbols = await parseManaSymbols(manaCost);
let cmc = 0;
symbols.forEach(({ raw }) => {
const clean = raw.replace(/[{}]/g, '');
// Numeric costs
const numMatch = clean.match(/^\d+$/);
if (numMatch) {
cmc += parseInt(numMatch[0]);
return;
}
// Single mana symbols (including hybrid) count as 1
if (clean.match(/^[WUBRG]$/) || clean.includes('/')) {
cmc += 1;
return;
}
// X, Y, Z count as 0 for CMC calculation
if (clean.match(/^[XYZ]$/)) {
return;
}
// Everything else counts as 1
cmc += 1;
});
return cmc;
}
/**
* Get mana cost analysis using Scryfall
* @param {string} manaCost - Mana cost string
* @returns {Promise<Object>} Complete mana cost analysis
*/
export async function getManaCostAnalysis(manaCost) {
if (!manaCost) return {
cost: '',
cmc: 0,
colors: [],
colorless: true,
monocolored: false,
multicolored: false,
symbols: []
};
const scryfallData = await parseManaCostWithScryfall(manaCost);
const symbols = await parseManaSymbols(manaCost);
return {
cost: scryfallData?.cost || manaCost,
cmc: scryfallData?.cmc || await calculateCMC(manaCost),
colors: scryfallData?.colors || await getColorIdentity(manaCost),
colorless: scryfallData?.colorless ?? (symbols.length === 0 || symbols.every(s => !['W', 'U', 'B', 'R', 'G'].some(c => s.raw.includes(c)))),
monocolored: scryfallData?.monocolored ?? false,
multicolored: scryfallData?.multicolored ?? false,
symbols
};
}

View file

@ -177,6 +177,23 @@ async function loadLorcanaCards() {
for (const card of data.data) { for (const card of data.data) {
try { try {
// Process image URL to get the best quality
let imageUrl = card.image_url;
let stockImageUrl = card.image_url;
// If the image URL contains a size indicator, try to get a larger version
if (imageUrl && imageUrl.includes('-716.webp')) {
// Try to get 1024px version for main image, keep 716px for thumbnail
const largeImageUrl = imageUrl.replace('-716.webp', '-1024.webp');
imageUrl = largeImageUrl;
stockImageUrl = card.image_url; // Keep original as thumbnail
} else if (imageUrl && imageUrl.includes('-512.webp')) {
// Try to get larger versions
const largeImageUrl = imageUrl.replace('-512.webp', '-1024.webp');
imageUrl = largeImageUrl;
stockImageUrl = card.image_url;
}
await sql.query(` await sql.query(`
INSERT INTO cards ( INSERT INTO cards (
name, set_name, set_code, card_number, rarity, game, mana_cost, cmc, name, set_name, set_code, card_number, rarity, game, mana_cost, cmc,
@ -198,8 +215,8 @@ async function loadLorcanaCards() {
card.text || null, card.text || null,
null, // No power in Lorcana null, // No power in Lorcana
null, // No toughness in Lorcana null, // No toughness in Lorcana
card.image_url, imageUrl,
card.image_url, stockImageUrl,
card.price?.average || null, card.price?.average || null,
card.price?.low || null, card.price?.low || null,
card.id, card.id,

View file

@ -0,0 +1,212 @@
import { sql } from '@vercel/postgres';
import { getUserFromRequest } from '../../../lib/permission-middleware';
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const user = await getUserFromRequest(req);
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
const {
name,
set,
setCode,
cardNumber,
game,
cardType,
rarity,
hp,
manaCost,
ocrData
} = req.body;
if (!name) {
return res.status(400).json({ error: 'Card name is required' });
}
console.log(`🔍 Looking for card: "${name}" | Set: "${set || setCode}" | Number: "${cardNumber}" | Game: "${game}"`);
// First, try exact match by name, set, and card number (most specific)
let existingCard = null;
if ((set || setCode) && cardNumber) {
console.log('🎯 Trying exact match with card number...');
const exactQuery = sql`
SELECT * FROM cards
WHERE LOWER(name) = LOWER(${name})
AND (LOWER(set_name) = LOWER(${set || setCode}) OR LOWER(set_code) = LOWER(${setCode || set}))
AND LOWER(card_number) = LOWER(${cardNumber})
LIMIT 1
`;
const exactResult = await exactQuery;
if (exactResult.rows.length > 0) {
existingCard = exactResult.rows[0];
console.log('✅ Found exact match with card number:', existingCard.name);
}
}
// Second, try exact match by name and set (without card number)
if (!existingCard && (set || setCode)) {
console.log('🎯 Trying exact match by name and set...');
const setQuery = set ?
sql`SELECT * FROM cards WHERE LOWER(name) = LOWER(${name}) AND (LOWER(set_name) = LOWER(${set}) OR LOWER(set_code) = LOWER(${setCode || set})) LIMIT 1` :
sql`SELECT * FROM cards WHERE LOWER(name) = LOWER(${name}) AND LOWER(set_code) = LOWER(${setCode}) LIMIT 1`;
const setResult = await setQuery;
if (setResult.rows.length > 0) {
existingCard = setResult.rows[0];
console.log('✅ Found exact match by name and set:', existingCard.name);
}
}
// Third, try exact name match (any set)
if (!existingCard) {
console.log('🎯 Trying exact name match (any set)...');
const nameQuery = sql`
SELECT * FROM cards
WHERE LOWER(name) = LOWER(${name})
ORDER BY
CASE WHEN game = ${game || 'UNKNOWN'} THEN 1 ELSE 2 END,
created_at DESC
LIMIT 1
`;
const nameResult = await nameQuery;
if (nameResult.rows.length > 0) {
existingCard = nameResult.rows[0];
console.log('✅ Found exact name match:', existingCard.name);
}
}
// Fourth, try fuzzy name matching with game preference
if (!existingCard) {
console.log('🎯 Trying fuzzy name matching...');
const fuzzyResult = await sql`
SELECT * FROM cards
WHERE LOWER(name) ILIKE LOWER(${`%${name}%`})
ORDER BY
CASE
WHEN LOWER(name) = LOWER(${name}) THEN 1
WHEN LOWER(name) LIKE LOWER(${name + '%'}) THEN 2
WHEN LOWER(name) LIKE LOWER(${'%' + name + '%'}) THEN 3
ELSE 4
END,
CASE WHEN game = ${game || 'UNKNOWN'} THEN 1 ELSE 2 END,
LENGTH(name)
LIMIT 5
`;
if (fuzzyResult.rows.length > 0) {
console.log(`🔍 Found ${fuzzyResult.rows.length} fuzzy matches`);
// If we have high confidence and an exact match, use it
const exactFuzzyMatch = fuzzyResult.rows.find(row =>
row.name.toLowerCase() === name.toLowerCase()
);
if (exactFuzzyMatch && ocrData?.confidence >= 80) {
existingCard = exactFuzzyMatch;
console.log('✅ Using high-confidence fuzzy exact match:', existingCard.name);
} else if (ocrData?.confidence < 80 && fuzzyResult.rows.length > 1) {
// Low confidence with multiple matches - let user choose
console.log('⚠️ Multiple matches with low confidence - requiring user selection');
return res.status(200).json({
card: null,
matches: fuzzyResult.rows.map(card => ({
id: card.id,
name: card.name,
set_name: card.set_name,
set_code: card.set_code,
card_number: card.card_number,
game: card.game,
rarity: card.rarity,
image_url: card.image_url
})),
needsUserSelection: true,
message: `Found ${fuzzyResult.rows.length} possible matches for "${name}". Please select the correct card.`
});
} else {
// Use the best match
existingCard = fuzzyResult.rows[0];
console.log('✅ Using best fuzzy match:', existingCard.name);
}
}
}
// If we found an existing card, return it
if (existingCard) {
console.log('🎉 Returning existing card:', existingCard.name);
return res.status(200).json({
card: existingCard,
isExisting: true,
message: `Found existing card: "${existingCard.name}"`
});
}
// If no existing card found, decide whether to create a new one
const confidenceThreshold = 75; // Increased threshold for better accuracy
if (!ocrData || ocrData.confidence < confidenceThreshold) {
console.log(`❌ No match found and confidence too low (${ocrData?.confidence || 0}% < ${confidenceThreshold}%)`);
return res.status(200).json({
card: null,
matches: [],
needsUserInput: true,
message: `Could not find card "${name}" in database and confidence is low (${ocrData?.confidence || 0}%). Please verify the card name and try again.`
});
}
// Create new card entry with enhanced data
console.log('🆕 Creating new card from OCR data...');
const newCardResult = await sql`
INSERT INTO cards (
name, set_name, set_code, card_number, rarity, game,
mana_cost, cmc, card_type, colors, oracle_text,
power, toughness, image_url, stock_image_url,
current_price, market_price, scryfall_id, verified
) VALUES (
${name.trim()},
${set || null},
${setCode || null},
${cardNumber || null},
${rarity || null},
${game || 'UNKNOWN'},
${manaCost || null},
${null}, -- cmc (calculated from mana cost)
${cardType || null},
${null}, -- colors (unknown from OCR)
${ocrData?.rawText || null}, -- Store OCR text in oracle_text temporarily
${hp || null}, -- power (HP for Pokemon)
${null}, -- toughness
${null}, -- image_url (to be fetched later)
${null}, -- stock_image_url
${null}, -- current_price
${null}, -- market_price
${null}, -- scryfall_id (to be populated later)
${false} -- not verified since it's from OCR
)
RETURNING *
`;
const newCard = newCardResult.rows[0];
// Log the OCR creation for potential review
console.log(`✅ Created new card from OCR: ${name} (${game}) - Confidence: ${ocrData?.confidence}%`);
return res.status(201).json({
card: newCard,
isExisting: false,
message: `Created new card "${name}" from scan data. This card may need verification.`
});
} catch (error) {
console.error('Error in find-or-create card:', error);
return res.status(500).json({ error: 'Internal server error' });
}
}

View file

@ -137,7 +137,7 @@ export default async function handler(req, res) {
${rarity}, 'Lorcana', ${card.cost}, ${card.cost}, ${cardType}, ${rarity}, 'Lorcana', ${card.cost}, ${card.cost}, ${cardType},
${JSON.stringify(card.ink ? [card.ink] : [])}, ${cardText || flavorText}, ${JSON.stringify(card.ink ? [card.ink] : [])}, ${cardText || flavorText},
${strength}, ${willpower || lore}, ${strength}, ${willpower || lore},
${card.image_uris?.digital?.small || null}, ${card.image_uris?.digital?.large || null}, ${card.image_uris?.digital?.large || card.image_uris?.digital?.small || null}, ${card.image_uris?.digital?.small || null},
${currentPrice}, null, ${card.id}, true ${currentPrice}, null, ${card.id}, true
) )
`; `;

164
pages/api/cards/owned.js Normal file
View file

@ -0,0 +1,164 @@
import { sql } from '@vercel/postgres';
import { getUserFromRequest } from '../../../lib/permission-middleware';
export default async function handler(req, res) {
// Set CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
// Handle preflight requests
if (req.method === 'OPTIONS') {
res.status(200).end();
return;
}
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
// Get authenticated user
const user = await getUserFromRequest(req);
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
const {
search = '',
tcg = 'all',
rarity = 'all',
set = 'all',
valueRange = 'all',
page = '1',
limit = '50'
} = req.query;
const pageNum = parseInt(page) || 1;
const limitNum = parseInt(limit) || 50;
const offset = (pageNum - 1) * limitNum;
// Build WHERE conditions
let whereConditions = ['uc.user_id = $1'];
let params = [user.userId];
let paramIndex = 2;
if (search.trim()) {
whereConditions.push(`c.name ILIKE $${paramIndex}`);
params.push(`%${search.trim()}%`);
paramIndex++;
}
if (tcg !== 'all') {
whereConditions.push(`c.game = $${paramIndex}`);
params.push(tcg);
paramIndex++;
}
if (rarity !== 'all') {
whereConditions.push(`c.rarity = $${paramIndex}`);
params.push(rarity);
paramIndex++;
}
if (set !== 'all') {
whereConditions.push(`c.set_name = $${paramIndex}`);
params.push(set);
paramIndex++;
}
if (valueRange !== 'all') {
// Handle value range filtering
const ranges = {
'under-1': [0, 1],
'1-5': [1, 5],
'5-25': [5, 25],
'25-100': [25, 100],
'over-100': [100, 999999]
};
if (ranges[valueRange]) {
const [min, max] = ranges[valueRange];
if (valueRange === 'over-100') {
whereConditions.push(`c.market_price >= $${paramIndex}`);
params.push(min);
paramIndex++;
} else {
whereConditions.push(`c.market_price >= $${paramIndex} AND c.market_price <= $${paramIndex + 1}`);
params.push(min, max);
paramIndex += 2;
}
}
}
const whereClause = whereConditions.join(' AND ');
// Get owned cards with user ownership data
const cardsQuery = `
SELECT
c.id, c.name, c.set_name, c.set_code, c.card_number, c.rarity, c.game,
c.mana_cost, c.cmc, c.card_type, c.colors, c.oracle_text,
c.power, c.toughness, c.image_url, c.stock_image_url,
c.current_price, c.market_price, c.scryfall_id, c.verified,
uc.quantity as owned_quantity,
uc.condition,
uc.created_at as owned_since
FROM cards c
INNER JOIN user_cards uc ON c.id = uc.card_id
WHERE ${whereClause}
ORDER BY c.name ASC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
const countQuery = `
SELECT COUNT(*) as total
FROM cards c
INNER JOIN user_cards uc ON c.id = uc.card_id
WHERE ${whereClause}
`;
// Execute queries
const result = await sql.query(cardsQuery, [...params, limitNum, offset]);
const countResult = await sql.query(countQuery, params);
const total = parseInt(countResult.rows[0].total);
const totalPages = Math.ceil(total / limitNum);
// Get filter options for owned cards only
const filtersQuery = `
SELECT DISTINCT
c.game,
c.rarity,
c.set_name
FROM cards c
INNER JOIN user_cards uc ON c.id = uc.card_id
WHERE uc.user_id = $1
ORDER BY c.game, c.rarity, c.set_name
`;
const filtersResult = await sql.query(filtersQuery, [user.userId]);
const games = [...new Set(filtersResult.rows.map(row => row.game))].filter(Boolean);
const rarities = [...new Set(filtersResult.rows.map(row => row.rarity))].filter(Boolean);
const sets = [...new Set(filtersResult.rows.map(row => row.set_name))].filter(Boolean);
res.status(200).json({
cards: result.rows,
pagination: {
page: pageNum,
limit: limitNum,
total,
pages: totalPages
},
filters: {
games,
rarities,
sets
}
});
} catch (error) {
console.error('Error fetching owned cards:', error);
res.status(500).json({ error: 'Internal server error' });
}
}

View file

@ -15,11 +15,8 @@ export default async function handler(req, res) {
} }
try { try {
// Get authenticated user // Try to get authenticated user (optional for public collections)
const user = await getUserFromRequest(req); const user = await getUserFromRequest(req);
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
const { identifier } = req.query; const { identifier } = req.query;
@ -39,20 +36,20 @@ export default async function handler(req, res) {
u.email as creator_email, u.email as creator_email,
COUNT(cc.card_id) as card_count, COUNT(cc.card_id) as card_count,
COALESCE(SUM(cards.market_price * cc.quantity), 0) as total_value, COALESCE(SUM(cards.market_price * cc.quantity), 0) as total_value,
cp.role as user_role, ${user ? sql`cp.role as user_role,
CASE CASE
WHEN c.user_id = ${user.userId} THEN 'owner' WHEN c.user_id = ${user.userId} THEN 'owner'
WHEN cp.role IS NOT NULL THEN cp.role WHEN cp.role IS NOT NULL THEN cp.role
ELSE NULL ELSE NULL
END as effective_role END as effective_role` : sql`NULL as user_role, NULL as effective_role`}
FROM collections c FROM collections c
LEFT JOIN users u ON c.user_id = u.id LEFT JOIN users u ON c.user_id = u.id
LEFT JOIN collection_cards cc ON c.id = cc.collection_id LEFT JOIN collection_cards cc ON c.id = cc.collection_id
LEFT JOIN cards ON cc.card_id = cards.id LEFT JOIN cards ON cc.card_id = cards.id
LEFT JOIN collection_permissions cp ON c.id = cp.collection_id AND cp.user_id = ${user.userId} AND cp.status = 'active' LEFT JOIN collection_permissions cp ON c.id = cp.collection_id AND cp.user_id = ${user ? user.userId : null} AND cp.status = 'active'
WHERE c.slug = ${identifier} WHERE c.slug = ${identifier}
AND ( AND (
c.user_id = ${user.userId} OR c.user_id = ${user ? user.userId : null} OR
cp.id IS NOT NULL OR cp.id IS NOT NULL OR
c.is_public = true c.is_public = true
) )
@ -66,20 +63,20 @@ export default async function handler(req, res) {
u.email as creator_email, u.email as creator_email,
COUNT(cc.card_id) as card_count, COUNT(cc.card_id) as card_count,
COALESCE(SUM(cards.market_price * cc.quantity), 0) as total_value, COALESCE(SUM(cards.market_price * cc.quantity), 0) as total_value,
cp.role as user_role, ${user ? sql`cp.role as user_role,
CASE CASE
WHEN c.user_id = ${user.userId} THEN 'owner' WHEN c.user_id = ${user.userId} THEN 'owner'
WHEN cp.role IS NOT NULL THEN cp.role WHEN cp.role IS NOT NULL THEN cp.role
ELSE NULL ELSE NULL
END as effective_role END as effective_role` : sql`NULL as user_role, NULL as effective_role`}
FROM collections c FROM collections c
LEFT JOIN users u ON c.user_id = u.id LEFT JOIN users u ON c.user_id = u.id
LEFT JOIN collection_cards cc ON c.id = cc.collection_id LEFT JOIN collection_cards cc ON c.id = cc.collection_id
LEFT JOIN cards ON cc.card_id = cards.id LEFT JOIN cards ON cc.card_id = cards.id
LEFT JOIN collection_permissions cp ON c.id = cp.collection_id AND cp.user_id = ${user.userId} AND cp.status = 'active' LEFT JOIN collection_permissions cp ON c.id = cp.collection_id AND cp.user_id = ${user ? user.userId : null} AND cp.status = 'active'
WHERE c.id = ${numericId} WHERE c.id = ${numericId}
AND ( AND (
c.user_id = ${user.userId} OR c.user_id = ${user ? user.userId : null} OR
cp.id IS NOT NULL OR cp.id IS NOT NULL OR
c.is_public = true c.is_public = true
) )
@ -118,7 +115,7 @@ export default async function handler(req, res) {
} else if (req.method === 'PUT') { } else if (req.method === 'PUT') {
// Only allow updates by owner // Only allow updates by owner
if (collection.user_id !== user.userId) { if (collection.user_id !== user?.userId) {
return res.status(403).json({ error: 'Only collection owners can edit collections' }); return res.status(403).json({ error: 'Only collection owners can edit collections' });
} }
@ -206,7 +203,7 @@ export default async function handler(req, res) {
} else if (req.method === 'DELETE') { } else if (req.method === 'DELETE') {
// Only allow deletion by owner // Only allow deletion by owner
if (collection.user_id !== user.userId) { if (collection.user_id !== user?.userId) {
return res.status(403).json({ error: 'Only collection owners can delete collections' }); return res.status(403).json({ error: 'Only collection owners can delete collections' });
} }

View file

@ -15,11 +15,8 @@ export default async function handler(req, res) {
} }
try { try {
// Get authenticated user // Try to get authenticated user (optional for public collections)
const user = await getUserFromRequest(req); const user = await getUserFromRequest(req);
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
const { identifier } = req.query; const { identifier } = req.query;
@ -34,26 +31,24 @@ export default async function handler(req, res) {
let collectionResult; let collectionResult;
if (isSlug) { if (isSlug) {
collectionResult = await sql` collectionResult = await sql`
SELECT c.*, cp.role as user_role SELECT c.*, ${user ? sql`cp.role as user_role` : sql`NULL as user_role`}
FROM collections c FROM collections c
LEFT JOIN collection_permissions cp ON c.id = cp.collection_id AND cp.user_id = ${user.userId} AND cp.status = 'active' ${user ? sql`LEFT JOIN collection_permissions cp ON c.id = cp.collection_id AND cp.user_id = ${user.userId} AND cp.status = 'active'` : sql``}
WHERE c.slug = ${identifier} WHERE c.slug = ${identifier}
AND ( AND (
c.user_id = ${user.userId} OR ${user ? sql`c.user_id = ${user.userId} OR cp.id IS NOT NULL OR` : sql``}
cp.id IS NOT NULL OR
c.is_public = true c.is_public = true
) )
`; `;
} else { } else {
const numericId = parseInt(identifier); const numericId = parseInt(identifier);
collectionResult = await sql` collectionResult = await sql`
SELECT c.*, cp.role as user_role SELECT c.*, ${user ? sql`cp.role as user_role` : sql`NULL as user_role`}
FROM collections c FROM collections c
LEFT JOIN collection_permissions cp ON c.id = cp.collection_id AND cp.user_id = ${user.userId} AND cp.status = 'active' ${user ? sql`LEFT JOIN collection_permissions cp ON c.id = cp.collection_id AND cp.user_id = ${user.userId} AND cp.status = 'active'` : sql``}
WHERE c.id = ${numericId} WHERE c.id = ${numericId}
AND ( AND (
c.user_id = ${user.userId} OR ${user ? sql`c.user_id = ${user.userId} OR cp.id IS NOT NULL OR` : sql``}
cp.id IS NOT NULL OR
c.is_public = true c.is_public = true
) )
`; `;

View file

@ -0,0 +1,25 @@
export default async function handler(req, res) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
// Get Gemini API key from environment
const geminiApiKey = process.env.GEMINI_AI_API_KEY;
if (geminiApiKey) {
return res.status(200).json({
hasKey: true,
apiKey: geminiApiKey
});
} else {
return res.status(200).json({
hasKey: false,
message: 'No Gemini API key found in environment'
});
}
} catch (error) {
console.error('Error getting Gemini config:', error);
return res.status(500).json({ error: 'Internal server error' });
}
}

View file

@ -2,45 +2,48 @@ import { sql } from '@vercel/postgres';
import { getUserFromRequest } from '../../lib/permission-middleware'; import { getUserFromRequest } from '../../lib/permission-middleware';
export default async function handler(req, res) { export default async function handler(req, res) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
try { try {
// Get authenticated user
const user = await getUserFromRequest(req); const user = await getUserFromRequest(req);
if (!user) { if (!user) {
return res.status(401).json({ error: 'Authentication required' }); return res.status(401).json({ error: 'Authentication required' });
} }
// Get user's decks from database if (req.method === 'GET') {
const result = await sql` // Get user's decks
SELECT const result = await sql`
d.*, SELECT d.*,
COUNT(dc.card_id) as card_count, COUNT(dc.card_id) as card_count,
COALESCE(SUM(cards.market_price * dc.quantity), 0) as total_value SUM(dc.quantity) as total_cards
FROM decks d FROM decks d
LEFT JOIN deck_cards dc ON d.id = dc.deck_id LEFT JOIN deck_cards dc ON d.id = dc.deck_id
LEFT JOIN cards ON dc.card_id = cards.id WHERE d.user_id = ${user.userId}
WHERE d.user_id = ${user.userId} GROUP BY d.id
GROUP BY d.id ORDER BY d.created_at DESC
ORDER BY d.updated_at DESC `;
`;
const decks = result.rows.map(deck => ({ return res.status(200).json(result.rows);
id: deck.id,
name: deck.name, } else if (req.method === 'POST') {
description: deck.description, const { name, description, game, is_public = false } = req.body;
game: deck.game,
cardCount: parseInt(deck.card_count) || 0, if (!name) {
value: parseFloat(deck.total_value) || 0, return res.status(400).json({ error: 'Deck name is required' });
createdAt: deck.created_at, }
updatedAt: deck.updated_at
})); const result = await sql`
INSERT INTO decks (user_id, name, description, game, is_public)
VALUES (${user.userId}, ${name}, ${description || ''}, ${game || 'UNKNOWN'}, ${is_public})
RETURNING *
`;
return res.status(201).json(result.rows[0]);
} else {
return res.status(405).json({ error: 'Method not allowed' });
}
res.status(200).json(decks);
} catch (error) { } catch (error) {
console.error('Error fetching decks:', error); console.error('Error in decks API:', error);
res.status(500).json({ error: 'Failed to fetch decks' }); return res.status(500).json({ error: 'Internal server error' });
} }
} }

91
pages/api/decks/[id].js Normal file
View file

@ -0,0 +1,91 @@
import { sql } from '@vercel/postgres';
import { getUserFromRequest } from '../../../lib/permission-middleware';
export default async function handler(req, res) {
try {
const user = await getUserFromRequest(req);
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
const { id: deckId } = req.query;
if (req.method === 'GET') {
// Get deck details with cards
const deckResult = await sql`
SELECT d.*, u.username as creator_username
FROM decks d
JOIN users u ON d.user_id = u.id
WHERE d.id = ${deckId} AND (d.user_id = ${user.userId} OR d.is_public = true)
`;
if (deckResult.rows.length === 0) {
return res.status(404).json({ error: 'Deck not found or access denied' });
}
const deck = deckResult.rows[0];
// Get deck cards with details
const cardsResult = await sql`
SELECT dc.*, c.name, c.set_name, c.rarity, c.mana_cost, c.cmc,
c.card_type, c.colors, c.image_url, c.oracle_text
FROM deck_cards dc
JOIN cards c ON dc.card_id = c.id
WHERE dc.deck_id = ${deckId}
ORDER BY c.name ASC
`;
deck.cards = cardsResult.rows;
deck.card_count = cardsResult.rows.reduce((sum, card) => sum + card.quantity, 0);
return res.status(200).json(deck);
} else if (req.method === 'PUT') {
// Update deck (only owner can update)
const deckResult = await sql`
SELECT * FROM decks WHERE id = ${deckId} AND user_id = ${user.userId}
`;
if (deckResult.rows.length === 0) {
return res.status(404).json({ error: 'Deck not found or access denied' });
}
const { name, description, format, is_public, commander_id } = req.body;
const updatedDeck = await sql`
UPDATE decks
SET name = ${name}, description = ${description}, format = ${format},
is_public = ${is_public}, commander_id = ${commander_id}, updated_at = CURRENT_TIMESTAMP
WHERE id = ${deckId} AND user_id = ${user.userId}
RETURNING *
`;
return res.status(200).json(updatedDeck.rows[0]);
} else if (req.method === 'DELETE') {
// Delete deck (only owner can delete)
const deckResult = await sql`
SELECT * FROM decks WHERE id = ${deckId} AND user_id = ${user.userId}
`;
if (deckResult.rows.length === 0) {
return res.status(404).json({ error: 'Deck not found or access denied' });
}
// Delete deck cards first (foreign key constraint)
await sql`DELETE FROM deck_cards WHERE deck_id = ${deckId}`;
// Delete deck
await sql`DELETE FROM decks WHERE id = ${deckId} AND user_id = ${user.userId}`;
return res.status(200).json({ message: 'Deck deleted successfully' });
} else {
return res.status(405).json({ error: 'Method not allowed' });
}
} catch (error) {
console.error('Error in deck API:', error);
return res.status(500).json({ error: 'Internal server error' });
}
}

View file

@ -0,0 +1,110 @@
import { sql } from '@vercel/postgres';
import { getUserFromRequest } from '../../../../lib/permission-middleware';
export default async function handler(req, res) {
try {
const user = await getUserFromRequest(req);
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
const { id: deckId } = req.query;
// Verify user owns this deck
const deckResult = await sql`
SELECT * FROM decks WHERE id = ${deckId} AND user_id = ${user.userId}
`;
if (deckResult.rows.length === 0) {
return res.status(404).json({ error: 'Deck not found or access denied' });
}
if (req.method === 'POST') {
const { cardId, quantity = 1 } = req.body;
if (!cardId) {
return res.status(400).json({ error: 'Card ID is required' });
}
// Check if card already exists in deck
const existingResult = await sql`
SELECT * FROM deck_cards
WHERE deck_id = ${deckId} AND card_id = ${cardId}
`;
if (existingResult.rows.length > 0) {
// Update quantity
const newQuantity = existingResult.rows[0].quantity + quantity;
await sql`
UPDATE deck_cards
SET quantity = ${newQuantity}, updated_at = CURRENT_TIMESTAMP
WHERE deck_id = ${deckId} AND card_id = ${cardId}
`;
} else {
// Insert new record
await sql`
INSERT INTO deck_cards (deck_id, card_id, quantity)
VALUES (${deckId}, ${cardId}, ${quantity})
`;
}
return res.status(200).json({ message: 'Card added to deck' });
} else if (req.method === 'GET') {
// Get cards in deck
const result = await sql`
SELECT dc.*, c.name, c.set_name, c.rarity, c.game, c.image_url, c.mana_cost, c.cmc, c.card_type, c.colors
FROM deck_cards dc
JOIN cards c ON dc.card_id = c.id
WHERE dc.deck_id = ${deckId}
ORDER BY c.name ASC
`;
return res.status(200).json(result.rows);
} else if (req.method === 'DELETE') {
const { cardId, quantity = 1 } = req.body;
if (!cardId) {
return res.status(400).json({ error: 'Card ID is required' });
}
// Check if card exists in deck
const existingResult = await sql`
SELECT * FROM deck_cards
WHERE deck_id = ${deckId} AND card_id = ${cardId}
`;
if (existingResult.rows.length === 0) {
return res.status(404).json({ error: 'Card not found in deck' });
}
const currentQuantity = existingResult.rows[0].quantity;
const newQuantity = currentQuantity - quantity;
if (newQuantity <= 0) {
// Remove card entirely
await sql`
DELETE FROM deck_cards
WHERE deck_id = ${deckId} AND card_id = ${cardId}
`;
} else {
// Update quantity
await sql`
UPDATE deck_cards
SET quantity = ${newQuantity}, updated_at = CURRENT_TIMESTAMP
WHERE deck_id = ${deckId} AND card_id = ${cardId}
`;
}
return res.status(200).json({ message: 'Card removed from deck' });
} else {
return res.status(405).json({ error: 'Method not allowed' });
}
} catch (error) {
console.error('Error in deck cards API:', error);
return res.status(500).json({ error: 'Internal server error' });
}
}

View file

@ -0,0 +1,63 @@
import { sql } from '@vercel/postgres';
export default async function handler(req, res) {
// Set CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
// Handle preflight requests
if (req.method === 'OPTIONS') {
res.status(200).end();
return;
}
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const { limit = 6 } = req.query;
// Get featured public collections for landing page (no auth required)
const result = await sql`
SELECT DISTINCT
c.*,
u.email as creator_email,
u.username as creator_username,
COUNT(cc.card_id) as card_count,
COALESCE(SUM(cards.market_price * cc.quantity), 0) as total_value
FROM collections c
LEFT JOIN users u ON c.user_id = u.id
LEFT JOIN collection_cards cc ON c.id = cc.collection_id
LEFT JOIN cards ON cc.card_id = cards.id
WHERE c.is_public = true
AND (c.is_system_collection IS NULL OR c.is_system_collection = false)
GROUP BY c.id, u.email, u.username
ORDER BY c.updated_at DESC, c.created_at DESC
LIMIT ${parseInt(limit)}
`;
const collections = result.rows.map(collection => ({
id: collection.id,
slug: collection.slug,
name: collection.name,
description: collection.description,
tcg: collection.tcg || 'MTG',
cardCount: parseInt(collection.card_count) || 0,
value: parseFloat(collection.total_value) || 0,
lastViewed: collection.updated_at,
createdAt: collection.created_at,
isPublic: collection.is_public || false,
tags: collection.tags ? collection.tags.split(',') : [],
creator: collection.creator_username || collection.creator_email,
image: collection.image
}));
res.status(200).json(collections);
} catch (error) {
console.error('Error fetching public collections:', error);
res.status(500).json({ error: 'Internal server error' });
}
}

62
pages/api/user-cards.js Normal file
View file

@ -0,0 +1,62 @@
import { sql } from '@vercel/postgres';
import { getUserFromRequest } from '../../lib/permission-middleware';
export default async function handler(req, res) {
try {
const user = await getUserFromRequest(req);
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (req.method === 'POST') {
const { cardId, quantity = 1, condition = 'NM', is_foil = false } = req.body;
if (!cardId) {
return res.status(400).json({ error: 'Card ID is required' });
}
// Check if user already owns this card
const existingResult = await sql`
SELECT * FROM user_cards
WHERE user_id = ${user.userId} AND card_id = ${cardId} AND is_foil = ${is_foil}
`;
if (existingResult.rows.length > 0) {
// Update quantity
const newQuantity = existingResult.rows[0].quantity + quantity;
await sql`
UPDATE user_cards
SET quantity = ${newQuantity}, updated_at = CURRENT_TIMESTAMP
WHERE user_id = ${user.userId} AND card_id = ${cardId} AND is_foil = ${is_foil}
`;
} else {
// Insert new record
await sql`
INSERT INTO user_cards (user_id, card_id, quantity, condition, is_foil)
VALUES (${user.userId}, ${cardId}, ${quantity}, ${condition}, ${is_foil})
`;
}
return res.status(200).json({ message: 'Card added to owned cards' });
} else if (req.method === 'GET') {
// Get user's owned cards
const result = await sql`
SELECT uc.*, c.name, c.set_name, c.rarity, c.game, c.image_url
FROM user_cards uc
JOIN cards c ON uc.card_id = c.id
WHERE uc.user_id = ${user.userId}
ORDER BY uc.created_at DESC
`;
return res.status(200).json(result.rows);
} else {
return res.status(405).json({ error: 'Method not allowed' });
}
} catch (error) {
console.error('Error in user-cards API:', error);
return res.status(500).json({ error: 'Internal server error' });
}
}

View file

@ -3,6 +3,8 @@ import { useRouter } from 'next/router';
import Layout from '../../components/Layout'; import Layout from '../../components/Layout';
import { useIsAdmin } from '../../lib/admin-auth'; import { useIsAdmin } from '../../lib/admin-auth';
import CollectionSelectionModal from '../../components/CollectionSelectionModal'; import CollectionSelectionModal from '../../components/CollectionSelectionModal';
import { ManaCost, ColorIdentity, AdvancedManaCost } from '../../components/ManaSymbols';
import ManaSymbolSettings from '../../components/ManaSymbolSettings';
export default function CardDetail() { export default function CardDetail() {
const router = useRouter(); const router = useRouter();
@ -29,6 +31,9 @@ export default function CardDetail() {
const [cardCollections, setCardCollections] = useState([]); const [cardCollections, setCardCollections] = useState([]);
const [cardDecks, setCardDecks] = useState([]); const [cardDecks, setCardDecks] = useState([]);
// Mana symbol settings
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
// Check admin status // Check admin status
const { isAdmin, loading: adminLoading } = useIsAdmin(); const { isAdmin, loading: adminLoading } = useIsAdmin();
@ -617,7 +622,7 @@ export default function CardDetail() {
{card.mana_cost && ( {card.mana_cost && (
<div className="flex justify-between py-3 border-b" style={{ borderColor: 'var(--border)' }}> <div className="flex justify-between py-3 border-b" style={{ borderColor: 'var(--border)' }}>
<span style={{ color: 'var(--text-secondary)' }}>Cost to Play</span> <span style={{ color: 'var(--text-secondary)' }}>Cost to Play</span>
<span style={{ color: 'var(--text-primary)' }}>{card.mana_cost}</span> <ManaCost cost={card.mana_cost} size="md" useSVG={manaSymbolSettings.useSVG} />
</div> </div>
)} )}
{card.power && ( {card.power && (

View file

@ -1,16 +1,78 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import Link from 'next/link';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import CardItem from '../components/CardItem'; import CardItem from '../components/CardItem';
import BulkSelectionToolbar from '../components/BulkSelectionToolbar'; import BulkSelectionToolbar from '../components/BulkSelectionToolbar';
import CollectionSelectionModal from '../components/CollectionSelectionModal'; import CollectionSelectionModal from '../components/CollectionSelectionModal';
import ProtectedRoute from '../components/ProtectedRoute';
import { ManaCost, ColorFilterSymbol } from '../components/ManaSymbols';
import ManaSymbolSettings from '../components/ManaSymbolSettings';
import { useAuth } from '../lib/use-auth';
export default function Cards() { // Public fallback component for non-authenticated users
function PublicCardsView() {
return (
<Layout user={null}>
<div className="p-6">
<div className="max-w-7xl mx-auto">
{/* Header with login prompt */}
<div className="mb-8 text-center">
<h1 className="text-4xl font-bold mb-4 gradient-text-flame">Browse Trading Cards</h1>
<p className="text-xl mb-6" style={{ color: 'var(--text-secondary)' }}>
Discover thousands of trading cards from popular TCGs
</p>
<div className="bg-gradient-to-r from-orange-50 to-red-50 dark:from-orange-900/20 dark:to-red-900/20 rounded-2xl p-6 border border-orange-200 dark:border-orange-800">
<h3 className="text-lg font-semibold mb-2 gradient-text-ember">Sign Up to Unlock Full Features</h3>
<p className="mb-4" style={{ color: 'var(--text-secondary)' }}>
Create collections, mark favorites, add cards to your inventory, and more!
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link
href="/signup"
className="px-6 py-3 font-medium rounded-xl transition-all duration-200 hover:opacity-90"
style={{
backgroundColor: 'var(--accent-ember)',
color: 'white'
}}
>
Create Free Account
</Link>
<Link
href="/login"
className="px-6 py-3 font-medium rounded-xl border-2 transition-all duration-200 hover:opacity-80"
style={{
color: 'var(--text-primary)',
borderColor: 'var(--accent-ember)'
}}
>
Sign In
</Link>
</div>
</div>
</div>
{/* Basic card browser would go here - simplified version */}
<div className="text-center py-20">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl flex items-center justify-center" style={{ backgroundColor: 'var(--accent-ember)' }}>
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h3 className="text-xl font-semibold mb-2 gradient-text-flame">Full Card Browser Coming Soon</h3>
<p style={{ color: 'var(--text-secondary)' }}>
Sign up now to get early access to our complete card database and collection tools.
</p>
</div>
</div>
</div>
</Layout>
);
}
function AuthenticatedCards() {
const router = useRouter(); const router = useRouter();
const user = { const { user, loading: authLoading } = useAuth();
email: 'me@randallstillwell.com',
role: 'user'
};
const [cards, setCards] = useState([]); const [cards, setCards] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -44,6 +106,9 @@ export default function Cards() {
const [showCollectionModal, setShowCollectionModal] = useState(false); const [showCollectionModal, setShowCollectionModal] = useState(false);
const [cardsToAdd, setCardsToAdd] = useState([]); const [cardsToAdd, setCardsToAdd] = useState([]);
// Mana symbol settings
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
// Fetch cards from database // Fetch cards from database
const fetchCards = async (isLoadMore = false) => { const fetchCards = async (isLoadMore = false) => {
try { try {
@ -892,6 +957,17 @@ export default function Cards() {
); );
} }
export default function Cards() {
return (
<ProtectedRoute
allowPublic={true}
publicFallback={<PublicCardsView />}
>
<AuthenticatedCards />
</ProtectedRoute>
);
}
// 3D Card Component // 3D Card Component
function Card3D({ card, viewMode, getRarityLabel, getRarityColor }) { function Card3D({ card, viewMode, getRarityLabel, getRarityColor }) {
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
@ -1304,7 +1380,7 @@ function Card3D({ card, viewMode, getRarityLabel, getRarityColor }) {
{card.mana_cost && ( {card.mana_cost && (
<div> <div>
<div className="text-xs font-bold text-white mb-1">Cost to Play</div> <div className="text-xs font-bold text-white mb-1">Cost to Play</div>
<div className="text-xs text-white">{card.mana_cost}</div> <ManaCost cost={card.mana_cost} size="sm" useSVG={manaSymbolSettings.useSVG} />
</div> </div>
)} )}

View file

@ -6,6 +6,9 @@ import ShareModal from '../../components/ShareModal';
import CollaboratorFacepile from '../../components/CollaboratorFacepile'; import CollaboratorFacepile from '../../components/CollaboratorFacepile';
import CardItem from '../../components/CardItem'; import CardItem from '../../components/CardItem';
import Layout from '../../components/Layout'; import Layout from '../../components/Layout';
import LoginCTA from '../../components/LoginCTA';
import { ManaCost, ColorFilterSymbol } from '../../components/ManaSymbols';
import ManaSymbolSettings from '../../components/ManaSymbolSettings';
import { useAuth } from '../../lib/use-auth'; import { useAuth } from '../../lib/use-auth';
export default function CollectionView() { export default function CollectionView() {
@ -49,26 +52,28 @@ export default function CollectionView() {
const [favoritedCards, setFavoritedCards] = useState(new Set()); const [favoritedCards, setFavoritedCards] = useState(new Set());
useEffect(() => { useEffect(() => {
if (identifier && user) { if (identifier) {
fetchCollectionData(); fetchCollectionData();
loadFavoritedCards(); if (user) {
loadFavoritedCards();
}
} }
}, [identifier, user]); }, [identifier, user]);
// Redirect to login if not authenticated
useEffect(() => {
if (!authLoading && !user) {
router.push('/login');
}
}, [authLoading, user, router]);
const fetchCollectionData = async () => { const fetchCollectionData = async () => {
try { try {
const response = await fetch(`/api/collections/${identifier}`, { // First try to fetch without authentication (for public collections)
headers: { let response = await fetch(`/api/collections/${identifier}`);
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
} // If that fails and we have a user, try with authentication
}); if (!response.ok && user) {
response = await fetch(`/api/collections/${identifier}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
}
});
}
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
@ -88,12 +93,17 @@ export default function CollectionView() {
tags: Array.isArray(data.tags) ? data.tags : (data.tags ? data.tags.split(',') : []) tags: Array.isArray(data.tags) ? data.tags : (data.tags ? data.tags.split(',') : [])
}); });
// Fetch collection cards // Fetch collection cards (try public first, then authenticated)
const cardsResponse = await fetch(`/api/collections/${identifier}/cards`, { let cardsResponse = await fetch(`/api/collections/${identifier}/cards`);
headers: {
'Authorization': `Bearer ${localStorage.getItem('auth_token')}` if (!cardsResponse.ok && user) {
} cardsResponse = await fetch(`/api/collections/${identifier}/cards`, {
}); headers: {
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
}
});
}
if (cardsResponse.ok) { if (cardsResponse.ok) {
const cardsData = await cardsResponse.json(); const cardsData = await cardsResponse.json();
setCards(cardsData.cards || []); setCards(cardsData.cards || []);
@ -101,15 +111,17 @@ export default function CollectionView() {
// Check if collection is favorited // Check if collection is favorited
checkIfFavorited(); checkIfFavorited();
} else { } else if (response.status === 401 || response.status === 403) {
console.error('Failed to fetch collection'); // Collection is private and user is not authenticated/authorized
setCollection(null); if (!user) {
setCards([]); router.push('/login');
} else {
// User is authenticated but doesn't have access
router.push('/collections');
}
} }
} catch (error) { } catch (error) {
console.error('Error fetching collection:', error); console.error('Error fetching collection:', error);
setCollection(null);
setCards([]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -500,11 +512,6 @@ export default function CollectionView() {
); );
} }
// Redirect to login if not authenticated (handled by useEffect, but this is a fallback)
if (!user) {
return null;
}
if (!collection) { if (!collection) {
return ( return (
<Layout user={user}> <Layout user={user}>
@ -1027,6 +1034,11 @@ export default function CollectionView() {
onInviteUser={(email) => console.log('Invited:', email)} onInviteUser={(email) => console.log('Invited:', email)}
/> />
</div> </div>
{/* Show login CTA for non-authenticated users */}
{!user && collection?.isPublic && (
<LoginCTA message="Sign up to create your own collections and collaborate with others!" />
)}
</Layout> </Layout>
); );
} }

View file

@ -14,31 +14,15 @@ export default function CommunityCollections() {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState('name'); const [sortBy, setSortBy] = useState('name');
// Redirect to login if not authenticated // Fetch collections on mount, regardless of auth status
useEffect(() => { useEffect(() => {
if (!authLoading && !user) { fetchPublicCollections();
router.push('/login'); }, []);
}
}, [authLoading, user, router]);
useEffect(() => {
if (user) {
fetchPublicCollections();
}
}, [user]);
const fetchPublicCollections = async () => { const fetchPublicCollections = async () => {
try { try {
const token = localStorage.getItem('auth_token'); // Use public API endpoint that doesn't require authentication
const headers = { const response = await fetch('/api/public/collections?limit=50');
'Content-Type': 'application/json',
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch('/api/community/collections', { headers });
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
@ -48,7 +32,7 @@ export default function CommunityCollections() {
data.map(async (collection) => { data.map(async (collection) => {
try { try {
const identifier = collection.slug || collection.id; const identifier = collection.slug || collection.id;
const thumbnailResponse = await fetch(`/api/collections/${identifier}/thumbnails`, { headers }); const thumbnailResponse = await fetch(`/api/collections/${identifier}/thumbnails`);
if (thumbnailResponse.ok) { if (thumbnailResponse.ok) {
const thumbnailData = await thumbnailResponse.json(); const thumbnailData = await thumbnailResponse.json();
return { ...collection, thumbnails: thumbnailData.thumbnails }; return { ...collection, thumbnails: thumbnailData.thumbnails };

View file

@ -1,241 +1,231 @@
import { useState, useContext } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import PermissionIndicator from '../components/PermissionIndicator';
import { useAuth } from '../lib/use-auth';
import Link from 'next/link';
export default function Dashboard() { export default function Dashboard() {
const user = { const router = useRouter();
email: 'me@randallstillwell.com', const { user, loading: authLoading } = useAuth();
role: 'user'
const [collections, setCollections] = useState([]);
const [loading, setLoading] = useState(true);
// Redirect to login if not authenticated
useEffect(() => {
if (!authLoading && !user) {
router.push('/login');
}
}, [authLoading, user, router]);
useEffect(() => {
if (user) {
fetchCollections();
}
}, [user]);
const fetchCollections = async () => {
try {
const token = localStorage.getItem('auth_token');
const headers = {
'Content-Type': 'application/json',
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch('/api/collections', { headers });
if (response.ok) {
const data = await response.json();
// Fetch thumbnails for each collection
const collectionsWithThumbnails = await Promise.all(
data.map(async (collection) => {
try {
const identifier = collection.slug || collection.id;
const thumbnailResponse = await fetch(`/api/collections/${identifier}/thumbnails`, { headers });
if (thumbnailResponse.ok) {
const thumbnailData = await thumbnailResponse.json();
return { ...collection, thumbnails: thumbnailData.thumbnails };
}
return { ...collection, thumbnails: [] };
} catch (error) {
console.error(`Error fetching thumbnails for collection ${collection.id}:`, error);
return { ...collection, thumbnails: [] };
}
})
);
setCollections(collectionsWithThumbnails);
} else {
console.error('Failed to fetch collections');
setCollections([]);
}
} catch (error) {
console.error('Error fetching collections:', error);
setCollections([]);
} finally {
setLoading(false);
}
}; };
const [searchQuery, setSearchQuery] = useState('');
return ( return (
<Layout user={user} showSearch={true}> <Layout user={user}>
{/* Dashboard Content */} {/* Header */}
<div className="p-6"> <div className="p-4 sm:p-6" style={{ backgroundColor: 'var(--bg-secondary)', borderBottom: '1px solid var(--border)' }}>
{/* Dashboard Header */} <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center justify-between mb-8">
<div> <div>
<h1 className="text-3xl font-bold mb-2" style={{ color: 'var(--text-primary-light)' }}> <h1 className="text-2xl sm:text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
Dashboard My Collections
</h1> </h1>
<p className="text-lg" style={{ color: 'var(--text-secondary-light)' }}> <p className="text-base sm:text-lg" style={{ color: 'var(--text-secondary)' }}>
Track your collection, build decks, and connect with the TCG community. Manage your collection of trading cards and decks
</p> </p>
</div> </div>
<div className="flex space-x-2 sm:space-x-4">
{/* Action Buttons */} <Link href="/collections">
<div className="flex space-x-4"> <button className="px-4 py-2 text-sm font-medium rounded-xl transition-all duration-200 hover:opacity-90" style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}>
<button className="action-btn-primary flex items-center space-x-2"> <div className="flex items-center space-x-2">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span>+ Add Cards</span>
</button>
<button className="action-btn-secondary flex items-center space-x-2">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>Scan Cards</span>
</button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div className="card">
<div className="flex items-center">
<div className="p-3 rounded-2xl mr-4" style={{ backgroundColor: 'var(--accent-blue-light)' }}>
<svg className="h-8 w-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<div>
<p className="text-2xl font-bold" style={{ color: 'var(--text-primary-light)' }}>247</p>
<p className="text-sm" style={{ color: 'var(--text-secondary-light)' }}>Total Cards</p>
<p className="text-xs text-green-500">+12 from last month</p>
</div>
</div>
</div>
<div className="card">
<div className="flex items-center">
<div className="p-3 rounded-2xl mr-4" style={{ backgroundColor: 'var(--accent-purple-light)' }}>
<svg className="h-8 w-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
</svg>
</div>
<div>
<p className="text-2xl font-bold" style={{ color: 'var(--text-primary-light)' }}>$2,847.5</p>
<p className="text-sm" style={{ color: 'var(--text-secondary-light)' }}>Collection Value</p>
<p className="text-xs text-green-500">+$245 from last month</p>
</div>
</div>
</div>
<div className="card">
<div className="flex items-center">
<div className="p-3 rounded-2xl mr-4" style={{ backgroundColor: 'var(--accent-pink-light)' }}>
<svg className="h-8 w-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<div>
<p className="text-2xl font-bold" style={{ color: 'var(--text-primary-light)' }}>12</p>
<p className="text-sm" style={{ color: 'var(--text-secondary-light)' }}>Total Decks</p>
<p className="text-xs text-green-500">+2 from last month</p>
</div>
</div>
</div>
<div className="card">
<div className="flex items-center">
<div className="p-3 rounded-2xl mr-4" style={{ backgroundColor: 'var(--accent-blue-light)' }}>
<svg className="h-8 w-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
</svg>
</div>
<div>
<p className="text-2xl font-bold" style={{ color: 'var(--text-primary-light)' }}>8</p>
<p className="text-sm" style={{ color: 'var(--text-secondary-light)' }}>Followers</p>
<p className="text-xs text-green-500">+3 from last month</p>
</div>
</div>
</div>
</div>
{/* Dashboard Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Recent Cards */}
<div className="card">
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-semibold" style={{ color: 'var(--text-primary-light)' }}>Recent Cards</h3>
<button className="text-sm gradient-text-blue hover:underline">View All</button>
</div>
<div className="space-y-4">
<div className="flex items-center p-4 rounded-2xl" style={{ backgroundColor: 'var(--bg-tertiary-light)' }}>
<div className="w-10 h-10 rounded-lg mr-4 flex items-center justify-center" style={{ backgroundColor: 'var(--accent-purple-light)' }}>
<span className="text-white font-bold text-sm">MTG</span>
</div>
<div className="flex-1">
<h4 className="font-semibold" style={{ color: 'var(--text-primary-light)' }}>Black Lotus</h4>
<p className="text-sm" style={{ color: 'var(--text-secondary-light)' }}>Mythic MTG</p>
</div>
<div className="text-right">
<p className="font-semibold" style={{ color: 'var(--text-primary-light)' }}>$25,000 Value</p>
</div>
</div>
<div className="flex items-center p-4 rounded-2xl" style={{ backgroundColor: 'var(--bg-tertiary-light)' }}>
<div className="w-10 h-10 rounded-lg mr-4 flex items-center justify-center" style={{ backgroundColor: 'var(--accent-blue-light)' }}>
<span className="text-white font-bold text-sm">PKM</span>
</div>
<div className="flex-1">
<h4 className="font-semibold" style={{ color: 'var(--text-primary-light)' }}>Charizard</h4>
<p className="text-sm" style={{ color: 'var(--text-secondary-light)' }}>Holo Pokemon</p>
</div>
<div className="text-right">
<p className="font-semibold" style={{ color: 'var(--text-primary-light)' }}>$350 Value</p>
</div>
</div>
<div className="flex items-center p-4 rounded-2xl" style={{ backgroundColor: 'var(--bg-tertiary-light)' }}>
<div className="w-10 h-10 rounded-lg mr-4 flex items-center justify-center" style={{ backgroundColor: 'var(--accent-pink-light)' }}>
<span className="text-white font-bold text-sm">LOR</span>
</div>
<div className="flex-1">
<h4 className="font-semibold" style={{ color: 'var(--text-primary-light)' }}>Mickey Mouse</h4>
<p className="text-sm" style={{ color: 'var(--text-secondary-light)' }}>Legendary Lorcana</p>
</div>
<div className="text-right">
<p className="font-semibold" style={{ color: 'var(--text-primary-light)' }}>$45 Value</p>
</div>
</div>
</div>
</div>
{/* Collection Goals */}
<div className="card">
<h3 className="text-xl font-semibold mb-6" style={{ color: 'var(--text-primary-light)' }}>Collection Goals</h3>
<div className="space-y-4">
<div>
<div className="flex justify-between mb-2">
<span className="text-sm font-medium" style={{ color: 'var(--text-primary-light)' }}>Complete Modern Masters Set</span>
<span className="text-sm" style={{ color: 'var(--text-secondary-light)' }}>75/100</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div className="h-2 rounded-full" style={{ backgroundColor: 'var(--accent-purple-light)', width: '75%' }}></div>
</div>
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary-light)' }}>75% complete</p>
</div>
<div>
<div className="flex justify-between mb-2">
<span className="text-sm font-medium" style={{ color: 'var(--text-primary-light)' }}>Build 5 Competitive Decks</span>
<span className="text-sm" style={{ color: 'var(--text-secondary-light)' }}>3/5</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div className="h-2 rounded-full" style={{ backgroundColor: 'var(--accent-purple-light)', width: '60%' }}></div>
</div>
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary-light)' }}>60% complete</p>
</div>
<div>
<div className="flex justify-between mb-2">
<span className="text-sm font-medium" style={{ color: 'var(--text-primary-light)' }}>Reach $5000 Collection Value</span>
<span className="text-sm" style={{ color: 'var(--text-secondary-light)' }}>2847.5/5000</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div className="h-2 rounded-full" style={{ backgroundColor: 'var(--accent-purple-light)', width: '57%' }}></div>
</div>
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary-light)' }}>57% complete</p>
</div>
</div>
</div>
{/* Recent Decks */}
<div className="card">
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-semibold" style={{ color: 'var(--text-primary-light)' }}>Recent Decks</h3>
<button className="text-sm gradient-text-blue hover:underline">View All</button>
</div>
<div className="space-y-4">
<div className="flex items-center p-4 rounded-2xl" style={{ backgroundColor: 'var(--bg-tertiary-light)' }}>
<div className="w-10 h-10 rounded-lg mr-4 flex items-center justify-center" style={{ backgroundColor: 'var(--accent-purple-light)' }}>
<span className="text-white font-bold text-sm">MTG</span>
</div>
<div className="flex-1">
<h4 className="font-semibold" style={{ color: 'var(--text-primary-light)' }}>Blue Control</h4>
<p className="text-sm" style={{ color: 'var(--text-secondary-light)' }}>60 cards</p>
</div>
</div>
<div className="flex items-center p-4 rounded-2xl" style={{ backgroundColor: 'var(--bg-tertiary-light)' }}>
<div className="w-10 h-10 rounded-lg mr-4 flex items-center justify-center" style={{ backgroundColor: 'var(--accent-blue-light)' }}>
<span className="text-white font-bold text-sm">PKM</span>
</div>
<div className="flex-1">
<h4 className="font-semibold" style={{ color: 'var(--text-primary-light)' }}>Fire Red</h4>
<p className="text-sm" style={{ color: 'var(--text-secondary-light)' }}>60 cards</p>
</div>
</div>
</div>
</div>
{/* Community */}
<div className="card">
<h3 className="text-xl font-semibold mb-6" style={{ color: 'var(--text-primary-light)' }}>Community</h3>
<div className="space-y-4">
<div className="flex items-center p-4 rounded-2xl" style={{ backgroundColor: 'var(--bg-tertiary-light)' }}>
<div className="w-10 h-10 rounded-full mr-4 flex items-center justify-center bg-green-500">
<svg className="h-5 w-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg> </svg>
<span>Create Collection</span>
</div> </div>
<div className="flex-1"> </button>
<h4 className="font-semibold" style={{ color: 'var(--text-primary-light)' }}>New follower</h4> </Link>
<p className="text-sm" style={{ color: 'var(--text-secondary-light)' }}>@cardcollector joined</p> </div>
</div>
</div>
{/* Content */}
<div className="p-4 sm:p-6">
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-12 w-12 border-b-2" style={{ borderColor: 'var(--accent-ember)' }}></div>
</div>
) : (
<div className="max-w-7xl mx-auto">
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="rounded-2xl p-6" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="flex items-center">
<div className="p-3 rounded-2xl mr-4" style={{ backgroundColor: 'var(--accent-ember)' }}>
<svg className="h-8 w-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<div>
<p className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{collections.length}</p>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>Collections</p>
</div>
</div>
</div>
<div className="rounded-2xl p-6" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="flex items-center">
<div className="p-3 rounded-2xl mr-4" style={{ backgroundColor: 'var(--accent-gold)' }}>
<svg className="h-8 w-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
</div>
<div>
<p className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>
{collections.reduce((total, col) => total + (col.cardCount || 0), 0)}
</p>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>Total Cards</p>
</div>
</div>
</div>
<div className="rounded-2xl p-6" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="flex items-center">
<div className="p-3 rounded-2xl mr-4" style={{ backgroundColor: 'var(--accent-flame)' }}>
<svg className="h-8 w-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
</svg>
</div>
<div>
<p className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>
${collections.reduce((total, col) => total + (col.value || 0), 0).toLocaleString()}
</p>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>Total Value</p>
</div>
</div> </div>
</div> </div>
</div> </div>
{/* Collections Grid */}
<div className="mb-6">
<h2 className="text-xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>Recent Collections</h2>
{collections.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl flex items-center justify-center" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: 'var(--text-secondary)' }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>No Collections Yet</h3>
<p className="mb-4" style={{ color: 'var(--text-secondary)' }}>
Create your first collection to start organizing your cards
</p>
<Link href="/collections">
<button className="px-6 py-3 font-medium rounded-xl transition-all duration-200 hover:opacity-90" style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}>
Create Your First Collection
</button>
</Link>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{collections.slice(0, 6).map((collection) => (
<Link key={collection.id} href={`/collection/${collection.slug || collection.id}`}>
<div className="rounded-2xl p-6 transition-all duration-200 hover:scale-105 cursor-pointer" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="flex items-center mb-4">
<div className="w-12 h-12 rounded-xl mr-4 flex items-center justify-center gradient-bg-ember">
<span className="text-white font-bold">
{collection.name?.charAt(0)?.toUpperCase() || 'C'}
</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold truncate" style={{ color: 'var(--text-primary)' }}>
{collection.name}
</h3>
<p className="text-sm truncate" style={{ color: 'var(--text-secondary)' }}>
{collection.cardCount || 0} cards
</p>
</div>
</div>
{collection.description && (
<p className="text-sm mb-3 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
{collection.description}
</p>
)}
<div className="flex items-center justify-between">
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
${(collection.value || 0).toLocaleString()}
</span>
<PermissionIndicator isPublic={collection.isPublic} />
</div>
</div>
</Link>
))}
</div>
)}
</div>
{collections.length > 6 && (
<div className="text-center">
<Link href="/collections">
<button className="px-6 py-3 font-medium rounded-xl border-2 transition-all duration-200 hover:opacity-80" style={{ color: 'var(--text-primary)', borderColor: 'var(--accent-ember)' }}>
View All Collections
</button>
</Link>
</div>
)}
</div> </div>
</div> )}
</div> </div>
</Layout> </Layout>
); );

823
pages/deck-builder.js Normal file
View file

@ -0,0 +1,823 @@
import { useState, useEffect, useRef } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import Layout from '../components/Layout';
import { ManaCost, ColorIdentity, ColorFilterSymbol } from '../components/ManaSymbols';
import ManaSymbolSettings from '../components/ManaSymbolSettings';
import { useAuth } from '../lib/auth-context';
import { getColorIdentity, getColorSymbol } from '../lib/mana-symbols';
export default function DeckBuilder() {
const { user } = useAuth();
const router = useRouter();
const { deck: deckId } = router.query;
const [deck, setDeck] = useState(null);
const [deckCards, setDeckCards] = useState([]);
const [searchResults, setSearchResults] = useState([]);
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(true);
const [searchLoading, setSearchLoading] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [selectedCard, setSelectedCard] = useState(null);
const [showFilters, setShowFilters] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [viewMode, setViewMode] = useState('list'); // 'list' or 'thumbnail'
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
const [filters, setFilters] = useState({
colors: [],
types: [],
cmc: '',
rarity: ''
});
const searchTimeoutRef = useRef(null);
useEffect(() => {
if (user && deckId) {
fetchDeck();
}
}, [user, deckId]);
// Load initial cards when component mounts
useEffect(() => {
if (user) {
searchCards();
}
}, [user]);
useEffect(() => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
searchTimeoutRef.current = setTimeout(() => {
searchCards();
}, 300);
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, [searchQuery, filters]);
const fetchDeck = async () => {
try {
const token = localStorage.getItem('auth_token');
const response = await fetch(`/api/decks/${deckId}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
setDeck(data);
setDeckCards(data.cards || []);
} else {
console.error('Failed to fetch deck');
router.push('/decks');
}
} catch (error) {
console.error('Error fetching deck:', error);
router.push('/decks');
} finally {
setLoading(false);
}
};
const searchCards = async () => {
setSearchLoading(true);
try {
const token = localStorage.getItem('auth_token');
const params = new URLSearchParams({
game: 'MTG',
limit: '50'
});
// Only add search if there's a query
if (searchQuery.trim()) {
params.append('search', searchQuery);
}
if (filters.colors.length > 0) {
params.append('colors', filters.colors.join(','));
}
if (filters.types.length > 0) {
params.append('types', filters.types.join(','));
}
if (filters.cmc) {
params.append('cmc', filters.cmc);
}
if (filters.rarity) {
params.append('rarity', filters.rarity);
}
const response = await fetch(`/api/cards/search?${params}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
setSearchResults(data.cards || []);
}
} catch (error) {
console.error('Error searching cards:', error);
} finally {
setSearchLoading(false);
}
};
const addCardToDeck = async (card, quantity = 1) => {
// Commander format validation
if (deck.format === 'Commander') {
const existingCard = deckCards.find(dc => dc.card_id === card.id);
const currentQuantity = existingCard ? existingCard.quantity : 0;
// Check singleton rule (except basic lands)
if (!isBasicLand(card) && currentQuantity + quantity > 1) {
alert('Commander format allows only 1 copy of each non-basic land card.');
return;
}
// Check total deck size
const totalCards = deckCards.reduce((sum, dc) => sum + dc.quantity, 0);
if (totalCards + quantity > 100) {
alert('Commander decks can have a maximum of 100 cards.');
return;
}
}
try {
const token = localStorage.getItem('auth_token');
const response = await fetch(`/api/decks/${deckId}/cards`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
cardId: card.id,
quantity
})
});
if (response.ok) {
// Refresh deck cards
fetchDeck();
} else {
console.error('Failed to add card to deck');
}
} catch (error) {
console.error('Error adding card to deck:', error);
}
};
const removeCardFromDeck = async (cardId, quantity = 1) => {
try {
const token = localStorage.getItem('auth_token');
const response = await fetch(`/api/decks/${deckId}/cards`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
cardId,
quantity
})
});
if (response.ok) {
// Refresh deck cards
fetchDeck();
} else {
console.error('Failed to remove card from deck');
}
} catch (error) {
console.error('Error removing card from deck:', error);
}
};
const isBasicLand = (card) => {
const basicLands = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest'];
return basicLands.includes(card.name);
};
const getDeckStats = () => {
const totalCards = deckCards.reduce((sum, card) => sum + card.quantity, 0);
const avgCmc = deckCards.length > 0
? (deckCards.reduce((sum, card) => sum + (card.cmc || 0) * card.quantity, 0) / totalCards).toFixed(1)
: 0;
const colorCounts = deckCards.reduce((counts, card) => {
if (card.colors) {
try {
const colors = JSON.parse(card.colors);
colors.forEach(color => {
counts[color] = (counts[color] || 0) + card.quantity;
});
} catch (e) {
// Handle non-JSON color format
}
}
return counts;
}, {});
const typeCounts = deckCards.reduce((counts, card) => {
if (card.card_type) {
const types = card.card_type.split(' — ')[0].split(' ');
types.forEach(type => {
counts[type] = (counts[type] || 0) + card.quantity;
});
}
return counts;
}, {});
return { totalCards, avgCmc, colorCounts, typeCounts };
};
const toggleColorFilter = (color) => {
setFilters(prev => ({
...prev,
colors: prev.colors.includes(color)
? prev.colors.filter(c => c !== color)
: [...prev.colors, color]
}));
};
const clearFilters = () => {
setFilters({
colors: [],
types: [],
cmc: '',
rarity: ''
});
};
if (!user) {
return (
<Layout>
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<h1 className="text-2xl font-bold mb-4">Please log in to use the deck builder</h1>
<Link href="/login" className="text-accent-ember hover:underline">
Go to Login
</Link>
</div>
</div>
</Layout>
);
}
if (loading) {
return (
<Layout>
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-accent-ember"></div>
</div>
</Layout>
);
}
if (!deck) {
return (
<Layout>
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<h1 className="text-2xl font-bold mb-4">Deck not found</h1>
<Link href="/decks" className="text-accent-ember hover:underline">
Back to My Decks
</Link>
</div>
</div>
</Layout>
);
}
const stats = getDeckStats();
return (
<Layout>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="flex justify-between items-center mb-6">
<div>
<div className="flex items-center space-x-3">
<Link href="/decks" className="text-accent-ember hover:underline">
Back to Decks
</Link>
</div>
<h1 className="text-3xl font-bold text-text-primary mt-2">{deck.name}</h1>
<p className="text-text-secondary">
{deck.format} {stats.totalCards}/100 cards
</p>
</div>
<div className="flex space-x-3">
<button className="bg-bg-secondary text-text-primary px-4 py-2 rounded-lg hover:bg-bg-tertiary transition-colors">
Save Deck
</button>
<Link
href={`/deck/${deck.id}`}
className="bg-accent-ember text-white px-4 py-2 rounded-lg hover:bg-accent-ember-dark transition-colors"
>
View Deck
</Link>
</div>
</div>
<div className="flex gap-6 h-[calc(100vh-12rem)]">
{/* Main Deck View - Left Side */}
<div className={`flex-1 transition-all duration-300`}>
<div className="bg-bg-secondary rounded-lg p-6 h-full flex flex-col">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-text-primary">
Deck Cards ({stats.totalCards})
</h2>
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="bg-accent-ember text-white px-4 py-2 rounded-lg hover:bg-accent-ember-dark transition-colors flex items-center space-x-2"
>
<span>{sidebarOpen ? 'Hide' : 'Show'} Browser</span>
<span>{sidebarOpen ? '→' : '←'}</span>
</button>
</div>
{/* Deck Stats Bar */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6 p-4 bg-bg-primary rounded-lg">
<div className="text-center">
<div className="text-lg font-bold text-text-primary">{stats.totalCards}/100</div>
<div className="text-text-secondary text-sm">Cards</div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-text-primary">{stats.avgCmc}</div>
<div className="text-text-secondary text-sm">Avg CMC</div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-text-primary">
{Object.keys(stats.colorCounts).length}
</div>
<div className="text-text-secondary text-sm">Colors</div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-text-primary">
{Object.keys(stats.typeCounts).length}
</div>
<div className="text-text-secondary text-sm">Types</div>
</div>
</div>
{/* Deck Cards List */}
<div className="flex-1 overflow-y-auto space-y-2">
{deckCards.length === 0 ? (
<div className="text-center py-12">
<div className="text-6xl mb-4">🃏</div>
<h3 className="text-xl font-semibold text-text-primary mb-2">Empty Deck</h3>
<p className="text-text-secondary mb-4">Start building your deck by searching for cards</p>
{!sidebarOpen && (
<button
onClick={() => setSidebarOpen(true)}
className="bg-accent-ember text-white px-4 py-2 rounded-lg hover:bg-accent-ember-dark transition-colors"
>
Open Card Browser
</button>
)}
</div>
) : (
deckCards
.sort((a, b) => a.name.localeCompare(b.name))
.map((card) => (
<div key={`${card.card_id}-${card.id}`} className="flex items-center justify-between p-4 bg-bg-primary rounded-lg hover:bg-bg-tertiary transition-colors">
<div className="flex items-center space-x-4">
{card.image_url && (
<img
src={card.image_url}
alt={card.name}
className="w-14 h-20 object-cover rounded shadow-md"
/>
)}
<div>
<h4 className="font-semibold text-text-primary text-lg">{card.name}</h4>
<p className="text-text-secondary text-sm">{card.set_name}</p>
<div className="flex items-center space-x-3 mt-1">
{card.mana_cost && (
<div className="bg-bg-secondary px-2 py-1 rounded">
<ManaCost cost={card.mana_cost} size="sm" useSVG={manaSymbolSettings.useSVG} />
</div>
)}
{card.rarity && (
<span className={`text-xs px-2 py-1 rounded capitalize ${
card.rarity === 'mythic' ? 'bg-orange-100 text-orange-800' :
card.rarity === 'rare' ? 'bg-yellow-100 text-yellow-800' :
card.rarity === 'uncommon' ? 'bg-gray-100 text-gray-800' :
'bg-green-100 text-green-800'
}`}>
{card.rarity}
</span>
)}
</div>
</div>
</div>
<div className="flex items-center space-x-3">
<span className="text-text-primary font-bold text-lg">{card.quantity}x</span>
<div className="flex items-center space-x-1">
<button
onClick={() => removeCardFromDeck(card.card_id, 1)}
className="w-8 h-8 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors flex items-center justify-center font-bold"
>
</button>
<button
onClick={() => addCardToDeck(card, 1)}
className="w-8 h-8 bg-accent-ember text-white rounded-full hover:bg-accent-ember-dark transition-colors flex items-center justify-center font-bold"
>
+
</button>
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
{/* Full Height Right Sidebar - Card Browser */}
<div className={`transition-all duration-300 ${sidebarOpen ? 'w-96' : 'w-0 overflow-hidden'}`}>
<div className="bg-bg-tertiary rounded-lg h-full flex flex-col relative border border-border">
{!selectedCard ? (
<>
{/* Header */}
<div className="flex justify-between items-center p-4 border-b border-border bg-bg-secondary rounded-t-lg">
<h3 className="text-lg font-semibold text-text-primary">Card Browser</h3>
<div className="flex items-center space-x-2">
{/* View Mode Toggle */}
<div className="flex bg-bg-primary rounded-lg p-1">
<button
onClick={() => setViewMode('list')}
className={`px-2 py-1 rounded text-xs transition-colors ${
viewMode === 'list'
? 'bg-accent-ember text-white'
: 'text-text-secondary hover:text-text-primary'
}`}
title="List View"
>
</button>
<button
onClick={() => setViewMode('thumbnail')}
className={`px-2 py-1 rounded text-xs transition-colors ${
viewMode === 'thumbnail'
? 'bg-accent-ember text-white'
: 'text-text-secondary hover:text-text-primary'
}`}
title="Thumbnail View"
>
</button>
</div>
<button
onClick={() => setShowSettings(!showSettings)}
className={`text-text-secondary hover:text-text-primary transition-colors p-1 ${
showSettings ? 'text-accent-ember' : ''
}`}
title="Settings"
>
</button>
<button
onClick={() => setSidebarOpen(false)}
className="text-text-secondary hover:text-text-primary transition-colors p-1"
title="Collapse"
>
</button>
</div>
</div>
{/* Search */}
<div className="p-4 border-b border-border">
<div className="flex space-x-2">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search for cards..."
className="flex-1 px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-primary text-text-primary text-sm"
/>
<button
onClick={() => setShowFilters(!showFilters)}
className={`p-2 rounded-lg transition-colors ${
showFilters || filters.colors.length > 0 || filters.cmc || filters.rarity
? 'bg-accent-ember text-white'
: 'bg-bg-primary text-text-secondary hover:bg-bg-tertiary'
}`}
title="Filters"
>
🔍
</button>
</div>
</div>
{/* Settings Panel */}
{showSettings && (
<div className="p-4 border-b border-border bg-bg-primary">
<ManaSymbolSettings onSettingsChange={setManaSymbolSettings} />
</div>
)}
{/* Quick Filters */}
{showFilters && (
<div className="p-4 border-b border-border bg-bg-primary space-y-3">
{/* Color Filters */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-text-secondary text-xs font-medium">Colors</label>
{(filters.colors.length > 0 || filters.cmc || filters.rarity) && (
<button
onClick={clearFilters}
className="text-xs text-accent-ember hover:underline"
>
Clear All
</button>
)}
</div>
<div className="flex space-x-1">
{['W', 'U', 'B', 'R', 'G'].map(color => (
<ColorFilterSymbol
key={color}
color={color}
isActive={filters.colors.includes(color)}
onClick={toggleColorFilter}
size="sm"
useSVG={manaSymbolSettings.useSVG}
/>
))}
</div>
</div>
{/* CMC and Rarity */}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-text-secondary text-xs font-medium mb-1">CMC</label>
<select
value={filters.cmc}
onChange={(e) => setFilters({...filters, cmc: e.target.value})}
className="w-full px-2 py-1 border border-border rounded text-xs focus:outline-none focus:ring-1 focus:ring-accent-ember bg-bg-secondary text-text-primary"
>
<option value="">Any</option>
<option value="0">0</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6+">6+</option>
</select>
</div>
<div>
<label className="block text-text-secondary text-xs font-medium mb-1">Rarity</label>
<select
value={filters.rarity}
onChange={(e) => setFilters({...filters, rarity: e.target.value})}
className="w-full px-2 py-1 border border-border rounded text-xs focus:outline-none focus:ring-1 focus:ring-accent-ember bg-bg-secondary text-text-primary"
>
<option value="">Any</option>
<option value="common">Common</option>
<option value="uncommon">Uncommon</option>
<option value="rare">Rare</option>
<option value="mythic">Mythic</option>
</select>
</div>
</div>
</div>
)}
{/* Card List */}
<div className="flex-1 overflow-y-auto p-4">
{viewMode === 'list' ? (
/* List View */
<div className="space-y-1">
{searchLoading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-accent-ember mx-auto"></div>
<p className="text-text-secondary mt-2 text-xs">Searching...</p>
</div>
) : searchResults.length === 0 ? (
<div className="text-center py-8">
<div className="text-2xl mb-2">🔍</div>
<p className="text-text-secondary text-xs">
{searchQuery ? 'No cards found' : 'No cards available'}
</p>
</div>
) : (
searchResults.map((card) => (
<div
key={card.id}
className="flex items-center p-2 bg-bg-secondary rounded hover:bg-bg-primary transition-colors cursor-pointer"
onClick={() => setSelectedCard(card)}
>
<div className="flex items-center space-x-2 flex-1 min-w-0">
{card.image_url && (
<img
src={card.image_url}
alt={card.name}
className="w-8 h-11 object-cover rounded flex-shrink-0"
/>
)}
<div className="min-w-0 flex-1">
<h4 className="font-medium text-text-primary text-xs truncate">{card.name}</h4>
<p className="text-text-secondary text-xs truncate">{card.set_name}</p>
<div className="flex items-center space-x-1">
{card.mana_cost && (
<ManaCost cost={card.mana_cost} size="xs" useSVG={manaSymbolSettings.useSVG} />
)}
{card.rarity && (
<span className={`text-xs px-1 rounded capitalize ${
card.rarity === 'mythic' ? 'bg-orange-100 text-orange-800' :
card.rarity === 'rare' ? 'bg-yellow-100 text-yellow-800' :
card.rarity === 'uncommon' ? 'bg-gray-100 text-gray-800' :
'bg-green-100 text-green-800'
}`}>
{card.rarity[0].toUpperCase()}
</span>
)}
</div>
</div>
</div>
</div>
))
)}
</div>
) : (
/* Thumbnail View */
<div>
{searchLoading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-accent-ember mx-auto"></div>
<p className="text-text-secondary mt-2 text-xs">Searching...</p>
</div>
) : searchResults.length === 0 ? (
<div className="text-center py-8">
<div className="text-2xl mb-2">🔍</div>
<p className="text-text-secondary text-xs">
{searchQuery ? 'No cards found' : 'No cards available'}
</p>
</div>
) : (
<div className="grid grid-cols-3 gap-2">
{searchResults.map((card) => (
<div
key={card.id}
className="relative group cursor-pointer"
onClick={() => setSelectedCard(card)}
>
{card.image_url ? (
<div className="relative">
<img
src={card.image_url}
alt={card.name}
className="w-full aspect-[2.5/3.5] object-cover rounded-lg shadow-sm group-hover:shadow-md transition-shadow"
/>
{/* Hover overlay with card name */}
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-60 transition-all duration-200 rounded-lg flex items-end">
<div className="p-2 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<p className="text-xs font-medium truncate">{card.name}</p>
<p className="text-xs opacity-75 truncate">{card.set_name}</p>
</div>
</div>
{/* Rarity indicator */}
{card.rarity && (
<div className={`absolute top-1 right-1 w-2 h-2 rounded-full ${
card.rarity === 'mythic' ? 'bg-orange-500' :
card.rarity === 'rare' ? 'bg-yellow-500' :
card.rarity === 'uncommon' ? 'bg-gray-400' :
'bg-green-500'
}`}></div>
)}
</div>
) : (
<div className="w-full aspect-[2.5/3.5] bg-bg-secondary rounded-lg flex items-center justify-center group-hover:bg-bg-primary transition-colors">
<div className="text-center p-2">
<p className="text-xs font-medium text-text-primary truncate">{card.name}</p>
<p className="text-xs text-text-secondary truncate">{card.set_name}</p>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
</>
) : (
/* Card Detail View */
<>
{/* Detail Header */}
<div className="flex items-center p-4 border-b border-border bg-bg-secondary rounded-t-lg">
<button
onClick={() => setSelectedCard(null)}
className="text-text-secondary hover:text-text-primary transition-colors mr-3"
>
Back
</button>
<h3 className="text-lg font-semibold text-text-primary truncate">{selectedCard.name}</h3>
</div>
{/* Card Detail Content */}
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
{/* Card Image */}
{selectedCard.image_url && (
<div className="text-center">
<img
src={selectedCard.image_url}
alt={selectedCard.name}
className="w-full max-w-64 mx-auto rounded-lg shadow-lg"
/>
</div>
)}
{/* Card Info */}
<div className="space-y-3">
<div>
<h4 className="font-semibold text-text-primary text-lg">{selectedCard.name}</h4>
<p className="text-text-secondary text-sm">{selectedCard.set_name}</p>
</div>
{selectedCard.mana_cost && (
<div>
<label className="block text-text-secondary text-xs font-medium mb-1">Mana Cost</label>
<div className="bg-bg-primary px-3 py-2 rounded">
<ManaCost cost={selectedCard.mana_cost} size="md" useSVG={manaSymbolSettings.useSVG} />
</div>
</div>
)}
{selectedCard.card_type && (
<div>
<label className="block text-text-secondary text-xs font-medium mb-1">Type</label>
<div className="bg-bg-primary px-3 py-2 rounded text-sm">{selectedCard.card_type}</div>
</div>
)}
{selectedCard.oracle_text && (
<div>
<label className="block text-text-secondary text-xs font-medium mb-1">Oracle Text</label>
<div className="bg-bg-primary px-3 py-2 rounded text-sm whitespace-pre-wrap">{selectedCard.oracle_text}</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
{selectedCard.rarity && (
<div>
<label className="block text-text-secondary text-xs font-medium mb-1">Rarity</label>
<div className={`px-3 py-2 rounded text-sm capitalize ${
selectedCard.rarity === 'mythic' ? 'bg-orange-100 text-orange-800' :
selectedCard.rarity === 'rare' ? 'bg-yellow-100 text-yellow-800' :
selectedCard.rarity === 'uncommon' ? 'bg-gray-100 text-gray-800' :
'bg-green-100 text-green-800'
}`}>
{selectedCard.rarity}
</div>
</div>
)}
{selectedCard.cmc !== undefined && (
<div>
<label className="block text-text-secondary text-xs font-medium mb-1">CMC</label>
<div className="bg-bg-primary px-3 py-2 rounded text-sm">{selectedCard.cmc}</div>
</div>
)}
</div>
</div>
</div>
</div>
</>
)}
{/* Floating Action Button */}
{selectedCard && (
<div className="absolute bottom-4 left-4 right-4">
<button
onClick={() => {
addCardToDeck(selectedCard);
setSelectedCard(null);
}}
className="w-full bg-accent-ember text-white py-3 rounded-lg hover:bg-accent-ember-dark transition-colors font-semibold shadow-lg"
>
Add to Deck
</button>
</div>
)}
</div>
</div>
</div>
</div>
</Layout>
);
}

341
pages/deck/[id].js Normal file
View file

@ -0,0 +1,341 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import Layout from '../../components/Layout';
import { ManaCost, ColorIdentity } from '../../components/ManaSymbols';
import { useAuth } from '../../lib/auth-context';
import { getColorIdentity } from '../../lib/mana-symbols';
export default function DeckDetail() {
const { user } = useAuth();
const router = useRouter();
const { id: deckId } = router.query;
const [deck, setDeck] = useState(null);
const [loading, setLoading] = useState(true);
const [groupBy, setGroupBy] = useState('type');
useEffect(() => {
if (deckId) {
fetchDeck();
}
}, [deckId]);
const fetchDeck = async () => {
try {
const token = localStorage.getItem('auth_token');
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
const response = await fetch(`/api/decks/${deckId}`, { headers });
if (response.ok) {
const data = await response.json();
setDeck(data);
} else {
console.error('Failed to fetch deck');
router.push('/decks');
}
} catch (error) {
console.error('Error fetching deck:', error);
router.push('/decks');
} finally {
setLoading(false);
}
};
const getDeckStats = () => {
if (!deck?.cards) return { totalCards: 0, avgCmc: 0, colorCounts: {}, typeCounts: {} };
const totalCards = deck.cards.reduce((sum, card) => sum + card.quantity, 0);
const avgCmc = deck.cards.length > 0
? (deck.cards.reduce((sum, card) => sum + (card.cmc || 0) * card.quantity, 0) / totalCards).toFixed(1)
: 0;
const colorCounts = deck.cards.reduce((counts, card) => {
if (card.colors) {
try {
const colors = JSON.parse(card.colors);
colors.forEach(color => {
counts[color] = (counts[color] || 0) + card.quantity;
});
} catch (e) {
// Handle non-JSON color format
}
}
return counts;
}, {});
const typeCounts = deck.cards.reduce((counts, card) => {
if (card.card_type) {
const types = card.card_type.split(' — ')[0].split(' ');
types.forEach(type => {
counts[type] = (counts[type] || 0) + card.quantity;
});
}
return counts;
}, {});
return { totalCards, avgCmc, colorCounts, typeCounts };
};
const getGroupedCards = () => {
if (!deck?.cards) return {};
return deck.cards.reduce((groups, card) => {
let key;
switch (groupBy) {
case 'type':
key = card.card_type ? card.card_type.split(' — ')[0] : 'Unknown';
break;
case 'cmc':
key = `${card.cmc || 0} Mana`;
break;
case 'color':
try {
const colors = card.colors ? JSON.parse(card.colors) : [];
key = colors.length === 0 ? 'Colorless' : colors.map(c => c).join('');
} catch (e) {
key = 'Colorless';
}
break;
case 'rarity':
key = card.rarity || 'Unknown';
break;
default:
key = 'All Cards';
}
if (!groups[key]) groups[key] = [];
groups[key].push(card);
return groups;
}, {});
};
const getFormatIcon = (format) => {
switch (format) {
case 'Commander':
return '⚔️';
case 'Standard':
return '🏆';
case 'Modern':
return '🔥';
case 'Legacy':
return '💎';
default:
return '🃏';
}
};
if (loading) {
return (
<Layout>
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-accent-ember"></div>
</div>
</Layout>
);
}
if (!deck) {
return (
<Layout>
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<h1 className="text-2xl font-bold mb-4">Deck not found</h1>
<Link href="/decks" className="text-accent-ember hover:underline">
Back to Decks
</Link>
</div>
</div>
</Layout>
);
}
const stats = getDeckStats();
const groupedCards = getGroupedCards();
const isOwner = user && deck.user_id === user.userId;
return (
<Layout>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="flex justify-between items-start mb-8">
<div>
<div className="flex items-center space-x-3 mb-2">
<Link href="/decks" className="text-accent-ember hover:underline">
Back to Decks
</Link>
</div>
<div className="flex items-center space-x-3 mb-2">
<span className="text-3xl">{getFormatIcon(deck.format)}</span>
<h1 className="text-3xl font-bold text-text-primary">{deck.name}</h1>
{deck.is_public && (
<span className="bg-green-100 text-green-800 px-2 py-1 rounded-full text-xs font-medium">
Public
</span>
)}
</div>
<p className="text-text-secondary mb-2">
by {deck.creator_username} {deck.format} {stats.totalCards} cards
</p>
{deck.description && (
<p className="text-text-secondary max-w-2xl">{deck.description}</p>
)}
</div>
{isOwner && (
<div className="flex space-x-3">
<Link
href={`/deck-builder?deck=${deck.id}`}
className="bg-accent-ember text-white px-4 py-2 rounded-lg hover:bg-accent-ember-dark transition-colors"
>
Edit Deck
</Link>
</div>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Stats Sidebar */}
<div className="lg:col-span-1">
<div className="bg-bg-secondary rounded-lg p-6 mb-6">
<h3 className="text-lg font-semibold text-text-primary mb-4">Statistics</h3>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-text-secondary">Total Cards:</span>
<span className="text-text-primary font-medium">{stats.totalCards}</span>
</div>
<div className="flex justify-between">
<span className="text-text-secondary">Avg. CMC:</span>
<span className="text-text-primary font-medium">{stats.avgCmc}</span>
</div>
<div className="flex justify-between">
<span className="text-text-secondary">Format:</span>
<span className="text-text-primary font-medium">{deck.format}</span>
</div>
</div>
{/* Color Distribution */}
{Object.keys(stats.colorCounts).length > 0 && (
<div className="mt-6">
<h4 className="text-text-secondary text-sm font-medium mb-3">Color Distribution</h4>
<div className="space-y-2">
{Object.entries(stats.colorCounts)
.sort(([,a], [,b]) => b - a)
.map(([color, count]) => (
<div key={color} className="flex justify-between items-center">
<div className="flex items-center space-x-2">
<span className="text-lg">{color}</span>
<span className="text-text-secondary text-sm">{color}</span>
</div>
<span className="text-text-primary font-medium">{count}</span>
</div>
))}
</div>
</div>
)}
{/* Type Distribution */}
{Object.keys(stats.typeCounts).length > 0 && (
<div className="mt-6">
<h4 className="text-text-secondary text-sm font-medium mb-3">Card Types</h4>
<div className="space-y-2">
{Object.entries(stats.typeCounts)
.sort(([,a], [,b]) => b - a)
.slice(0, 8)
.map(([type, count]) => (
<div key={type} className="flex justify-between">
<span className="text-text-secondary text-sm">{type}</span>
<span className="text-text-primary font-medium">{count}</span>
</div>
))}
</div>
</div>
)}
</div>
{/* Group By Controls */}
<div className="bg-bg-secondary rounded-lg p-6">
<h3 className="text-lg font-semibold text-text-primary mb-4">Group Cards By</h3>
<div className="space-y-2">
{[
{ value: 'type', label: 'Card Type' },
{ value: 'cmc', label: 'Mana Cost' },
{ value: 'color', label: 'Color' },
{ value: 'rarity', label: 'Rarity' }
].map(option => (
<button
key={option.value}
onClick={() => setGroupBy(option.value)}
className={`w-full text-left px-3 py-2 rounded-lg transition-colors ${
groupBy === option.value
? 'bg-accent-ember text-white'
: 'text-text-secondary hover:bg-bg-tertiary'
}`}
>
{option.label}
</button>
))}
</div>
</div>
</div>
{/* Card List */}
<div className="lg:col-span-3">
<div className="bg-bg-secondary rounded-lg p-6">
{deck.cards && deck.cards.length === 0 ? (
<div className="text-center py-12">
<div className="text-6xl mb-4">🃏</div>
<h3 className="text-xl font-semibold text-text-primary mb-2">Empty Deck</h3>
<p className="text-text-secondary">This deck doesn't have any cards yet</p>
</div>
) : (
<div className="space-y-6">
{Object.entries(groupedCards)
.sort(([a], [b]) => a.localeCompare(b))
.map(([group, cards]) => (
<div key={group}>
<h3 className="text-lg font-semibold text-text-primary mb-3 border-b border-border pb-2">
{group} ({cards.reduce((sum, card) => sum + card.quantity, 0)})
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{cards
.sort((a, b) => a.name.localeCompare(b.name))
.map((card) => (
<div key={`${card.card_id}-${card.id}`} className="flex items-center space-x-3 p-3 bg-bg-primary rounded-lg hover:bg-bg-tertiary transition-colors">
{card.image_url && (
<img
src={card.image_url}
alt={card.name}
className="w-12 h-16 object-cover rounded"
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<h4 className="font-medium text-text-primary truncate">{card.name}</h4>
<span className="text-text-primary font-medium ml-2">{card.quantity}x</span>
</div>
<p className="text-text-secondary text-sm">{card.set_name}</p>
<div className="flex items-center space-x-2 text-xs text-text-secondary">
{card.mana_cost && (
<ManaCost cost={card.mana_cost} size="xs" />
)}
{card.rarity && <span className="capitalize">{card.rarity}</span>}
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
</Layout>
);
}

461
pages/decks.js Normal file
View file

@ -0,0 +1,461 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import Layout from '../components/Layout';
import { useAuth } from '../lib/auth-context';
export default function Decks() {
const { user } = useAuth();
const router = useRouter();
const [decks, setDecks] = useState([]);
const [loading, setLoading] = useState(true);
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingDeck, setEditingDeck] = useState(null);
const [newDeck, setNewDeck] = useState({
name: '',
description: '',
format: 'Commander',
is_public: false
});
useEffect(() => {
if (user) {
fetchDecks();
}
}, [user]);
const fetchDecks = async () => {
try {
const token = localStorage.getItem('auth_token');
const response = await fetch('/api/decks', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
setDecks(data);
} else {
console.error('Failed to fetch decks');
}
} catch (error) {
console.error('Error fetching decks:', error);
} finally {
setLoading(false);
}
};
const handleCreateDeck = async (e) => {
e.preventDefault();
try {
const token = localStorage.getItem('auth_token');
const response = await fetch('/api/decks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(newDeck)
});
if (response.ok) {
const createdDeck = await response.json();
setDecks([createdDeck, ...decks]);
setShowCreateModal(false);
setNewDeck({ name: '', description: '', format: 'Commander', is_public: false });
// Navigate to deck builder for the new deck
router.push(`/deck-builder?deck=${createdDeck.id}`);
} else {
console.error('Failed to create deck');
}
} catch (error) {
console.error('Error creating deck:', error);
}
};
const handleEditDeck = async (e) => {
e.preventDefault();
try {
const token = localStorage.getItem('auth_token');
const response = await fetch(`/api/decks/${editingDeck.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(editingDeck)
});
if (response.ok) {
const updatedDeck = await response.json();
setDecks(decks.map(deck => deck.id === updatedDeck.id ? updatedDeck : deck));
setEditingDeck(null);
} else {
console.error('Failed to update deck');
}
} catch (error) {
console.error('Error updating deck:', error);
}
};
const handleDeleteDeck = async (deckId) => {
if (!confirm('Are you sure you want to delete this deck? This action cannot be undone.')) {
return;
}
try {
const token = localStorage.getItem('auth_token');
const response = await fetch(`/api/decks/${deckId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
setDecks(decks.filter(deck => deck.id !== deckId));
} else {
console.error('Failed to delete deck');
}
} catch (error) {
console.error('Error deleting deck:', error);
}
};
const getFormatIcon = (format) => {
switch (format) {
case 'Commander':
return '⚔️';
case 'Standard':
return '🏆';
case 'Modern':
return '🔥';
case 'Legacy':
return '💎';
default:
return '🃏';
}
};
const getFormatColor = (format) => {
switch (format) {
case 'Commander':
return 'bg-purple-100 text-purple-800';
case 'Standard':
return 'bg-blue-100 text-blue-800';
case 'Modern':
return 'bg-red-100 text-red-800';
case 'Legacy':
return 'bg-yellow-100 text-yellow-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
if (!user) {
return (
<Layout>
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<h1 className="text-2xl font-bold mb-4">Please log in to view your decks</h1>
<Link href="/login" className="text-accent-ember hover:underline">
Go to Login
</Link>
</div>
</div>
</Layout>
);
}
if (loading) {
return (
<Layout>
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-accent-ember"></div>
</div>
</Layout>
);
}
return (
<Layout>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold text-text-primary">My Decks</h1>
<p className="text-text-secondary mt-2">
Build and manage your MTG decks
</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="bg-accent-ember text-white px-6 py-3 rounded-lg hover:bg-accent-ember-dark transition-colors"
>
Create New Deck
</button>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div className="bg-bg-secondary rounded-lg p-6">
<div className="text-2xl font-bold text-text-primary">{decks.length}</div>
<div className="text-text-secondary">Total Decks</div>
</div>
<div className="bg-bg-secondary rounded-lg p-6">
<div className="text-2xl font-bold text-text-primary">
{decks.filter(d => d.format === 'Commander').length}
</div>
<div className="text-text-secondary">Commander</div>
</div>
<div className="bg-bg-secondary rounded-lg p-6">
<div className="text-2xl font-bold text-text-primary">
{decks.filter(d => d.is_public).length}
</div>
<div className="text-text-secondary">Public</div>
</div>
<div className="bg-bg-secondary rounded-lg p-6">
<div className="text-2xl font-bold text-text-primary">
{decks.reduce((sum, deck) => sum + (deck.card_count || 0), 0)}
</div>
<div className="text-text-secondary">Total Cards</div>
</div>
</div>
{/* Decks Grid */}
{decks.length === 0 ? (
<div className="text-center py-12">
<div className="text-6xl mb-4">🃏</div>
<h3 className="text-xl font-semibold text-text-primary mb-2">No decks yet</h3>
<p className="text-text-secondary mb-6">Create your first deck to get started</p>
<button
onClick={() => setShowCreateModal(true)}
className="bg-accent-ember text-white px-6 py-3 rounded-lg hover:bg-accent-ember-dark transition-colors"
>
Create Your First Deck
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{decks.map((deck) => (
<div key={deck.id} className="bg-bg-secondary rounded-lg p-6 hover:shadow-lg transition-shadow">
<div className="flex justify-between items-start mb-4">
<div className="flex items-center space-x-2">
<span className="text-2xl">{getFormatIcon(deck.format)}</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getFormatColor(deck.format)}`}>
{deck.format}
</span>
</div>
<div className="flex space-x-2">
<button
onClick={() => setEditingDeck({...deck})}
className="text-text-secondary hover:text-accent-ember transition-colors"
>
</button>
<button
onClick={() => handleDeleteDeck(deck.id)}
className="text-text-secondary hover:text-red-500 transition-colors"
>
🗑
</button>
</div>
</div>
<h3 className="text-xl font-bold text-text-primary mb-2">{deck.name}</h3>
{deck.description && (
<p className="text-text-secondary text-sm mb-4 line-clamp-2">{deck.description}</p>
)}
<div className="flex justify-between items-center text-sm text-text-secondary mb-4">
<span>{deck.card_count || 0} cards</span>
{deck.is_public && <span className="text-green-600">Public</span>}
</div>
<div className="flex space-x-2">
<Link
href={`/deck-builder?deck=${deck.id}`}
className="flex-1 bg-accent-ember text-white text-center py-2 rounded-lg hover:bg-accent-ember-dark transition-colors"
>
Edit Deck
</Link>
<Link
href={`/deck/${deck.id}`}
className="flex-1 bg-bg-tertiary text-text-primary text-center py-2 rounded-lg hover:bg-bg-primary transition-colors"
>
View
</Link>
</div>
</div>
))}
</div>
)}
{/* Create Deck Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-bg-primary rounded-lg p-6 w-full max-w-md">
<h2 className="text-xl font-bold text-text-primary mb-4">Create New Deck</h2>
<form onSubmit={handleCreateDeck}>
<div className="mb-4">
<label className="block text-text-secondary text-sm font-medium mb-2">
Deck Name *
</label>
<input
type="text"
required
value={newDeck.name}
onChange={(e) => setNewDeck({...newDeck, name: e.target.value})}
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
placeholder="Enter deck name"
/>
</div>
<div className="mb-4">
<label className="block text-text-secondary text-sm font-medium mb-2">
Format
</label>
<select
value={newDeck.format}
onChange={(e) => setNewDeck({...newDeck, format: e.target.value})}
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
>
<option value="Commander">Commander</option>
<option value="Standard">Standard</option>
<option value="Modern">Modern</option>
<option value="Legacy">Legacy</option>
</select>
</div>
<div className="mb-4">
<label className="block text-text-secondary text-sm font-medium mb-2">
Description
</label>
<textarea
value={newDeck.description}
onChange={(e) => setNewDeck({...newDeck, description: e.target.value})}
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
rows="3"
placeholder="Describe your deck strategy..."
/>
</div>
<div className="mb-6">
<label className="flex items-center">
<input
type="checkbox"
checked={newDeck.is_public}
onChange={(e) => setNewDeck({...newDeck, is_public: e.target.checked})}
className="mr-2"
/>
<span className="text-text-secondary text-sm">Make deck public</span>
</label>
</div>
<div className="flex space-x-3">
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="flex-1 px-4 py-2 border border-border rounded-lg text-text-secondary hover:bg-bg-secondary transition-colors"
>
Cancel
</button>
<button
type="submit"
className="flex-1 bg-accent-ember text-white px-4 py-2 rounded-lg hover:bg-accent-ember-dark transition-colors"
>
Create Deck
</button>
</div>
</form>
</div>
</div>
)}
{/* Edit Deck Modal */}
{editingDeck && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-bg-primary rounded-lg p-6 w-full max-w-md">
<h2 className="text-xl font-bold text-text-primary mb-4">Edit Deck</h2>
<form onSubmit={handleEditDeck}>
<div className="mb-4">
<label className="block text-text-secondary text-sm font-medium mb-2">
Deck Name *
</label>
<input
type="text"
required
value={editingDeck.name}
onChange={(e) => setEditingDeck({...editingDeck, name: e.target.value})}
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
/>
</div>
<div className="mb-4">
<label className="block text-text-secondary text-sm font-medium mb-2">
Format
</label>
<select
value={editingDeck.format}
onChange={(e) => setEditingDeck({...editingDeck, format: e.target.value})}
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
>
<option value="Commander">Commander</option>
<option value="Standard">Standard</option>
<option value="Modern">Modern</option>
<option value="Legacy">Legacy</option>
</select>
</div>
<div className="mb-4">
<label className="block text-text-secondary text-sm font-medium mb-2">
Description
</label>
<textarea
value={editingDeck.description || ''}
onChange={(e) => setEditingDeck({...editingDeck, description: e.target.value})}
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
rows="3"
/>
</div>
<div className="mb-6">
<label className="flex items-center">
<input
type="checkbox"
checked={editingDeck.is_public}
onChange={(e) => setEditingDeck({...editingDeck, is_public: e.target.checked})}
className="mr-2"
/>
<span className="text-text-secondary text-sm">Make deck public</span>
</label>
</div>
<div className="flex space-x-3">
<button
type="button"
onClick={() => setEditingDeck(null)}
className="flex-1 px-4 py-2 border border-border rounded-lg text-text-secondary hover:bg-bg-secondary transition-colors"
>
Cancel
</button>
<button
type="submit"
className="flex-1 bg-accent-ember text-white px-4 py-2 rounded-lg hover:bg-accent-ember-dark transition-colors"
>
Save Changes
</button>
</div>
</form>
</div>
</div>
)}
</div>
</Layout>
);
}

View file

@ -1,28 +1,322 @@
import { useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import Link from 'next/link';
import { useAuth } from '../lib/auth-context.js'; import { useAuth } from '../lib/auth-context.js';
import AnimatedFireLogo from '../components/AnimatedFireLogo';
export default function Home() { export default function Home() {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
const router = useRouter(); const router = useRouter();
const [featuredCollections, setFeaturedCollections] = useState([]);
const [collectionsLoading, setCollectionsLoading] = useState(true);
// If user is logged in, redirect to dashboard
useEffect(() => { useEffect(() => {
if (!loading) { if (!loading && user) {
if (user) { router.push('/dashboard');
router.push('/dashboard');
} else {
router.push('/login');
}
} }
}, [user, loading, router]); }, [user, loading, router]);
// Show loading while determining redirect // Fetch featured collections for public display
return ( useEffect(() => {
<div className="min-h-screen bg-gray-50 flex items-center justify-center"> const fetchFeaturedCollections = async () => {
<div className="text-center"> try {
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div> const response = await fetch('/api/public/collections?limit=6');
<p className="mt-4 text-gray-600">Loading...</p> if (response.ok) {
const collections = await response.json();
setFeaturedCollections(collections);
}
} catch (error) {
console.error('Error fetching featured collections:', error);
} finally {
setCollectionsLoading(false);
}
};
fetchFeaturedCollections();
}, []);
// Show loading while checking auth
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: 'var(--bg-primary)' }}>
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 mx-auto mb-4" style={{ borderColor: 'var(--accent-ember)' }}></div>
<p style={{ color: 'var(--text-secondary)' }}>Loading...</p>
</div>
</div> </div>
);
}
// If user is logged in, don't show landing page (redirect handled above)
if (user) {
return null;
}
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--bg-primary)' }}>
{/* Navigation Bar */}
<nav className="border-b" style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-4">
<div className="flex items-center space-x-3">
<AnimatedFireLogo size={40} />
<h1 className="text-2xl font-bold gradient-text-flame">Deck Hearth</h1>
</div>
<div className="flex items-center space-x-4">
<Link
href="/login"
className="px-4 py-2 text-sm font-medium rounded-xl transition-all duration-200 hover:opacity-80"
style={{ color: 'var(--text-secondary)' }}
>
Sign In
</Link>
<Link
href="/signup"
className="px-6 py-2 text-sm font-medium rounded-xl transition-all duration-200 hover:opacity-90"
style={{
backgroundColor: 'var(--accent-ember)',
color: 'white'
}}
>
Get Started
</Link>
</div>
</div>
</div>
</nav>
{/* Hero Section */}
<section className="relative py-20 px-4">
<div className="max-w-6xl mx-auto text-center">
<div className="mb-8">
<AnimatedFireLogo size={120} />
</div>
<h1 className="text-5xl md:text-7xl font-bold mb-6">
<span className="gradient-text-flame">Deck Hearth</span>
</h1>
<p className="text-xl md:text-2xl mb-8 max-w-3xl mx-auto leading-relaxed" style={{ color: 'var(--text-secondary)' }}>
The ultimate hub for trading card collectors. Organize your collection,
discover rare cards, and connect with fellow enthusiasts in one beautiful platform.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
<Link
href="/signup"
className="px-8 py-4 text-lg font-medium rounded-2xl transition-all duration-200 hover:opacity-90 hover:scale-105"
style={{
backgroundColor: 'var(--accent-ember)',
color: 'white'
}}
>
Start Your Collection
</Link>
<Link
href="/community/collections"
className="px-8 py-4 text-lg font-medium rounded-2xl border-2 transition-all duration-200 hover:opacity-80"
style={{
color: 'var(--text-primary)',
borderColor: 'var(--accent-ember)'
}}
>
Explore Collections
</Link>
</div>
</div>
</section>
{/* Features Section */}
<section className="py-20 px-4" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="max-w-6xl mx-auto">
<h2 className="text-4xl font-bold text-center mb-16 gradient-text-gold">
Everything You Need to Manage Your Cards
</h2>
<div className="grid md:grid-cols-3 gap-8">
<div className="text-center p-8 rounded-2xl" style={{ backgroundColor: 'var(--bg-primary)' }}>
<div className="w-16 h-16 mx-auto mb-6 rounded-2xl flex items-center justify-center" style={{ backgroundColor: 'var(--accent-ember)' }}>
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h3 className="text-xl font-bold mb-4 gradient-text-flame">Organize Collections</h3>
<p style={{ color: 'var(--text-secondary)' }}>
Create custom collections, track card values, and organize by sets, rarity, or any system that works for you.
</p>
</div>
<div className="text-center p-8 rounded-2xl" style={{ backgroundColor: 'var(--bg-primary)' }}>
<div className="w-16 h-16 mx-auto mb-6 rounded-2xl flex items-center justify-center" style={{ backgroundColor: 'var(--accent-gold)' }}>
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<h3 className="text-xl font-bold mb-4 gradient-text-gold">Discover Cards</h3>
<p style={{ color: 'var(--text-secondary)' }}>
Search through thousands of cards across multiple TCGs. Find that missing piece for your collection.
</p>
</div>
<div className="text-center p-8 rounded-2xl" style={{ backgroundColor: 'var(--bg-primary)' }}>
<div className="w-16 h-16 mx-auto mb-6 rounded-2xl flex items-center justify-center" style={{ backgroundColor: 'var(--accent-flame)' }}>
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<h3 className="text-xl font-bold mb-4 gradient-text-ember">Connect & Share</h3>
<p style={{ color: 'var(--text-secondary)' }}>
Share your collections with the community, collaborate with friends, and discover amazing collections from other collectors.
</p>
</div>
</div>
</div>
</section>
{/* Featured Collections Section */}
<section className="py-20 px-4">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-16">
<h2 className="text-4xl font-bold mb-4 gradient-text-flame">
Featured Collections
</h2>
<p className="text-xl" style={{ color: 'var(--text-secondary)' }}>
Discover amazing collections from our community
</p>
</div>
{collectionsLoading ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<div key={i} className="rounded-2xl p-6 animate-pulse" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="h-32 rounded-xl mb-4" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
<div className="h-6 rounded mb-2" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
<div className="h-4 rounded mb-4" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
<div className="flex justify-between">
<div className="h-4 w-16 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
<div className="h-4 w-20 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
</div>
</div>
))}
</div>
) : (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{featuredCollections.map((collection) => (
<Link
key={collection.id}
href={`/collection/${collection.slug || collection.id}`}
className="block rounded-2xl p-6 transition-all duration-200 hover:scale-105 hover:shadow-xl"
style={{ backgroundColor: 'var(--bg-secondary)' }}
>
<div className="h-32 rounded-xl mb-4 flex items-center justify-center" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
{collection.image ? (
<img
src={collection.image}
alt={collection.name}
className="w-full h-full object-cover rounded-xl"
/>
) : (
<div className="text-center">
<div className="w-12 h-12 mx-auto mb-2 rounded-xl flex items-center justify-center" style={{ backgroundColor: 'var(--accent-ember)' }}>
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{collection.cardCount} cards</p>
</div>
)}
</div>
<h3 className="text-xl font-bold mb-2 gradient-text-flame">{collection.name}</h3>
<p className="text-sm mb-4 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
{collection.description || 'A carefully curated collection'}
</p>
<div className="flex justify-between items-center">
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{collection.cardCount} cards
</span>
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
by {collection.creator}
</span>
</div>
</Link>
))}
</div>
)}
<div className="text-center mt-12">
<Link
href="/community/collections"
className="inline-flex items-center px-6 py-3 text-lg font-medium rounded-xl transition-all duration-200 hover:opacity-80"
style={{
color: 'var(--accent-ember)',
border: '2px solid var(--accent-ember)'
}}
>
View All Collections
<svg className="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</Link>
</div>
</div>
</section>
{/* CTA Section */}
<section className="py-20 px-4" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="max-w-4xl mx-auto text-center">
<h2 className="text-4xl font-bold mb-6 gradient-text-flame">
Ready to Start Your Journey?
</h2>
<p className="text-xl mb-8" style={{ color: 'var(--text-secondary)' }}>
Join thousands of collectors who trust Deck Hearth to manage their trading card collections.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
<Link
href="/signup"
className="px-8 py-4 text-lg font-medium rounded-2xl transition-all duration-200 hover:opacity-90 hover:scale-105"
style={{
backgroundColor: 'var(--accent-ember)',
color: 'white'
}}
>
Create Free Account
</Link>
<Link
href="/cards"
className="px-8 py-4 text-lg font-medium rounded-2xl border-2 transition-all duration-200 hover:opacity-80"
style={{
color: 'var(--text-primary)',
borderColor: 'var(--accent-ember)'
}}
>
Browse Cards
</Link>
</div>
</div>
</section>
{/* Footer */}
<footer className="py-12 px-4 border-t" style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border)' }}>
<div className="max-w-6xl mx-auto">
<div className="flex flex-col md:flex-row justify-between items-center">
<div className="flex items-center space-x-3 mb-4 md:mb-0">
<AnimatedFireLogo size={32} />
<span className="text-xl font-bold gradient-text-flame">Deck Hearth</span>
</div>
<div className="flex space-x-6">
<Link href="/community/collections" className="hover:opacity-80 transition-opacity" style={{ color: 'var(--text-secondary)' }}>
Community
</Link>
<Link href="/cards" className="hover:opacity-80 transition-opacity" style={{ color: 'var(--text-secondary)' }}>
Cards
</Link>
<Link href="/login" className="hover:opacity-80 transition-opacity" style={{ color: 'var(--text-secondary)' }}>
Sign In
</Link>
</div>
</div>
<div className="mt-8 pt-8 border-t text-center" style={{ borderColor: 'var(--border)' }}>
<p style={{ color: 'var(--text-secondary)' }}>
© 2024 Deck Hearth. Built for collectors, by collectors.
</p>
</div>
</div>
</footer>
</div> </div>
); );
} }

457
pages/my-cards.js Normal file
View file

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

766
pages/scanner.js Normal file
View file

@ -0,0 +1,766 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Layout from '../components/Layout';
import CameraScanner from '../components/CameraScanner';
import OCRSettings from '../components/OCRSettings';
import { ManaCost, ColorIdentity } from '../components/ManaSymbols';
import ManaSymbolSettings from '../components/ManaSymbolSettings';
import { useAuth } from '../lib/auth-context';
export default function Scanner() {
const { user } = useAuth();
const router = useRouter();
const [scannedCards, setScannedCards] = useState([]);
const [collections, setCollections] = useState([]);
const [decks, setDecks] = useState([]);
const [showCreateCollection, setShowCreateCollection] = useState(false);
const [newCollectionName, setNewCollectionName] = useState('');
const [showOCRSettings, setShowOCRSettings] = useState(false);
// Bulk action states
const [selectedCards, setSelectedCards] = useState(new Set());
const [bulkAction, setBulkAction] = useState(''); // 'owned', 'collection', 'deck'
const [bulkTarget, setBulkTarget] = useState('');
const [isProcessing, setIsProcessing] = useState(false);
// Mana symbol settings
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
// Redirect to login if not authenticated
useEffect(() => {
if (!user) {
router.push('/login');
return;
}
}, [user, router]);
// Load collections and decks
useEffect(() => {
if (user) {
loadCollections();
loadDecks();
}
}, [user]);
const loadCollections = async () => {
try {
const response = await fetch('/api/collections', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
}
});
if (response.ok) {
const data = await response.json();
// Filter out system collections (like "All My Cards")
const userCollections = data.filter(collection => !collection.is_system_collection);
setCollections(userCollections);
}
} catch (error) {
console.error('Error loading collections:', error);
}
};
const loadDecks = async () => {
try {
const response = await fetch('/api/decks', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
}
});
if (response.ok) {
const data = await response.json();
setDecks(data);
}
} catch (error) {
console.error('Error loading decks:', error);
}
};
const handleCardScanned = async (cardData) => {
console.log('Card scanned:', cardData);
// Check if this card already exists in the queue
setScannedCards(prev => {
const existingCardIndex = prev.findIndex(existing =>
existing.name === cardData.cardName &&
existing.set === cardData.setName &&
!existing.processed
);
if (existingCardIndex !== -1) {
// Increment quantity of existing card
const updatedCards = [...prev];
updatedCards[existingCardIndex] = {
...updatedCards[existingCardIndex],
quantity: (updatedCards[existingCardIndex].quantity || 1) + 1,
timestamp: new Date().toISOString() // Update timestamp
};
return updatedCards;
} else {
// Add new card to queue
const scannedCard = {
...cardData,
id: Date.now(), // Temporary ID for the queue
name: cardData.cardName,
set: cardData.setName,
quantity: 1,
timestamp: new Date().toISOString(),
processed: false
};
return [scannedCard, ...prev];
}
});
};
// Quantity management functions
const incrementCardQuantity = (cardId) => {
setScannedCards(prev => prev.map(card =>
card.id === cardId
? { ...card, quantity: (card.quantity || 1) + 1 }
: card
));
};
const decrementCardQuantity = (cardId) => {
setScannedCards(prev => prev.map(card =>
card.id === cardId
? { ...card, quantity: Math.max(1, (card.quantity || 1) - 1) }
: card
));
};
const handleError = (error) => {
console.error('Scanner error:', error);
// You could show a toast notification here
};
// Individual card actions
const addSingleCardToOwned = async (card) => {
try {
await addToOwnedCards(card);
markCardAsProcessed(card.id, 'owned');
} catch (error) {
console.error('Error adding card to owned:', error);
}
};
const addSingleCardToCollection = async (card, collectionId) => {
try {
await addToCollection(card, collectionId);
markCardAsProcessed(card.id, 'collection');
} catch (error) {
console.error('Error adding card to collection:', error);
}
};
const addSingleCardToDeck = async (card, deckId) => {
try {
await addToDeck(card, deckId);
markCardAsProcessed(card.id, 'deck');
} catch (error) {
console.error('Error adding card to deck:', error);
}
};
// Bulk actions
const handleBulkAction = async () => {
if (!bulkAction || selectedCards.size === 0) return;
setIsProcessing(true);
try {
const cardsToProcess = scannedCards.filter(card => selectedCards.has(card.id));
for (const card of cardsToProcess) {
if (bulkAction === 'owned') {
await addToOwnedCards(card);
} else if (bulkAction === 'collection' && bulkTarget) {
await addToCollection(card, bulkTarget);
} else if (bulkAction === 'deck' && bulkTarget) {
await addToDeck(card, bulkTarget);
}
markCardAsProcessed(card.id, bulkAction);
}
// Clear selections and reset bulk action state
setSelectedCards(new Set());
setBulkAction('');
setBulkTarget('');
} catch (error) {
console.error('Error processing bulk action:', error);
} finally {
setIsProcessing(false);
}
};
const markCardAsProcessed = (cardId, action) => {
setScannedCards(prev => prev.map(card =>
card.id === cardId
? { ...card, processed: true, processedAction: action }
: card
));
};
// Helper functions for API calls
const addToOwnedCards = async (cardData) => {
const response = await fetch('/api/user-cards', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
},
body: JSON.stringify({
cardId: cardData.databaseId,
quantity: 1,
condition: 'NM'
})
});
if (!response.ok) {
throw new Error('Failed to add to owned cards');
}
};
const addToCollection = async (cardData, collectionId) => {
const response = await fetch(`/api/collections/${collectionId}/cards`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
},
body: JSON.stringify({
cardId: cardData.databaseId,
quantity: 1
})
});
if (!response.ok) {
throw new Error('Failed to add to collection');
}
};
const addToDeck = async (cardData, deckId) => {
const response = await fetch(`/api/decks/${deckId}/cards`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
},
body: JSON.stringify({
cardId: cardData.databaseId,
quantity: 1
})
});
if (!response.ok) {
throw new Error('Failed to add to deck');
}
};
const createCollection = async () => {
if (!newCollectionName.trim()) return;
try {
const response = await fetch('/api/collections', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
},
body: JSON.stringify({
name: newCollectionName,
description: 'Created from card scanner',
is_public: false
})
});
if (response.ok) {
const newCollection = await response.json();
setCollections(prev => [newCollection, ...prev]);
setBulkTarget(newCollection.id.toString());
setNewCollectionName('');
setShowCreateCollection(false);
}
} catch (error) {
console.error('Error creating collection:', error);
}
};
const clearScannedCards = () => {
setScannedCards([]);
setSelectedCards(new Set());
};
const removeScannedCard = (cardId) => {
setScannedCards(prev => prev.filter(card => card.id !== cardId));
setSelectedCards(prev => {
const newSet = new Set(prev);
newSet.delete(cardId);
return newSet;
});
};
const toggleCardSelection = (cardId) => {
setSelectedCards(prev => {
const newSet = new Set(prev);
if (newSet.has(cardId)) {
newSet.delete(cardId);
} else {
newSet.add(cardId);
}
return newSet;
});
};
const selectAllCards = () => {
const unprocessedCards = scannedCards.filter(card => !card.processed);
setSelectedCards(new Set(unprocessedCards.map(card => card.id)));
};
const deselectAllCards = () => {
setSelectedCards(new Set());
};
if (!user) {
return <div>Redirecting to login...</div>;
}
return (
<Layout>
<div className="max-w-6xl mx-auto p-6">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
🃏 Card Scanner
</h1>
<p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
Scan cards to identify them, then choose what to do with your collection
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Camera Scanner */}
<div className="lg:col-span-2">
<div className="rounded-xl p-6" style={{ backgroundColor: 'var(--bg-secondary)', border: '1px solid var(--border)' }}>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>
Camera Scanner
</h2>
<button
onClick={() => setShowOCRSettings(true)}
className="px-4 py-2 rounded-xl font-medium border transition-all duration-200 hover:opacity-80"
style={{
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
>
OCR Settings
</button>
</div>
<CameraScanner
onCardScanned={handleCardScanned}
onError={handleError}
/>
</div>
</div>
{/* Scanned Cards Queue */}
<div className="lg:col-span-1">
<div className="rounded-xl p-6" style={{ backgroundColor: 'var(--bg-secondary)', border: '1px solid var(--border)' }}>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>
Scanned Cards
</h2>
<div className="flex items-center gap-2">
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{scannedCards.length} cards
</div>
{scannedCards.length > 0 && (
<button
onClick={clearScannedCards}
className="px-3 py-1 rounded-lg text-sm border hover:opacity-80"
style={{
borderColor: 'var(--border)',
color: 'var(--text-secondary)'
}}
>
Clear All
</button>
)}
</div>
</div>
{/* Scanned Cards Queue */}
<div className="space-y-3">
{scannedCards.length === 0 ? (
<div className="text-center py-8" style={{ color: 'var(--text-secondary)' }}>
<div className="text-4xl mb-2">📱</div>
<div className="font-medium">No cards scanned yet</div>
<div className="text-sm">Start scanning to see cards here</div>
</div>
) : (
scannedCards.map((card) => (
<div
key={card.id}
className={`flex gap-4 p-4 rounded-lg border ${card.processed ? 'opacity-60' : ''}`}
style={{
backgroundColor: 'var(--bg-tertiary)',
borderColor: selectedCards.has(card.id) ? 'var(--accent-ember)' : 'var(--border)',
borderWidth: selectedCards.has(card.id) ? '2px' : '1px'
}}
>
{/* Card Thumbnail with Checkbox Overlay */}
<div className="relative flex-shrink-0">
<div className="w-20 h-28 rounded-lg overflow-hidden bg-gray-200 flex items-center justify-center">
{card.image_url ? (
<img
src={card.image_url}
alt={card.name}
className="w-full h-full object-cover"
/>
) : (
<div className="text-center text-xs text-gray-500 p-2">
<div className="text-2xl mb-1">🃏</div>
<div>No Image</div>
</div>
)}
</div>
{/* Checkbox Overlay */}
{!card.processed && (
<div className="absolute top-1 left-1">
<input
type="checkbox"
checked={selectedCards.has(card.id)}
onChange={() => toggleCardSelection(card.id)}
className="w-5 h-5 rounded border-2 border-white shadow-lg"
style={{ accentColor: 'var(--accent-ember)' }}
/>
</div>
)}
</div>
{/* Card Content */}
<div className="flex-1 min-w-0">
{/* Confidence Badge */}
{card.confidence && (
<div className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium mb-2"
style={{
backgroundColor: card.confidence >= 90 ? 'var(--accent-gold)' :
card.confidence >= 70 ? 'var(--accent-ember)' : 'var(--text-secondary)',
color: 'white'
}}>
{Math.round(card.confidence)}% confidence
</div>
)}
{/* Title and Quantity Row */}
<div className="flex items-start justify-between mb-2 gap-4">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-lg truncate" style={{ color: 'var(--text-primary)' }}>
{card.name}
</h3>
{/* Database Status */}
{card.isExisting && (
<div className="text-xs text-green-600 font-medium">
Found in database
</div>
)}
</div>
{/* Quantity Controls */}
{!card.processed && (
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={() => decrementCardQuantity(card.id)}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold hover:opacity-80"
style={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
>
</button>
<span className="min-w-[2rem] text-center font-medium" style={{ color: 'var(--text-primary)' }}>
{card.quantity || 1}
</span>
<button
onClick={() => incrementCardQuantity(card.id)}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold hover:opacity-80"
style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}
>
+
</button>
</div>
)}
</div>
{/* Card Details */}
<div className="space-y-1 mb-3">
{card.set && (
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
<span className="font-semibold">Set:</span> {card.set}
{card.setCode && <span className="ml-2 text-xs">({card.setCode})</span>}
</div>
)}
{card.cardNumber && (
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
<span className="font-semibold">Number:</span> {card.cardNumber}
</div>
)}
{card.cardType && (
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
<span className="font-semibold">Type:</span> {card.cardType}
</div>
)}
{card.rarity && (
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
<span className="font-semibold">Rarity:</span> {card.rarity}
</div>
)}
{card.hp && (
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
<span className="font-semibold">HP:</span> {card.hp}
</div>
)}
{card.manaCost && (
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
<span className="font-semibold">Mana Cost:</span> {card.manaCost}
</div>
)}
{card.ocrText && (
<div className="mt-2">
<div className="text-xs font-semibold mb-1" style={{ color: 'var(--text-secondary)' }}>
Scanned Text:
</div>
<div className="text-xs p-2 rounded max-h-16 overflow-y-auto"
style={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-secondary)' }}>
{card.ocrText.substring(0, 150)}{card.ocrText.length > 150 ? '...' : ''}
</div>
</div>
)}
</div>
{/* Actions */}
{!card.processed ? (
<div className="space-y-2">
{/* Primary Actions Row */}
<div className="flex gap-2">
<button
onClick={() => addSingleCardToOwned(card)}
className="flex-1 px-3 py-2 rounded text-sm font-medium hover:opacity-80 flex items-center justify-center gap-1"
style={{ backgroundColor: 'var(--accent-gold)', color: 'white' }}
>
💎 Mark Owned
</button>
<button
onClick={() => removeScannedCard(card.id)}
className="px-3 py-2 rounded text-sm hover:opacity-80"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--bg-secondary)' }}
title="Remove"
>
🗑
</button>
</div>
{/* Secondary Actions Row */}
<div className="flex gap-2">
{collections.length > 0 && (
<select
onChange={(e) => e.target.value && addSingleCardToCollection(card, e.target.value)}
className="flex-1 px-3 py-2 rounded text-sm"
style={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
>
<option value="">📚 Add to Collection</option>
{collections.map(collection => (
<option key={collection.id} value={collection.id}>
{collection.name}
</option>
))}
</select>
)}
{decks.length > 0 && (
<select
onChange={(e) => e.target.value && addSingleCardToDeck(card, e.target.value)}
className="flex-1 px-3 py-2 rounded text-sm"
style={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
>
<option value="">🃏 Add to Deck</option>
{decks.map(deck => (
<option key={deck.id} value={deck.id}>
{deck.name}
</option>
))}
</select>
)}
</div>
</div>
) : (
<div className="text-sm flex items-center gap-2" style={{ color: 'var(--accent-ember)' }}>
<span></span>
<span>Added to {card.processedAction}</span>
</div>
)}
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
{/* Floating Bulk Actions Toolbar */}
{selectedCards.size > 0 && (
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 z-50">
<div className="rounded-2xl shadow-2xl border px-6 py-4 flex items-center gap-4 max-w-4xl"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border)',
backdropFilter: 'blur(10px)'
}}>
{/* Selection Count */}
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold text-white"
style={{ backgroundColor: 'var(--accent-ember)' }}>
{selectedCards.size}
</div>
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
{selectedCards.size === 1 ? 'card selected' : 'cards selected'}
</span>
</div>
{/* Divider */}
<div className="w-px h-8" style={{ backgroundColor: 'var(--border)' }}></div>
{/* Quick Actions */}
<div className="flex items-center gap-3">
<button
onClick={() => {
setBulkAction('owned');
handleBulkAction();
}}
disabled={isProcessing}
className="px-4 py-2 rounded-lg font-medium hover:opacity-80 disabled:opacity-50 flex items-center gap-2"
style={{ backgroundColor: 'var(--accent-gold)', color: 'white' }}
>
💎 Mark Owned
</button>
{collections.length > 0 && (
<select
onChange={(e) => {
if (e.target.value) {
setBulkAction('collection');
setBulkTarget(e.target.value);
setTimeout(() => handleBulkAction(), 100);
}
}}
disabled={isProcessing}
className="px-4 py-2 rounded-lg font-medium"
style={{ backgroundColor: 'var(--accent-ember)', color: 'white', border: 'none' }}
>
<option value="">📚 Add to Collection</option>
{collections.map(collection => (
<option key={collection.id} value={collection.id}>
{collection.name}
</option>
))}
</select>
)}
{decks.length > 0 && (
<select
onChange={(e) => {
if (e.target.value) {
setBulkAction('deck');
setBulkTarget(e.target.value);
setTimeout(() => handleBulkAction(), 100);
}
}}
disabled={isProcessing}
className="px-4 py-2 rounded-lg font-medium"
style={{ backgroundColor: 'var(--accent-flame)', color: 'white', border: 'none' }}
>
<option value="">🃏 Add to Deck</option>
{decks.map(deck => (
<option key={deck.id} value={deck.id}>
{deck.name} ({deck.game})
</option>
))}
</select>
)}
</div>
{/* Divider */}
<div className="w-px h-8" style={{ backgroundColor: 'var(--border)' }}></div>
{/* Clear Selection */}
<button
onClick={() => setSelectedCards(new Set())}
className="px-3 py-2 rounded-lg hover:opacity-80"
style={{ color: 'var(--text-secondary)' }}
title="Clear Selection"
>
</button>
</div>
</div>
)}
{/* Bulk Actions Modal */}
{/* This modal is no longer needed as bulk actions are in a floating toolbar */}
{/* Create Collection Modal */}
{showCreateCollection && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="rounded-xl p-6 max-w-md w-full mx-4" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<h3 className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Create New Collection
</h3>
<input
type="text"
placeholder="Collection name..."
value={newCollectionName}
onChange={(e) => setNewCollectionName(e.target.value)}
className="w-full px-4 py-2 rounded-lg border mb-4"
style={{
backgroundColor: 'var(--bg-tertiary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
onKeyPress={(e) => {
if (e.key === 'Enter') {
createCollection();
}
}}
/>
<div className="flex gap-3">
<button
onClick={createCollection}
disabled={!newCollectionName.trim()}
className="flex-1 px-4 py-2 rounded-lg font-medium disabled:opacity-50"
style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}
>
Create
</button>
<button
onClick={() => setShowCreateCollection(false)}
className="flex-1 px-4 py-2 rounded-lg border font-medium"
style={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* OCR Settings Modal */}
{showOCRSettings && (
<OCRSettings onClose={() => setShowOCRSettings(false)} />
)}
</div>
</Layout>
);
}

View file

@ -0,0 +1,88 @@
import dotenv from 'dotenv';
import { neon } from '@neondatabase/serverless';
dotenv.config({ path: '.env.local' });
async function fixLorcanaImages() {
const sql = neon(process.env.POSTGRES_URL);
try {
console.log('🏰 Fixing Lorcana card images...');
// Get all Lorcana cards with image URLs
const lorcanaCards = await sql`
SELECT id, name, image_url, stock_image_url
FROM cards
WHERE game = 'Lorcana' AND image_url IS NOT NULL
`;
console.log(`📸 Found ${lorcanaCards.length} Lorcana cards to process`);
let updatedCount = 0;
for (const card of lorcanaCards) {
let needsUpdate = false;
let newImageUrl = card.image_url;
let newStockImageUrl = card.stock_image_url;
// Check if we're using a small image as the main image
if (card.image_url && card.image_url.includes('-716.webp')) {
// Replace with 1024px version for main image
newImageUrl = card.image_url.replace('-716.webp', '-1024.webp');
newStockImageUrl = card.image_url; // Keep 716px as thumbnail
needsUpdate = true;
} else if (card.image_url && card.image_url.includes('-512.webp')) {
// Replace with 1024px version for main image
newImageUrl = card.image_url.replace('-512.webp', '-1024.webp');
newStockImageUrl = card.image_url; // Keep 512px as thumbnail
needsUpdate = true;
}
// Also check if both image_url and stock_image_url are the same small image
if (card.image_url === card.stock_image_url && card.image_url &&
(card.image_url.includes('-716.webp') || card.image_url.includes('-512.webp'))) {
// They're the same small image, fix this
if (card.image_url.includes('-716.webp')) {
newImageUrl = card.image_url.replace('-716.webp', '-1024.webp');
newStockImageUrl = card.image_url; // Keep original as thumbnail
} else if (card.image_url.includes('-512.webp')) {
newImageUrl = card.image_url.replace('-512.webp', '-1024.webp');
newStockImageUrl = card.image_url; // Keep original as thumbnail
}
needsUpdate = true;
}
if (needsUpdate) {
await sql`
UPDATE cards
SET image_url = ${newImageUrl},
stock_image_url = ${newStockImageUrl},
updated_at = CURRENT_TIMESTAMP
WHERE id = ${card.id}
`;
console.log(`✅ Updated ${card.name}: ${card.image_url}${newImageUrl}`);
updatedCount++;
}
}
console.log(`🎉 Updated ${updatedCount} Lorcana cards with higher quality images`);
if (updatedCount === 0) {
console.log(' No cards needed updating - they may already have high quality images');
}
} catch (error) {
console.error('❌ Error fixing Lorcana images:', error);
}
}
// Run the script
fixLorcanaImages()
.then(() => {
console.log('✅ Script completed');
process.exit(0);
})
.catch((error) => {
console.error('❌ Script failed:', error);
process.exit(1);
});

View file

@ -38,6 +38,7 @@ body {
--bg-primary-rgb: 254, 252, 248; --bg-primary-rgb: 254, 252, 248;
--bg-secondary-rgb: 247, 243, 237; --bg-secondary-rgb: 247, 243, 237;
--bg-tertiary-rgb: 240, 230, 214; --bg-tertiary-rgb: 240, 230, 214;
--accent-ember-rgb: 216, 67, 21; /* RGB version of #d84315 */
} }
[data-theme="dark"] { [data-theme="dark"] {
@ -69,6 +70,7 @@ body {
--bg-primary-rgb: 26, 15, 10; --bg-primary-rgb: 26, 15, 10;
--bg-secondary-rgb: 45, 27, 18; --bg-secondary-rgb: 45, 27, 18;
--bg-tertiary-rgb: 61, 35, 23; --bg-tertiary-rgb: 61, 35, 23;
--accent-ember-rgb: 216, 67, 21; /* RGB version of #d84315 */
} }
/* Apply theme colors */ /* Apply theme colors */
@ -82,6 +84,18 @@ body {
color: var(--text-primary-dark); color: var(--text-primary-dark);
} }
/* Card Scanner Animations */
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.8;
transform: scale(1.02);
}
}
/* Default theme variables for light theme */ /* Default theme variables for light theme */
:root { :root {
--bg-primary: var(--bg-primary-light); --bg-primary: var(--bg-primary-light);