✨ 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:
parent
b3240dbb3c
commit
afb79c57d9
35 changed files with 7380 additions and 443 deletions
988
components/CameraScanner.js
Normal file
988
components/CameraScanner.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,8 +324,27 @@ 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 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
|
<div
|
||||||
className={`
|
className={`
|
||||||
nav-item flex items-center justify-between px-4 py-3 rounded-2xl
|
nav-item flex items-center justify-between px-4 py-3 rounded-2xl
|
||||||
|
|
@ -312,34 +362,146 @@ export default function Layout({ children, user = { email: 'me@randallstillwell.
|
||||||
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
||||||
}}
|
}}
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
role="menuitem"
|
>
|
||||||
aria-current={item.active ? 'page' : undefined}
|
<div className="flex items-center">
|
||||||
tabIndex={0}
|
<span className="mr-3" aria-hidden="true">{getIcon(item.icon)}</span>
|
||||||
onKeyDown={(e) => {
|
<span className="font-medium">{item.name}</span>
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
</div>
|
||||||
e.preventDefault();
|
</div>
|
||||||
setIsMobileMenuOpen(false);
|
</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">
|
<div className="flex items-center">
|
||||||
<span className="mr-3" aria-hidden="true">{getIcon(item.icon)}</span>
|
<span className="mr-3" aria-hidden="true">{getIcon(item.icon)}</span>
|
||||||
<span className="font-medium">{item.name}</span>
|
<span className="font-medium">{item.name}</span>
|
||||||
</div>
|
</div>
|
||||||
{item.badge && (
|
<span className="text-xs px-2 py-1 rounded-full" style={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-secondary)' }}>
|
||||||
<span
|
Coming Soon
|
||||||
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>
|
||||||
|
) : (
|
||||||
|
<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>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Community Section with Sub-items */}
|
{/* Community Section with Sub-items */}
|
||||||
|
|
@ -430,10 +592,78 @@ 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) */}
|
||||||
|
{adminNavigation.map((item) => (
|
||||||
|
<Link key={item.name} 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>
|
||||||
|
{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 */}
|
||||||
|
<UserProfileDropdown
|
||||||
|
user={user}
|
||||||
|
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
|
<button
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
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"
|
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={{
|
style={{
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
color: 'var(--text-secondary)',
|
color: 'var(--text-secondary)',
|
||||||
|
|
@ -443,25 +673,17 @@ export default function Layout({ children, user = { email: 'me@randallstillwell.
|
||||||
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||||
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||||
>
|
>
|
||||||
<span className="mr-3" aria-hidden="true">
|
|
||||||
{theme === 'light' ? (
|
{theme === 'light' ? (
|
||||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</span>
|
|
||||||
<span className="font-medium">{theme === 'light' ? 'Dark Mode' : 'Light Mode'}</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
{/* User Profile Dropdown */}
|
|
||||||
<UserProfileDropdown
|
|
||||||
user={user}
|
|
||||||
onMobileMenuClose={() => setIsMobileMenuOpen(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
53
components/LoginCTA.js
Normal file
53
components/LoginCTA.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
components/ManaSymbolSettings.js
Normal file
53
components/ManaSymbolSettings.js
Normal 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
233
components/ManaSymbols.js
Normal 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
464
components/OCRSettings.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
components/ProtectedRoute.js
Normal file
57
components/ProtectedRoute.js
Normal 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
527
lib/ai-ocr.js
Normal 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();
|
||||||
|
|
@ -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
290
lib/mana-symbols.js
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
212
pages/api/cards/find-or-create.js
Normal file
212
pages/api/cards/find-or-create.js
Normal 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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
164
pages/api/cards/owned.js
Normal 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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
25
pages/api/config/gemini.js
Normal file
25
pages/api/config/gemini.js
Normal 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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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') {
|
||||||
|
// Get user's decks
|
||||||
const result = await sql`
|
const result = await sql`
|
||||||
SELECT
|
SELECT d.*,
|
||||||
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.updated_at DESC
|
ORDER BY d.created_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
91
pages/api/decks/[id].js
Normal 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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
110
pages/api/decks/[id]/cards.js
Normal file
110
pages/api/decks/[id]/cards.js
Normal 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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
63
pages/api/public/collections.js
Normal file
63
pages/api/public/collections.js
Normal 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
62
pages/api/user-cards.js
Normal 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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
if (user) {
|
||||||
loadFavoritedCards();
|
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)
|
||||||
|
let response = await fetch(`/api/collections/${identifier}`);
|
||||||
|
|
||||||
|
// If that fails and we have a user, try with authentication
|
||||||
|
if (!response.ok && user) {
|
||||||
|
response = await fetch(`/api/collections/${identifier}`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
'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`);
|
||||||
|
|
||||||
|
if (!cardsResponse.ok && user) {
|
||||||
|
cardsResponse = await fetch(`/api/collections/${identifier}/cards`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
'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 if (response.status === 401 || response.status === 403) {
|
||||||
|
// Collection is private and user is not authenticated/authorized
|
||||||
|
if (!user) {
|
||||||
|
router.push('/login');
|
||||||
} else {
|
} else {
|
||||||
console.error('Failed to fetch collection');
|
// User is authenticated but doesn't have access
|
||||||
setCollection(null);
|
router.push('/collections');
|
||||||
setCards([]);
|
}
|
||||||
}
|
}
|
||||||
} 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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) {
|
|
||||||
router.push('/login');
|
|
||||||
}
|
|
||||||
}, [authLoading, user, router]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (user) {
|
|
||||||
fetchPublicCollections();
|
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 };
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
};
|
};
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
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>
|
</svg>
|
||||||
<span>+ Add Cards</span>
|
<span>Create Collection</span>
|
||||||
</button>
|
</div>
|
||||||
<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>
|
</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</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 */}
|
{/* Stats Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
<div className="card">
|
<div className="rounded-2xl p-6" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="p-3 rounded-2xl mr-4" style={{ backgroundColor: 'var(--accent-blue-light)' }}>
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-bold" style={{ color: 'var(--text-primary-light)' }}>247</p>
|
<p className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{collections.length}</p>
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary-light)' }}>Total Cards</p>
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>Collections</p>
|
||||||
<p className="text-xs text-green-500">+12 from last month</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="rounded-2xl p-6" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="p-3 rounded-2xl mr-4" style={{ backgroundColor: 'var(--accent-purple-light)' }}>
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-bold" style={{ color: 'var(--text-primary-light)' }}>$2,847.5</p>
|
<p className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary-light)' }}>Collection Value</p>
|
${collections.reduce((total, col) => total + (col.value || 0), 0).toLocaleString()}
|
||||||
<p className="text-xs text-green-500">+$245 from last month</p>
|
</p>
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>Total Value</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
{/* Collections Grid */}
|
||||||
<div className="flex items-center">
|
<div className="mb-6">
|
||||||
<div className="p-3 rounded-2xl mr-4" style={{ backgroundColor: 'var(--accent-pink-light)' }}>
|
<h2 className="text-xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>Recent Collections</h2>
|
||||||
<svg className="h-8 w-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
{collections.length === 0 ? (
|
||||||
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<h3 className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>No Collections Yet</h3>
|
||||||
<p className="text-2xl font-bold" style={{ color: 'var(--text-primary-light)' }}>12</p>
|
<p className="mb-4" style={{ color: 'var(--text-secondary)' }}>
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary-light)' }}>Total Decks</p>
|
Create your first collection to start organizing your cards
|
||||||
<p className="text-xs text-green-500">+2 from last month</p>
|
</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>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
{collections.length > 6 && (
|
||||||
<div className="flex items-center">
|
<div className="text-center">
|
||||||
<div className="p-3 rounded-2xl mr-4" style={{ backgroundColor: 'var(--accent-blue-light)' }}>
|
<Link href="/collections">
|
||||||
<svg className="h-8 w-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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)' }}>
|
||||||
<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" />
|
View All Collections
|
||||||
</svg>
|
</button>
|
||||||
</div>
|
</Link>
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h4 className="font-semibold" style={{ color: 'var(--text-primary-light)' }}>New follower</h4>
|
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary-light)' }}>@cardcollector joined</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
823
pages/deck-builder.js
Normal file
823
pages/deck-builder.js
Normal 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
341
pages/deck/[id].js
Normal 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
461
pages/decks.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
314
pages/index.js
314
pages/index.js
|
|
@ -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
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchFeaturedCollections = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/public/collections?limit=6');
|
||||||
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: 'var(--bg-primary)' }}>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 mx-auto mb-4" style={{ borderColor: 'var(--accent-ember)' }}></div>
|
||||||
<p className="mt-4 text-gray-600">Loading...</p>
|
<p style={{ color: 'var(--text-secondary)' }}>Loading...</p>
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
457
pages/my-cards.js
Normal file
457
pages/my-cards.js
Normal 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
766
pages/scanner.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
scripts/fix-lorcana-images.js
Normal file
88
scripts/fix-lorcana-images.js
Normal 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);
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue