Clean slate: Remove React traces and create pure Next.js setup

This commit is contained in:
Randall Stillwell 2025-07-23 09:32:31 -05:00
parent 2d4c4963b8
commit 7be41b4490
93 changed files with 58 additions and 15830 deletions

View file

@ -3,27 +3,6 @@ const nextConfig = {
images: {
domains: ['api.scryfall.com', 'images.pokemontcg.io', 'lorcana-api.com'],
},
async headers() {
return [
{
source: '/api/(.*)',
headers: [
{
key: 'Access-Control-Allow-Origin',
value: '*',
},
{
key: 'Access-Control-Allow-Methods',
value: 'GET, POST, PUT, DELETE, OPTIONS',
},
{
key: 'Access-Control-Allow-Headers',
value: 'Content-Type, Authorization',
},
],
},
];
},
};
module.exports = nextConfig;

27
package-lock.json generated
View file

@ -8,45 +8,28 @@
"name": "tcg-vault",
"version": "0.1.0",
"dependencies": {
"@stackframe/stack": "^2.8.22",
"@tanstack/react-query": "^5.83.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"@types/bcryptjs": "^2.4.6",
"@types/jest": "^27.5.2",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^16.18.126",
"@types/pg": "^8.15.4",
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"@types/react-router-dom": "^5.3.3",
"@vercel/analytics": "^1.5.0",
"@vercel/blob": "^1.1.1",
"@vercel/postgres": "^0.10.0",
"@vercel/speed-insights": "^1.2.0",
"axios": "^1.10.0",
"bcryptjs": "^3.0.2",
"jsonwebtoken": "^9.0.2",
"next": "^15.4.2",
"pg": "^8.16.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"web-vitals": "^2.1.4"
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^16.18.126",
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"autoprefixer": "^10.4.21",
"eslint": "^8",
"eslint-config-next": "15.4.2",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"typescript": "^4.9.5"
},
"engines": {
"node": "22.x"
}
},
"node_modules/@adobe/css-tools": {

View file

@ -2,9 +2,6 @@
"name": "tcg-vault",
"version": "0.1.0",
"private": true,
"engines": {
"node": "22.x"
},
"scripts": {
"dev": "next dev",
"build": "next build",
@ -12,53 +9,22 @@
"lint": "next lint"
},
"dependencies": {
"@stackframe/stack": "^2.8.22",
"@tanstack/react-query": "^5.83.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"@types/bcryptjs": "^2.4.6",
"@types/jest": "^27.5.2",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^16.18.126",
"@types/pg": "^8.15.4",
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"@types/react-router-dom": "^5.3.3",
"@vercel/analytics": "^1.5.0",
"@vercel/blob": "^1.1.1",
"@vercel/postgres": "^0.10.0",
"@vercel/analytics": "^1.5.0",
"@vercel/speed-insights": "^1.2.0",
"axios": "^1.10.0",
"bcryptjs": "^3.0.2",
"jsonwebtoken": "^9.0.2",
"next": "^15.4.2",
"pg": "^8.16.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"web-vitals": "^2.1.4"
},
"eslintConfig": {
"extends": [
"next"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
"bcryptjs": "^3.0.2",
"jsonwebtoken": "^9.0.2",
"axios": "^1.10.0"
},
"devDependencies": {
"@types/node": "^16.18.126",
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.10",
"autoprefixer": "^10.4.21",
"eslint": "^8",
"eslint-config-next": "15.4.2",
@ -66,4 +32,4 @@
"tailwindcss": "^3.4.17",
"typescript": "^4.9.5"
}
}
}

5
pages/_app.js Normal file
View file

@ -0,0 +1,5 @@
import '../styles/globals.css'
export default function App({ Component, pageProps }) {
return <Component {...pageProps} />
}

View file

@ -1,24 +0,0 @@
import React from 'react';
import type { AppProps } from 'next/app';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from '../src/contexts/AuthContext';
import '../src/index.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
},
},
});
export default function App({ Component, pageProps }: AppProps) {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<Component {...pageProps} />
</AuthProvider>
</QueryClientProvider>
);
}

View file

@ -1,409 +0,0 @@
import { sql } from '@vercel/postgres';
import { verifyToken, isAdmin } from '../auth-utils.js';
// Rate limiting for external APIs
const rateLimiters = {
mtg: { lastCall: 0, minInterval: 50 }, // 50ms between calls
pokemon: { lastCall: 0, minInterval: 100 }, // 100ms between calls
lorcana: { lastCall: 0, minInterval: 100 } // 100ms between calls
};
async function waitForRateLimit(api) {
const now = Date.now();
const limiter = rateLimiters[api];
const timeSinceLastCall = now - limiter.lastCall;
if (timeSinceLastCall < limiter.minInterval) {
await new Promise(resolve =>
setTimeout(resolve, limiter.minInterval - timeSinceLastCall)
);
}
limiter.lastCall = Date.now();
}
// Load MTG cards from Scryfall
async function loadMTGCards() {
console.log('🃏 Loading MTG cards from Scryfall...');
try {
// Get total count first
const countResponse = await fetch('https://api.scryfall.com/cards/search?q=game:paper');
const countData = await countResponse.json();
const totalCards = countData.total_cards;
console.log(`📊 Found ${totalCards} MTG cards to load`);
let loadedCount = 0;
let page = 1;
while (loadedCount < Math.min(totalCards, 1000)) { // Limit to 1000 for now
await waitForRateLimit('mtg');
const response = await fetch(`https://api.scryfall.com/cards/search?q=game:paper&page=${page}`);
const data = await response.json();
if (!data.data || data.data.length === 0) break;
for (const card of data.data) {
try {
await sql.query(`
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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
ON CONFLICT (scryfall_id) DO NOTHING
`, [
card.name,
card.set_name,
card.set,
card.collector_number,
card.rarity,
'MTG',
card.mana_cost,
card.cmc,
card.type_line,
JSON.stringify(card.colors),
card.oracle_text,
card.power,
card.toughness,
card.image_uris?.normal || card.image_uris?.small,
card.image_uris?.small,
card.prices?.usd ? parseFloat(card.prices.usd) : null,
card.prices?.usd_foil ? parseFloat(card.prices.usd_foil) : null,
card.id,
true
]);
loadedCount++;
} catch (error) {
console.error(`❌ Error loading MTG card ${card.name}:`, error);
}
}
page++;
console.log(`✅ Loaded ${loadedCount} MTG cards so far...`);
}
console.log(`🎉 Successfully loaded ${loadedCount} MTG cards`);
return loadedCount;
} catch (error) {
console.error('❌ Error loading MTG cards:', error);
return 0;
}
}
// Load Pokémon cards from Pokémon TCG API
async function loadPokemonCards() {
console.log('⚡ Loading Pokémon cards from Pokémon TCG API...');
try {
let loadedCount = 0;
let page = 1;
const pageSize = 250; // API limit
while (loadedCount < 1000) { // Limit to 1000 for now
await waitForRateLimit('pokemon');
const response = await fetch(`https://api.pokemontcg.io/v2/cards?page=${page}&pageSize=${pageSize}`);
const data = await response.json();
if (!data.data || data.data.length === 0) break;
for (const card of data.data) {
try {
await sql.query(`
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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
ON CONFLICT (scryfall_id) DO NOTHING
`, [
card.name,
card.set.name,
card.set.id,
card.number,
card.rarity,
'POKEMON',
null, // No mana cost in Pokémon
null, // No CMC in Pokémon
card.supertype,
JSON.stringify(card.types || []),
card.rules?.join(' ') || '',
card.nationalPokedexNumbers?.[0] || null,
null, // No toughness in Pokémon
card.images?.large,
card.images?.small,
card.cardmarket?.prices?.averageSellPrice || null,
card.cardmarket?.prices?.lowPrice || null,
card.id,
true
]);
loadedCount++;
} catch (error) {
console.error(`❌ Error loading Pokémon card ${card.name}:`, error);
}
}
page++;
console.log(`✅ Loaded ${loadedCount} Pokémon cards so far...`);
}
console.log(`🎉 Successfully loaded ${loadedCount} Pokémon cards`);
return loadedCount;
} catch (error) {
console.error('❌ Error loading Pokémon cards:', error);
return 0;
}
}
// Load Lorcana cards from Lorcana API
async function loadLorcanaCards() {
console.log('🏰 Loading Lorcana cards from Lorcana API...');
try {
let loadedCount = 0;
// Get all cards from Lorcana API
await waitForRateLimit('lorcana');
const response = await fetch('https://lorcana-api.com/api/v1/cards');
const data = await response.json();
if (!data.data || data.data.length === 0) {
console.log('❌ No Lorcana cards found');
return 0;
}
console.log(`📊 Found ${data.data.length} Lorcana cards to load`);
for (const card of data.data) {
try {
await sql.query(`
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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
ON CONFLICT (scryfall_id) DO NOTHING
`, [
card.name,
card.set?.name || 'Unknown Set',
card.set?.id || 'UNK',
card.number || '0',
card.rarity || 'Common',
'LORCANA',
null, // No mana cost in Lorcana
card.cost || null,
card.type || 'Character',
JSON.stringify(card.colors || []),
card.text || '',
card.strength || null,
card.willpower || null,
card.images?.full || card.images?.large,
card.images?.small,
null, // No price data available
null, // No price data available
card.id,
true
]);
loadedCount++;
} catch (error) {
console.error(`❌ Error loading Lorcana card ${card.name}:`, error);
}
}
console.log(`🎉 Successfully loaded ${loadedCount} Lorcana cards`);
return loadedCount;
} catch (error) {
console.error('❌ Error loading Lorcana cards:', error);
return 0;
}
}
// GET /api/admin - Get admin data
export default async function handler(req, res) {
// Set CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, 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' && req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
// Temporarily bypass auth for testing
// const token = req.headers.authorization?.replace('Bearer ', '');
// const user = await verifyToken(token);
// if (!user) {
// return res.status(401).json({ error: 'Unauthorized' });
// }
// if (!isAdmin(user)) {
// return res.status(403).json({ error: 'Admin access required' });
// }
const { action } = req.query;
// Simple test endpoint
if (action === 'test') {
return res.status(200).json({
success: true,
message: 'Admin API is working!',
timestamp: new Date().toISOString()
});
}
if (req.method === 'GET') {
if (action === 'card-counts') {
try {
// First check if the cards table exists
const tableCheck = await sql.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'cards'
);
`);
if (!tableCheck.rows[0].exists) {
return res.status(200).json({
success: true,
counts: {},
total: 0,
message: 'Cards table does not exist yet'
});
}
// Get card counts from database
const result = await sql.query(`
SELECT
game,
COUNT(*) as count
FROM cards
GROUP BY game
`);
const counts = {};
result.rows.forEach(row => {
counts[row.game] = parseInt(row.count);
});
return res.status(200).json({
success: true,
counts,
total: Object.values(counts).reduce((sum, count) => sum + count, 0)
});
} catch (dbError) {
console.error('❌ Database error:', dbError);
return res.status(500).json({
success: false,
error: 'Database error',
details: dbError.message
});
}
}
if (action === 'user-stats') {
// Get user statistics
const result = await sql.query(`
SELECT
COUNT(*) as total_users,
COUNT(CASE WHEN created_at >= NOW() - INTERVAL '7 days' THEN 1 END) as new_users_7d,
COUNT(CASE WHEN created_at >= NOW() - INTERVAL '30 days' THEN 1 END) as new_users_30d
FROM user_preferences
`);
return res.status(200).json({
success: true,
stats: result.rows[0]
});
}
return res.status(400).json({ error: 'Invalid action' });
}
if (req.method === 'POST') {
if (action === 'load-cards') {
const { game } = req.body;
console.log(`🚀 Starting card loading process for game: ${game}`);
let results = {};
if (game === 'MTG' || game === 'ALL') {
results.mtg = await loadMTGCards();
}
if (game === 'POKEMON' || game === 'ALL') {
results.pokemon = await loadPokemonCards();
}
if (game === 'LORCANA' || game === 'ALL') {
results.lorcana = await loadLorcanaCards();
}
const totalLoaded = Object.values(results).reduce((sum, count) => sum + count, 0);
console.log(`🎉 Card loading completed! Total loaded: ${totalLoaded}`);
return res.status(200).json({
success: true,
message: `Successfully loaded ${totalLoaded} cards`,
results
});
}
if (action === 'manage-users') {
const { operation, userId, data } = req.body;
if (operation === 'promote') {
await sql.query(`
UPDATE user_preferences
SET roles = array_append(roles, 'admin')
WHERE user_id = $1
`, [userId]);
return res.status(200).json({
success: true,
message: 'User promoted to admin'
});
}
if (operation === 'demote') {
await sql.query(`
UPDATE user_preferences
SET roles = array_remove(roles, 'admin')
WHERE user_id = $1
`, [userId]);
return res.status(200).json({
success: true,
message: 'User demoted from admin'
});
}
return res.status(400).json({ error: 'Invalid operation' });
}
return res.status(400).json({ error: 'Invalid action' });
}
} catch (error) {
console.error('❌ Error in admin API:', error);
return res.status(500).json(
{ error: 'Failed to process admin request', details: error.message }
);
}
}

View file

@ -1,53 +0,0 @@
import jwt from 'jsonwebtoken';
import { sql } from '@vercel/postgres';
export async function verifyToken(token) {
if (!token) {
return null;
}
try {
const jwtSecret = process.env.JWT_SECRET || 'fallback-secret-change-in-production';
const decoded = jwt.verify(token, jwtSecret);
// Get user from database
const result = await sql.query(`
SELECT
up.user_id,
up.username,
up.email,
up.first_name,
up.last_name,
up.avatar_url,
up.roles,
up.created_at,
up.updated_at
FROM user_preferences up
WHERE up.user_id = $1
`, [decoded.userId]);
if (result.rows.length === 0) {
return null;
}
const user = result.rows[0];
return {
id: user.user_id,
username: user.username,
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
avatarUrl: user.avatar_url,
roles: user.roles || [],
createdAt: user.created_at,
updatedAt: user.updated_at
};
} catch (error) {
console.error('Token verification failed:', error);
return null;
}
}
export function isAdmin(user) {
return user && user.roles && user.roles.includes('admin');
}

View file

@ -1,125 +0,0 @@
const { Pool } = require('pg');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
});
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const client = await pool.connect();
try {
const { username, password } = req.body;
// Validate input
if (!username || !password) {
return res.status(400).json({
error: 'Username and password are required'
});
}
// Get user by username or email
const userResult = await client.query(`
SELECT id, username, email, password_hash, first_name, last_name, is_active, last_login
FROM users
WHERE (username = $1 OR email = $1) AND is_active = true
`, [username]);
if (userResult.rows.length === 0) {
return res.status(401).json({
error: 'Invalid credentials'
});
}
const user = userResult.rows[0];
// Verify password
const isValidPassword = await bcrypt.compare(password, user.password_hash);
if (!isValidPassword) {
return res.status(401).json({
error: 'Invalid credentials'
});
}
// Get user roles and permissions
const userRoles = await client.query(`
SELECT r.name, r.description,
array_agg(p.name) as permissions
FROM roles r
JOIN user_roles ur ON r.id = ur.role_id
LEFT JOIN role_permissions rp ON r.id = rp.role_id
LEFT JOIN permissions p ON rp.permission_id = p.id
WHERE ur.user_id = $1
GROUP BY r.id, r.name, r.description
`, [user.id]);
const roles = userRoles.rows.map(r => r.name);
const permissions = [...new Set(userRoles.rows.flatMap(r => r.permissions || []))];
// Generate JWT token
const jwtSecret = process.env.JWT_SECRET || 'fallback-secret-change-in-production';
const token = jwt.sign(
{
userId: user.id,
username: user.username,
email: user.email,
roles,
permissions
},
jwtSecret,
{ expiresIn: '7d' }
);
// Store session
const tokenHash = await bcrypt.hash(token, 10);
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
await client.query(`
INSERT INTO user_sessions (user_id, token_hash, expires_at, user_agent, ip_address)
VALUES ($1, $2, $3, $4, $5)
`, [
user.id,
tokenHash,
expiresAt,
req.headers['user-agent'] || null,
req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || null
]);
// Update last login
await client.query(
'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = $1',
[user.id]
);
res.status(200).json({
success: true,
message: 'Login successful',
user: {
id: user.id,
username: user.username,
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
roles,
permissions,
lastLogin: user.last_login
},
token
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({
error: 'Login failed',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
});
} finally {
client.release();
}
}

View file

@ -1,158 +0,0 @@
const { Pool } = require('pg');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
});
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
// Check environment variables
if (!process.env.DATABASE_URL) {
return res.status(500).json({
error: 'Database configuration missing',
details: 'DATABASE_URL environment variable not set'
});
}
if (!process.env.JWT_SECRET) {
console.warn('JWT_SECRET not set, using fallback');
}
const client = await pool.connect();
try {
const { username, email, password, firstName, lastName } = req.body;
// Validate input
if (!username || !email || !password) {
return res.status(400).json({
error: 'Username, email, and password are required'
});
}
if (password.length < 6) {
return res.status(400).json({
error: 'Password must be at least 6 characters long'
});
}
// Check if users table exists
const tableCheck = await client.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'users'
);
`);
if (!tableCheck.rows[0].exists) {
return res.status(500).json({
error: 'Database not initialized',
details: 'Please run the setup-auth endpoint first'
});
}
// Check if user already exists
const existingUser = await client.query(
'SELECT id FROM users WHERE username = $1 OR email = $2',
[username, email]
);
if (existingUser.rows.length > 0) {
return res.status(409).json({
error: 'Username or email already exists'
});
}
// Hash password
const saltRounds = 12;
const passwordHash = await bcrypt.hash(password, saltRounds);
// Create user
const userResult = await client.query(`
INSERT INTO users (username, email, password_hash, first_name, last_name)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, email, first_name, last_name, created_at
`, [username, email, passwordHash, firstName || null, lastName || null]);
const user = userResult.rows[0];
// Assign default 'user' role
const roleResult = await client.query(
'SELECT id FROM roles WHERE name = $1',
['user']
);
if (roleResult.rows.length > 0) {
await client.query(
'INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2)',
[user.id, roleResult.rows[0].id]
);
}
// Generate JWT token
const jwtSecret = process.env.JWT_SECRET || 'fallback-secret-change-in-production';
const token = jwt.sign(
{
userId: user.id,
username: user.username,
email: user.email
},
jwtSecret,
{ expiresIn: '7d' }
);
// Store session
const tokenHash = await bcrypt.hash(token, 10);
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
await client.query(`
INSERT INTO user_sessions (user_id, token_hash, expires_at, user_agent, ip_address)
VALUES ($1, $2, $3, $4, $5)
`, [
user.id,
tokenHash,
expiresAt,
req.headers['user-agent'] || null,
req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || null
]);
// Get user roles for response
const userRoles = await client.query(`
SELECT r.name, r.description
FROM roles r
JOIN user_roles ur ON r.id = ur.role_id
WHERE ur.user_id = $1
`, [user.id]);
res.status(201).json({
success: true,
message: 'User registered successfully',
user: {
id: user.id,
username: user.username,
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
roles: userRoles.rows.map(r => r.name),
createdAt: user.created_at
},
token
});
} catch (error) {
console.error('Registration error:', error);
res.status(500).json({
error: 'Registration failed',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
});
} finally {
client.release();
}
}

View file

@ -1,163 +0,0 @@
import { sql } from '@vercel/postgres';
import { verifyToken } from '../auth-utils.js';
// GET /api/cards - Search cards from database
export default async function handler(req, res) {
// Set CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, 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' && req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const { q, search, game, page = '1', limit = '20' } = req.query;
const query = q || search || '';
const pageNum = parseInt(page);
const limitNum = parseInt(limit);
const offset = (pageNum - 1) * limitNum;
console.log(`🔍 Searching cards: "${query}" game: "${game}" page: ${pageNum}`);
// Build the SQL query
let sqlQuery = `
SELECT
id,
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,
verified,
created_at,
updated_at
FROM cards
WHERE 1=1
`;
const params = [];
let paramIndex = 1;
// Add search filter
if (query.trim()) {
sqlQuery += ` AND (
name ILIKE $${paramIndex} OR
oracle_text ILIKE $${paramIndex} OR
card_type ILIKE $${paramIndex} OR
set_name ILIKE $${paramIndex}
)`;
params.push(`%${query}%`);
paramIndex++;
}
// Add game filter
if (game && game !== 'ALL') {
sqlQuery += ` AND game = $${paramIndex}`;
params.push(game);
paramIndex++;
}
// Add ordering and pagination
sqlQuery += ` ORDER BY name ASC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`;
params.push(limitNum, offset);
console.log(`📝 SQL Query: ${sqlQuery}`);
console.log(`📝 Parameters:`, params);
// Execute the query
const result = await sql.query(sqlQuery, params);
// Get total count for pagination
let countQuery = `
SELECT COUNT(*) as total
FROM cards
WHERE 1=1
`;
const countParams = [];
let countParamIndex = 1;
if (query.trim()) {
countQuery += ` AND (
name ILIKE $${countParamIndex} OR
oracle_text ILIKE $${countParamIndex} OR
card_type ILIKE $${countParamIndex} OR
set_name ILIKE $${countParamIndex}
)`;
countParams.push(`%${query}%`);
countParamIndex++;
}
if (game && game !== 'ALL') {
countQuery += ` AND game = $${countParamIndex}`;
countParams.push(game);
countParamIndex++;
}
const countResult = await sql.query(countQuery, countParams);
const total = parseInt(countResult.rows[0].total);
// Format the response
const cards = result.rows.map(card => ({
id: card.id,
name: card.name,
setName: card.set_name,
setCode: card.set_code,
cardNumber: card.card_number,
rarity: card.rarity,
game: card.game,
manaCost: card.mana_cost,
cmc: card.cmc,
cardType: card.card_type,
colors: card.colors ? JSON.parse(card.colors) : [],
oracleText: card.oracle_text,
power: card.power,
toughness: card.toughness,
imageUrl: card.image_url,
stockImageUrl: card.stock_image_url,
currentPrice: card.current_price,
marketPrice: card.market_price,
verified: card.verified,
createdAt: card.created_at,
updatedAt: card.updated_at
}));
return res.status(200).json({
success: true,
cards,
pagination: {
page: pageNum,
limit: limitNum,
total,
pages: Math.ceil(total / limitNum)
}
});
} catch (error) {
console.error('❌ Error in cards API:', error);
return res.status(500).json({
error: 'Failed to search cards',
details: error.message
});
}
}

View file

@ -1,170 +0,0 @@
import { sql } from '@vercel/postgres';
import { verifyToken } from '../auth-utils.js';
// GET /api/collections - Get user collections
export default async function handler(req, res) {
// Set CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, 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' && req.method !== 'POST' && req.method !== 'PUT' && req.method !== 'DELETE') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
// Temporarily bypass auth for testing
// const token = req.headers.authorization?.replace('Bearer ', '');
// const user = await verifyToken(token);
// if (!user) {
// return res.status(401).json({ error: 'Unauthorized' });
// }
const userId = 1; // Temporarily hardcoded for testing
if (req.method === 'GET') {
const { id } = req.query;
if (id) {
// Get specific collection
const result = await sql.query(`
SELECT
c.id,
c.name,
c.description,
c.is_public,
c.created_at,
c.updated_at,
COUNT(cc.user_card_id) as card_count
FROM collections c
LEFT JOIN collection_cards cc ON c.id = cc.collection_id
WHERE c.id = $1 AND c.user_id = $2
GROUP BY c.id
`, [id, userId]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Collection not found' });
}
return res.status(200).json({
success: true,
collection: result.rows[0]
});
} else {
// Get all collections
const result = await sql.query(`
SELECT
c.id,
c.name,
c.description,
c.is_public,
c.created_at,
c.updated_at,
COUNT(cc.user_card_id) as card_count
FROM collections c
LEFT JOIN collection_cards cc ON c.id = cc.collection_id
WHERE c.user_id = $1
GROUP BY c.id
ORDER BY c.created_at DESC
`, [userId]);
return res.status(200).json({
success: true,
collections: result.rows
});
}
}
if (req.method === 'POST') {
const { name, description = '', isPublic = false } = req.body;
if (!name) {
return res.status(400).json({ error: 'Collection name is required' });
}
const result = await sql.query(`
INSERT INTO collections (user_id, name, description, is_public)
VALUES ($1, $2, $3, $4)
RETURNING *
`, [userId, name, description, isPublic]);
return res.status(201).json({
success: true,
message: 'Collection created',
collection: result.rows[0]
});
}
if (req.method === 'PUT') {
const { id, name, description, isPublic } = req.body;
if (!id) {
return res.status(400).json({ error: 'Collection ID is required' });
}
const result = await sql.query(`
UPDATE collections
SET name = COALESCE($1, name),
description = COALESCE($2, description),
is_public = COALESCE($3, is_public),
updated_at = NOW()
WHERE id = $4 AND user_id = $5
RETURNING *
`, [name, description, isPublic, id, userId]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Collection not found' });
}
return res.status(200).json({
success: true,
message: 'Collection updated',
collection: result.rows[0]
});
}
if (req.method === 'DELETE') {
const { id } = req.query;
if (!id) {
return res.status(400).json({ error: 'Collection ID is required' });
}
// Delete collection cards first
await sql.query(`
DELETE FROM collection_cards
WHERE collection_id = $1
`, [id]);
// Delete the collection
const result = await sql.query(`
DELETE FROM collections
WHERE id = $1 AND user_id = $2
RETURNING *
`, [id, userId]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Collection not found' });
}
return res.status(200).json({
success: true,
message: 'Collection deleted'
});
}
} catch (error) {
console.error('❌ Error in collections API:', error);
return res.status(500).json({
error: 'Failed to process collections request',
details: error.message
});
}
}

View file

@ -1,9 +1,7 @@
export default function handler(req, res) {
// Minimal health check - no external dependencies
res.status(200).json({
status: 'ok',
timestamp: new Date().toISOString(),
method: req.method,
url: req.url
message: 'Clean Next.js API is working!',
timestamp: new Date().toISOString()
});
}

View file

@ -1,3 +0,0 @@
export default function handler(req, res) {
res.status(200).json({ message: 'Hello from Next.js API!' })
}

View file

@ -1,68 +0,0 @@
import { NextResponse } from 'next/server';
// Proxy for Lorcana APIs to handle CORS
export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
const query = searchParams.get('q');
const search = searchParams.get('search');
const limit = searchParams.get('limit') || '20';
const api = searchParams.get('api') || 'lorcana'; // 'lorcana' or 'lorcast'
let url;
let headers = {};
if (api === 'lorcana') {
// Lorcana API - using correct endpoint from docs
if (search) {
// Use the correct search parameter format for Lorcana API
url = `https://api.lorcana-api.com/cards/fetch?search=name~${encodeURIComponent(search)}`;
} else {
url = `https://api.lorcana-api.com/cards/fetch?pagesize=${limit}`;
}
headers = {
'Accept': 'application/json',
'User-Agent': 'TCG-Vault/1.0'
};
} else {
// Lorcast API - using correct endpoint from docs
if (query) {
url = `https://api.lorcast.com/v0/cards?q=${encodeURIComponent(query)}`;
} else {
url = `https://api.lorcast.com/v0/cards`;
}
headers = {
'Accept': 'application/json',
'User-Agent': 'TCG-Vault/1.0'
};
}
console.log(`🔗 Proxying request to: ${url}`);
const response = await fetch(url, { headers });
if (!response.ok) {
console.error(`❌ Proxy error: ${response.status} ${response.statusText}`);
return NextResponse.json(
{ error: `External API error: ${response.status}` },
{ status: response.status }
);
}
const data = await response.json();
console.log(`✅ Proxy success: ${url}`);
return NextResponse.json({
success: true,
data: data,
source: api
});
} catch (error) {
console.error('Proxy error:', error);
return NextResponse.json(
{ error: 'Failed to fetch from external API' },
{ status: 500 }
);
}
}

View file

@ -1,172 +0,0 @@
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
});
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const client = await pool.connect();
try {
console.log('Setting up user authentication schema...');
// Check if users table already exists
const tableCheck = await client.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'users'
);
`);
if (tableCheck.rows[0].exists) {
return res.status(200).json({
success: true,
message: 'User authentication schema already exists',
already_setup: true
});
}
// Create tables one by one
await client.query(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
first_name VARCHAR(50),
last_name VARCHAR(50),
avatar_url TEXT,
is_active BOOLEAN DEFAULT true,
email_verified BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP WITH TIME ZONE
);
`);
await client.query(`
CREATE TABLE IF NOT EXISTS roles (
id SERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
`);
await client.query(`
CREATE TABLE IF NOT EXISTS user_roles (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE,
assigned_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
assigned_by INTEGER REFERENCES users(id),
UNIQUE(user_id, role_id)
);
`);
await client.query(`
CREATE TABLE IF NOT EXISTS permissions (
id SERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
resource VARCHAR(50),
action VARCHAR(50),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
`);
await client.query(`
CREATE TABLE IF NOT EXISTS role_permissions (
id SERIAL PRIMARY KEY,
role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE,
permission_id INTEGER REFERENCES permissions(id) ON DELETE CASCADE,
UNIQUE(role_id, permission_id)
);
`);
await client.query(`
CREATE TABLE IF NOT EXISTS user_sessions (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_used TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
user_agent TEXT,
ip_address INET
);
`);
// Insert default roles
await client.query(`
INSERT INTO roles (name, description) VALUES
('user', 'Standard user with basic permissions'),
('admin', 'Administrator with full system access')
ON CONFLICT (name) DO NOTHING;
`);
// Insert basic permissions
const permissions = [
['cards.read', 'View cards'],
['collections.manage', 'Manage collections'],
['decks.manage', 'Manage decks'],
['admin.access', 'Access admin panel']
];
for (const [name, description] of permissions) {
await client.query(`
INSERT INTO permissions (name, description)
VALUES ($1, $2)
ON CONFLICT (name) DO NOTHING;
`, [name, description]);
}
// Assign permissions to roles
await client.query(`
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r, permissions p
WHERE r.name = 'user'
AND p.name IN ('cards.read', 'collections.manage', 'decks.manage')
ON CONFLICT (role_id, permission_id) DO NOTHING;
`);
await client.query(`
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r, permissions p
WHERE r.name = 'admin'
ON CONFLICT (role_id, permission_id) DO NOTHING;
`);
// Create indexes
await client.query(`
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id);
`);
console.log('✅ User authentication schema setup completed!');
res.status(200).json({
success: true,
message: 'User authentication schema setup completed successfully'
});
} catch (error) {
console.error('Schema setup failed:', error);
res.status(500).json({
success: false,
error: 'Schema setup failed',
details: error.message
});
} finally {
client.release();
}
}

View file

@ -1,22 +0,0 @@
export default function handler(req, res) {
// Set CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
// Handle preflight requests
if (req.method === 'OPTIONS') {
res.status(200).end();
return;
}
// Simple response without any external dependencies
res.status(200).json({
success: true,
message: 'Basic API is working!',
timestamp: new Date().toISOString(),
method: req.method,
url: req.url,
headers: Object.keys(req.headers)
});
}

View file

@ -1,17 +0,0 @@
export default function handler(req, res) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
res.status(200).end();
return;
}
res.status(200).json({
success: true,
message: 'Force deployment test - this should trigger a fresh build!',
timestamp: new Date().toISOString(),
deployment: 'fresh'
});
}

View file

@ -1,38 +0,0 @@
export default async function handler(req, res) {
// Set CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, 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 {
// Dynamically import postgres to avoid module loading issues
const { sql } = await import('@vercel/postgres');
// Test database connection
const result = await sql`SELECT NOW() as current_time, version() as postgres_version`;
return res.status(200).json({
success: true,
message: 'PostgreSQL connection successful!',
data: result.rows[0],
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('❌ PostgreSQL connection error:', error);
return res.status(500).json({
error: 'PostgreSQL connection failed',
details: error.message,
stack: error.stack
});
}
}

View file

@ -1,38 +0,0 @@
export default async function handler(req, res) {
// Set CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, 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 {
// Use require instead of import
const { sql } = require('@vercel/postgres');
// Test database connection
const result = await sql`SELECT NOW() as current_time, version() as postgres_version`;
return res.status(200).json({
success: true,
message: 'PostgreSQL connection successful!',
data: result.rows[0],
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('❌ PostgreSQL connection error:', error);
return res.status(500).json({
error: 'PostgreSQL connection failed',
details: error.message,
stack: error.stack
});
}
}

View file

@ -1,37 +0,0 @@
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, POST, PUT, DELETE, 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 {
// Test database connection
const result = await sql`SELECT NOW() as current_time, version() as postgres_version`;
return res.status(200).json({
success: true,
message: 'PostgreSQL connection successful!',
data: result.rows[0],
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('❌ PostgreSQL connection error:', error);
return res.status(500).json({
error: 'PostgreSQL connection failed',
details: error.message,
stack: error.stack
});
}
}

View file

@ -1,19 +0,0 @@
export default function handler(req, res) {
// Set CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
// Handle preflight requests
if (req.method === 'OPTIONS') {
res.status(200).end();
return;
}
res.status(200).json({
message: 'Simple API test successful!',
timestamp: new Date().toISOString(),
method: req.method,
url: req.url
});
}

View file

@ -1,35 +0,0 @@
import { NextResponse } from 'next/server';
export async function GET(request) {
try {
return NextResponse.json({
success: true,
message: 'Test API is working!',
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('❌ Error in test API:', error);
return NextResponse.json(
{ error: 'Test API failed', details: error.message },
{ status: 500 }
);
}
}
export async function POST(request) {
try {
const body = await request.json();
return NextResponse.json({
success: true,
message: 'Test POST API is working!',
receivedData: body,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('❌ Error in test POST API:', error);
return NextResponse.json(
{ error: 'Test POST API failed', details: error.message },
{ status: 500 }
);
}
}

View file

@ -1,22 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"moduleResolution": "node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"outDir": "./dist"
},
"include": [
"**/*.ts"
],
"exclude": [
"node_modules",
"dist"
]
}

View file

@ -1,265 +0,0 @@
import { sql } from '@vercel/postgres';
import { verifyToken } from './auth-utils.js';
// GET /api/user-cards - Get user's cards with optional filters
export default async function handler(req, res) {
// Set CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, 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' && req.method !== 'POST' && req.method !== 'PUT' && req.method !== 'DELETE') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
// Temporarily bypass auth for testing
// const token = req.headers.authorization?.replace('Bearer ', '');
// const user = await verifyToken(token);
// if (!user) {
// return res.status(401).json({ error: 'Unauthorized' });
// }
const userId = 1; // Temporarily hardcoded for testing
if (req.method === 'GET') {
const { game, status, page = '1', limit = '20' } = req.query;
const pageNum = parseInt(page);
const limitNum = parseInt(limit);
const offset = (pageNum - 1) * limitNum;
let sqlQuery = `
SELECT
uc.id,
uc.user_id,
uc.card_id,
uc.quantity,
uc.status,
uc.condition,
uc.notes,
uc.created_at,
uc.updated_at,
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.verified
FROM user_cards uc
JOIN cards c ON uc.card_id = c.id
WHERE uc.user_id = $1
`;
const params = [userId];
let paramIndex = 2;
if (game && game !== 'ALL') {
sqlQuery += ` AND c.game = $${paramIndex}`;
params.push(game);
paramIndex++;
}
if (status && status !== 'ALL') {
sqlQuery += ` AND uc.status = $${paramIndex}`;
params.push(status);
paramIndex++;
}
sqlQuery += ` ORDER BY c.name ASC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`;
params.push(limitNum, offset);
const result = await sql.query(sqlQuery, params);
// Get total count
let countQuery = `
SELECT COUNT(*) as total
FROM user_cards uc
JOIN cards c ON uc.card_id = c.id
WHERE uc.user_id = $1
`;
const countParams = [userId];
let countParamIndex = 2;
if (game && game !== 'ALL') {
countQuery += ` AND c.game = $${countParamIndex}`;
countParams.push(game);
countParamIndex++;
}
if (status && status !== 'ALL') {
countQuery += ` AND uc.status = $${countParamIndex}`;
countParams.push(status);
countParamIndex++;
}
const countResult = await sql.query(countQuery, countParams);
const total = parseInt(countResult.rows[0].total);
const userCards = result.rows.map(row => ({
id: row.id,
userId: row.user_id,
cardId: row.card_id,
quantity: row.quantity,
status: row.status,
condition: row.condition,
notes: row.notes,
createdAt: row.created_at,
updatedAt: row.updated_at,
card: {
id: row.card_id,
name: row.name,
setName: row.set_name,
setCode: row.set_code,
cardNumber: row.card_number,
rarity: row.rarity,
game: row.game,
manaCost: row.mana_cost,
cmc: row.cmc,
cardType: row.card_type,
colors: row.colors ? JSON.parse(row.colors) : [],
oracleText: row.oracle_text,
power: row.power,
toughness: row.toughness,
imageUrl: row.image_url,
stockImageUrl: row.stock_image_url,
currentPrice: row.current_price,
marketPrice: row.market_price,
verified: row.verified
}
}));
return res.status(200).json({
success: true,
userCards,
pagination: {
page: pageNum,
limit: limitNum,
total,
pages: Math.ceil(total / limitNum)
}
});
}
if (req.method === 'POST') {
const { cardId, quantity = 1, status = 'OWNED', condition = 'NM', notes = '' } = req.body;
if (!cardId) {
return res.status(400).json({ error: 'Card ID is required' });
}
// Check if user already has this card
const existingCard = await sql.query(`
SELECT * FROM user_cards
WHERE user_id = $1 AND card_id = $2
`, [userId, cardId]);
if (existingCard.rows.length > 0) {
// Update existing card
const result = await sql.query(`
UPDATE user_cards
SET quantity = $1, status = $2, condition = $3, notes = $4, updated_at = NOW()
WHERE user_id = $5 AND card_id = $6
RETURNING *
`, [quantity, status, condition, notes, userId, cardId]);
return res.status(200).json({
success: true,
message: 'Card updated',
userCard: result.rows[0]
});
} else {
// Add new card
const result = await sql.query(`
INSERT INTO user_cards (user_id, card_id, quantity, status, condition, notes)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
`, [userId, cardId, quantity, status, condition, notes]);
return res.status(201).json({
success: true,
message: 'Card added',
userCard: result.rows[0]
});
}
}
if (req.method === 'PUT') {
const { id, quantity, status, condition, notes } = req.body;
if (!id) {
return res.status(400).json({ error: 'User card ID is required' });
}
const result = await sql.query(`
UPDATE user_cards
SET quantity = COALESCE($1, quantity),
status = COALESCE($2, status),
condition = COALESCE($3, condition),
notes = COALESCE($4, notes),
updated_at = NOW()
WHERE id = $5 AND user_id = $6
RETURNING *
`, [quantity, status, condition, notes, id, userId]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'User card not found' });
}
return res.status(200).json({
success: true,
message: 'Card updated',
userCard: result.rows[0]
});
}
if (req.method === 'DELETE') {
const { id } = req.query;
if (!id) {
return res.status(400).json({ error: 'User card ID is required' });
}
const result = await sql.query(`
DELETE FROM user_cards
WHERE id = $1 AND user_id = $2
RETURNING *
`, [id, userId]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'User card not found' });
}
return res.status(200).json({
success: true,
message: 'Card removed'
});
}
} catch (error) {
console.error('❌ Error in user-cards API:', error);
return res.status(500).json({
error: 'Failed to process user cards request',
details: error.message
});
}
}

View file

@ -1,282 +0,0 @@
import { NextResponse } from 'next/server';
import { sql } from '@vercel/postgres';
import { verifyToken } from '../auth-utils.js';
// GET /api/user-cards/[id] - Get single user card
export async function GET(request, { params }) {
try {
const token = request.headers.get('authorization')?.replace('Bearer ', '');
const user = await verifyToken(token);
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = params;
const result = await sql.query(
`SELECT
uc.id,
uc.user_id,
uc.card_id,
uc.status,
uc.quantity,
uc.condition,
uc.notes,
uc.acquired_date,
uc.acquired_price,
uc.acquired_from,
uc.created_at,
uc.updated_at,
c.name,
c.set_name,
c.set_code,
c.card_number,
c.rarity,
c.game,
c.card_type,
c.mana_cost,
c.cmc,
c.colors,
c.oracle_text,
c.power,
c.toughness,
c.image_url,
c.stock_image_url,
c.current_price,
c.market_price,
c.verified,
c.created_at as card_created_at,
c.updated_at as card_updated_at
FROM user_cards uc
JOIN cards c ON uc.card_id = c.id
WHERE uc.id = $1 AND uc.user_id = $2`,
[id, user.id]
);
if (result.rows.length === 0) {
return NextResponse.json(
{ error: 'Card not found or not owned by user' },
{ status: 404 }
);
}
const row = result.rows[0];
const userCard = {
id: row.id,
userId: row.user_id,
cardId: row.card_id,
status: row.status,
quantity: row.quantity,
condition: row.condition,
notes: row.notes,
acquiredDate: row.acquired_date,
acquiredPrice: row.acquired_price,
acquiredFrom: row.acquired_from,
createdAt: row.created_at,
updatedAt: row.updated_at,
card: {
id: row.card_id,
name: row.name,
set_name: row.set_name,
set_code: row.set_code,
card_number: row.card_number,
rarity: row.rarity,
game: row.game,
card_type: row.card_type,
mana_cost: row.mana_cost,
cmc: row.cmc,
colors: row.colors ? JSON.parse(row.colors) : [],
oracle_text: row.oracle_text,
power: row.power,
toughness: row.toughness,
image_url: row.image_url,
stock_image_url: row.stock_image_url,
current_price: row.current_price,
market_price: row.market_price,
verified: row.verified,
createdAt: row.card_created_at,
updatedAt: row.card_updated_at,
}
};
return NextResponse.json({
success: true,
data: userCard
});
} catch (error) {
console.error('Error fetching user card:', error);
return NextResponse.json(
{ error: 'Failed to fetch user card' },
{ status: 500 }
);
}
}
// PUT /api/user-cards/[id] - Update user card
export async function PUT(request, { params }) {
try {
const token = request.headers.get('authorization')?.replace('Bearer ', '');
const user = await verifyToken(token);
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = params;
const body = await request.json();
// Check if user owns this card
const ownershipResult = await sql.query(
'SELECT id FROM user_cards WHERE id = $1 AND user_id = $2',
[id, user.id]
);
if (ownershipResult.rows.length === 0) {
return NextResponse.json(
{ error: 'Card not found or not owned by user' },
{ status: 404 }
);
}
// Build update query dynamically
const updateFields = [];
const updateValues = [];
let paramIndex = 1;
if (body.status !== undefined) {
updateFields.push(`status = $${paramIndex}`);
updateValues.push(body.status);
paramIndex++;
}
if (body.quantity !== undefined) {
updateFields.push(`quantity = $${paramIndex}`);
updateValues.push(body.quantity);
paramIndex++;
}
if (body.condition !== undefined) {
updateFields.push(`condition = $${paramIndex}`);
updateValues.push(body.condition);
paramIndex++;
}
if (body.notes !== undefined) {
updateFields.push(`notes = $${paramIndex}`);
updateValues.push(body.notes);
paramIndex++;
}
if (body.acquiredDate !== undefined) {
updateFields.push(`acquired_date = $${paramIndex}`);
updateValues.push(body.acquiredDate);
paramIndex++;
}
if (body.acquiredPrice !== undefined) {
updateFields.push(`acquired_price = $${paramIndex}`);
updateValues.push(body.acquiredPrice);
paramIndex++;
}
if (body.acquiredFrom !== undefined) {
updateFields.push(`acquired_from = $${paramIndex}`);
updateValues.push(body.acquiredFrom);
paramIndex++;
}
if (updateFields.length === 0) {
return NextResponse.json(
{ error: 'No fields to update' },
{ status: 400 }
);
}
updateFields.push(`updated_at = NOW()`);
updateValues.push(id);
const query = `
UPDATE user_cards
SET ${updateFields.join(', ')}
WHERE id = $${paramIndex}
RETURNING *
`;
const result = await sql.query(query, updateValues);
const userCard = result.rows[0];
return NextResponse.json({
success: true,
data: {
id: userCard.id,
userId: userCard.user_id,
cardId: userCard.card_id,
status: userCard.status,
quantity: userCard.quantity,
condition: userCard.condition,
notes: userCard.notes,
acquiredDate: userCard.acquired_date,
acquiredPrice: userCard.acquired_price,
acquiredFrom: userCard.acquired_from,
createdAt: userCard.created_at,
updatedAt: userCard.updated_at,
},
message: 'Card updated successfully'
});
} catch (error) {
console.error('Error updating user card:', error);
return NextResponse.json(
{ error: 'Failed to update card' },
{ status: 500 }
);
}
}
// DELETE /api/user-cards/[id] - Delete user card
export async function DELETE(request, { params }) {
try {
const token = request.headers.get('authorization')?.replace('Bearer ', '');
const user = await verifyToken(token);
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = params;
// Check if user owns this card
const ownershipResult = await sql.query(
'SELECT id FROM user_cards WHERE id = $1 AND user_id = $2',
[id, user.id]
);
if (ownershipResult.rows.length === 0) {
return NextResponse.json(
{ error: 'Card not found or not owned by user' },
{ status: 404 }
);
}
// Delete from collections and decks first
await sql.query('DELETE FROM collection_cards WHERE user_card_id = $1', [id]);
await sql.query('DELETE FROM deck_cards WHERE user_card_id = $1', [id]);
// Delete the user card
await sql.query('DELETE FROM user_cards WHERE id = $1', [id]);
return NextResponse.json({
success: true,
message: 'Card removed from collection'
});
} catch (error) {
console.error('Error deleting user card:', error);
return NextResponse.json(
{ error: 'Failed to delete card' },
{ status: 500 }
);
}
}

View file

@ -1,219 +0,0 @@
const { Pool } = require('pg');
const jwt = require('jsonwebtoken');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
});
// Verify JWT token and extract user info
function verifyAuth(req) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new Error('No authorization token provided');
}
const token = authHeader.substring(7);
const jwtSecret = process.env.JWT_SECRET || 'fallback-secret-change-in-production';
try {
const decoded = jwt.verify(token, jwtSecret);
return decoded;
} catch (error) {
throw new Error('Invalid or expired token');
}
}
export default async function handler(req, res) {
const client = await pool.connect();
try {
const user = verifyAuth(req);
// Ensure ocr_settings column exists (auto-migration)
try {
await client.query(`
ALTER TABLE user_preferences
ADD COLUMN IF NOT EXISTS ocr_settings JSONB DEFAULT '{
"preferred_service": "openai",
"openai_api_key": "",
"ollama_url": "http://localhost:11434",
"auto_add_to_collection": false,
"confidence_threshold": 80
}'::jsonb;
`);
} catch (error) {
// Column might already exist, ignore error
console.log('OCR settings column may already exist:', error.message);
}
if (req.method === 'GET') {
// Get user preferences
const result = await client.query(`
SELECT
default_view,
items_per_page,
enable_animations,
enable_ocr,
theme,
privacy_settings,
ocr_settings,
created_at,
updated_at
FROM user_preferences
WHERE user_id = $1
`, [user.userId]);
if (result.rows.length === 0) {
// Create default preferences if none exist
const defaultPrefs = {
default_view: 'card',
items_per_page: 20,
enable_animations: true,
enable_ocr: true,
theme: 'light',
privacy_settings: { collections_public: false, decks_public: false },
ocr_settings: {
preferred_service: 'openai',
openai_api_key: '',
ollama_url: 'http://localhost:11434',
auto_add_to_collection: false,
confidence_threshold: 80
}
};
await client.query(`
INSERT INTO user_preferences (
user_id, default_view, items_per_page, enable_animations,
enable_ocr, theme, privacy_settings, ocr_settings
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, [
user.userId,
defaultPrefs.default_view,
defaultPrefs.items_per_page,
defaultPrefs.enable_animations,
defaultPrefs.enable_ocr,
defaultPrefs.theme,
JSON.stringify(defaultPrefs.privacy_settings),
JSON.stringify(defaultPrefs.ocr_settings)
]);
return res.status(200).json({
success: true,
preferences: defaultPrefs
});
}
const preferences = result.rows[0];
res.status(200).json({
success: true,
preferences: {
defaultView: preferences.default_view,
itemsPerPage: preferences.items_per_page,
enableAnimations: preferences.enable_animations,
enableOcr: preferences.enable_ocr,
theme: preferences.theme,
privacySettings: preferences.privacy_settings,
ocrSettings: preferences.ocr_settings || {
preferred_service: 'openai',
openai_api_key: '',
ollama_url: 'http://localhost:11434',
auto_add_to_collection: false,
confidence_threshold: 80
},
createdAt: preferences.created_at,
updatedAt: preferences.updated_at
}
});
} else if (req.method === 'PUT') {
// Update user preferences
const {
defaultView,
itemsPerPage,
enableAnimations,
enableOcr,
theme,
privacySettings,
ocrSettings
} = req.body;
// Validate OCR settings if provided
if (ocrSettings) {
const allowedServices = ['openai', 'ollama'];
if (ocrSettings.preferred_service && !allowedServices.includes(ocrSettings.preferred_service)) {
return res.status(400).json({
error: 'Invalid OCR service. Must be "openai" or "ollama"'
});
}
if (ocrSettings.confidence_threshold && (ocrSettings.confidence_threshold < 0 || ocrSettings.confidence_threshold > 100)) {
return res.status(400).json({
error: 'Confidence threshold must be between 0 and 100'
});
}
}
// Update preferences (upsert)
const result = await client.query(`
INSERT INTO user_preferences (
user_id, default_view, items_per_page, enable_animations,
enable_ocr, theme, privacy_settings, ocr_settings, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, CURRENT_TIMESTAMP)
ON CONFLICT (user_id)
DO UPDATE SET
default_view = COALESCE($2, user_preferences.default_view),
items_per_page = COALESCE($3, user_preferences.items_per_page),
enable_animations = COALESCE($4, user_preferences.enable_animations),
enable_ocr = COALESCE($5, user_preferences.enable_ocr),
theme = COALESCE($6, user_preferences.theme),
privacy_settings = COALESCE($7, user_preferences.privacy_settings),
ocr_settings = COALESCE($8, user_preferences.ocr_settings),
updated_at = CURRENT_TIMESTAMP
RETURNING *
`, [
user.userId,
defaultView,
itemsPerPage,
enableAnimations,
enableOcr,
theme,
privacySettings ? JSON.stringify(privacySettings) : null,
ocrSettings ? JSON.stringify(ocrSettings) : null
]);
const preferences = result.rows[0];
res.status(200).json({
success: true,
message: 'Preferences updated successfully',
preferences: {
defaultView: preferences.default_view,
itemsPerPage: preferences.items_per_page,
enableAnimations: preferences.enable_animations,
enableOcr: preferences.enable_ocr,
theme: preferences.theme,
privacySettings: preferences.privacy_settings,
ocrSettings: preferences.ocr_settings,
updatedAt: preferences.updated_at
}
});
} else {
res.status(405).json({ error: 'Method not allowed' });
}
} catch (error) {
console.error('User preferences error:', error);
if (error.message.includes('authorization') || error.message.includes('token')) {
res.status(401).json({ error: 'Unauthorized' });
} else {
res.status(500).json({
error: 'Failed to manage preferences',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
} finally {
client.release();
}
}

14
pages/index.js Normal file
View file

@ -0,0 +1,14 @@
export default function Home() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-4xl font-bold text-gray-900 mb-4">
TCG Vault
</h1>
<p className="text-gray-600">
Clean Next.js setup is working!
</p>
</div>
</div>
);
}

View file

@ -1,10 +0,0 @@
import React from 'react';
import dynamic from 'next/dynamic';
const App = dynamic(() => import('../src/App'), {
ssr: false,
});
export default function HomePage() {
return <App />;
}

View file

@ -1,4 +0,0 @@
Options -MultiViews
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.html [QSA,L]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

View file

@ -1,30 +0,0 @@
{
"short_name": "TCG Vault",
"name": "TCG Vault - Trading Card Game Collection Manager",
"description": "Mobile-first PWA for managing your trading card game collections, decks, and scanning cards with AI-powered OCR",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "any maskable"
}
],
"start_url": "/",
"display": "standalone",
"orientation": "portrait-primary",
"theme_color": "#8b5cf6",
"background_color": "#ffffff",
"categories": ["entertainment", "lifestyle", "utilities"],
"prefer_related_applications": false
}

View file

@ -1,3 +0,0 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View file

@ -1,38 +0,0 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View file

@ -1,9 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View file

@ -1,211 +0,0 @@
import React from 'react';
import { useRouter } from 'next/router';
import { Analytics } from '@vercel/analytics/react';
import { SpeedInsights } from '@vercel/speed-insights/react';
import { useAuth } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext';
import ResponsiveLayout from './components/ResponsiveLayout';
import Dashboard from './pages/Dashboard';
import Collections from './pages/Collections';
import Decks from './pages/Decks';
import Cards from './pages/Cards';
import Scanner from './pages/Scanner';
import Settings from './pages/Settings';
import LoginForm from './components/auth/LoginForm';
import RegisterForm from './components/auth/RegisterForm';
import AdminPanel from './components/admin/AdminPanel';
// Protected Route Component
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { user, isLoading } = useAuth();
const router = useRouter();
if (isLoading) {
return (
<div className="min-h-screen-safe flex items-center justify-center bg-white dark:bg-surface-900">
<div className="flex flex-col items-center space-y-4">
<div className="w-12 h-12 bg-gradient-to-r from-primary-500 to-accent-500 rounded-xl flex items-center justify-center animate-bounce-subtle">
<svg className="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<p className="text-surface-600 dark:text-surface-400 text-sm">Loading TCG Vault...</p>
</div>
</div>
);
}
if (!user) {
router.push('/login');
return null;
}
return <>{children}</>;
};
// Public Route Component (redirect if authenticated)
const PublicRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { user, isLoading } = useAuth();
const router = useRouter();
if (isLoading) {
return (
<div className="min-h-screen-safe flex items-center justify-center bg-white dark:bg-surface-900">
<div className="flex flex-col items-center space-y-4">
<div className="w-12 h-12 bg-gradient-to-r from-primary-500 to-accent-500 rounded-xl flex items-center justify-center animate-bounce-subtle">
<svg className="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<p className="text-surface-600 dark:text-surface-400 text-sm">Loading TCG Vault...</p>
</div>
</div>
);
}
if (user) {
router.push('/dashboard');
return null;
}
return <>{children}</>;
};
// Responsive Layout Component
const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return (
<ResponsiveLayout>
{children}
</ResponsiveLayout>
);
};
// Auth Layout Component for login/register
const AuthLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return (
<div className="min-h-screen-safe bg-gradient-to-br from-primary-50 via-white to-accent-50 dark:from-surface-900 dark:via-surface-800 dark:to-surface-900 flex items-center justify-center px-4 transition-colors">
<div className="w-full max-w-sm animate-slide-up">
{children}
</div>
</div>
);
};
function App() {
const router = useRouter();
const { pathname } = router;
return (
<ThemeProvider>
{/* Public Routes */}
{pathname === '/login' && (
<PublicRoute>
<AuthLayout>
<LoginForm />
</AuthLayout>
</PublicRoute>
)}
{pathname === '/register' && (
<PublicRoute>
<AuthLayout>
<RegisterForm />
</AuthLayout>
</PublicRoute>
)}
{/* Protected Routes */}
{pathname === '/dashboard' && (
<ProtectedRoute>
<AppLayout>
<Dashboard />
</AppLayout>
</ProtectedRoute>
)}
{pathname === '/collections' && (
<ProtectedRoute>
<AppLayout>
<Collections />
</AppLayout>
</ProtectedRoute>
)}
{pathname === '/decks' && (
<ProtectedRoute>
<AppLayout>
<Decks />
</AppLayout>
</ProtectedRoute>
)}
{pathname === '/cards' && (
<ProtectedRoute>
<AppLayout>
<Cards />
</AppLayout>
</ProtectedRoute>
)}
{pathname === '/scanner' && (
<ProtectedRoute>
<AppLayout>
<Scanner />
</AppLayout>
</ProtectedRoute>
)}
{pathname === '/settings' && (
<ProtectedRoute>
<AppLayout>
<Settings />
</AppLayout>
</ProtectedRoute>
)}
{/* Admin Routes */}
{pathname === '/admin' && (
<ProtectedRoute>
<AppLayout>
<AdminPanel />
</AppLayout>
</ProtectedRoute>
)}
{/* Default redirect */}
{pathname === '/' && (
<ProtectedRoute>
<AppLayout>
<Dashboard />
</AppLayout>
</ProtectedRoute>
)}
{/* 404 fallback */}
{!['/login', '/register', '/dashboard', '/collections', '/decks', '/cards', '/scanner', '/settings', '/admin', '/'].includes(pathname) && (
<AppLayout>
<div className="text-center py-12">
<div className="w-16 h-16 bg-surface-200 dark:bg-surface-700 rounded-2xl flex items-center justify-center mx-auto mb-6">
<svg className="w-8 h-8 text-surface-500 dark:text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h1 className="text-2xl font-bold text-surface-900 dark:text-white mb-2">Page Not Found</h1>
<p className="text-surface-600 dark:text-surface-400 mb-6">The page you&apos;re looking for doesn&apos;t exist.</p>
<button
onClick={() => router.back()}
className="bg-primary-600 hover:bg-primary-700 text-white px-6 py-3 rounded-xl font-medium transition-colors"
>
Go Back
</button>
</div>
</AppLayout>
)}
<Analytics />
<SpeedInsights />
</ThemeProvider>
);
}
export default App;

View file

@ -1,312 +0,0 @@
import React, { useState, useRef, useEffect } from 'react';
import { aiCardOCR, ollamaCardOCR, type CardOCRResult } from '../services/aiOcr';
interface CameraScannerProps {
onCardScanned: (cardData: any) => void;
onError: (error: string) => void;
}
interface ScanResult {
text: string;
confidence: number;
cardName?: string;
setName?: string;
}
const CameraScanner: React.FC<CameraScannerProps> = ({ onCardScanned, onError }) => {
const [isStreaming, setIsStreaming] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [capturedImage, setCapturedImage] = useState<string | null>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const streamRef = useRef<MediaStream | null>(null);
// Start camera stream
const startCamera = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment', // Use back camera on mobile
width: { ideal: 1920 },
height: { ideal: 1080 }
}
});
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: any) {
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;
}
setIsStreaming(false);
setCapturedImage(null);
setScanResult(null);
};
// Capture image from video stream
const captureImage = () => {
if (!videoRef.current || !canvasRef.current) return;
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
const imageDataUrl = canvas.toDataURL('image/jpeg', 0.8);
setCapturedImage(imageDataUrl);
// Process with OCR
processImage(imageDataUrl);
};
// Process image with AI OCR
const processImage = async (imageData: string) => {
setIsProcessing(true);
setScanResult(null);
try {
// Try OpenAI Vision first, fallback to Ollama if available
let ocrResult: CardOCRResult;
try {
console.log('🤖 Trying OpenAI Vision API...');
ocrResult = await aiCardOCR.analyzeCard(imageData);
console.log('✅ OpenAI Vision result:', ocrResult);
} catch (openaiError) {
console.log('❌ OpenAI failed, trying Ollama:', openaiError);
try {
ocrResult = await ollamaCardOCR.analyzeCard(imageData);
console.log('✅ Ollama Vision result:', ocrResult);
} catch (ollamaError) {
console.error('❌ All AI OCR methods failed');
throw new Error('AI OCR services unavailable. Please configure OpenAI API key or run Ollama locally.');
}
}
const result: ScanResult = {
text: ocrResult.rawText,
confidence: ocrResult.confidence,
cardName: ocrResult.cardName,
setName: ocrResult.setName
};
setScanResult(result);
// Send enhanced data to card matcher
if (ocrResult.cardName) {
onCardScanned({
name: ocrResult.cardName,
set: ocrResult.setName || ocrResult.setCode,
setCode: ocrResult.setCode,
game: ocrResult.game,
cardType: ocrResult.cardType,
rarity: ocrResult.rarity,
hp: ocrResult.hp,
attacks: ocrResult.attacks,
abilities: ocrResult.abilities,
ocrText: ocrResult.rawText,
confidence: ocrResult.confidence
});
}
} catch (error: any) {
console.error('AI OCR processing error:', error);
onError(`AI OCR failed: ${error.message}`);
} finally {
setIsProcessing(false);
}
};
// Cleanup on unmount
useEffect(() => {
return () => {
stopCamera();
};
}, []);
return (
<div className="camera-scanner">
{/* Camera Controls */}
<div className="flex gap-4 mb-4">
{!isStreaming ? (
<button
onClick={startCamera}
className="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-3 rounded-lg font-medium transition-colors flex items-center gap-2"
>
<span>📹</span> Start Camera
</button>
) : (
<>
<button
onClick={captureImage}
disabled={isProcessing}
className="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white px-6 py-3 rounded-lg font-medium transition-colors flex items-center gap-2"
>
<span>📸</span>
{isProcessing ? 'Processing...' : 'Capture Card'}
</button>
<button
onClick={stopCamera}
className="bg-red-600 hover:bg-red-700 text-white px-6 py-3 rounded-lg font-medium transition-colors flex items-center gap-2"
>
<span></span> Stop Camera
</button>
</>
)}
</div>
{/* Camera Preview - Always render video element */}
<div className="relative bg-black rounded-lg overflow-hidden mb-4">
<video
ref={videoRef}
autoPlay
playsInline
muted
className="w-full h-auto max-h-96 object-cover"
style={{ minHeight: isStreaming ? 'auto' : '200px' }}
/>
{/* Show overlay only when streaming */}
{isStreaming && (
<div className="absolute inset-0 pointer-events-none">
<div className="absolute inset-4 border-2 border-white border-dashed rounded-lg flex items-center justify-center">
<div className="bg-black bg-opacity-50 text-white px-4 py-2 rounded-lg text-sm text-center">
<div className="font-medium">Position card within this area</div>
<div className="text-xs opacity-75">Ensure good lighting and focus</div>
</div>
</div>
</div>
)}
{/* Show placeholder when not streaming */}
{!isStreaming && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-white text-center">
<div className="text-4xl mb-2">📹</div>
<div className="font-medium">Camera Preview</div>
<div className="text-sm opacity-75">Click &quot;Start Camera&quot; to begin</div>
</div>
</div>
)}
</div>
{/* Hidden canvas for image capture */}
<canvas ref={canvasRef} className="hidden" />
{/* Processing Status */}
{isProcessing && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<div>
<div className="font-medium text-blue-900">Processing image...</div>
<div className="text-sm text-blue-600">Extracting card information</div>
</div>
</div>
</div>
)}
{/* Captured Image Preview */}
{capturedImage && !isProcessing && (
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-4">
<div className="font-medium text-gray-900 mb-2">Captured Image</div>
<img
src={capturedImage}
alt="Captured card"
className="max-w-full h-auto max-h-48 rounded border border-gray-300"
/>
</div>
)}
{/* OCR Results */}
{scanResult && (
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="font-medium text-gray-900 mb-2">Scan Results</div>
{scanResult.cardName ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-green-600"></span>
<div>
<div className="font-medium">Card Found: {scanResult.cardName}</div>
{scanResult.setName && (
<div className="text-sm text-gray-600">Set: {scanResult.setName}</div>
)}
</div>
</div>
<div className="text-sm text-gray-500">
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">Card not recognized</div>
</div>
<details className="text-sm text-gray-600">
<summary className="cursor-pointer">View OCR text</summary>
<pre className="mt-2 whitespace-pre-wrap bg-gray-50 p-2 rounded text-xs">
{scanResult.text}
</pre>
</details>
</div>
)}
</div>
)}
{/* Instructions */}
<div className="bg-gray-50 rounded-lg p-4 mt-4">
<div className="font-medium text-gray-900 mb-2">📋 Scanning Tips</div>
<ul className="text-sm text-gray-600 space-y-1">
<li> Ensure good lighting and avoid shadows</li>
<li> Keep the card flat and in focus</li>
<li> Position the card name clearly in view</li>
<li> Avoid glare and reflections</li>
<li> Works best with English cards</li>
</ul>
</div>
</div>
);
};
export default CameraScanner;

View file

@ -1,175 +0,0 @@
import React, { useState } from 'react';
import { use3DTilt } from '../hooks/use3DTilt';
interface CardImageDisplayProps {
card: {
id: string;
name: string;
game: string;
stock_image_url?: string;
image_url?: string;
rarity: string;
};
userImages?: string[];
size?: 'small' | 'medium' | 'large';
showUserPhotos?: boolean;
className?: string;
}
const CardImageDisplay: React.FC<CardImageDisplayProps> = ({
card,
userImages = [],
size = 'medium',
showUserPhotos: initialShowUserPhotos = false,
className = ''
}) => {
const [imageError, setImageError] = useState(false);
const [currentUserImageIndex, setCurrentUserImageIndex] = useState(0);
const [showUserPhotos, setShowUserPhotos] = useState(initialShowUserPhotos);
// 3D Tilt effect (no scaling - main container handles expansion)
const { ref: tiltRef, tiltStyles } = use3DTilt({
maxTilt: size === 'large' ? 15 : 10,
scale: 1.0, // No image scaling - main container expands
speed: 400,
easing: 'cubic-bezier(0.23, 1, 0.320, 1)'
});
// Determine which image to show
const getDisplayImage = () => {
if (showUserPhotos && userImages.length > 0) {
return userImages[currentUserImageIndex];
}
return card.stock_image_url || card.image_url;
};
// Determine if card should have foil effects
const isFoilCard = () => {
const foilRarities = ['super rare', 'legendary', 'mythic'];
return foilRarities.includes(card.rarity.toLowerCase());
};
// Get rarity border class
const getRarityBorderClass = () => {
const rarity = card.rarity.toLowerCase().replace(/\s+/g, '');
return `rarity-border-${rarity}`;
};
// Size classes
const sizeClasses = {
small: 'w-16 h-22',
medium: 'w-24 h-32',
large: 'w-48 h-64'
};
// Placeholder based on game
const getPlaceholder = () => {
const gameColors = {
MTG: 'from-orange-400 to-red-500',
POKEMON: 'from-yellow-400 to-red-500',
LORCANA: 'from-purple-400 to-pink-500'
};
const gradientClass = gameColors[card.game as keyof typeof gameColors] || 'from-gray-400 to-gray-600';
const rarityBorderClass = getRarityBorderClass();
const foilClass = isFoilCard() ? 'foil-rainbow' : '';
return (
<div
ref={tiltRef}
style={tiltStyles}
className={`${sizeClasses[size]} bg-gradient-to-br ${gradientClass} rounded-lg flex flex-col items-center justify-center text-white shadow-md card-transition card-depth ${rarityBorderClass} ${foilClass} ${className}`}
>
<div className="text-lg font-bold mb-1">🃏</div>
<div className="text-xs text-center px-2 leading-tight">
{card.name.split(' ').slice(0, 2).join(' ')}
</div>
<div className="text-xs opacity-75 mt-1">
{card.game}
</div>
</div>
);
};
const displayImage = getDisplayImage();
if (!displayImage || imageError) {
return getPlaceholder();
}
const rarityBorderClass = getRarityBorderClass();
const foilClass = isFoilCard() ? 'foil-card' : '';
const legendaryHoloClass = card.rarity.toLowerCase() === 'legendary' || card.rarity.toLowerCase() === 'mythic' ? 'holographic' : '';
return (
<div className={`relative ${className}`}>
<div
ref={tiltRef}
style={tiltStyles}
className={`card-transition card-depth ${rarityBorderClass} ${foilClass} ${legendaryHoloClass} rounded-lg`}
>
<img
src={displayImage}
alt={card.name}
className={`${sizeClasses[size]} object-cover rounded-lg`}
onError={() => setImageError(true)}
/>
</div>
{/* User photo indicator */}
{showUserPhotos && userImages.length > 0 && (
<div className="absolute top-1 left-1 bg-blue-500 text-white text-xs px-1 py-0.5 rounded">
📸 {currentUserImageIndex + 1}/{userImages.length}
</div>
)}
{/* Stock/User toggle */}
{userImages.length > 0 && (
<div className="absolute bottom-1 right-1">
<button
onClick={() => setShowUserPhotos(!showUserPhotos)}
className="bg-black bg-opacity-50 text-white text-xs px-1 py-0.5 rounded hover:bg-opacity-75"
title={showUserPhotos ? 'Show stock image' : 'Show your photos'}
>
{showUserPhotos ? '📋' : '📸'}
</button>
</div>
)}
{/* User photo navigation */}
{showUserPhotos && userImages.length > 1 && (
<div className="absolute bottom-1 left-1 flex gap-1">
<button
onClick={() => setCurrentUserImageIndex((prev) => Math.max(0, prev - 1))}
disabled={currentUserImageIndex === 0}
className="bg-black bg-opacity-50 text-white text-xs px-1 py-0.5 rounded disabled:opacity-50"
>
</button>
<button
onClick={() => setCurrentUserImageIndex((prev) => Math.min(userImages.length - 1, prev + 1))}
disabled={currentUserImageIndex === userImages.length - 1}
className="bg-black bg-opacity-50 text-white text-xs px-1 py-0.5 rounded disabled:opacity-50"
>
</button>
</div>
)}
{/* Image type indicator */}
<div className="absolute top-1 right-1">
{showUserPhotos ? (
<span className="bg-blue-500 text-white text-xs px-1 py-0.5 rounded" title="Your card photo">
👤
</span>
) : (
<span className="bg-green-500 text-white text-xs px-1 py-0.5 rounded" title="Stock image">
</span>
)}
</div>
</div>
);
};
export default CardImageDisplay;

View file

@ -1,45 +0,0 @@
import React from 'react';
interface GlowingCardProps {
children: React.ReactNode;
rarity: string;
className?: string;
}
const GlowingCard: React.FC<GlowingCardProps> = ({
children,
rarity,
className = ''
}) => {
// No mouse glow - just static container
// Get rarity border class
const getRarityBorderClass = () => {
const rarityName = rarity.toLowerCase().replace(/\s+/g, '');
return `rarity-border-${rarityName}`;
};
// Get optimized animation class based on rarity
const getAnimationClass = () => {
switch (rarity.toLowerCase()) {
case 'mythic':
return 'mythic-expansion premium-card-animation';
case 'legendary':
return 'premium-card-animation';
case 'super rare':
return 'premium-card-animation'; // Optimized performance version
default:
return 'card-expansion-organic';
}
};
return (
<div
className={`${getRarityBorderClass()} ${getAnimationClass()} ${className}`}
>
{children}
</div>
);
};
export default GlowingCard;

View file

@ -1,266 +0,0 @@
import React, { useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useTheme } from '../contexts/ThemeContext';
const MobileNavbar: React.FC = () => {
const { user, logout, isAdmin } = useAuth();
const { toggleTheme, effectiveTheme } = useTheme();
const location = useLocation();
const navigate = useNavigate();
const [showUserMenu, setShowUserMenu] = useState(false);
const handleLogout = () => {
logout();
setShowUserMenu(false);
navigate('/login');
};
const isActive = (path: string) => location.pathname === path;
return (
<>
{/* Top Header */}
<header className="fixed top-0 left-0 right-0 z-40 bg-white dark:bg-surface-900 border-b border-surface-200 dark:border-surface-700 px-4 py-3 safe-area-inset-top">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="bg-gradient-to-r from-primary-500 to-accent-500 text-white p-2 rounded-xl">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<div>
<h1 className="text-lg font-bold text-surface-900 dark:text-white">TCG Vault</h1>
<p className="text-xs text-surface-600 dark:text-surface-400">
Hi, {user?.firstName || user?.username}
</p>
</div>
</div>
<div className="flex items-center space-x-2">
{/* Theme Toggle */}
<button
onClick={toggleTheme}
className="p-2 rounded-xl bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors"
>
{effectiveTheme === 'dark' ? (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>
)}
</button>
{/* User Avatar */}
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="relative p-1"
>
<div className="w-8 h-8 bg-gradient-to-r from-primary-500 to-accent-500 rounded-xl flex items-center justify-center text-white font-semibold text-sm">
{user?.firstName?.[0] || user?.username?.[0]?.toUpperCase() || 'U'}
</div>
{isAdmin() && (
<div className="absolute -top-1 -right-1 w-3 h-3 bg-yellow-400 rounded-full border border-white dark:border-surface-900"></div>
)}
</button>
</div>
</div>
</header>
{/* Bottom Navigation */}
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-white dark:bg-surface-900 border-t border-surface-200 dark:border-surface-700 safe-area-inset-bottom">
<div className="relative flex items-end justify-around px-4 py-2">
{/* Home */}
<Link
to="/dashboard"
className={`flex flex-col items-center justify-center p-2 rounded-xl transition-all duration-200 ${
isActive('/dashboard')
? 'text-primary-600 dark:text-primary-400'
: 'text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white'
}`}
>
<div className="mb-1">
{isActive('/dashboard') ? (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
</svg>
) : (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
)}
</div>
<span className="text-xs font-medium">Home</span>
</Link>
{/* Cards */}
<Link
to="/cards"
className={`flex flex-col items-center justify-center p-2 rounded-xl transition-all duration-200 ${
isActive('/cards')
? 'text-primary-600 dark:text-primary-400'
: 'text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white'
}`}
>
<div className="mb-1">
{isActive('/cards') ? (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
) : (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
)}
</div>
<span className="text-xs font-medium">Cards</span>
</Link>
{/* Scanner - Special Raised Button */}
<Link
to="/scanner"
className="relative flex flex-col items-center justify-center -mt-6 mb-2"
>
<div className={`w-14 h-14 rounded-full shadow-lg transition-all duration-200 flex items-center justify-center ${
isActive('/scanner')
? 'bg-gradient-to-r from-primary-600 to-accent-600 shadow-primary-500/25 shadow-xl'
: 'bg-gradient-to-r from-primary-500 to-accent-500 hover:from-primary-600 hover:to-accent-600 shadow-primary-500/20 hover:shadow-xl'
}`}>
<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="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>
</div>
<span className="text-xs font-medium text-primary-600 dark:text-primary-400 mt-1">Scanner</span>
</Link>
{/* Collections */}
<Link
to="/collections"
className={`flex flex-col items-center justify-center p-2 rounded-xl transition-all duration-200 ${
isActive('/collections')
? 'text-primary-600 dark:text-primary-400'
: 'text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white'
}`}
>
<div className="mb-1">
{isActive('/collections') ? (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2h3a1 1 0 110 2h-1v10a2 2 0 01-2 2H7a2 2 0 01-2-2V6H4a1 1 0 010-2h3zM9 4h6V3H9v1zm8 2H7v10h10V6z"/>
</svg>
) : (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
)}
</div>
<span className="text-xs font-medium">Collections</span>
</Link>
{/* More */}
<button
onClick={() => setShowUserMenu(true)}
className="flex flex-col items-center justify-center p-2 rounded-xl transition-all duration-200 text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white"
>
<div className="mb-1">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</div>
<span className="text-xs font-medium">More</span>
</button>
</div>
</nav>
{/* User Menu Modal */}
{showUserMenu && (
<div className="fixed inset-0 z-50 bg-black/50" onClick={() => setShowUserMenu(false)}>
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-surface-900 rounded-t-3xl p-6 safe-area-inset-bottom animate-slide-up">
<div className="w-12 h-1.5 bg-surface-300 dark:bg-surface-600 rounded-full mx-auto mb-6"></div>
<div className="flex items-center space-x-4 mb-6 p-4 bg-surface-50 dark:bg-surface-800 rounded-2xl">
<div className="w-12 h-12 bg-gradient-to-r from-primary-500 to-accent-500 rounded-xl flex items-center justify-center text-white font-semibold text-lg">
{user?.firstName?.[0] || user?.username?.[0]?.toUpperCase() || 'U'}
</div>
<div>
<p className="font-semibold text-surface-900 dark:text-white">
{user?.firstName && user?.lastName
? `${user.firstName} ${user.lastName}`
: user?.username}
</p>
<p className="text-sm text-surface-600 dark:text-surface-400">{user?.email}</p>
<div className="flex gap-1 mt-1">
{user?.roles.map((role) => (
<span
key={role}
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
role === 'admin'
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
: 'bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200'
}`}
>
{role}
</span>
))}
</div>
</div>
</div>
<div className="space-y-2 mb-6">
<Link
to="/decks"
onClick={() => setShowUserMenu(false)}
className="flex items-center space-x-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
>
<svg className="w-5 h-5 text-surface-600 dark:text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<span className="text-surface-900 dark:text-white font-medium">Decks</span>
</Link>
<Link
to="/settings"
onClick={() => setShowUserMenu(false)}
className="flex items-center space-x-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
>
<svg className="w-5 h-5 text-surface-600 dark:text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span className="text-surface-900 dark:text-white font-medium">Settings</span>
</Link>
{isAdmin() && (
<Link
to="/admin"
onClick={() => setShowUserMenu(false)}
className="flex items-center space-x-3 p-3 rounded-xl hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors"
>
<svg className="w-5 h-5 text-yellow-600 dark:text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<span className="text-yellow-700 dark:text-yellow-300 font-medium">Admin Panel</span>
</Link>
)}
</div>
<button
onClick={handleLogout}
className="w-full flex items-center justify-center space-x-2 p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-xl hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors font-medium"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<span>Sign Out</span>
</button>
</div>
</div>
)}
</>
);
};
export default MobileNavbar;

View file

@ -1,212 +0,0 @@
import React, { useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
const Navbar: React.FC = () => {
const { user, logout, isAdmin } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const handleLogout = () => {
logout();
setIsUserMenuOpen(false);
navigate('/login');
};
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: '📊' },
{ name: 'Cards', href: '/cards', icon: '🃏' },
{ name: 'Collections', href: '/collections', icon: '📚' },
{ name: 'Decks', href: '/decks', icon: '🎯' },
{ name: 'Scanner', href: '/scanner', icon: '📷' },
];
const isActive = (path: string) => location.pathname === path;
return (
<nav className="bg-white shadow-lg border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo and Brand */}
<div className="flex items-center">
<Link to="/dashboard" className="flex items-center space-x-2">
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 text-white p-2 rounded-lg">
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<span className="text-xl font-bold text-gray-900">TCG Vault</span>
</Link>
</div>
{/* Navigation Links */}
<div className="hidden md:flex items-center space-x-1">
{navigation.map((item) => (
<Link
key={item.name}
to={item.href}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200 flex items-center space-x-2 ${
isActive(item.href)
? 'bg-indigo-100 text-indigo-700'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<span>{item.icon}</span>
<span>{item.name}</span>
</Link>
))}
</div>
{/* User Menu */}
<div className="flex items-center space-x-4">
{/* Admin Badge */}
{isAdmin() && (
<Link
to="/admin"
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors duration-200 ${
isActive('/admin')
? 'bg-purple-100 text-purple-800'
: 'bg-gray-100 text-gray-600 hover:bg-purple-100 hover:text-purple-800'
}`}
>
Admin
</Link>
)}
{/* User Dropdown */}
<div className="relative">
<button
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
className="flex items-center space-x-3 text-sm rounded-full focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
<div className="bg-gradient-to-r from-indigo-500 to-purple-600 text-white w-8 h-8 rounded-full flex items-center justify-center font-medium">
{user?.firstName?.[0] || user?.username?.[0]?.toUpperCase() || 'U'}
</div>
<div className="hidden md:block text-left">
<div className="text-sm font-medium text-gray-900">
{user?.firstName && user?.lastName
? `${user.firstName} ${user.lastName}`
: user?.username}
</div>
<div className="text-xs text-gray-500">{user?.email}</div>
</div>
<svg className="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
{/* Dropdown Menu */}
{isUserMenuOpen && (
<div className="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none z-50">
<div className="py-1">
{/* User Info */}
<div className="px-4 py-3 border-b border-gray-100">
<p className="text-sm font-medium text-gray-900">
{user?.firstName && user?.lastName
? `${user.firstName} ${user.lastName}`
: user?.username}
</p>
<p className="text-sm text-gray-500">{user?.email}</p>
<div className="flex flex-wrap gap-1 mt-2">
{user?.roles.map((role) => (
<span
key={role}
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
role === 'admin'
? 'bg-purple-100 text-purple-800'
: 'bg-blue-100 text-blue-800'
}`}
>
{role}
</span>
))}
</div>
</div>
{/* Menu Items */}
<Link
to="/dashboard"
onClick={() => setIsUserMenuOpen(false)}
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
📊 Dashboard
</Link>
<Link
to="/settings"
onClick={() => setIsUserMenuOpen(false)}
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
Settings
</Link>
{isAdmin() && (
<Link
to="/admin"
onClick={() => setIsUserMenuOpen(false)}
className="block px-4 py-2 text-sm text-purple-700 hover:bg-purple-50 font-medium"
>
👑 Admin Panel
</Link>
)}
<div className="border-t border-gray-100 my-1"></div>
<button
onClick={handleLogout}
className="block w-full text-left px-4 py-2 text-sm text-red-700 hover:bg-red-50"
>
🚪 Sign Out
</button>
</div>
</div>
)}
</div>
</div>
</div>
{/* Mobile Navigation */}
<div className="md:hidden border-t border-gray-200">
<div className="px-2 pt-2 pb-3 space-y-1">
{navigation.map((item) => (
<Link
key={item.name}
to={item.href}
className={`block px-3 py-2 rounded-md text-sm font-medium transition-colors duration-200 ${
isActive(item.href)
? 'bg-indigo-100 text-indigo-700'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
}`}
>
{item.icon} {item.name}
</Link>
))}
{isAdmin() && (
<Link
to="/admin"
className={`block px-3 py-2 rounded-md text-sm font-medium transition-colors duration-200 ${
isActive('/admin')
? 'bg-purple-100 text-purple-700'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
}`}
>
Admin Panel
</Link>
)}
</div>
</div>
</div>
{/* Click outside to close menu */}
{isUserMenuOpen && (
<div
className="fixed inset-0 z-40"
onClick={() => setIsUserMenuOpen(false)}
/>
)}
</nav>
);
};
export default Navbar;

View file

@ -1,217 +0,0 @@
import React, { useState, useEffect } from 'react';
import { aiCardOCR, ollamaCardOCR } from '../services/aiOcr';
interface OCRSettingsProps {
onClose: () => void;
}
const OCRSettings: React.FC<OCRSettingsProps> = ({ onClose }) => {
const [openaiKey, setOpenaiKey] = useState('');
const [ollamaUrl, setOllamaUrl] = useState('http://localhost:11434');
const [selectedService, setSelectedService] = useState<'openai' | 'ollama'>('openai');
const [isTestingOpenAI, setIsTestingOpenAI] = useState(false);
const [isTestingOllama, setIsTestingOllama] = useState(false);
const [testResults, setTestResults] = useState<{
openai?: string;
ollama?: string;
}>({});
useEffect(() => {
// Load saved settings
const savedKey = localStorage.getItem('openai_api_key');
const savedUrl = localStorage.getItem('ollama_url');
const savedService = localStorage.getItem('preferred_ocr_service') as 'openai' | 'ollama';
if (savedKey) setOpenaiKey(savedKey);
if (savedUrl) setOllamaUrl(savedUrl);
if (savedService) setSelectedService(savedService);
}, []);
const saveSettings = () => {
if (openaiKey) {
localStorage.setItem('openai_api_key', openaiKey);
aiCardOCR.setApiKey(openaiKey);
}
localStorage.setItem('ollama_url', ollamaUrl);
localStorage.setItem('preferred_ocr_service', selectedService);
onClose();
};
const testOpenAI = async () => {
if (!openaiKey) {
setTestResults(prev => ({ ...prev, openai: '❌ API key required' }));
return;
}
setIsTestingOpenAI(true);
try {
// Test with a simple request
const response = await fetch('https://api.openai.com/v1/models', {
headers: {
'Authorization': `Bearer ${openaiKey}`,
},
});
if (response.ok) {
setTestResults(prev => ({ ...prev, openai: '✅ API key valid' }));
} else {
setTestResults(prev => ({ ...prev, openai: `❌ API error: ${response.status}` }));
}
} catch (error: any) {
setTestResults(prev => ({ ...prev, openai: `❌ Connection failed: ${error.message}` }));
} finally {
setIsTestingOpenAI(false);
}
};
const testOllama = async () => {
setIsTestingOllama(true);
try {
const response = await fetch(`${ollamaUrl}/api/tags`);
if (response.ok) {
const data = await response.json();
const hasVisionModel = data.models?.some((model: any) =>
model.name.includes('llava') || model.name.includes('vision')
);
if (hasVisionModel) {
setTestResults(prev => ({ ...prev, ollama: '✅ Ollama with vision models available' }));
} else {
setTestResults(prev => ({ ...prev, ollama: '⚠️ Ollama running but no vision models found' }));
}
} else {
setTestResults(prev => ({ ...prev, ollama: `❌ Ollama error: ${response.status}` }));
}
} catch (error: any) {
setTestResults(prev => ({ ...prev, ollama: `❌ Cannot reach Ollama: ${error.message}` }));
} finally {
setIsTestingOllama(false);
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold">AI OCR Settings</h3>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700 text-xl"
>
</button>
</div>
<div className="space-y-6">
{/* Service Selection */}
<div>
<label className="block text-sm font-medium mb-2">Preferred OCR Service</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
value="openai"
checked={selectedService === 'openai'}
onChange={(e) => setSelectedService(e.target.value as 'openai')}
className="mr-2"
/>
OpenAI Vision API (Recommended)
</label>
<label className="flex items-center">
<input
type="radio"
value="ollama"
checked={selectedService === 'ollama'}
onChange={(e) => setSelectedService(e.target.value as 'ollama')}
className="mr-2"
/>
Ollama (Local/Private)
</label>
</div>
</div>
{/* OpenAI Settings */}
<div className="border rounded-lg p-4">
<h4 className="font-medium mb-2">OpenAI Configuration</h4>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium mb-1">API Key</label>
<input
type="password"
value={openaiKey}
onChange={(e) => setOpenaiKey(e.target.value)}
placeholder="sk-..."
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
/>
<p className="text-xs text-gray-500 mt-1">
Get your API key from <a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">OpenAI Platform</a>
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={testOpenAI}
disabled={isTestingOpenAI || !openaiKey}
className="bg-blue-600 text-white px-3 py-1 rounded text-sm disabled:bg-gray-400"
>
{isTestingOpenAI ? 'Testing...' : 'Test API Key'}
</button>
{testResults.openai && (
<span className="text-sm">{testResults.openai}</span>
)}
</div>
</div>
</div>
{/* Ollama Settings */}
<div className="border rounded-lg p-4">
<h4 className="font-medium mb-2">Ollama Configuration</h4>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium mb-1">Ollama URL</label>
<input
type="text"
value={ollamaUrl}
onChange={(e) => setOllamaUrl(e.target.value)}
placeholder="http://localhost:11434"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
/>
<p className="text-xs text-gray-500 mt-1">
Requires LLaVA or similar vision model: <code className="bg-gray-100 px-1">ollama pull llava</code>
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={testOllama}
disabled={isTestingOllama}
className="bg-green-600 text-white px-3 py-1 rounded text-sm disabled:bg-gray-400"
>
{isTestingOllama ? 'Testing...' : 'Test Ollama'}
</button>
{testResults.ollama && (
<span className="text-sm">{testResults.ollama}</span>
)}
</div>
</div>
</div>
</div>
<div className="flex gap-3 mt-6">
<button
onClick={saveSettings}
className="flex-1 bg-indigo-600 text-white py-2 px-4 rounded-lg hover:bg-indigo-700"
>
Save Settings
</button>
<button
onClick={onClose}
className="flex-1 bg-gray-300 text-gray-700 py-2 px-4 rounded-lg hover:bg-gray-400"
>
Cancel
</button>
</div>
</div>
</div>
);
};
export default OCRSettings;

View file

@ -1,520 +0,0 @@
import React, { useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useTheme } from '../contexts/ThemeContext';
const ResponsiveLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { user, logout, isAdmin } = useAuth();
const { toggleTheme, effectiveTheme } = useTheme();
const location = useLocation();
const navigate = useNavigate();
const [showUserMenu, setShowUserMenu] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const handleLogout = () => {
logout();
setShowUserMenu(false);
setSidebarOpen(false);
navigate('/login');
};
const isActive = (path: string) => location.pathname === path;
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: '📊', mobileIcon: '🏠' },
{ name: 'Cards', href: '/cards', icon: '🃏', mobileIcon: '🃏' },
{ name: 'Collections', href: '/collections', icon: '📚', mobileIcon: '📚' },
{ name: 'Decks', href: '/decks', icon: '🎯', mobileIcon: '🎯' },
{ name: 'Scanner', href: '/scanner', icon: '📷', mobileIcon: '📷' },
];
return (
<div className="min-h-screen bg-surface-50 dark:bg-surface-900 transition-colors">
{/* Desktop Layout */}
<div className="hidden lg:flex h-screen">
{/* Sidebar */}
<div className="w-64 bg-white dark:bg-surface-800 border-r border-surface-200 dark:border-surface-700 flex flex-col">
{/* Logo */}
<div className="p-6 border-b border-surface-200 dark:border-surface-700">
<Link to="/dashboard" className="flex items-center space-x-3">
<div className="bg-gradient-to-r from-primary-500 to-accent-500 text-white p-3 rounded-xl">
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<div>
<h1 className="text-xl font-bold text-surface-900 dark:text-white">TCG Vault</h1>
<p className="text-sm text-surface-600 dark:text-surface-400">
Hi, {user?.firstName || user?.username}
</p>
</div>
</Link>
</div>
{/* Navigation */}
<nav className="flex-1 p-4 space-y-2">
{navigation.map((item) => (
<Link
key={item.name}
to={item.href}
className={`flex items-center space-x-3 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-200 ${
isActive(item.href)
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300'
: 'text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white hover:bg-surface-100 dark:hover:bg-surface-700'
}`}
>
<span className="text-lg">{item.icon}</span>
<span>{item.name}</span>
</Link>
))}
</nav>
{/* User Section */}
<div className="p-4 border-t border-surface-200 dark:border-surface-700">
<div className="flex items-center space-x-3 p-3 rounded-xl bg-surface-50 dark:bg-surface-700">
<div className="w-10 h-10 bg-gradient-to-r from-primary-500 to-accent-500 rounded-xl flex items-center justify-center text-white font-semibold">
{user?.firstName?.[0] || user?.username?.[0]?.toUpperCase() || 'U'}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-surface-900 dark:text-white truncate">
{user?.firstName && user?.lastName
? `${user.firstName} ${user.lastName}`
: user?.username}
</p>
<p className="text-xs text-surface-600 dark:text-surface-400 truncate">{user?.email}</p>
</div>
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="p-1 rounded-lg hover:bg-surface-200 dark:hover:bg-surface-600 transition-colors"
>
<svg className="w-4 h-4 text-surface-600 dark:text-surface-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
{/* User Menu Dropdown */}
{showUserMenu && (
<div className="absolute bottom-20 left-4 w-56 bg-white dark:bg-surface-800 rounded-xl shadow-lg ring-1 ring-black ring-opacity-5 border border-surface-200 dark:border-surface-700">
<div className="py-2">
<Link
to="/settings"
onClick={() => setShowUserMenu(false)}
className="flex items-center space-x-3 px-4 py-2 text-sm text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-700"
>
<span></span>
<span>Settings</span>
</Link>
{isAdmin() && (
<Link
to="/admin"
onClick={() => setShowUserMenu(false)}
className="flex items-center space-x-3 px-4 py-2 text-sm text-yellow-700 dark:text-yellow-300 hover:bg-yellow-50 dark:hover:bg-yellow-900/20"
>
<span>👑</span>
<span>Admin Panel</span>
</Link>
)}
<div className="border-t border-surface-200 dark:border-surface-700 my-1"></div>
<button
onClick={handleLogout}
className="flex items-center space-x-3 px-4 py-2 text-sm text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 w-full text-left"
>
<span>🚪</span>
<span>Sign Out</span>
</button>
</div>
</div>
)}
</div>
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Top Header */}
<header className="bg-white dark:bg-surface-800 border-b border-surface-200 dark:border-surface-700 px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<h2 className="text-2xl font-bold text-surface-900 dark:text-white">
{navigation.find(item => isActive(item.href))?.name || 'TCG Vault'}
</h2>
{isAdmin() && (
<span className="px-3 py-1 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200 text-xs font-medium rounded-full">
Admin
</span>
)}
</div>
<div className="flex items-center space-x-3">
{/* Theme Toggle */}
<button
onClick={toggleTheme}
className="p-2 rounded-xl bg-surface-100 dark:bg-surface-700 text-surface-600 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-600 transition-colors"
>
{effectiveTheme === 'dark' ? (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>
)}
</button>
</div>
</div>
</header>
{/* Page Content */}
<main className="flex-1 overflow-auto p-6">
<div className="max-w-7xl mx-auto">
{children}
</div>
</main>
</div>
</div>
{/* Mobile Layout */}
<div className="lg:hidden">
{/* Top Header */}
<header className="fixed top-0 left-0 right-0 z-40 bg-white dark:bg-surface-900 border-b border-surface-200 dark:border-surface-700 px-4 py-3 safe-area-inset-top">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="p-2 rounded-xl bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors"
>
<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 12h16M4 18h16" />
</svg>
</button>
<div className="bg-gradient-to-r from-primary-500 to-accent-500 text-white p-2 rounded-xl">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<div>
<h1 className="text-lg font-bold text-surface-900 dark:text-white">TCG Vault</h1>
<p className="text-xs text-surface-600 dark:text-surface-400">
Hi, {user?.firstName || user?.username}
</p>
</div>
</div>
<div className="flex items-center space-x-2">
{/* Theme Toggle */}
<button
onClick={toggleTheme}
className="p-2 rounded-xl bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors"
>
{effectiveTheme === 'dark' ? (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>
)}
</button>
{/* User Avatar */}
<button
onClick={() => setShowUserMenu(true)}
className="relative p-1"
>
<div className="w-8 h-8 bg-gradient-to-r from-primary-500 to-accent-500 rounded-xl flex items-center justify-center text-white font-semibold text-sm">
{user?.firstName?.[0] || user?.username?.[0]?.toUpperCase() || 'U'}
</div>
{isAdmin() && (
<div className="absolute -top-1 -right-1 w-3 h-3 bg-yellow-400 rounded-full border border-white dark:border-surface-900"></div>
)}
</button>
</div>
</div>
</header>
{/* Mobile Sidebar */}
{sidebarOpen && (
<div className="fixed inset-0 z-50 lg:hidden">
<div className="fixed inset-0 bg-black/50" onClick={() => setSidebarOpen(false)}></div>
<div className="fixed left-0 top-0 bottom-0 w-64 bg-white dark:bg-surface-800 border-r border-surface-200 dark:border-surface-700 transform transition-transform duration-300 ease-in-out">
<div className="p-6 border-b border-surface-200 dark:border-surface-700">
<div className="flex items-center justify-between">
<Link to="/dashboard" className="flex items-center space-x-3" onClick={() => setSidebarOpen(false)}>
<div className="bg-gradient-to-r from-primary-500 to-accent-500 text-white p-3 rounded-xl">
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<div>
<h1 className="text-xl font-bold text-surface-900 dark:text-white">TCG Vault</h1>
<p className="text-sm text-surface-600 dark:text-surface-400">
Hi, {user?.firstName || user?.username}
</p>
</div>
</Link>
<button
onClick={() => setSidebarOpen(false)}
className="p-2 rounded-xl bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<nav className="flex-1 p-4 space-y-2">
{navigation.map((item) => (
<Link
key={item.name}
to={item.href}
onClick={() => setSidebarOpen(false)}
className={`flex items-center space-x-3 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-200 ${
isActive(item.href)
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300'
: 'text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white hover:bg-surface-100 dark:hover:bg-surface-700'
}`}
>
<span className="text-lg">{item.icon}</span>
<span>{item.name}</span>
</Link>
))}
</nav>
<div className="p-4 border-t border-surface-200 dark:border-surface-700">
<div className="flex items-center space-x-3 p-3 rounded-xl bg-surface-50 dark:bg-surface-700">
<div className="w-10 h-10 bg-gradient-to-r from-primary-500 to-accent-500 rounded-xl flex items-center justify-center text-white font-semibold">
{user?.firstName?.[0] || user?.username?.[0]?.toUpperCase() || 'U'}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-surface-900 dark:text-white truncate">
{user?.firstName && user?.lastName
? `${user.firstName} ${user.lastName}`
: user?.username}
</p>
<p className="text-xs text-surface-600 dark:text-surface-400 truncate">{user?.email}</p>
</div>
</div>
</div>
</div>
</div>
)}
{/* Bottom Navigation */}
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-white dark:bg-surface-900 border-t border-surface-200 dark:border-surface-700 safe-area-inset-bottom">
<div className="relative flex items-end justify-around px-4 py-2">
{/* Home */}
<Link
to="/dashboard"
className={`flex flex-col items-center justify-center p-2 rounded-xl transition-all duration-200 ${
isActive('/dashboard')
? 'text-primary-600 dark:text-primary-400'
: 'text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white'
}`}
>
<div className="mb-1">
{isActive('/dashboard') ? (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
</svg>
) : (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
)}
</div>
<span className="text-xs font-medium">Home</span>
</Link>
{/* Cards */}
<Link
to="/cards"
className={`flex flex-col items-center justify-center p-2 rounded-xl transition-all duration-200 ${
isActive('/cards')
? 'text-primary-600 dark:text-primary-400'
: 'text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white'
}`}
>
<div className="mb-1">
{isActive('/cards') ? (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
) : (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
)}
</div>
<span className="text-xs font-medium">Cards</span>
</Link>
{/* Scanner - Special Raised Button */}
<Link
to="/scanner"
className="relative flex flex-col items-center justify-center -mt-6 mb-2"
>
<div className={`w-14 h-14 rounded-full shadow-lg transition-all duration-200 flex items-center justify-center ${
isActive('/scanner')
? 'bg-gradient-to-r from-primary-600 to-accent-600 shadow-primary-500/25 shadow-xl'
: 'bg-gradient-to-r from-primary-500 to-accent-500 hover:from-primary-600 hover:to-accent-600 shadow-primary-500/20 hover:shadow-xl'
}`}>
<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="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>
</div>
<span className="text-xs font-medium text-primary-600 dark:text-primary-400 mt-1">Scanner</span>
</Link>
{/* Collections */}
<Link
to="/collections"
className={`flex flex-col items-center justify-center p-2 rounded-xl transition-all duration-200 ${
isActive('/collections')
? 'text-primary-600 dark:text-primary-400'
: 'text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white'
}`}
>
<div className="mb-1">
{isActive('/collections') ? (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2h3a1 1 0 110 2h-1v10a2 2 0 01-2 2H7a2 2 0 01-2-2V6H4a1 1 0 010-2h3zM9 4h6V3H9v1zm8 2H7v10h10V6z"/>
</svg>
) : (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
)}
</div>
<span className="text-xs font-medium">Collections</span>
</Link>
{/* More */}
<button
onClick={() => setShowUserMenu(true)}
className="flex flex-col items-center justify-center p-2 rounded-xl transition-all duration-200 text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white"
>
<div className="mb-1">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</div>
<span className="text-xs font-medium">More</span>
</button>
</div>
</nav>
{/* Page Content */}
<main className="pt-16 pb-20 px-4 max-w-md mx-auto">
<div className="animate-fade-in">
{children}
</div>
</main>
{/* User Menu Modal */}
{showUserMenu && (
<div className="fixed inset-0 z-50 bg-black/50" onClick={() => setShowUserMenu(false)}>
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-surface-900 rounded-t-3xl p-6 safe-area-inset-bottom animate-slide-up">
<div className="w-12 h-1.5 bg-surface-300 dark:bg-surface-600 rounded-full mx-auto mb-6"></div>
<div className="flex items-center space-x-4 mb-6 p-4 bg-surface-50 dark:bg-surface-800 rounded-2xl">
<div className="w-12 h-12 bg-gradient-to-r from-primary-500 to-accent-500 rounded-xl flex items-center justify-center text-white font-semibold text-lg">
{user?.firstName?.[0] || user?.username?.[0]?.toUpperCase() || 'U'}
</div>
<div>
<p className="font-semibold text-surface-900 dark:text-white">
{user?.firstName && user?.lastName
? `${user.firstName} ${user.lastName}`
: user?.username}
</p>
<p className="text-sm text-surface-600 dark:text-surface-400">{user?.email}</p>
<div className="flex gap-1 mt-1">
{user?.roles.map((role) => (
<span
key={role}
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
role === 'admin'
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
: 'bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200'
}`}
>
{role}
</span>
))}
</div>
</div>
</div>
<div className="space-y-2 mb-6">
<Link
to="/decks"
onClick={() => setShowUserMenu(false)}
className="flex items-center space-x-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
>
<svg className="w-5 h-5 text-surface-600 dark:text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<span className="text-surface-900 dark:text-white font-medium">Decks</span>
</Link>
<Link
to="/settings"
onClick={() => setShowUserMenu(false)}
className="flex items-center space-x-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
>
<svg className="w-5 h-5 text-surface-600 dark:text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span className="text-surface-900 dark:text-white font-medium">Settings</span>
</Link>
{isAdmin() && (
<Link
to="/admin"
onClick={() => setShowUserMenu(false)}
className="flex items-center space-x-3 p-3 rounded-xl hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors"
>
<svg className="w-5 h-5 text-yellow-600 dark:text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<span className="text-yellow-700 dark:text-yellow-300 font-medium">Admin Panel</span>
</Link>
)}
</div>
<button
onClick={handleLogout}
className="w-full flex items-center justify-center space-x-2 p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-xl hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors font-medium"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<span>Sign Out</span>
</button>
</div>
</div>
)}
</div>
{/* Click outside to close menus */}
{(showUserMenu || sidebarOpen) && (
<div
className="fixed inset-0 z-40"
onClick={() => {
setShowUserMenu(false);
setSidebarOpen(false);
}}
/>
)}
</div>
);
};
export default ResponsiveLayout;

View file

@ -1,126 +0,0 @@
import React, { useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { Navigate } from 'react-router-dom';
import UserManagement from './UserManagement';
import CardManagement from './CardManagement';
import SystemStats from './SystemStats';
import CardLoader from './CardLoader';
type AdminTab = 'dashboard' | 'users' | 'cards' | 'decks' | 'settings';
const AdminPanel: React.FC = () => {
const { user, isAdmin } = useAuth();
const [activeTab, setActiveTab] = useState<AdminTab>('dashboard');
// Redirect if not admin
if (!isAdmin()) {
return <Navigate to="/dashboard" replace />;
}
const tabs = [
{ id: 'dashboard' as AdminTab, name: 'Dashboard', icon: '📊' },
{ id: 'users' as AdminTab, name: 'Users', icon: '👥' },
{ id: 'cards' as AdminTab, name: 'Cards', icon: '🃏' },
{ id: 'decks' as AdminTab, name: 'Decks', icon: '📚' },
{ id: 'settings' as AdminTab, name: 'Settings', icon: '⚙️' },
];
const renderContent = () => {
switch (activeTab) {
case 'dashboard':
return <SystemStats />;
case 'users':
return <UserManagement />;
case 'cards':
return <CardLoader />;
case 'decks':
return <div className="p-6">Deck Management - Coming Soon</div>;
case 'settings':
return <div className="p-6">System Settings - Coming Soon</div>;
default:
return <SystemStats />;
}
};
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white shadow-sm border-b border-gray-200">
<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>
<h1 className="text-2xl font-bold text-gray-900">Admin Panel</h1>
<p className="text-sm text-gray-600">
Welcome back, {user?.firstName || user?.username}
</p>
</div>
<div className="flex items-center space-x-4">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
Admin
</span>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex flex-col lg:flex-row gap-8">
{/* Sidebar Navigation */}
<div className="lg:w-64 flex-shrink-0">
<nav className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div className="p-4 bg-gray-50 border-b border-gray-200">
<h2 className="text-sm font-semibold text-gray-900 uppercase tracking-wide">
Administration
</h2>
</div>
<div className="p-2">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`w-full flex items-center px-3 py-2 text-sm font-medium rounded-md mb-1 transition-colors duration-200 ${
activeTab === tab.id
? 'bg-indigo-100 text-indigo-700 border-r-2 border-indigo-500'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`}
>
<span className="mr-3 text-lg">{tab.icon}</span>
{tab.name}
</button>
))}
</div>
</nav>
{/* Quick Stats */}
<div className="mt-6 bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3">Quick Stats</h3>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600">Total Users</span>
<span className="font-medium text-gray-900">-</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Total Cards</span>
<span className="font-medium text-gray-900">-</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Active Sessions</span>
<span className="font-medium text-gray-900">-</span>
</div>
</div>
</div>
</div>
{/* Main Content */}
<div className="flex-1">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 min-h-[600px]">
{renderContent()}
</div>
</div>
</div>
</div>
</div>
);
};
export default AdminPanel;

View file

@ -1,186 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../../contexts/AuthContext';
interface CardCounts {
MTG?: number;
POKEMON?: number;
LORCANA?: number;
total?: number;
}
interface LoadingResults {
mtg?: number;
pokemon?: number;
lorcana?: number;
}
const CardLoader: React.FC = () => {
const { user, token } = useAuth();
const [cardCounts, setCardCounts] = useState<CardCounts>({});
const [loading, setLoading] = useState(false);
const [loadingResults, setLoadingResults] = useState<LoadingResults>({});
const [selectedGame, setSelectedGame] = useState<string>('ALL');
const [message, setMessage] = useState<string>('');
// Fetch current card counts
const fetchCardCounts = async () => {
try {
const response = await fetch('https://tcg-vault.vercel.app/api/admin?action=card-counts', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
setCardCounts(data.counts || {});
}
} catch (error) {
console.error('Error fetching card counts:', error);
}
};
useEffect(() => {
fetchCardCounts();
}, []);
// Load cards from external APIs
const loadCards = async (game: string) => {
setLoading(true);
setMessage(`Loading ${game} cards...`);
setLoadingResults({});
try {
const response = await fetch(`https://tcg-vault.vercel.app/api/admin?action=load-cards`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ game })
});
if (response.ok) {
const data = await response.json();
setLoadingResults(data.results || {});
setMessage(`Successfully loaded cards! ${data.message}`);
// Refresh card counts
setTimeout(() => {
fetchCardCounts();
}, 1000);
} else {
const errorData = await response.json();
setMessage(`Error loading cards: ${errorData.error}`);
}
} catch (error) {
console.error('Error loading cards:', error);
setMessage('Error loading cards. Please try again.');
} finally {
setLoading(false);
}
};
const handleLoadCards = () => {
loadCards(selectedGame);
};
return (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-2xl font-bold text-gray-800 mb-6">Card Database Loader</h2>
{/* Current Card Counts */}
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-700 mb-3">Current Database Status</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-blue-50 p-4 rounded-lg">
<div className="text-2xl font-bold text-blue-600">{cardCounts.MTG || 0}</div>
<div className="text-sm text-blue-500">MTG Cards</div>
</div>
<div className="bg-yellow-50 p-4 rounded-lg">
<div className="text-2xl font-bold text-yellow-600">{cardCounts.POKEMON || 0}</div>
<div className="text-sm text-yellow-500">Pokémon Cards</div>
</div>
<div className="bg-purple-50 p-4 rounded-lg">
<div className="text-2xl font-bold text-purple-600">{cardCounts.LORCANA || 0}</div>
<div className="text-sm text-purple-500">Lorcana Cards</div>
</div>
<div className="bg-green-50 p-4 rounded-lg">
<div className="text-2xl font-bold text-green-600">{cardCounts.total || 0}</div>
<div className="text-sm text-green-500">Total Cards</div>
</div>
</div>
</div>
{/* Load Cards Section */}
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-700 mb-3">Load Cards from External APIs</h3>
<div className="flex flex-col sm:flex-row gap-4 mb-4">
<select
value={selectedGame}
onChange={(e) => setSelectedGame(e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="ALL">All Games (MTG, Pokémon, Lorcana)</option>
<option value="MTG">Magic: The Gathering</option>
<option value="POKEMON">Pokémon</option>
<option value="LORCANA">Disney Lorcana</option>
</select>
<button
onClick={handleLoadCards}
disabled={loading}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Loading...' : 'Load Cards'}
</button>
</div>
{/* Loading Results */}
{Object.keys(loadingResults).length > 0 && (
<div className="bg-gray-50 p-4 rounded-lg">
<h4 className="font-semibold text-gray-700 mb-2">Loading Results:</h4>
<div className="space-y-2">
{loadingResults.mtg !== undefined && (
<div className="text-blue-600">MTG: {loadingResults.mtg} cards loaded</div>
)}
{loadingResults.pokemon !== undefined && (
<div className="text-yellow-600">Pokémon: {loadingResults.pokemon} cards loaded</div>
)}
{loadingResults.lorcana !== undefined && (
<div className="text-purple-600">Lorcana: {loadingResults.lorcana} cards loaded</div>
)}
</div>
</div>
)}
{/* Message */}
{message && (
<div className={`mt-4 p-3 rounded-lg ${
message.includes('Error')
? 'bg-red-50 text-red-700 border border-red-200'
: 'bg-green-50 text-green-700 border border-green-200'
}`}>
{message}
</div>
)}
</div>
{/* Instructions */}
<div className="bg-gray-50 p-4 rounded-lg">
<h3 className="text-lg font-semibold text-gray-700 mb-2">Instructions</h3>
<ul className="text-sm text-gray-600 space-y-1">
<li> This will load cards from external APIs into your database</li>
<li> MTG cards come from Scryfall API</li>
<li> Pokémon cards come from Pokémon TCG API</li>
<li> Lorcana cards come from Lorcana API and Lorcast API</li>
<li> Loading may take several minutes for large datasets</li>
<li> Cards are deduplicated automatically</li>
</ul>
</div>
</div>
);
};
export default CardLoader;

View file

@ -1,41 +0,0 @@
import React from 'react';
const CardManagement: React.FC = () => {
return (
<div className="p-6">
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-2">Card Management</h2>
<p className="text-sm text-gray-600">Manage card database, pricing, and metadata.</p>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
<div className="flex items-center mb-4">
<div className="bg-blue-100 rounded-full p-2 mr-3">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h3 className="text-lg font-medium text-blue-900">Card Management Features</h3>
</div>
<div className="space-y-3 text-sm text-blue-800">
<p>🃏 <strong>Card Database:</strong> View and manage all cards in the system</p>
<p>💰 <strong>Pricing Updates:</strong> Bulk update card prices from external sources</p>
<p>🖼 <strong>Image Management:</strong> Upload and manage card images</p>
<p>📊 <strong>Metadata:</strong> Edit card details, sets, and rarity information</p>
<p>🔍 <strong>Search & Filter:</strong> Advanced search and filtering capabilities</p>
</div>
<div className="mt-6 p-4 bg-white rounded-md border border-blue-200">
<h4 className="font-medium text-blue-900 mb-2">Coming Soon</h4>
<p className="text-sm text-blue-700">
This feature is currently under development. It will include comprehensive
card management tools for administrators.
</p>
</div>
</div>
</div>
);
};
export default CardManagement;

View file

@ -1,182 +0,0 @@
import React from 'react';
const SystemStats: React.FC = () => {
return (
<div className="p-6">
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-2">System Dashboard</h2>
<p className="text-sm text-gray-600">Overview of system statistics and health.</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div className="bg-gradient-to-r from-blue-500 to-blue-600 rounded-lg p-6 text-white">
<div className="flex items-center justify-between">
<div>
<p className="text-blue-100 text-sm">Total Users</p>
<p className="text-3xl font-bold">-</p>
</div>
<div className="bg-blue-400 bg-opacity-30 rounded-full p-3">
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
</svg>
</div>
</div>
</div>
<div className="bg-gradient-to-r from-green-500 to-green-600 rounded-lg p-6 text-white">
<div className="flex items-center justify-between">
<div>
<p className="text-green-100 text-sm">Total Cards</p>
<p className="text-3xl font-bold">-</p>
</div>
<div className="bg-green-400 bg-opacity-30 rounded-full p-3">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
</div>
</div>
<div className="bg-gradient-to-r from-purple-500 to-purple-600 rounded-lg p-6 text-white">
<div className="flex items-center justify-between">
<div>
<p className="text-purple-100 text-sm">Active Sessions</p>
<p className="text-3xl font-bold">-</p>
</div>
<div className="bg-purple-400 bg-opacity-30 rounded-full p-3">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
</div>
</div>
<div className="bg-gradient-to-r from-orange-500 to-orange-600 rounded-lg p-6 text-white">
<div className="flex items-center justify-between">
<div>
<p className="text-orange-100 text-sm">Collections</p>
<p className="text-3xl font-bold">-</p>
</div>
<div className="bg-orange-400 bg-opacity-30 rounded-full p-3">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
</div>
</div>
</div>
{/* System Health */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<div className="bg-white border border-gray-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">System Health</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Database Status</span>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Online
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">API Status</span>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Operational
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Storage</span>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
75% Used
</span>
</div>
</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Recent Activity</h3>
<div className="space-y-3">
<div className="flex items-start">
<div className="bg-blue-100 rounded-full p-1 mr-3 mt-0.5">
<svg className="w-4 h-4 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<p className="text-sm text-gray-900">New user registration</p>
<p className="text-xs text-gray-500">2 minutes ago</p>
</div>
</div>
<div className="flex items-start">
<div className="bg-green-100 rounded-full p-1 mr-3 mt-0.5">
<svg className="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<div>
<p className="text-sm text-gray-900">Card database updated</p>
<p className="text-xs text-gray-500">15 minutes ago</p>
</div>
</div>
<div className="flex items-start">
<div className="bg-purple-100 rounded-full p-1 mr-3 mt-0.5">
<svg className="w-4 h-4 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<p className="text-sm text-gray-900">System backup completed</p>
<p className="text-xs text-gray-500">1 hour ago</p>
</div>
</div>
</div>
</div>
</div>
{/* Quick Actions */}
<div className="bg-white border border-gray-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Quick Actions</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<button className="p-4 text-center border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
<div className="text-indigo-600 mb-2">
<svg className="w-8 h-8 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
<p className="text-sm font-medium text-gray-900">Add User</p>
</button>
<button className="p-4 text-center border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
<div className="text-green-600 mb-2">
<svg className="w-8 h-8 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
</div>
<p className="text-sm font-medium text-gray-900">Import Cards</p>
</button>
<button className="p-4 text-center border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
<div className="text-orange-600 mb-2">
<svg className="w-8 h-8 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<p className="text-sm font-medium text-gray-900">View Reports</p>
</button>
<button className="p-4 text-center border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
<div className="text-red-600 mb-2">
<svg className="w-8 h-8 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<p className="text-sm font-medium text-gray-900">Settings</p>
</button>
</div>
</div>
</div>
);
};
export default SystemStats;

View file

@ -1,329 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../../contexts/AuthContext';
interface User {
id: number;
username: string;
email: string;
first_name?: string;
last_name?: string;
is_active: boolean;
email_verified: boolean;
roles: string[];
created_at: string;
last_login?: string;
}
interface UserResponse {
success: boolean;
users: User[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
const UserManagement: React.FC = () => {
const { token } = useAuth();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [editingUser, setEditingUser] = useState<User | null>(null);
const fetchUsers = async (page = 1, search = '') => {
try {
setLoading(true);
const response = await fetch(
`/api/admin/users?page=${page}&limit=20&search=${encodeURIComponent(search)}`,
{
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
throw new Error('Failed to fetch users');
}
const data: UserResponse = await response.json();
setUsers(data.users);
setCurrentPage(data.pagination.page);
setTotalPages(data.pagination.totalPages);
setError('');
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUsers(currentPage, searchTerm);
}, [currentPage, token]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setCurrentPage(1);
fetchUsers(1, searchTerm);
};
const handleUserUpdate = async (userId: number, updates: { isActive?: boolean; roles?: string[] }) => {
try {
const response = await fetch(`/api/admin/users?id=${userId}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(updates),
});
if (!response.ok) {
throw new Error('Failed to update user');
}
// Refresh users list
fetchUsers(currentPage, searchTerm);
setEditingUser(null);
} catch (err) {
setError((err as Error).message);
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const getRoleColor = (role: string) => {
switch (role) {
case 'admin':
return 'bg-purple-100 text-purple-800';
case 'user':
return 'bg-blue-100 text-blue-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
if (loading && users.length === 0) {
return (
<div className="p-6 flex justify-center items-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
</div>
);
}
return (
<div className="p-6">
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-2">User Management</h2>
<p className="text-sm text-gray-600">Manage user accounts, roles, and permissions.</p>
</div>
{/* Search Bar */}
<form onSubmit={handleSearch} className="mb-6">
<div className="flex gap-4">
<div className="flex-1">
<input
type="text"
placeholder="Search users by username, email, or name..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<button
type="submit"
className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
Search
</button>
</div>
</form>
{error && (
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{error}
</div>
)}
{/* Users Table */}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Roles
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Last Login
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div>
<div className="text-sm font-medium text-gray-900">
{user.first_name || user.last_name
? `${user.first_name || ''} ${user.last_name || ''}`.trim()
: user.username}
</div>
<div className="text-sm text-gray-500">{user.email}</div>
{(user.first_name || user.last_name) && (
<div className="text-xs text-gray-400">@{user.username}</div>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex flex-wrap gap-1">
{user.roles.map((role) => (
<span
key={role}
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getRoleColor(role)}`}
>
{role}
</span>
))}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.is_active
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{user.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDate(user.created_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.last_login ? formatDate(user.last_login) : 'Never'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2">
<button
onClick={() => setEditingUser(user)}
className="text-indigo-600 hover:text-indigo-900"
>
Edit
</button>
<button
onClick={() => handleUserUpdate(user.id, { isActive: !user.is_active })}
className={user.is_active ? 'text-red-600 hover:text-red-900' : 'text-green-600 hover:text-green-900'}
>
{user.is_active ? 'Deactivate' : 'Activate'}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-6 flex justify-center">
<nav className="flex space-x-2">
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="px-3 py-2 text-sm border border-gray-300 rounded-md disabled:opacity-50"
>
Previous
</button>
<span className="px-3 py-2 text-sm">
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className="px-3 py-2 text-sm border border-gray-300 rounded-md disabled:opacity-50"
>
Next
</button>
</nav>
</div>
)}
{/* Edit User Modal */}
{editingUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-medium text-gray-900 mb-4">Edit User</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Roles</label>
<div className="mt-2 space-y-2">
{['user', 'admin'].map((role) => (
<label key={role} className="flex items-center">
<input
type="checkbox"
checked={editingUser.roles.includes(role)}
onChange={(e) => {
const newRoles = e.target.checked
? [...editingUser.roles, role]
: editingUser.roles.filter(r => r !== role);
setEditingUser({ ...editingUser, roles: newRoles });
}}
className="mr-2"
/>
<span className="capitalize">{role}</span>
</label>
))}
</div>
</div>
</div>
<div className="mt-6 flex justify-end space-x-3">
<button
onClick={() => setEditingUser(null)}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200"
>
Cancel
</button>
<button
onClick={() => handleUserUpdate(editingUser.id, { roles: editingUser.roles })}
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
>
Save Changes
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default UserManagement;

View file

@ -1,154 +0,0 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
const LoginForm: React.FC = () => {
const { login } = useAuth();
const [formData, setFormData] = useState({
email: '',
password: '',
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError(null);
try {
await login(formData.email, formData.password);
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setIsLoading(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({
...prev,
[e.target.name]: e.target.value
}));
};
return (
<div className="bg-white dark:bg-surface-800 p-8 rounded-2xl shadow-xl border border-surface-200 dark:border-surface-700 transition-colors">
{/* Logo Section */}
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-r from-primary-500 to-accent-500 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
<svg className="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h1 className="text-2xl font-bold text-surface-900 dark:text-white mb-2">
Welcome Back
</h1>
<p className="text-surface-600 dark:text-surface-400">
Sign in to your TCG Vault
</p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl">
<div className="flex items-center">
<svg className="w-5 h-5 text-red-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<span className="text-red-700 dark:text-red-400 text-sm">{error}</span>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="email" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Email Address
</label>
<div className="relative">
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors"
placeholder="Enter your email"
required
/>
<svg className="absolute right-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-surface-400 dark:text-surface-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207" />
</svg>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Password
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
id="password"
name="password"
value={formData.password}
onChange={handleChange}
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors pr-12"
placeholder="Enter your password"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300 transition-colors"
>
{showPassword ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-gradient-to-r from-primary-500 to-accent-500 hover:from-primary-600 hover:to-accent-600 disabled:from-surface-400 disabled:to-surface-400 text-white font-semibold py-3 px-4 rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl disabled:shadow-none transform hover:-translate-y-0.5 disabled:transform-none"
>
{isLoading ? (
<div className="flex items-center justify-center">
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Signing In...
</div>
) : (
'Sign In'
)}
</button>
<div className="text-center">
<p className="text-surface-600 dark:text-surface-400">
Don&apos;t have an account?{' '}
<Link
to="/register"
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-medium transition-colors"
>
Sign up
</Link>
</p>
</div>
</form>
</div>
);
};
export default LoginForm;

View file

@ -1,249 +0,0 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
const RegisterForm: React.FC = () => {
const { register } = useAuth();
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
username: '',
email: '',
password: '',
confirmPassword: '',
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError(null);
// Validate passwords match
if (formData.password !== formData.confirmPassword) {
setError('Passwords do not match');
setIsLoading(false);
return;
}
try {
await register(formData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed');
} finally {
setIsLoading(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({
...prev,
[e.target.name]: e.target.value
}));
};
return (
<div className="bg-white dark:bg-surface-800 p-8 rounded-2xl shadow-xl border border-surface-200 dark:border-surface-700 transition-colors">
{/* Logo Section */}
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-r from-primary-500 to-accent-500 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
<svg className="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h1 className="text-2xl font-bold text-surface-900 dark:text-white mb-2">
Join TCG Vault
</h1>
<p className="text-surface-600 dark:text-surface-400">
Create your account to get started
</p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl">
<div className="flex items-center">
<svg className="w-5 h-5 text-red-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<span className="text-red-700 dark:text-red-400 text-sm">{error}</span>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="firstName" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
First Name
</label>
<input
type="text"
id="firstName"
name="firstName"
value={formData.firstName}
onChange={handleChange}
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors"
placeholder="John"
required
/>
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Last Name
</label>
<input
type="text"
id="lastName"
name="lastName"
value={formData.lastName}
onChange={handleChange}
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors"
placeholder="Doe"
required
/>
</div>
</div>
<div>
<label htmlFor="username" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Username
</label>
<input
type="text"
id="username"
name="username"
value={formData.username}
onChange={handleChange}
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors"
placeholder="johndoe"
required
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Email Address
</label>
<div className="relative">
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors"
placeholder="john@example.com"
required
/>
<svg className="absolute right-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-surface-400 dark:text-surface-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207" />
</svg>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Password
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
id="password"
name="password"
value={formData.password}
onChange={handleChange}
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors pr-12"
placeholder="Create a strong password"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300 transition-colors"
>
{showPassword ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Confirm Password
</label>
<div className="relative">
<input
type={showConfirmPassword ? 'text' : 'password'}
id="confirmPassword"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleChange}
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors pr-12"
placeholder="Confirm your password"
required
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300 transition-colors"
>
{showConfirmPassword ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-gradient-to-r from-primary-500 to-accent-500 hover:from-primary-600 hover:to-accent-600 disabled:from-surface-400 disabled:to-surface-400 text-white font-semibold py-3 px-4 rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl disabled:shadow-none transform hover:-translate-y-0.5 disabled:transform-none"
>
{isLoading ? (
<div className="flex items-center justify-center">
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Creating Account...
</div>
) : (
'Create Account'
)}
</button>
<div className="text-center">
<p className="text-surface-600 dark:text-surface-400">
Already have an account?{' '}
<Link
to="/login"
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-medium transition-colors"
>
Sign in
</Link>
</p>
</div>
</form>
</div>
);
};
export default RegisterForm;

View file

@ -1,607 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import cardDataService from '../../services/cardDataSources';
import type { Card } from '../../types';
interface CardDatabaseBrowserProps {
isOpen: boolean;
onClose: () => void;
onCardSelect?: (card: Card) => void;
}
const CardDatabaseBrowser: React.FC<CardDatabaseBrowserProps> = ({
isOpen,
onClose,
onCardSelect
}) => {
const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [selectedGame, setSelectedGame] = useState<string>('');
const [selectedCard, setSelectedCard] = useState<Card | null>(null);
const [showCardManager, setShowCardManager] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const [viewMode, setViewMode] = useState<'cards' | 'table'>('cards');
const queryClient = useQueryClient();
// Debounce search input
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(searchTerm);
}, 500);
return () => clearTimeout(timer);
}, [searchTerm]);
// Search external APIs
const { data: externalCards = [], isLoading: isSearchingExternal } = useQuery({
queryKey: ['external-cards', debouncedSearch, selectedGame],
queryFn: async () => {
if (!debouncedSearch.trim()) return [];
setIsSearching(true);
try {
const cards = await cardDataService.searchCards(debouncedSearch, selectedGame || undefined);
console.log(`Found ${cards.length} cards for search "${debouncedSearch}" in game "${selectedGame}"`);
return cards;
} catch (error) {
console.error('Error searching external cards:', error);
return [];
} finally {
setIsSearching(false);
}
},
enabled: !!debouncedSearch.trim() && isOpen,
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 2,
});
// Get random cards for discovery
const { data: randomCards = [] } = useQuery({
queryKey: ['random-cards', selectedGame],
queryFn: async () => {
try {
const cards = await cardDataService.getRandomCards(20, selectedGame || undefined);
console.log(`Found ${cards.length} random cards for game "${selectedGame}"`);
return cards;
} catch (error) {
console.error('Error fetching random cards:', error);
return [];
}
},
enabled: isOpen && !debouncedSearch.trim(),
staleTime: 10 * 60 * 1000, // 10 minutes
retry: 2,
});
// Add card to database mutation
const addToDatabaseMutation = useMutation({
mutationFn: async (card: Card) => {
// First, try to add the card to our database
const response = await fetch('/api/cards/find-or-create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('tcg-vault-token')}`,
},
body: JSON.stringify({
name: card.name,
game: card.game,
setName: card.set_name,
setCode: card.set_code,
rarity: card.rarity,
cardType: card.card_type,
manaCost: card.mana_cost,
imageUrl: card.stock_image_url,
}),
});
if (!response.ok) {
throw new Error('Failed to add card to database');
}
const result = await response.json();
return result.card;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cards'] });
},
});
const handleCardClick = async (card: Card) => {
if (onCardSelect) {
onCardSelect(card);
onClose();
} else {
setSelectedCard(card);
setShowCardManager(true);
}
};
const handleAddToDatabase = async (card: Card) => {
try {
await addToDatabaseMutation.mutateAsync(card);
// Show success message
alert(`${card.name} has been added to the database!`);
} catch (error) {
console.error('Error adding card to database:', error);
alert('Failed to add card to database. Please try again.');
}
};
const getGameBadgeColor = (game: string) => {
switch (game) {
case 'MTG': return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300';
case 'POKEMON': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
case 'LORCANA': return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300';
case 'YUGIOH': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
default: return 'bg-surface-100 text-surface-800 dark:bg-surface-700 dark:text-surface-300';
}
};
const getRarityBadgeColor = (rarity: string) => {
switch (rarity?.toLowerCase()) {
case 'common': return 'bg-surface-100 text-surface-800 dark:bg-surface-700 dark:text-surface-300';
case 'uncommon': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
case 'rare': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
case 'super rare': return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300';
case 'legendary': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
case 'mythic': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
default: return 'bg-surface-100 text-surface-800 dark:bg-surface-700 dark:text-surface-300';
}
};
if (!isOpen) return null;
const displayCards = debouncedSearch.trim() ? externalCards : randomCards;
const isLoading = isSearchingExternal || isSearching;
// Debug logging
console.log('CardDatabaseBrowser state:', {
isOpen,
searchTerm,
debouncedSearch,
selectedGame,
externalCards: externalCards.length,
randomCards: randomCards.length,
displayCards: displayCards.length,
isLoading
});
return (
<>
<div className="fixed inset-0 z-50 bg-black/50" onClick={onClose}>
<div
className="fixed bottom-0 left-0 right-0 bg-white dark:bg-surface-900 rounded-t-3xl h-[90vh] overflow-hidden animate-slide-up"
onClick={(e) => e.stopPropagation()}
>
<div className="p-4 pb-0">
<div className="w-12 h-1.5 bg-surface-300 dark:bg-surface-600 rounded-full mx-auto mb-4"></div>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-surface-900 dark:text-white">
Card Database Browser
</h2>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClose();
}}
className="p-2 rounded-xl text-surface-400 hover:text-surface-600 dark:hover:text-surface-300 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Search Bar */}
<div className="relative mb-4">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-surface-400" 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>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
placeholder="Search cards from external databases..."
className="w-full pl-10 pr-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors"
/>
</div>
{/* Game Filter */}
<div className="flex space-x-2 mb-4 overflow-x-auto pb-2">
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setSelectedGame('');
}}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
!selectedGame
? 'bg-primary-500 text-white'
: 'bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300'
}`}
>
All Games
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setSelectedGame('MTG');
}}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
selectedGame === 'MTG'
? 'bg-orange-500 text-white'
: 'bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300'
}`}
>
Magic: The Gathering
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setSelectedGame('POKEMON');
}}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
selectedGame === 'POKEMON'
? 'bg-yellow-500 text-white'
: 'bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300'
}`}
>
Pokémon
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setSelectedGame('YUGIOH');
}}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
selectedGame === 'YUGIOH'
? 'bg-blue-500 text-white'
: 'bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300'
}`}
>
Yu-Gi-Oh!
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setSelectedGame('LORCANA');
}}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
selectedGame === 'LORCANA'
? 'bg-purple-500 text-white'
: 'bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300'
}`}
>
Disney Lorcana
</button>
</div>
{/* View Toggle */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<span className="text-sm font-medium text-surface-700 dark:text-surface-300">View:</span>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setViewMode('cards');
}}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
viewMode === 'cards'
? 'bg-primary-500 text-white'
: 'bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-600'
}`}
>
<svg className="w-4 h-4 inline mr-1" 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>
Cards
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setViewMode('table');
}}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
viewMode === 'table'
? 'bg-primary-500 text-white'
: 'bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-600'
}`}
>
<svg className="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H6a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
Table
</button>
</div>
{displayCards.length > 0 && (
<span className="text-sm text-surface-600 dark:text-surface-400">
{displayCards.length} card{displayCards.length !== 1 ? 's' : ''}
</span>
)}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto pb-20">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
<span className="ml-3 text-surface-600 dark:text-surface-400">
Searching external databases...
</span>
</div>
) : displayCards.length === 0 ? (
<div className="text-center py-8">
<div className="text-6xl mb-4">🔍</div>
<h3 className="text-lg font-semibold text-surface-900 dark:text-white mb-2">
{debouncedSearch.trim() ? 'No cards found' : 'Discover Cards'}
</h3>
<p className="text-surface-600 dark:text-surface-400">
{debouncedSearch.trim()
? 'Try adjusting your search terms or game filter'
: 'Search for cards to see results from external databases'
}
</p>
</div>
) : (
<>
{/* Card View */}
{viewMode === 'cards' && (
<div className="grid grid-cols-1 gap-4">
{displayCards.map((card) => (
<div
key={`${card.game}-${card.id}`}
className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-4 transition-all duration-200 hover:shadow-md"
>
<div className="flex items-start space-x-4">
{/* Card Image */}
<div className="w-16 h-20 bg-surface-200 dark:bg-surface-700 rounded-lg flex items-center justify-center flex-shrink-0">
{card.stock_image_url ? (
<img
src={card.stock_image_url}
alt={card.name}
className="w-full h-full object-cover rounded-lg"
/>
) : (
<svg className="w-6 h-6 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
)}
</div>
{/* Card Info */}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-surface-900 dark:text-white truncate">
{card.name}
</h3>
<p className="text-sm text-surface-600 dark:text-surface-400">
{card.set_name} {card.card_number}
</p>
{/* Badges */}
<div className="flex flex-wrap gap-2 mt-2">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getGameBadgeColor(card.game)}`}>
{card.game}
</span>
{card.rarity && (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getRarityBadgeColor(card.rarity)}`}>
{card.rarity}
</span>
)}
{card.current_price && (
<span className="px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 rounded-full text-xs font-medium">
${card.current_price}
</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex flex-col space-y-2">
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCardClick(card);
}}
className="px-3 py-1 bg-primary-500 hover:bg-primary-600 text-white text-xs font-medium rounded-lg transition-colors"
>
Add to Collection
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleAddToDatabase(card);
}}
disabled={addToDatabaseMutation.isPending}
className="px-3 py-1 bg-surface-100 dark:bg-surface-700 hover:bg-surface-200 dark:hover:bg-surface-600 text-surface-700 dark:text-surface-300 text-xs font-medium rounded-lg transition-colors disabled:opacity-50"
>
{addToDatabaseMutation.isPending ? 'Adding...' : 'Add to DB'}
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Table View */}
{viewMode === 'table' && (
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-surface-50 dark:bg-surface-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-surface-600 dark:text-surface-400 uppercase tracking-wider">
Card
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-surface-600 dark:text-surface-400 uppercase tracking-wider">
Set
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-surface-600 dark:text-surface-400 uppercase tracking-wider">
Game
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-surface-600 dark:text-surface-400 uppercase tracking-wider">
Rarity
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-surface-600 dark:text-surface-400 uppercase tracking-wider">
Price
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-surface-600 dark:text-surface-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-surface-200 dark:divide-surface-700">
{displayCards.map((card) => (
<tr key={`${card.game}-${card.id}`} className="hover:bg-surface-50 dark:hover:bg-surface-700 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center space-x-3">
<div className="w-10 h-12 bg-surface-200 dark:bg-surface-700 rounded flex items-center justify-center flex-shrink-0">
{card.stock_image_url ? (
<img
src={card.stock_image_url}
alt={card.name}
className="w-full h-full object-cover rounded"
/>
) : (
<svg className="w-4 h-4 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
)}
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-surface-900 dark:text-white truncate">
{card.name}
</div>
<div className="text-xs text-surface-500 dark:text-surface-400">
#{card.card_number}
</div>
</div>
</div>
</td>
<td className="px-4 py-3 text-sm text-surface-900 dark:text-white">
{card.set_name}
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getGameBadgeColor(card.game)}`}>
{card.game}
</span>
</td>
<td className="px-4 py-3">
{card.rarity && (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getRarityBadgeColor(card.rarity)}`}>
{card.rarity}
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-surface-900 dark:text-white">
{card.current_price ? `$${card.current_price}` : '-'}
</td>
<td className="px-4 py-3">
<div className="flex space-x-2">
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCardClick(card);
}}
className="px-2 py-1 bg-primary-500 hover:bg-primary-600 text-white text-xs font-medium rounded transition-colors"
>
Add
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleAddToDatabase(card);
}}
disabled={addToDatabaseMutation.isPending}
className="px-2 py-1 bg-surface-100 dark:bg-surface-700 hover:bg-surface-200 dark:hover:bg-surface-600 text-surface-700 dark:text-surface-300 text-xs font-medium rounded transition-colors disabled:opacity-50"
>
DB
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
)}
</div>
</div>
</div>
</div>
{/* Card Manager Modal */}
{selectedCard && showCardManager && (
<div className="fixed inset-0 z-60">
{/* This would render the CardManager component */}
<div className="fixed inset-0 bg-black/50" onClick={() => setShowCardManager(false)}>
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-surface-900 rounded-t-3xl p-6 max-h-[80vh] overflow-y-auto">
<div className="text-center">
<h3 className="text-lg font-semibold text-surface-900 dark:text-white mb-4">
Add {selectedCard.name} to your collection
</h3>
<p className="text-surface-600 dark:text-surface-400 mb-6">
This card will be added to your collection with the details you specify.
</p>
<div className="flex space-x-4">
<button
onClick={() => {
handleCardClick(selectedCard);
setShowCardManager(false);
}}
className="flex-1 bg-primary-500 hover:bg-primary-600 text-white font-medium py-3 px-6 rounded-xl transition-colors"
>
Continue
</button>
<button
onClick={() => setShowCardManager(false)}
className="flex-1 bg-surface-100 dark:bg-surface-700 hover:bg-surface-200 dark:hover:bg-surface-600 text-surface-700 dark:text-surface-300 font-medium py-3 px-6 rounded-xl transition-colors"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
)}
</>
);
};
export default CardDatabaseBrowser;

View file

@ -1,365 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { tcgApi } from '../../services/tcgApi';
import type { Card, CreateCardData } from '../../types';
interface CardManagerProps {
isOpen: boolean;
onClose: () => void;
cardId?: string; // For editing existing card
initialCardData?: Card; // For adding card from database
}
const CardManager: React.FC<CardManagerProps> = ({
isOpen,
onClose,
cardId,
initialCardData
}) => {
const queryClient = useQueryClient();
const [formData, setFormData] = useState<CreateCardData>({
cardId: initialCardData?.id || '',
status: 'owned',
quantity: 1,
condition: 'near_mint',
notes: '',
tags: [],
collectionIds: [],
deckIds: [],
});
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedCollections, setSelectedCollections] = useState<string[]>([]);
// Queries
const { data: existingCard } = useQuery({
queryKey: ['user-card', cardId],
queryFn: () => tcgApi.cards.getUserCard(cardId!),
enabled: !!cardId,
});
const { data: cardInfo } = useQuery({
queryKey: ['card-info', formData.cardId],
queryFn: () => tcgApi.cards.getCard(formData.cardId),
enabled: !!formData.cardId,
});
const { data: tags = [] } = useQuery({
queryKey: ['tags'],
queryFn: () => tcgApi.tags.getTags(),
});
const { data: collections = [] } = useQuery({
queryKey: ['collections'],
queryFn: () => tcgApi.collections.getCollections(),
});
// Mutations
const addCardMutation = useMutation({
mutationFn: tcgApi.cards.addCard,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['user-cards'] });
queryClient.invalidateQueries({ queryKey: ['collections'] });
onClose();
},
});
const updateCardMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<CreateCardData> }) =>
tcgApi.cards.updateCard(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['user-cards'] });
queryClient.invalidateQueries({ queryKey: ['user-card', cardId] });
onClose();
},
});
// Initialize form with existing card data
useEffect(() => {
if (existingCard) {
setFormData({
cardId: existingCard.cardId,
status: existingCard.status,
quantity: existingCard.quantity,
condition: existingCard.condition || 'near_mint',
notes: existingCard.notes || '',
tags: existingCard.tags,
collectionIds: existingCard.collectionIds,
});
setSelectedTags(existingCard.tags);
setSelectedCollections(existingCard.collectionIds);
}
}, [existingCard]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const submitData = {
...formData,
tags: selectedTags,
collectionIds: selectedCollections,
};
if (cardId && existingCard) {
updateCardMutation.mutate({ id: cardId, data: submitData });
} else {
addCardMutation.mutate(submitData);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 bg-black/50" onClick={onClose}>
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-surface-900 rounded-t-3xl max-h-[90vh] overflow-hidden animate-slide-up">
<div className="p-6">
<div className="w-12 h-1.5 bg-surface-300 dark:bg-surface-600 rounded-full mx-auto mb-6"></div>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-surface-900 dark:text-white">
{cardId ? 'Edit Card' : 'Add Card'}
</h2>
<button
onClick={onClose}
className="p-2 rounded-xl text-surface-400 hover:text-surface-600 dark:hover:text-surface-300 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="max-h-[calc(90vh-8rem)] overflow-y-auto">
{/* Card Preview */}
{cardInfo && (
<div className="mb-6 p-4 bg-surface-50 dark:bg-surface-800 rounded-2xl">
<div className="flex items-center space-x-4">
<div className="w-16 h-20 bg-surface-200 dark:bg-surface-700 rounded-lg flex items-center justify-center">
{cardInfo.image_url ? (
<img
src={cardInfo.image_url}
alt={cardInfo.name}
className="w-full h-full object-cover rounded-lg"
/>
) : (
<svg className="w-6 h-6 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
)}
</div>
<div className="flex-1">
<h3 className="font-semibold text-surface-900 dark:text-white">{cardInfo.name}</h3>
<p className="text-sm text-surface-600 dark:text-surface-400">{cardInfo.set_name}</p>
<div className="flex items-center space-x-2 mt-2">
<span className="px-2 py-1 bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 rounded-full text-xs font-medium">
{cardInfo.game}
</span>
<span className="px-2 py-1 bg-accent-100 dark:bg-accent-900/30 text-accent-700 dark:text-accent-300 rounded-full text-xs font-medium">
{cardInfo.rarity}
</span>
</div>
</div>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6" onClick={(e) => e.stopPropagation()}>
{/* Status Toggle */}
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-3">
Ownership Status
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setFormData(prev => ({ ...prev, status: 'owned' }))}
className={`p-4 rounded-xl border-2 transition-all ${
formData.status === 'owned'
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-surface-200 dark:border-surface-700'
}`}
>
<div className="text-center">
<div className="text-2xl mb-2"></div>
<div className="font-medium text-surface-900 dark:text-white">Owned</div>
<div className="text-sm text-surface-600 dark:text-surface-400">Cards you own</div>
</div>
</button>
<button
type="button"
onClick={() => setFormData(prev => ({ ...prev, status: 'wanted' }))}
className={`p-4 rounded-xl border-2 transition-all ${
formData.status === 'wanted'
? 'border-accent-500 bg-accent-50 dark:bg-accent-900/20'
: 'border-surface-200 dark:border-surface-700'
}`}
>
<div className="text-center">
<div className="text-2xl mb-2"></div>
<div className="font-medium text-surface-900 dark:text-white">Wanted</div>
<div className="text-sm text-surface-600 dark:text-surface-400">Wishlist cards</div>
</div>
</button>
</div>
</div>
{/* Quantity */}
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Quantity
</label>
<div className="flex items-center space-x-4">
<button
type="button"
onClick={() => setFormData(prev => ({ ...prev, quantity: Math.max(1, prev.quantity - 1) }))}
className="w-10 h-10 bg-surface-100 dark:bg-surface-700 rounded-lg flex items-center justify-center text-surface-600 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-600 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
</svg>
</button>
<span className="text-xl font-semibold text-surface-900 dark:text-white min-w-[3rem] text-center">
{formData.quantity}
</span>
<button
type="button"
onClick={() => setFormData(prev => ({ ...prev, quantity: prev.quantity + 1 }))}
className="w-10 h-10 bg-surface-100 dark:bg-surface-700 rounded-lg flex items-center justify-center text-surface-600 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-600 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</button>
</div>
</div>
{/* Condition */}
{formData.status === 'owned' && (
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Condition
</label>
<select
value={formData.condition}
onChange={(e) => setFormData(prev => ({ ...prev, condition: e.target.value as any }))}
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors"
>
<option value="mint">Mint (M)</option>
<option value="near_mint">Near Mint (NM)</option>
<option value="excellent">Excellent (EX)</option>
<option value="good">Good (G)</option>
<option value="light_played">Light Played (LP)</option>
<option value="played">Played (P)</option>
<option value="poor">Poor (PO)</option>
</select>
</div>
)}
{/* Tags */}
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Tags
</label>
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<button
key={tag.id}
type="button"
onClick={() => {
setSelectedTags(prev =>
prev.includes(tag.id)
? prev.filter(id => id !== tag.id)
: [...prev, tag.id]
);
}}
className={`px-3 py-2 rounded-full text-sm font-medium transition-colors ${
selectedTags.includes(tag.id)
? 'text-white'
: 'bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300'
}`}
style={{
backgroundColor: selectedTags.includes(tag.id) ? tag.color : undefined,
}}
>
{tag.name}
</button>
))}
</div>
</div>
{/* Collections */}
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Collections
</label>
<div className="space-y-2 max-h-32 overflow-y-auto">
{collections.map((collection) => (
<label key={collection.id} className="flex items-center space-x-3">
<input
type="checkbox"
checked={selectedCollections.includes(collection.id)}
onChange={(e) => {
setSelectedCollections(prev =>
e.target.checked
? [...prev, collection.id]
: prev.filter(id => id !== collection.id)
);
}}
className="w-4 h-4 text-primary-600 bg-surface-50 border-surface-300 rounded focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-surface-800 dark:bg-surface-700 dark:border-surface-600"
/>
<span className="text-sm text-surface-700 dark:text-surface-300">
{collection.name}
</span>
</label>
))}
</div>
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Notes
</label>
<textarea
value={formData.notes}
onChange={(e) => setFormData(prev => ({ ...prev, notes: e.target.value }))}
rows={3}
placeholder="Add any notes about this card..."
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors resize-none"
/>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={!formData.cardId || addCardMutation.isPending || updateCardMutation.isPending}
className="w-full bg-gradient-to-r from-primary-500 to-accent-500 hover:from-primary-600 hover:to-accent-600 disabled:from-surface-400 disabled:to-surface-400 text-white font-semibold py-4 px-6 rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl disabled:shadow-none transform hover:-translate-y-0.5 disabled:transform-none"
>
{(addCardMutation.isPending || updateCardMutation.isPending) ? (
<div className="flex items-center justify-center">
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{cardId ? 'Updating...' : 'Adding...'}
</div>
) : (
cardId ? 'Update Card' : 'Add Card'
)}
</button>
</form>
</div>
</div>
</div>
</div>
);
};
export default CardManager;

View file

@ -1,279 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { tcgApi } from '../../services/tcgApi';
import CardManager from './CardManager';
import type { Card, CardFilters } from '../../types';
interface CardSearchProps {
isOpen: boolean;
onClose: () => void;
onCardSelect?: (card: Card) => void;
}
const CardSearch: React.FC<CardSearchProps> = ({
isOpen,
onClose,
onCardSelect
}) => {
const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [filters, setFilters] = useState<CardFilters>({
game: '',
rarity: '',
status: 'all'
});
const [selectedCard, setSelectedCard] = useState<Card | null>(null);
const [showCardManager, setShowCardManager] = useState(false);
// Debounce search input
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(searchTerm);
}, 300);
return () => clearTimeout(timer);
}, [searchTerm]);
// Search cards query
const { data: searchResults = [], isLoading } = useQuery({
queryKey: ['card-search', debouncedSearch, filters],
queryFn: () => {
if (debouncedSearch.trim()) {
return tcgApi.cards.searchCards(debouncedSearch, filters);
} else {
return tcgApi.cards.getAllCards(filters);
}
},
enabled: isOpen,
});
const getGameBadgeColor = (game: string) => {
switch (game) {
case 'MTG': return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300';
case 'POKEMON': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
case 'LORCANA': return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300';
case 'YUGIOH': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
default: return 'bg-surface-100 text-surface-800 dark:bg-surface-700 dark:text-surface-300';
}
};
const getRarityBadgeColor = (rarity: string) => {
switch (rarity.toLowerCase()) {
case 'common': return 'bg-surface-100 text-surface-800 dark:bg-surface-700 dark:text-surface-300';
case 'uncommon': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
case 'rare': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
case 'super rare': return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300';
case 'legendary': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
case 'mythic': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
default: return 'bg-surface-100 text-surface-800 dark:bg-surface-700 dark:text-surface-300';
}
};
const handleAddCard = (card: Card) => {
setSelectedCard(card);
setShowCardManager(true);
};
if (!isOpen) return null;
return (
<>
<div className="fixed inset-0 z-50 bg-black/50" onClick={onClose}>
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-surface-900 rounded-t-3xl h-[90vh] overflow-hidden animate-slide-up">
<div className="p-4 pb-0">
<div className="w-12 h-1.5 bg-surface-300 dark:bg-surface-600 rounded-full mx-auto mb-4"></div>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-surface-900 dark:text-white">
Browse Cards
</h2>
<button
onClick={onClose}
className="p-2 rounded-xl text-surface-400 hover:text-surface-600 dark:hover:text-surface-300 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Search Bar */}
<div className="relative mb-4">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-surface-400" 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>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search cards..."
className="w-full pl-10 pr-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors"
/>
</div>
{/* Filters */}
<div className="flex space-x-2 mb-4 overflow-x-auto pb-2">
<select
value={filters.game}
onChange={(e) => setFilters(prev => ({ ...prev, game: e.target.value }))}
className="px-3 py-2 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors whitespace-nowrap"
>
<option value="">All Games</option>
<option value="MTG">Magic: The Gathering</option>
<option value="POKEMON">Pokémon</option>
<option value="LORCANA">Disney Lorcana</option>
<option value="YUGIOH">Yu-Gi-Oh!</option>
</select>
<select
value={filters.rarity}
onChange={(e) => setFilters(prev => ({ ...prev, rarity: e.target.value }))}
className="px-3 py-2 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors whitespace-nowrap"
>
<option value="">All Rarities</option>
<option value="Common">Common</option>
<option value="Uncommon">Uncommon</option>
<option value="Rare">Rare</option>
<option value="Super Rare">Super Rare</option>
<option value="Legendary">Legendary</option>
<option value="Mythic">Mythic</option>
</select>
</div>
</div>
{/* Results */}
<div className="flex-1 overflow-y-auto px-4 pb-4" onClick={(e) => e.stopPropagation()}>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="flex flex-col items-center">
<div className="w-8 h-8 bg-gradient-to-r from-primary-500 to-accent-500 rounded-full flex items-center justify-center animate-bounce-subtle mb-3">
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<p className="text-surface-600 dark:text-surface-400 text-sm">Searching cards...</p>
</div>
</div>
) : searchResults.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 bg-surface-100 dark:bg-surface-700 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-surface-900 dark:text-white mb-2">
No cards found
</h3>
<p className="text-surface-600 dark:text-surface-400">
Try adjusting your search or filters
</p>
</div>
) : (
<div className="space-y-3">
<p className="text-sm text-surface-600 dark:text-surface-400 mb-4">
{searchResults.length} cards found
</p>
{searchResults.map((card) => (
<div
key={card.id}
className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-4 transition-colors"
>
<div className="flex items-start space-x-4">
{/* Card Image */}
<div className="w-12 h-16 bg-surface-200 dark:bg-surface-700 rounded-lg flex items-center justify-center flex-shrink-0">
{card.image_url ? (
<img
src={card.image_url}
alt={card.name}
className="w-full h-full object-cover rounded-lg"
/>
) : (
<svg className="w-4 h-4 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
)}
</div>
{/* Card Info */}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-surface-900 dark:text-white truncate">
{card.name}
</h3>
<p className="text-sm text-surface-600 dark:text-surface-400 truncate">
{card.set_name}
</p>
<p className="text-xs text-surface-500 dark:text-surface-500 mt-1">
{card.card_type}
</p>
<div className="flex items-center space-x-2 mt-2">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getGameBadgeColor(card.game)}`}>
{card.game}
</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getRarityBadgeColor(card.rarity)}`}>
{card.rarity}
</span>
</div>
{card.current_price && (
<p className="text-sm font-medium text-green-600 dark:text-green-400 mt-2">
${card.current_price.toFixed(2)}
</p>
)}
</div>
{/* Add Button */}
<button
onClick={() => handleAddCard(card)}
className="bg-gradient-to-r from-primary-500 to-accent-500 hover:from-primary-600 hover:to-accent-600 text-white p-2 rounded-xl transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5 flex-shrink-0"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</button>
</div>
{/* Additional Info Row */}
{(card.mana_cost || (card.power && card.toughness)) && (
<div className="flex items-center justify-between mt-3 pt-3 border-t border-surface-200 dark:border-surface-700">
{card.mana_cost && (
<span className="text-xs text-surface-600 dark:text-surface-400">
Mana Cost: {card.mana_cost}
</span>
)}
{card.power && card.toughness && (
<span className="text-xs text-surface-600 dark:text-surface-400">
{card.power}/{card.toughness}
</span>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
</div>
{/* Card Manager Modal */}
{showCardManager && selectedCard && (
<CardManager
isOpen={showCardManager}
onClose={() => {
setShowCardManager(false);
setSelectedCard(null);
}}
initialCardData={selectedCard}
/>
)}
</>
);
};
export default CardSearch;

View file

@ -1,356 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { tcgApi } from '../../services/tcgApi';
import type { CreateCollectionData } from '../../types';
interface CollectionManagerProps {
isOpen: boolean;
onClose: () => void;
collectionId?: string; // For editing existing collection
}
const CollectionManager: React.FC<CollectionManagerProps> = ({
isOpen,
onClose,
collectionId
}) => {
const queryClient = useQueryClient();
const [formData, setFormData] = useState<CreateCollectionData>({
name: '',
description: '',
game: '',
tags: [],
color: '#8b5cf6',
icon: '📚',
isPublic: false,
});
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [showIconPicker, setShowIconPicker] = useState(false);
const icons = [
'📚', '🎴', '⚡', '🔥', '💧', '🌿', '⚫', '🟤', '🟡', '🔴',
'🟢', '🔵', '🟣', '⚪', '🌟', '💎', '👑', '🏆', '🎯', '🚀'
];
const colors = [
'#8b5cf6', '#3b82f6', '#10b981', '#f59e0b', '#ef4444',
'#ec4899', '#8b5cf6', '#6366f1', '#06b6d4', '#84cc16'
];
// Queries
const { data: existingCollection } = useQuery({
queryKey: ['collection', collectionId],
queryFn: () => tcgApi.collections.getCollection(collectionId!, false),
enabled: !!collectionId,
});
const { data: tags = [] } = useQuery({
queryKey: ['tags'],
queryFn: () => tcgApi.tags.getTags(),
});
// Mutations
const createMutation = useMutation({
mutationFn: tcgApi.collections.createCollection,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['collections'] });
onClose();
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<CreateCollectionData> }) =>
tcgApi.collections.updateCollection(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['collections'] });
queryClient.invalidateQueries({ queryKey: ['collection', collectionId] });
onClose();
},
});
// Initialize form with existing collection data
useEffect(() => {
if (existingCollection) {
setFormData({
name: existingCollection.name,
description: existingCollection.description || '',
game: existingCollection.game || '',
tags: existingCollection.tags,
color: existingCollection.color || '#8b5cf6',
icon: existingCollection.icon || '📚',
isPublic: existingCollection.isPublic,
});
setSelectedTags(existingCollection.tags);
}
}, [existingCollection]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const submitData = {
...formData,
tags: selectedTags,
};
if (collectionId && existingCollection) {
updateMutation.mutate({ id: collectionId, data: submitData });
} else {
createMutation.mutate(submitData);
}
};
const getGameBadgeColor = (game: string) => {
switch (game) {
case 'MTG': return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300';
case 'POKEMON': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
case 'LORCANA': return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300';
case 'YUGIOH': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
default: return 'bg-surface-100 text-surface-800 dark:bg-surface-700 dark:text-surface-300';
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 bg-black/50" onClick={onClose}>
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-surface-900 rounded-t-3xl max-h-[90vh] overflow-hidden animate-slide-up">
<div className="p-6">
<div className="w-12 h-1.5 bg-surface-300 dark:bg-surface-600 rounded-full mx-auto mb-6"></div>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-surface-900 dark:text-white">
{collectionId ? 'Edit Collection' : 'New Collection'}
</h2>
<button
onClick={onClose}
className="p-2 rounded-xl text-surface-400 hover:text-surface-600 dark:hover:text-surface-300 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="max-h-[calc(90vh-8rem)] overflow-y-auto">
<form onSubmit={handleSubmit} className="space-y-6" onClick={(e) => e.stopPropagation()}>
{/* Collection Preview */}
<div className="p-4 bg-surface-50 dark:bg-surface-800 rounded-2xl">
<div className="flex items-center space-x-4">
<div
className="w-12 h-12 rounded-xl flex items-center justify-center text-white text-xl font-semibold shadow-md"
style={{ backgroundColor: formData.color }}
>
{formData.icon}
</div>
<div className="flex-1">
<h3 className="font-semibold text-surface-900 dark:text-white">
{formData.name || 'Collection Name'}
</h3>
{formData.description && (
<p className="text-sm text-surface-600 dark:text-surface-400">
{formData.description}
</p>
)}
{formData.game && (
<span className={`inline-block mt-2 px-2 py-1 rounded-full text-xs font-medium ${getGameBadgeColor(formData.game)}`}>
{formData.game}
</span>
)}
</div>
</div>
</div>
{/* Name */}
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Collection Name *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="Enter collection name..."
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors"
required
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
rows={3}
placeholder="Describe your collection..."
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors resize-none"
/>
</div>
{/* Game */}
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Game (Optional)
</label>
<select
value={formData.game}
onChange={(e) => setFormData(prev => ({ ...prev, game: e.target.value }))}
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors"
>
<option value="">All Games</option>
<option value="MTG">Magic: The Gathering</option>
<option value="POKEMON">Pokémon</option>
<option value="LORCANA">Disney Lorcana</option>
<option value="YUGIOH">Yu-Gi-Oh!</option>
<option value="OTHER">Other</option>
</select>
</div>
{/* Icon & Color */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Icon
</label>
<button
type="button"
onClick={() => setShowIconPicker(!showIconPicker)}
className="w-full p-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl flex items-center justify-center text-2xl hover:bg-surface-100 dark:hover:bg-surface-600 transition-colors"
>
{formData.icon}
</button>
{showIconPicker && (
<div className="mt-2 p-3 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl">
<div className="grid grid-cols-5 gap-2">
{icons.map((icon) => (
<button
key={icon}
type="button"
onClick={() => {
setFormData(prev => ({ ...prev, icon }));
setShowIconPicker(false);
}}
className={`p-2 rounded-lg text-xl hover:bg-surface-100 dark:hover:bg-surface-700 transition-colors ${
formData.icon === icon ? 'bg-primary-100 dark:bg-primary-900/30' : ''
}`}
>
{icon}
</button>
))}
</div>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Color
</label>
<div className="grid grid-cols-5 gap-2">
{colors.map((color) => (
<button
key={color}
type="button"
onClick={() => setFormData(prev => ({ ...prev, color }))}
className={`w-full h-10 rounded-lg transition-all ${
formData.color === color ? 'ring-2 ring-surface-400 ring-offset-2 dark:ring-offset-surface-900' : ''
}`}
style={{ backgroundColor: color }}
/>
))}
</div>
</div>
</div>
{/* Tags */}
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Tags
</label>
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<button
key={tag.id}
type="button"
onClick={() => {
setSelectedTags(prev =>
prev.includes(tag.id)
? prev.filter(id => id !== tag.id)
: [...prev, tag.id]
);
}}
className={`px-3 py-2 rounded-full text-sm font-medium transition-colors ${
selectedTags.includes(tag.id)
? 'text-white'
: 'bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300'
}`}
style={{
backgroundColor: selectedTags.includes(tag.id) ? tag.color : undefined,
}}
>
{tag.name}
</button>
))}
</div>
</div>
{/* Privacy Toggle */}
<div>
<label className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-surface-700 dark:text-surface-300">
Public Collection
</span>
<p className="text-xs text-surface-600 dark:text-surface-400 mt-1">
Allow others to view your collection
</p>
</div>
<button
type="button"
onClick={() => setFormData(prev => ({ ...prev, isPublic: !prev.isPublic }))}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
formData.isPublic ? 'bg-primary-600' : 'bg-surface-200 dark:bg-surface-700'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
formData.isPublic ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</label>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={!formData.name.trim() || createMutation.isPending || updateMutation.isPending}
className="w-full bg-gradient-to-r from-primary-500 to-accent-500 hover:from-primary-600 hover:to-accent-600 disabled:from-surface-400 disabled:to-surface-400 text-white font-semibold py-4 px-6 rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl disabled:shadow-none transform hover:-translate-y-0.5 disabled:transform-none"
>
{(createMutation.isPending || updateMutation.isPending) ? (
<div className="flex items-center justify-center">
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{collectionId ? 'Updating...' : 'Creating...'}
</div>
) : (
collectionId ? 'Update Collection' : 'Create Collection'
)}
</button>
</form>
</div>
</div>
</div>
</div>
);
};
export default CollectionManager;

View file

@ -1,408 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { tcgApi } from '../../services/tcgApi';
import type { CreateDeckData } from '../../types';
interface DeckManagerProps {
isOpen: boolean;
onClose: () => void;
deckId?: string; // For editing existing deck
}
const DeckManager: React.FC<DeckManagerProps> = ({
isOpen,
onClose,
deckId
}) => {
const queryClient = useQueryClient();
const [formData, setFormData] = useState<CreateDeckData>({
name: '',
description: '',
game: 'MTG',
format: '',
tags: [],
color: '#8b5cf6',
isPublic: false,
});
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const colors = [
'#8b5cf6', '#3b82f6', '#10b981', '#f59e0b', '#ef4444',
'#ec4899', '#6366f1', '#06b6d4', '#84cc16', '#f97316'
];
// Queries
const { data: existingDeck } = useQuery({
queryKey: ['deck', deckId],
queryFn: () => tcgApi.decks.getDeck(deckId!),
enabled: !!deckId,
});
const { data: tags = [] } = useQuery({
queryKey: ['tags'],
queryFn: () => tcgApi.tags.getTags(),
});
// Mutations
const createMutation = useMutation({
mutationFn: tcgApi.decks.createDeck,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['decks'] });
onClose();
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<CreateDeckData> }) =>
tcgApi.decks.updateDeck(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['decks'] });
queryClient.invalidateQueries({ queryKey: ['deck', deckId] });
onClose();
},
});
// Initialize form with existing deck data
useEffect(() => {
if (existingDeck) {
setFormData({
name: existingDeck.name,
description: existingDeck.description || '',
game: existingDeck.game,
format: existingDeck.format || '',
tags: existingDeck.tags,
color: existingDeck.color || '#8b5cf6',
isPublic: existingDeck.isPublic,
});
setSelectedTags(existingDeck.tags);
}
}, [existingDeck]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const submitData = {
...formData,
tags: selectedTags,
};
if (deckId && existingDeck) {
updateMutation.mutate({ id: deckId, data: submitData });
} else {
createMutation.mutate(submitData);
}
};
const getGameBadgeColor = (game: string) => {
switch (game) {
case 'MTG': return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300';
case 'POKEMON': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
case 'LORCANA': return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300';
case 'YUGIOH': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
default: return 'bg-surface-100 text-surface-800 dark:bg-surface-700 dark:text-surface-300';
}
};
const getFormatOptions = (game: string) => {
switch (game) {
case 'MTG':
return [
{ value: 'standard', label: 'Standard' },
{ value: 'modern', label: 'Modern' },
{ value: 'commander', label: 'Commander' },
{ value: 'pioneer', label: 'Pioneer' },
{ value: 'legacy', label: 'Legacy' },
{ value: 'vintage', label: 'Vintage' },
{ value: 'draft', label: 'Draft' },
{ value: 'sealed', label: 'Sealed' },
];
case 'POKEMON':
return [
{ value: 'standard', label: 'Standard' },
{ value: 'expanded', label: 'Expanded' },
{ value: 'unlimited', label: 'Unlimited' },
];
case 'LORCANA':
return [
{ value: 'standard', label: 'Standard' },
{ value: 'constructed', label: 'Constructed' },
];
case 'YUGIOH':
return [
{ value: 'advanced', label: 'Advanced' },
{ value: 'traditional', label: 'Traditional' },
];
default:
return [];
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 bg-black/50" onClick={onClose}>
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-surface-900 rounded-t-3xl max-h-[90vh] overflow-hidden animate-slide-up">
<div className="p-6">
<div className="w-12 h-1.5 bg-surface-300 dark:bg-surface-600 rounded-full mx-auto mb-6"></div>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-surface-900 dark:text-white">
{deckId ? 'Edit Deck' : 'New Deck'}
</h2>
<button
onClick={onClose}
className="p-2 rounded-xl text-surface-400 hover:text-surface-600 dark:hover:text-surface-300 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="max-h-[calc(90vh-8rem)] overflow-y-auto">
<form onSubmit={handleSubmit} className="space-y-6" onClick={(e) => e.stopPropagation()}>
{/* Deck Preview */}
<div className="p-4 bg-surface-50 dark:bg-surface-800 rounded-2xl">
<div className="flex items-center space-x-4">
<div
className="w-12 h-12 rounded-xl flex items-center justify-center text-white text-xl font-semibold shadow-md"
style={{ backgroundColor: formData.color }}
>
🎴
</div>
<div className="flex-1">
<h3 className="font-semibold text-surface-900 dark:text-white">
{formData.name || 'Deck Name'}
</h3>
{formData.description && (
<p className="text-sm text-surface-600 dark:text-surface-400">
{formData.description}
</p>
)}
<div className="flex items-center space-x-2 mt-2">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getGameBadgeColor(formData.game)}`}>
{formData.game}
</span>
{formData.format && (
<span className="px-2 py-1 bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300 rounded-full text-xs font-medium">
{formData.format}
</span>
)}
</div>
</div>
</div>
</div>
{/* Name */}
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Deck Name *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="Enter deck name..."
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors"
required
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
rows={3}
placeholder="Describe your deck..."
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors resize-none"
/>
</div>
{/* Game and Format */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Game *
</label>
<select
value={formData.game}
onChange={(e) => setFormData(prev => ({ ...prev, game: e.target.value as any, format: '' }))}
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors"
>
<option value="MTG">Magic: The Gathering</option>
<option value="POKEMON">Pokémon</option>
<option value="LORCANA">Disney Lorcana</option>
<option value="YUGIOH">Yu-Gi-Oh!</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Format
</label>
<select
value={formData.format}
onChange={(e) => setFormData(prev => ({ ...prev, format: e.target.value }))}
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors"
>
<option value="">Select Format</option>
{getFormatOptions(formData.game).map((format) => (
<option key={format.value} value={format.value}>
{format.label}
</option>
))}
</select>
</div>
</div>
{/* Color */}
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Color
</label>
<div className="grid grid-cols-5 gap-2">
{colors.map((color) => (
<button
key={color}
type="button"
onClick={() => setFormData(prev => ({ ...prev, color }))}
className={`w-full h-12 rounded-lg transition-all ${
formData.color === color ? 'ring-2 ring-surface-400 ring-offset-2 dark:ring-offset-surface-900' : ''
}`}
style={{ backgroundColor: color }}
/>
))}
</div>
</div>
{/* Tags */}
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Tags
</label>
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<button
key={tag.id}
type="button"
onClick={() => {
setSelectedTags(prev =>
prev.includes(tag.id)
? prev.filter(id => id !== tag.id)
: [...prev, tag.id]
);
}}
className={`px-3 py-2 rounded-full text-sm font-medium transition-colors ${
selectedTags.includes(tag.id)
? 'text-white'
: 'bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300'
}`}
style={{
backgroundColor: selectedTags.includes(tag.id) ? tag.color : undefined,
}}
>
{tag.name}
</button>
))}
</div>
</div>
{/* Privacy Toggle */}
<div>
<label className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-surface-700 dark:text-surface-300">
Public Deck
</span>
<p className="text-xs text-surface-600 dark:text-surface-400 mt-1">
Allow others to view your deck
</p>
</div>
<button
type="button"
onClick={() => setFormData(prev => ({ ...prev, isPublic: !prev.isPublic }))}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
formData.isPublic ? 'bg-primary-600' : 'bg-surface-200 dark:bg-surface-700'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
formData.isPublic ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</label>
</div>
{/* Deck Cards Section */}
{deckId && existingDeck && (
<div>
<div className="mb-4">
<h3 className="text-lg font-semibold text-surface-900 dark:text-white">
Deck Cards ({existingDeck.mainboard?.length || 0})
</h3>
</div>
{/* Deck Cards List */}
<div className="space-y-2 max-h-48 overflow-y-auto">
{existingDeck.mainboard?.map((deckCard, index) => (
<div key={index} className="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-700 rounded-lg">
<div className="flex items-center space-x-3">
<span className="text-sm font-medium text-surface-900 dark:text-white">
{deckCard.quantity}x
</span>
<span className="text-sm text-surface-700 dark:text-surface-300">
{deckCard.card?.name || 'Unknown Card'}
</span>
</div>
<button
type="button"
onClick={() => {/* Remove card from deck */}}
className="p-1 text-red-400 hover:text-red-600 rounded transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
</div>
)}
{/* Submit Button */}
<button
type="submit"
disabled={!formData.name.trim() || createMutation.isPending || updateMutation.isPending}
className="w-full bg-gradient-to-r from-primary-500 to-accent-500 hover:from-primary-600 hover:to-accent-600 disabled:from-surface-400 disabled:to-surface-400 text-white font-semibold py-4 px-6 rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl disabled:shadow-none transform hover:-translate-y-0.5 disabled:transform-none"
>
{(createMutation.isPending || updateMutation.isPending) ? (
<div className="flex items-center justify-center">
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{deckId ? 'Updating...' : 'Creating...'}
</div>
) : (
deckId ? 'Update Deck' : 'Create Deck'
)}
</button>
</form>
</div>
</div>
</div>
</div>
);
};
export default DeckManager;

View file

@ -1,510 +0,0 @@
import React, { useRef, useState, useEffect, useCallback } from 'react';
import ScanningToast from './ScanningToast';
import { aiCardOCR, ollamaCardOCR, type CardOCRResult } from '../../services/aiOcr';
interface ScannedCard {
id: string;
cardData: CardOCRResult;
imageDataUrl: string;
timestamp: number;
queuePosition: number;
}
interface AutoScanningCameraProps {
onCardScanned: (card: ScannedCard) => void;
onError: (error: string) => void;
isActive: boolean;
maxQueueSize?: number;
}
const AutoScanningCamera: React.FC<AutoScanningCameraProps> = ({
onCardScanned,
onError,
isActive,
maxQueueSize = 100
}) => {
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const scanTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastScanTimeRef = useRef<number>(0);
const [isStreaming, setIsStreaming] = useState(false);
const [scanState, setScanState] = useState<{
isScanning: boolean;
isProcessing: boolean;
message: string;
countdown: number;
}>({
isScanning: false,
isProcessing: false,
message: '',
countdown: 0
});
const [queueCount, setQueueCount] = useState(0);
const [recentScans, setRecentScans] = useState<Set<string>>(new Set());
const [isOcrConfigured, setIsOcrConfigured] = useState(false);
// Initialize OCR services with saved settings
useEffect(() => {
const initializeOCR = async () => {
try {
// Check for saved API key in localStorage (from OCRSettings component)
const savedApiKey = localStorage.getItem('openai_api_key');
// Also check user preferences (from Settings page)
const token = localStorage.getItem('tcg_vault_token');
if (token) {
try {
const response = await fetch('/api/user/preferences', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
const userApiKey = data.preferences?.ocrSettings?.openai_api_key;
if (userApiKey) {
aiCardOCR.setApiKey(userApiKey);
setIsOcrConfigured(true);
console.log('✅ OCR configured with user preferences API key');
return;
}
}
} catch (error) {
console.log('Could not load user preferences, checking localStorage...');
}
}
// Fallback to localStorage API key
if (savedApiKey) {
aiCardOCR.setApiKey(savedApiKey);
setIsOcrConfigured(true);
console.log('✅ OCR configured with localStorage API key');
} else {
console.log('❌ No OpenAI API key found. OCR scanning will not work.');
setIsOcrConfigured(false);
}
} catch (error) {
console.error('Error initializing OCR:', error);
setIsOcrConfigured(false);
}
};
initializeOCR();
}, []);
// Initialize camera
useEffect(() => {
if (isActive) {
startCamera();
} else {
stopCamera();
}
return () => {
stopCamera();
};
}, [isActive]);
// Auto-scan detection
useEffect(() => {
if (!isStreaming || !isActive || !isOcrConfigured) return;
const detectCardStabilization = () => {
if (scanState.isProcessing || queueCount >= maxQueueSize) return;
// Clear existing timeout
if (scanTimeoutRef.current) {
clearTimeout(scanTimeoutRef.current);
}
// Start countdown
setScanState(prev => ({
...prev,
isScanning: true,
message: 'Card detected, stabilizing...',
countdown: 3
}));
let countdown = 3;
const countdownInterval = setInterval(() => {
countdown--;
setScanState(prev => ({
...prev,
countdown,
message: countdown > 0 ? `Scanning in ${countdown}...` : 'Scanning card...'
}));
if (countdown <= 0) {
clearInterval(countdownInterval);
triggerScan();
}
}, 1000);
// Set timeout for actual scan
scanTimeoutRef.current = setTimeout(() => {
clearInterval(countdownInterval);
triggerScan();
}, 3000);
};
// Simulate card detection (in real implementation, this would use computer vision)
const detectionInterval = setInterval(() => {
// Only trigger if enough time has passed since last scan (prevent rapid scanning)
const now = Date.now();
if (now - lastScanTimeRef.current > 5000) { // 5 second minimum between scans
detectCardStabilization();
}
}, 2000);
return () => {
clearInterval(detectionInterval);
if (scanTimeoutRef.current) {
clearTimeout(scanTimeoutRef.current);
}
};
}, [isStreaming, isActive, scanState.isProcessing, queueCount, maxQueueSize, isOcrConfigured]);
const startCamera = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 1920 },
height: { ideal: 1080 }
}
});
if (videoRef.current) {
videoRef.current.srcObject = stream;
streamRef.current = stream;
videoRef.current.onloadedmetadata = () => {
videoRef.current?.play().then(() => {
setIsStreaming(true);
});
};
}
} catch (err: any) {
console.error('Camera error:', err);
onError(`Camera access failed: ${err.message}`);
}
};
const stopCamera = () => {
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
setIsStreaming(false);
setScanState({
isScanning: false,
isProcessing: false,
message: '',
countdown: 0
});
};
const captureImage = useCallback((): string | null => {
if (!videoRef.current || !canvasRef.current) return null;
const video = videoRef.current;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
// 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);
// Return image data URL
return canvas.toDataURL('image/jpeg', 0.8);
}, []);
const triggerScan = useCallback(async () => {
if (scanState.isProcessing || queueCount >= maxQueueSize) return;
// Check if OCR is configured
if (!isOcrConfigured) {
setScanState(prev => ({
...prev,
isScanning: false,
isProcessing: false,
message: 'OCR not configured - please set up your API key in Settings',
}));
setTimeout(() => {
setScanState(prev => ({
...prev,
message: '',
}));
}, 4000);
return;
}
setScanState(prev => ({
...prev,
isScanning: false,
isProcessing: true,
message: 'Processing card with AI...',
countdown: 0
}));
try {
const imageDataUrl = captureImage();
if (!imageDataUrl) {
throw new Error('Failed to capture image');
}
// Process with AI OCR - try OpenAI first, fallback to Ollama
let cardData: CardOCRResult;
try {
console.log('🤖 Trying OpenAI Vision API...');
cardData = await aiCardOCR.analyzeCard(imageDataUrl);
console.log('✅ OpenAI Vision result:', cardData);
} catch (openaiError) {
console.log('❌ OpenAI failed, trying Ollama:', openaiError);
try {
cardData = await ollamaCardOCR.analyzeCard(imageDataUrl);
console.log('✅ Ollama Vision result:', cardData);
} catch (ollamaError) {
console.error('❌ All AI OCR methods failed');
throw new Error('AI OCR services unavailable. Please configure OpenAI API key or run Ollama locally.');
}
}
// Create unique ID for this scan
const cardId = `scan_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// Check for recent duplicates (simple hash of card name)
const cardHash = (cardData.cardName || 'unknown').toLowerCase().replace(/\s+/g, '');
const isRecentDuplicate = recentScans.has(cardHash);
if (isRecentDuplicate) {
setScanState(prev => ({
...prev,
isProcessing: false,
message: `Duplicate detected: ${cardData.cardName || 'Unknown Card'}`,
}));
} else {
// Add to recent scans (keep last 10)
setRecentScans(prev => {
const newSet = new Set(prev);
newSet.add(cardHash);
if (newSet.size > 10) {
const firstKey = newSet.values().next().value;
newSet.delete(firstKey);
}
return newSet;
});
// Create scanned card
const scannedCard: ScannedCard = {
id: cardId,
cardData,
imageDataUrl,
timestamp: Date.now(),
queuePosition: queueCount + 1
};
// Add to queue
onCardScanned(scannedCard);
setQueueCount(prev => prev + 1);
lastScanTimeRef.current = Date.now();
setScanState(prev => ({
...prev,
isProcessing: false,
message: `Added: ${cardData.cardName || 'Unknown Card'}`,
}));
}
// Clear message after 2 seconds
setTimeout(() => {
setScanState(prev => ({
...prev,
message: '',
}));
}, 2000);
} catch (error: any) {
console.error('Scan processing error:', error);
let errorMessage = 'Scan failed, try again';
// Provide more specific error messages
if (error.message.includes('OpenAI API key not configured')) {
errorMessage = 'OpenAI API key not configured - check Settings';
} else if (error.message.includes('OpenAI API error: 401')) {
errorMessage = 'Invalid OpenAI API key - check Settings';
} else if (error.message.includes('OpenAI API error: 429')) {
errorMessage = 'API rate limit exceeded - wait a moment';
} else if (error.message.includes('AI OCR services unavailable')) {
errorMessage = 'OCR service unavailable - configure API key';
}
setScanState(prev => ({
...prev,
isProcessing: false,
message: errorMessage,
}));
setTimeout(() => {
setScanState(prev => ({
...prev,
message: '',
}));
}, 4000);
}
}, [captureImage, onCardScanned, queueCount, maxQueueSize, recentScans, scanState.isProcessing, isOcrConfigured]);
const manualScan = () => {
if (!scanState.isProcessing && queueCount < maxQueueSize) {
triggerScan();
}
};
return (
<div className="relative px-4 sm:px-0">
{/* Scanning Toast */}
<ScanningToast
isVisible={scanState.isScanning || scanState.isProcessing || !!scanState.message}
message={scanState.message}
progress={scanState.isScanning ? ((3 - scanState.countdown) / 3) * 100 : undefined}
type={scanState.isProcessing ? 'processing' : scanState.message.includes('Added:') ? 'success' : scanState.message.includes('not configured') || scanState.message.includes('unavailable') ? 'error' : 'scanning'}
/>
{/* OCR Configuration Warning */}
{!isOcrConfigured && isStreaming && (
<div className="mb-4 bg-yellow-50 border border-yellow-200 rounded-xl p-4">
<div className="flex items-start">
<span className="text-yellow-600 text-xl mr-3 flex-shrink-0 mt-0.5"></span>
<div className="flex-1 min-w-0">
<p className="font-medium text-yellow-900 text-sm sm:text-base">OCR Not Configured</p>
<p className="text-yellow-700 text-sm mt-1">
Please configure your OpenAI API key in Settings to enable card scanning.
</p>
</div>
</div>
</div>
)}
{/* Camera Preview - Mobile optimized */}
<div className="relative bg-black rounded-xl overflow-hidden shadow-lg">
<video
ref={videoRef}
autoPlay
playsInline
muted
className="w-full h-auto object-cover"
style={{
minHeight: '280px',
maxHeight: 'calc(100vh - 300px)',
aspectRatio: '4/3'
}}
/>
{/* Scan Guide Overlay */}
{isStreaming && (
<div className="absolute inset-0 pointer-events-none">
{/* Card frame guide - Mobile optimized */}
<div className="absolute inset-4 sm:inset-6 border-2 border-white border-dashed rounded-lg flex items-center justify-center">
<div className="bg-black bg-opacity-60 text-white px-4 py-3 rounded-xl text-center max-w-xs">
<div className="font-medium text-sm sm:text-base mb-1">Position card within this area</div>
<div className="text-xs sm:text-sm opacity-90">
{isOcrConfigured ? 'Auto-scan in 2-3 seconds' : 'Configure OCR in Settings first'}
</div>
</div>
</div>
{/* Queue counter - Mobile positioned */}
<div className="absolute top-3 right-3 sm:top-4 sm:right-4 bg-blue-600 text-white px-3 py-1.5 rounded-full text-xs sm:text-sm font-medium shadow-lg">
Queue: {queueCount}/{maxQueueSize}
</div>
{/* Scanning indicator */}
{scanState.isScanning && (
<div className="absolute inset-0 bg-blue-500 bg-opacity-20 border-2 border-blue-500 rounded-xl animate-pulse" />
)}
{/* Camera controls overlay - Mobile */}
<div className="absolute bottom-4 left-4 right-4 flex justify-center">
<div className="bg-black bg-opacity-50 backdrop-blur-sm rounded-full px-4 py-2">
<div className="flex items-center space-x-3 text-white text-xs">
<div className="flex items-center space-x-1">
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
<span>Live</span>
</div>
{scanState.isScanning && (
<div className="flex items-center space-x-1">
<div className="w-2 h-2 bg-blue-400 rounded-full animate-pulse"></div>
<span>Scanning...</span>
</div>
)}
</div>
</div>
</div>
</div>
)}
{/* Placeholder when not streaming */}
{!isStreaming && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-white text-center">
<div className="text-4xl sm:text-5xl mb-3">📹</div>
<div className="font-medium text-base sm:text-lg">Starting Camera...</div>
<div className="text-sm sm:text-base opacity-75 mt-1">Please allow camera access</div>
</div>
</div>
)}
</div>
{/* Hidden canvas for image capture */}
<canvas ref={canvasRef} className="hidden" />
{/* Manual scan button - Mobile optimized */}
{isStreaming && (
<div className="mt-6 text-center">
<button
onClick={manualScan}
disabled={scanState.isProcessing || queueCount >= maxQueueSize || !isOcrConfigured}
className="w-full sm:w-auto bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white px-8 py-4 sm:px-6 sm:py-3 rounded-xl font-medium transition-all duration-200 shadow-lg hover:shadow-xl disabled:shadow-none text-base sm:text-sm"
>
{scanState.isProcessing ? (
<div className="flex items-center justify-center space-x-2">
<div className="animate-spin rounded-full h-5 w-5 border-2 border-white border-t-transparent"></div>
<span>Processing...</span>
</div>
) : !isOcrConfigured ? (
'Configure OCR First'
) : (
<div className="flex items-center justify-center space-x-2">
<span>📷</span>
<span>Manual Scan</span>
</div>
)}
</button>
{!isOcrConfigured && (
<p className="text-sm text-gray-600 mt-3 px-4">
Go to Settings OCR to configure your OpenAI API key
</p>
)}
</div>
)}
</div>
);
};
export default AutoScanningCamera;

View file

@ -1,277 +0,0 @@
import React, { useState } from 'react';
interface Collection {
id: string;
name: string;
}
interface Deck {
id: string;
name: string;
}
interface BulkOperationsProps {
selectedCount: number;
onAddToCollection: (collectionId: string, newCollectionName?: string) => void;
onAddToDeck: (deckId: string, newDeckName?: string) => void;
onAddTags: (tags: string[]) => void;
onRemoveSelected: () => void;
collections: Collection[];
decks: Deck[];
isProcessing: boolean;
}
const BulkOperations: React.FC<BulkOperationsProps> = ({
selectedCount,
onAddToCollection,
onAddToDeck,
onAddTags,
onRemoveSelected,
collections,
decks,
isProcessing
}) => {
const [showTagInput, setShowTagInput] = useState(false);
const [tagInput, setTagInput] = useState('');
const [showNewCollection, setShowNewCollection] = useState(false);
const [newCollectionName, setNewCollectionName] = useState('');
const [showNewDeck, setShowNewDeck] = useState(false);
const [newDeckName, setNewDeckName] = useState('');
if (selectedCount === 0) return null;
const handleAddTags = () => {
if (tagInput.trim()) {
const tags = tagInput.split(',').map(tag => tag.trim()).filter(Boolean);
onAddTags(tags);
setTagInput('');
setShowTagInput(false);
}
};
const handleCreateCollection = () => {
if (newCollectionName.trim()) {
onAddToCollection('', newCollectionName.trim());
setNewCollectionName('');
setShowNewCollection(false);
}
};
const handleCreateDeck = () => {
if (newDeckName.trim()) {
onAddToDeck('', newDeckName.trim());
setNewDeckName('');
setShowNewDeck(false);
}
};
return (
<div className="bg-white border-t border-gray-200 px-4 py-6 sm:px-6">
{/* Header - Mobile optimized */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<h3 className="text-lg sm:text-xl font-semibold text-gray-900">
Bulk Operations ({selectedCount} selected)
</h3>
<button
onClick={onRemoveSelected}
className="self-start sm:self-auto px-4 py-2 text-sm bg-red-50 text-red-700 rounded-lg hover:bg-red-100 active:bg-red-200 transition-colors font-medium"
disabled={isProcessing}
>
Remove Selected
</button>
</div>
{/* Operations Grid - Mobile-first responsive */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Add to Collection */}
<div className="space-y-4">
<h4 className="font-medium text-gray-900 text-base">Add to Collection</h4>
{collections.length > 0 && (
<select
className="w-full px-4 py-3 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
onChange={(e) => e.target.value && onAddToCollection(e.target.value)}
defaultValue=""
disabled={isProcessing}
>
<option value="">Select existing collection...</option>
{collections.map(collection => (
<option key={collection.id} value={collection.id}>
{collection.name}
</option>
))}
</select>
)}
{showNewCollection ? (
<div className="space-y-3">
<input
type="text"
value={newCollectionName}
onChange={(e) => setNewCollectionName(e.target.value)}
placeholder="Collection name..."
className="w-full px-4 py-3 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={isProcessing}
/>
<div className="flex gap-2">
<button
onClick={handleCreateCollection}
className="flex-1 px-4 py-3 bg-blue-600 text-white rounded-xl text-sm font-medium hover:bg-blue-700 active:bg-blue-800 disabled:bg-gray-400 transition-colors"
disabled={!newCollectionName.trim() || isProcessing}
>
Create
</button>
<button
onClick={() => setShowNewCollection(false)}
className="px-4 py-3 bg-gray-100 text-gray-700 rounded-xl text-sm font-medium hover:bg-gray-200 active:bg-gray-300 transition-colors"
disabled={isProcessing}
>
Cancel
</button>
</div>
</div>
) : (
<button
onClick={() => setShowNewCollection(true)}
className="w-full px-4 py-3 border-2 border-dashed border-gray-300 rounded-xl text-sm text-gray-600 hover:border-blue-400 hover:text-blue-600 active:border-blue-500 transition-colors font-medium"
disabled={isProcessing}
>
+ Create New Collection
</button>
)}
</div>
{/* Add to Deck */}
<div className="space-y-4">
<h4 className="font-medium text-gray-900 text-base">Add to Deck</h4>
{decks.length > 0 && (
<select
className="w-full px-4 py-3 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
onChange={(e) => e.target.value && onAddToDeck(e.target.value)}
defaultValue=""
disabled={isProcessing}
>
<option value="">Select existing deck...</option>
{decks.map(deck => (
<option key={deck.id} value={deck.id}>
{deck.name}
</option>
))}
</select>
)}
{showNewDeck ? (
<div className="space-y-3">
<input
type="text"
value={newDeckName}
onChange={(e) => setNewDeckName(e.target.value)}
placeholder="Deck name..."
className="w-full px-4 py-3 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={isProcessing}
/>
<div className="flex gap-2">
<button
onClick={handleCreateDeck}
className="flex-1 px-4 py-3 bg-purple-600 text-white rounded-xl text-sm font-medium hover:bg-purple-700 active:bg-purple-800 disabled:bg-gray-400 transition-colors"
disabled={!newDeckName.trim() || isProcessing}
>
Create
</button>
<button
onClick={() => setShowNewDeck(false)}
className="px-4 py-3 bg-gray-100 text-gray-700 rounded-xl text-sm font-medium hover:bg-gray-200 active:bg-gray-300 transition-colors"
disabled={isProcessing}
>
Cancel
</button>
</div>
</div>
) : (
<button
onClick={() => setShowNewDeck(true)}
className="w-full px-4 py-3 border-2 border-dashed border-gray-300 rounded-xl text-sm text-gray-600 hover:border-purple-400 hover:text-purple-600 active:border-purple-500 transition-colors font-medium"
disabled={isProcessing}
>
+ Create New Deck
</button>
)}
</div>
{/* Add Tags */}
<div className="space-y-4">
<h4 className="font-medium text-gray-900 text-base">Add Tags</h4>
{showTagInput ? (
<div className="space-y-3">
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
placeholder="Enter tags (comma-separated)..."
className="w-full px-4 py-3 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={isProcessing}
onKeyPress={(e) => e.key === 'Enter' && handleAddTags()}
/>
<div className="flex gap-2">
<button
onClick={handleAddTags}
className="flex-1 px-4 py-3 bg-green-600 text-white rounded-xl text-sm font-medium hover:bg-green-700 active:bg-green-800 disabled:bg-gray-400 transition-colors"
disabled={!tagInput.trim() || isProcessing}
>
Add Tags
</button>
<button
onClick={() => setShowTagInput(false)}
className="px-4 py-3 bg-gray-100 text-gray-700 rounded-xl text-sm font-medium hover:bg-gray-200 active:bg-gray-300 transition-colors"
disabled={isProcessing}
>
Cancel
</button>
</div>
</div>
) : (
<button
onClick={() => setShowTagInput(true)}
className="w-full px-4 py-3 border-2 border-dashed border-gray-300 rounded-xl text-sm text-gray-600 hover:border-green-400 hover:text-green-600 active:border-green-500 transition-colors font-medium"
disabled={isProcessing}
>
+ Add Tags
</button>
)}
{/* Common Tag Suggestions - Mobile friendly */}
<div className="space-y-3">
<p className="text-xs text-gray-500 font-medium">Quick Tags:</p>
<div className="flex flex-wrap gap-2">
{['Foil', 'Mint', 'Near Mint', 'Played', 'Favorite'].map(tag => (
<button
key={tag}
onClick={() => onAddTags([tag])}
className="px-3 py-2 bg-gray-100 text-gray-700 rounded-lg text-xs font-medium hover:bg-gray-200 active:bg-gray-300 transition-colors"
disabled={isProcessing}
>
{tag}
</button>
))}
</div>
</div>
</div>
</div>
{/* Processing Indicator */}
{isProcessing && (
<div className="flex items-center justify-center py-8 mt-6 border-t border-gray-100">
<div className="flex items-center space-x-3">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-blue-600 border-t-transparent"></div>
<span className="text-sm text-gray-600 font-medium">Processing...</span>
</div>
</div>
)}
</div>
);
};
export default BulkOperations;

View file

@ -1,224 +0,0 @@
import React, { useState, useCallback } from 'react';
interface ScannedCard {
id: string;
cardData: {
cardName?: string;
setName?: string;
rarity?: string;
game?: string;
confidence?: number;
};
imageDataUrl: string;
timestamp: number;
queuePosition: number;
}
interface CardQueueProps {
cards: ScannedCard[];
onRemoveCard: (cardId: string) => void;
onClearQueue: () => void;
onCardSelect: (cardId: string, selected: boolean) => void;
selectedCards: Set<string>;
onViewCard: (card: ScannedCard) => void;
}
const CardQueue: React.FC<CardQueueProps> = ({
cards,
onRemoveCard,
onClearQueue,
onCardSelect,
selectedCards,
onViewCard
}) => {
const [selectAll, setSelectAll] = useState(false);
const handleSelectAll = useCallback(() => {
const newSelectAll = !selectAll;
setSelectAll(newSelectAll);
cards.forEach(card => {
onCardSelect(card.id, newSelectAll);
});
}, [selectAll, cards, onCardSelect]);
const getConfidenceColor = (confidence?: number) => {
if (!confidence) return 'bg-gray-400';
if (confidence >= 0.9) return 'bg-green-500';
if (confidence >= 0.7) return 'bg-yellow-500';
return 'bg-red-500';
};
const getConfidenceText = (confidence?: number) => {
if (!confidence) return 'Unknown';
return `${Math.round(confidence * 100)}%`;
};
if (cards.length === 0) {
return (
<div className="text-center py-16 px-4">
<div className="text-6xl sm:text-7xl mb-4">📷</div>
<h3 className="text-xl sm:text-2xl font-semibold text-gray-700 mb-2">Queue is Empty</h3>
<p className="text-gray-500 text-sm sm:text-base">Scanned cards will appear here</p>
</div>
);
}
return (
<div className="space-y-4 px-4 sm:px-0">
{/* Queue Header - Mobile optimized */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 sm:p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4">
<h3 className="text-lg sm:text-xl font-semibold text-gray-900">
Scanned Cards ({cards.length}/100)
</h3>
{/* Select All Checkbox - Touch friendly */}
<label className="flex items-center space-x-2 cursor-pointer select-none">
<input
type="checkbox"
checked={selectAll}
onChange={handleSelectAll}
className="w-5 h-5 sm:w-4 sm:h-4 text-blue-600 rounded focus:ring-blue-500 focus:ring-2"
/>
<span className="text-sm sm:text-sm text-gray-700 font-medium">Select All</span>
</label>
</div>
{/* Bulk Actions */}
<div className="flex items-center justify-between sm:justify-end gap-2">
{selectedCards.size > 0 && (
<span className="text-xs sm:text-sm text-gray-600 bg-blue-50 px-3 py-1.5 rounded-full font-medium">
{selectedCards.size} selected
</span>
)}
<button
onClick={onClearQueue}
className="px-4 py-2 text-sm bg-red-50 text-red-700 rounded-lg hover:bg-red-100 active:bg-red-200 transition-colors font-medium"
>
Clear All
</button>
</div>
</div>
</div>
{/* Card Grid - Mobile-first responsive */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{cards.map((card) => (
<div
key={card.id}
className={`relative bg-white rounded-xl shadow-sm border-2 transition-all duration-200 hover:shadow-md ${
selectedCards.has(card.id)
? 'border-blue-500 bg-blue-50/30'
: 'border-gray-100 hover:border-gray-200'
}`}
>
{/* Selection Checkbox - Mobile optimized */}
<div className="absolute top-3 left-3 z-10">
<label className="cursor-pointer">
<input
type="checkbox"
checked={selectedCards.has(card.id)}
onChange={(e) => onCardSelect(card.id, e.target.checked)}
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500 focus:ring-2 bg-white shadow-sm"
/>
</label>
</div>
{/* Remove Button - Touch friendly */}
<button
onClick={() => onRemoveCard(card.id)}
className="absolute top-3 right-3 z-10 w-8 h-8 bg-red-500 text-white rounded-full text-sm hover:bg-red-600 active:bg-red-700 transition-colors flex items-center justify-center shadow-sm"
aria-label="Remove card"
>
×
</button>
{/* Card Image - Touch optimized */}
<div
className="aspect-[2.5/3.5] bg-gray-100 cursor-pointer overflow-hidden rounded-t-xl"
onClick={() => onViewCard(card)}
>
<img
src={card.imageDataUrl}
alt={card.cardData.cardName || 'Scanned card'}
className="w-full h-full object-cover transition-transform duration-200 hover:scale-105"
/>
</div>
{/* Card Info - Mobile optimized */}
<div className="p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<h4 className="font-medium text-gray-900 text-sm leading-tight line-clamp-2">
{card.cardData.cardName || 'Unknown Card'}
</h4>
{card.cardData.setName && (
<p className="text-xs text-gray-500 mt-1 line-clamp-1">
{card.cardData.setName}
</p>
)}
</div>
{/* Confidence Badge - Mobile sized */}
<div className={`px-2 py-1 rounded-full text-xs font-medium text-white flex-shrink-0 ${getConfidenceColor(card.cardData.confidence)}`}>
{getConfidenceText(card.cardData.confidence)}
</div>
</div>
{/* Additional Info */}
<div className="flex items-center justify-between text-xs text-gray-500">
<span className="truncate">{card.cardData.game || 'Unknown Game'}</span>
{card.cardData.rarity && (
<span className="capitalize flex-shrink-0 ml-2">{card.cardData.rarity}</span>
)}
</div>
{/* Queue Position */}
<div className="flex items-center justify-between">
<div className="text-xs text-gray-400">
#{card.queuePosition}
</div>
<div className="text-xs text-gray-400">
{new Date(card.timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})}
</div>
</div>
</div>
</div>
))}
</div>
{/* Queue Stats - Mobile friendly */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 sm:p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex flex-wrap items-center gap-3 sm:gap-4">
<span className="text-sm font-medium text-gray-900">Total: {cards.length} cards</span>
<span className="text-sm text-gray-600">Selected: {selectedCards.size}</span>
</div>
<div className="flex flex-wrap items-center gap-3 sm:gap-4">
<span className="flex items-center space-x-1.5 text-xs sm:text-sm">
<div className="w-2.5 h-2.5 bg-green-500 rounded-full"></div>
<span className="text-gray-600">High confidence</span>
</span>
<span className="flex items-center space-x-1.5 text-xs sm:text-sm">
<div className="w-2.5 h-2.5 bg-yellow-500 rounded-full"></div>
<span className="text-gray-600">Medium</span>
</span>
<span className="flex items-center space-x-1.5 text-xs sm:text-sm">
<div className="w-2.5 h-2.5 bg-red-500 rounded-full"></div>
<span className="text-gray-600">Low</span>
</span>
</div>
</div>
</div>
</div>
);
};
export default CardQueue;

View file

@ -1,125 +0,0 @@
import React from 'react';
interface ScanMode {
id: 'add-to-database' | 'search-collections' | 'price-check' | 'deck-building';
title: string;
description: string;
icon: string;
color: string;
}
interface ScanModeSelectorProps {
onModeSelect: (mode: ScanMode['id']) => void;
}
const ScanModeSelector: React.FC<ScanModeSelectorProps> = ({ onModeSelect }) => {
const scanModes: ScanMode[] = [
{
id: 'add-to-database',
title: 'Add Cards to Database',
description: 'Scan new cards and add them to your collections',
icon: '📚',
color: 'bg-blue-500 hover:bg-blue-600 active:bg-blue-700'
},
{
id: 'search-collections',
title: 'Search Collections',
description: 'Find these cards in your existing collections',
icon: '🔍',
color: 'bg-green-500 hover:bg-green-600 active:bg-green-700'
},
{
id: 'price-check',
title: 'Price Check',
description: 'Get real-time pricing for your cards',
icon: '💰',
color: 'bg-yellow-500 hover:bg-yellow-600 active:bg-yellow-700'
},
{
id: 'deck-building',
title: 'Deck Building',
description: 'Scan cards directly into a deck with legality checks',
icon: '🎯',
color: 'bg-purple-500 hover:bg-purple-600 active:bg-purple-700'
}
];
return (
<div className="min-h-screen bg-gray-50 px-4 py-6 sm:px-6 lg:px-8">
<div className="max-w-md mx-auto sm:max-w-2xl">
{/* Header - Mobile optimized */}
<div className="text-center mb-8">
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 mb-3">Card Scanner</h1>
<p className="text-gray-600 text-sm sm:text-base px-2">Choose your scanning mode to get started</p>
</div>
{/* Mode Grid - Mobile-first responsive */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 mb-8">
{scanModes.map((mode) => (
<button
key={mode.id}
onClick={() => onModeSelect(mode.id)}
className={`${mode.color} text-white rounded-xl shadow-lg transition-all duration-200 hover:shadow-xl transform hover:scale-[1.02] active:scale-[0.98] text-left relative overflow-hidden`}
style={{ minHeight: '120px' }}
>
{/* Touch-friendly padding and layout */}
<div className="p-6 sm:p-6 h-full flex flex-col">
{/* Icon and Title Row */}
<div className="flex items-start space-x-4 mb-3">
<div className="text-3xl sm:text-4xl flex-shrink-0" role="img" aria-label={mode.title}>
{mode.icon}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg sm:text-xl font-semibold leading-tight mb-2">
{mode.title}
</h3>
</div>
</div>
{/* Description */}
<p className="text-white/90 text-sm sm:text-base leading-relaxed flex-1">
{mode.description}
</p>
</div>
{/* Subtle gradient overlay for depth */}
<div className="absolute inset-0 bg-gradient-to-br from-white/10 to-transparent pointer-events-none" />
</button>
))}
</div>
{/* Pro Tips - Mobile optimized */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5 sm:p-6">
<div className="flex items-center mb-4">
<span className="text-2xl mr-3" role="img" aria-label="Tips">💡</span>
<h2 className="font-semibold text-gray-900 text-lg">Pro Tips</h2>
</div>
<div className="space-y-3">
<div className="flex items-start space-x-3">
<div className="w-2 h-2 bg-blue-500 rounded-full mt-2 flex-shrink-0"></div>
<p className="text-gray-700 text-sm sm:text-base">Position cards clearly within the camera frame</p>
</div>
<div className="flex items-start space-x-3">
<div className="w-2 h-2 bg-green-500 rounded-full mt-2 flex-shrink-0"></div>
<p className="text-gray-700 text-sm sm:text-base">Scanner will auto-detect cards after 2-3 seconds</p>
</div>
<div className="flex items-start space-x-3">
<div className="w-2 h-2 bg-purple-500 rounded-full mt-2 flex-shrink-0"></div>
<p className="text-gray-700 text-sm sm:text-base">You can scan up to 100 cards in a single session</p>
</div>
<div className="flex items-start space-x-3">
<div className="w-2 h-2 bg-yellow-500 rounded-full mt-2 flex-shrink-0"></div>
<p className="text-gray-700 text-sm sm:text-base">Duplicates are allowed and will be tracked separately</p>
</div>
</div>
</div>
{/* Mobile-specific footer spacing */}
<div className="h-8 sm:h-4"></div>
</div>
</div>
);
};
export default ScanModeSelector;

View file

@ -1,475 +0,0 @@
import React, { useState, useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import ScanModeSelector from './ScanModeSelector';
import AutoScanningCamera from './AutoScanningCamera';
import CardQueue from './CardQueue';
import BulkOperations from './BulkOperations';
import AutoTagger from '../../services/autoTagger';
type ScanMode = 'add-to-database' | 'search-collections' | 'price-check' | 'deck-building';
type WizardStep = 'mode-selection' | 'scanning' | 'review' | 'processing' | 'complete';
interface ScannedCard {
id: string;
cardData: {
cardName?: string;
setName?: string;
rarity?: string;
game?: string;
confidence?: number;
cardType?: string;
manaCost?: string;
power?: string;
toughness?: string;
element?: string;
};
imageDataUrl: string;
timestamp: number;
queuePosition: number;
autoTags?: string[];
}
interface Collection {
id: string;
name: string;
}
interface Deck {
id: string;
name: string;
}
const ScannerWizard: React.FC = () => {
const navigate = useNavigate();
// Wizard state
const [currentStep, setCurrentStep] = useState<WizardStep>('mode-selection');
const [scanMode, setScanMode] = useState<ScanMode | null>(null);
const [scannedCards, setScannedCards] = useState<ScannedCard[]>([]);
const [selectedCards, setSelectedCards] = useState<Set<string>>(new Set());
const [isProcessing, setIsProcessing] = useState(false);
// Mock data - replace with actual API calls
const [collections] = useState<Collection[]>([
{ id: '1', name: 'My Collection' },
{ id: '2', name: 'Trade Binder' },
{ id: '3', name: 'Deck Ideas' }
]);
const [decks] = useState<Deck[]>([
{ id: '1', name: 'Standard Deck' },
{ id: '2', name: 'Commander' },
{ id: '3', name: 'Draft Picks' }
]);
// Local image cache for session
const [imageCache] = useState<Map<string, string>>(new Map());
// Memory cleanup
useEffect(() => {
return () => {
// Cleanup image URLs when component unmounts
imageCache.forEach(url => {
if (url.startsWith('blob:')) {
URL.revokeObjectURL(url);
}
});
imageCache.clear();
};
}, [imageCache]);
// Handle mode selection
const handleModeSelect = useCallback((mode: ScanMode) => {
setScanMode(mode);
setCurrentStep('scanning');
}, []);
// Handle card scanned
const handleCardScanned = useCallback((card: ScannedCard) => {
// Generate auto-tags
const autoTagResult = AutoTagger.generateTags(card.cardData);
const enhancedCard: ScannedCard = {
...card,
autoTags: autoTagResult.tags
};
// Cache the image
imageCache.set(card.id, card.imageDataUrl);
// Add to queue
setScannedCards(prev => [...prev, enhancedCard]);
// Memory management - remove oldest if over limit
setScannedCards(prev => {
if (prev.length > 100) {
const removed = prev.slice(0, prev.length - 100);
// Cleanup removed images
removed.forEach(removedCard => {
const cachedUrl = imageCache.get(removedCard.id);
if (cachedUrl && cachedUrl.startsWith('blob:')) {
URL.revokeObjectURL(cachedUrl);
}
imageCache.delete(removedCard.id);
});
return prev.slice(prev.length - 100);
}
return prev;
});
}, [imageCache]);
// Handle card removal
const handleRemoveCard = useCallback((cardId: string) => {
setScannedCards(prev => prev.filter(card => card.id !== cardId));
setSelectedCards(prev => {
const newSet = new Set(prev);
newSet.delete(cardId);
return newSet;
});
// Cleanup cached image
const cachedUrl = imageCache.get(cardId);
if (cachedUrl && cachedUrl.startsWith('blob:')) {
URL.revokeObjectURL(cachedUrl);
}
imageCache.delete(cardId);
}, [imageCache]);
// Handle queue clear
const handleClearQueue = useCallback(() => {
// Cleanup all cached images
scannedCards.forEach(card => {
const cachedUrl = imageCache.get(card.id);
if (cachedUrl && cachedUrl.startsWith('blob:')) {
URL.revokeObjectURL(cachedUrl);
}
});
imageCache.clear();
setScannedCards([]);
setSelectedCards(new Set());
}, [scannedCards, imageCache]);
// Handle card selection
const handleCardSelect = useCallback((cardId: string, selected: boolean) => {
setSelectedCards(prev => {
const newSet = new Set(prev);
if (selected) {
newSet.add(cardId);
} else {
newSet.delete(cardId);
}
return newSet;
});
}, []);
// Handle card view (full screen)
const handleViewCard = useCallback((card: ScannedCard) => {
// TODO: Implement full-screen card viewer modal
console.log('Viewing card:', card);
}, []);
// Handle bulk operations
const handleAddToCollection = useCallback(async (collectionId: string, newCollectionName?: string) => {
if (selectedCards.size === 0) return;
setIsProcessing(true);
try {
// TODO: Implement actual API calls
console.log('Adding to collection:', { collectionId, newCollectionName, cards: Array.from(selectedCards) });
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
// Success feedback
alert(`Added ${selectedCards.size} cards to ${newCollectionName || 'collection'}`);
} catch (error) {
console.error('Failed to add to collection:', error);
alert('Failed to add cards to collection');
} finally {
setIsProcessing(false);
}
}, [selectedCards]);
const handleAddToDeck = useCallback(async (deckId: string, newDeckName?: string) => {
if (selectedCards.size === 0) return;
setIsProcessing(true);
try {
// TODO: Implement actual API calls
console.log('Adding to deck:', { deckId, newDeckName, cards: Array.from(selectedCards) });
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
// Success feedback
alert(`Added ${selectedCards.size} cards to ${newDeckName || 'deck'}`);
} catch (error) {
console.error('Failed to add to deck:', error);
alert('Failed to add cards to deck');
} finally {
setIsProcessing(false);
}
}, [selectedCards]);
const handleAddTags = useCallback(async (tags: string[]) => {
if (selectedCards.size === 0) return;
setIsProcessing(true);
try {
// TODO: Implement actual API calls
console.log('Adding tags:', { tags, cards: Array.from(selectedCards) });
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
// Success feedback
alert(`Added tags "${tags.join(', ')}" to ${selectedCards.size} cards`);
} catch (error) {
console.error('Failed to add tags:', error);
alert('Failed to add tags');
} finally {
setIsProcessing(false);
}
}, [selectedCards]);
const handleRemoveSelected = useCallback(() => {
selectedCards.forEach(cardId => handleRemoveCard(cardId));
}, [selectedCards, handleRemoveCard]);
// Handle finish workflow
const handleFinish = useCallback(async () => {
if (scannedCards.length === 0) {
navigate('/scanner');
return;
}
setCurrentStep('processing');
setIsProcessing(true);
try {
// Process all cards based on scan mode
switch (scanMode) {
case 'add-to-database':
// TODO: Add all cards to database
console.log('Adding all cards to database:', scannedCards);
break;
case 'search-collections':
// TODO: Search for cards in collections
console.log('Searching collections for:', scannedCards);
break;
case 'price-check':
// TODO: Get pricing for all cards
console.log('Getting prices for:', scannedCards);
break;
case 'deck-building':
// TODO: Add to deck with legality checks
console.log('Adding to deck:', scannedCards);
break;
}
// Simulate processing
await new Promise(resolve => setTimeout(resolve, 2000));
setCurrentStep('complete');
// Auto-redirect after success
setTimeout(() => {
navigate('/scanner');
}, 3000);
} catch (error) {
console.error('Failed to process cards:', error);
alert('Failed to process cards');
setCurrentStep('review');
} finally {
setIsProcessing(false);
}
}, [scannedCards, scanMode, navigate]);
// Navigation handlers
const handleBackToScanning = useCallback(() => {
setCurrentStep('scanning');
}, []);
const handleProceedToReview = useCallback(() => {
setCurrentStep('review');
}, []);
const handleError = useCallback((error: string) => {
console.error('Scanner error:', error);
alert(error);
}, []);
// Render current step
const renderStep = () => {
switch (currentStep) {
case 'mode-selection':
return <ScanModeSelector onModeSelect={handleModeSelect} />;
case 'scanning':
return (
<div className="space-y-6">
{/* Header - Mobile optimized */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 sm:p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex-1">
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 mb-2">
{scanMode === 'add-to-database' && '📚 Adding Cards to Database'}
{scanMode === 'search-collections' && '🔍 Searching Collections'}
{scanMode === 'price-check' && '💰 Price Checking Cards'}
{scanMode === 'deck-building' && '🎯 Building Deck'}
</h2>
<p className="text-gray-600 text-sm sm:text-base">
Position cards clearly within the camera frame
</p>
</div>
<div className="flex flex-col sm:flex-row gap-2">
{scannedCards.length > 0 && (
<button
onClick={handleProceedToReview}
className="w-full sm:w-auto bg-green-600 text-white px-4 py-3 sm:px-6 sm:py-2 rounded-xl font-medium hover:bg-green-700 active:bg-green-800 transition-colors text-sm sm:text-base shadow-lg"
>
<div className="flex items-center justify-center space-x-2">
<span>Review Queue</span>
<span className="bg-green-700 px-2 py-0.5 rounded-full text-xs">
{scannedCards.length}
</span>
</div>
</button>
)}
<button
onClick={() => setCurrentStep('mode-selection')}
className="w-full sm:w-auto bg-gray-100 text-gray-700 px-4 py-3 sm:px-6 sm:py-2 rounded-xl font-medium hover:bg-gray-200 active:bg-gray-300 transition-colors text-sm sm:text-base"
>
Change Mode
</button>
</div>
</div>
</div>
<AutoScanningCamera
onCardScanned={handleCardScanned}
onError={handleError}
isActive={currentStep === 'scanning'}
maxQueueSize={100}
/>
</div>
);
case 'review':
return (
<div className="space-y-6">
{/* Header - Mobile optimized */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 sm:p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex-1">
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 mb-2">
Review Scanned Cards
</h2>
<p className="text-gray-600 text-sm sm:text-base">
Select cards and choose where to add them
</p>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<button
onClick={handleBackToScanning}
className="w-full sm:w-auto bg-blue-600 text-white px-4 py-3 sm:px-6 sm:py-2 rounded-xl font-medium hover:bg-blue-700 active:bg-blue-800 transition-colors text-sm sm:text-base"
>
Continue Scanning
</button>
<button
onClick={handleFinish}
disabled={scannedCards.length === 0 || isProcessing}
className="w-full sm:w-auto bg-green-600 text-white px-4 py-3 sm:px-6 sm:py-2 rounded-xl font-medium hover:bg-green-700 active:bg-green-800 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors text-sm sm:text-base shadow-lg"
>
{isProcessing ? (
<div className="flex items-center justify-center space-x-2">
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div>
<span>Processing...</span>
</div>
) : (
'Finish & Process'
)}
</button>
</div>
</div>
</div>
<CardQueue
cards={scannedCards}
onRemoveCard={handleRemoveCard}
onClearQueue={handleClearQueue}
onCardSelect={handleCardSelect}
selectedCards={selectedCards}
onViewCard={handleViewCard}
/>
<BulkOperations
selectedCount={selectedCards.size}
onAddToCollection={handleAddToCollection}
onAddToDeck={handleAddToDeck}
onAddTags={handleAddTags}
onRemoveSelected={handleRemoveSelected}
collections={collections}
decks={decks}
isProcessing={isProcessing}
/>
</div>
);
case 'processing':
return (
<div className="flex items-center justify-center min-h-[60vh] px-4">
<div className="text-center max-w-md mx-auto">
<div className="animate-spin rounded-full h-16 w-16 sm:h-20 sm:w-20 border-4 border-blue-600 border-t-transparent mx-auto mb-6"></div>
<h3 className="text-xl sm:text-2xl font-semibold text-gray-900 mb-3">Processing Cards...</h3>
<p className="text-gray-600 text-sm sm:text-base mb-4">
Adding {scannedCards.length} cards to your collection
</p>
<div className="bg-gray-100 rounded-full h-2 w-full max-w-xs mx-auto">
<div className="bg-blue-600 h-2 rounded-full animate-pulse" style={{ width: '60%' }}></div>
</div>
</div>
</div>
);
case 'complete':
return (
<div className="flex items-center justify-center min-h-[60vh] px-4">
<div className="text-center max-w-md mx-auto">
<div className="text-6xl sm:text-7xl mb-6"></div>
<h3 className="text-xl sm:text-2xl font-semibold text-green-600 mb-3">Success!</h3>
<p className="text-gray-600 text-sm sm:text-base mb-6">
Successfully processed {scannedCards.length} cards
</p>
<div className="bg-green-50 border border-green-200 rounded-xl p-4 mb-6">
<p className="text-green-800 text-sm font-medium">
🎉 All cards have been added to your collection!
</p>
</div>
<p className="text-sm text-gray-500">Redirecting to scanner...</p>
</div>
</div>
);
default:
return null;
}
};
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-6">
{renderStep()}
</div>
</div>
);
};
export default ScannerWizard;

View file

@ -1,86 +0,0 @@
import React from 'react';
interface ScanningToastProps {
isVisible: boolean;
message: string;
progress?: number; // 0-100 for progress bar
type?: 'scanning' | 'processing' | 'success' | 'error';
}
const ScanningToast: React.FC<ScanningToastProps> = ({
isVisible,
message,
progress,
type = 'scanning'
}) => {
if (!isVisible) return null;
const getTypeStyles = () => {
switch (type) {
case 'scanning':
return 'bg-blue-600 text-white';
case 'processing':
return 'bg-purple-600 text-white';
case 'success':
return 'bg-green-600 text-white';
case 'error':
return 'bg-red-600 text-white';
default:
return 'bg-blue-600 text-white';
}
};
const getIcon = () => {
switch (type) {
case 'scanning':
return '📷';
case 'processing':
return '🤖';
case 'success':
return '✅';
case 'error':
return '❌';
default:
return '📷';
}
};
return (
<div className="fixed top-4 left-4 right-4 z-50 animate-slide-down sm:left-1/2 sm:right-auto sm:transform sm:-translate-x-1/2 sm:w-auto sm:min-w-80 sm:max-w-md">
<div className={`${getTypeStyles()} rounded-xl shadow-lg px-4 py-3 sm:px-5 sm:py-4`}>
<div className="flex items-center space-x-3">
{/* Icon */}
<div className="text-lg sm:text-xl flex-shrink-0" role="img" aria-label={type}>
{getIcon()}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm sm:text-base leading-tight">{message}</div>
{/* Progress bar */}
{progress !== undefined && (
<div className="mt-2">
<div className="w-full bg-white/20 rounded-full h-1.5 sm:h-2">
<div
className="bg-white h-1.5 sm:h-2 rounded-full transition-all duration-300 ease-out"
style={{ width: `${Math.min(100, Math.max(0, progress))}%` }}
/>
</div>
</div>
)}
</div>
{/* Spinner for scanning/processing */}
{(type === 'scanning' || type === 'processing') && (
<div className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0">
<div className="animate-spin rounded-full h-5 w-5 sm:h-6 sm:w-6 border-2 border-white border-t-transparent"></div>
</div>
)}
</div>
</div>
</div>
);
};
export default ScanningToast;

View file

@ -1,222 +0,0 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { tcgApi } from '../../services/tcgApi';
import type { Tag, CreateTagData } from '../../types';
interface TagManagerProps {
isOpen: boolean;
onClose: () => void;
tagId?: string; // For editing existing tag
}
const TagManager: React.FC<TagManagerProps> = ({
isOpen,
onClose,
tagId
}) => {
const queryClient = useQueryClient();
const [formData, setFormData] = useState<CreateTagData>({
name: '',
color: '#8b5cf6',
});
const colors = [
'#8b5cf6', '#3b82f6', '#10b981', '#f59e0b', '#ef4444',
'#ec4899', '#6366f1', '#06b6d4', '#84cc16', '#f97316',
'#06b6d4', '#8b5cf6', '#ec4899', '#10b981', '#f59e0b'
];
// Queries
const { data: existingTag } = useQuery({
queryKey: ['tag', tagId],
queryFn: () => tcgApi.tags.getTags().then(tags => tags.find(t => t.id === tagId)),
enabled: !!tagId,
});
// Mutations
const createMutation = useMutation({
mutationFn: tcgApi.tags.createTag,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tags'] });
onClose();
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<CreateTagData> }) =>
tcgApi.tags.updateTag(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tags'] });
queryClient.invalidateQueries({ queryKey: ['tag', tagId] });
onClose();
},
});
const deleteMutation = useMutation({
mutationFn: tcgApi.tags.deleteTag,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tags'] });
onClose();
},
});
// Initialize form with existing tag data
React.useEffect(() => {
if (existingTag) {
setFormData({
name: existingTag.name,
color: existingTag.color,
});
}
}, [existingTag]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (tagId && existingTag) {
updateMutation.mutate({ id: tagId, data: formData });
} else {
createMutation.mutate(formData);
}
};
const handleDelete = () => {
if (tagId && existingTag) {
if (window.confirm(`Are you sure you want to delete "${existingTag.name}"? This will remove it from all cards, collections, and decks.`)) {
deleteMutation.mutate(tagId);
}
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 bg-black/50" onClick={onClose}>
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-surface-900 rounded-t-3xl max-h-[90vh] overflow-hidden animate-slide-up">
<div className="p-6">
<div className="w-12 h-1.5 bg-surface-300 dark:bg-surface-600 rounded-full mx-auto mb-6"></div>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-surface-900 dark:text-white">
{tagId ? 'Edit Tag' : 'New Tag'}
</h2>
<button
onClick={onClose}
className="p-2 rounded-xl text-surface-400 hover:text-surface-600 dark:hover:text-surface-300 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="max-h-[calc(90vh-8rem)] overflow-y-auto">
<form onSubmit={handleSubmit} className="space-y-6" onClick={(e) => e.stopPropagation()}>
{/* Tag Preview */}
<div className="p-4 bg-surface-50 dark:bg-surface-800 rounded-2xl">
<div className="flex items-center space-x-4">
<div
className="w-12 h-12 rounded-xl flex items-center justify-center text-white text-lg font-semibold shadow-md"
style={{ backgroundColor: formData.color }}
>
{formData.name ? formData.name.charAt(0).toUpperCase() : 'T'}
</div>
<div className="flex-1">
<h3 className="font-semibold text-surface-900 dark:text-white">
{formData.name || 'Tag Name'}
</h3>
<p className="text-sm text-surface-600 dark:text-surface-400">
This tag will be available for cards, collections, and decks
</p>
</div>
</div>
</div>
{/* Name */}
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Tag Name *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="Enter tag name..."
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors"
required
/>
</div>
{/* Color */}
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Color
</label>
<div className="grid grid-cols-5 gap-2">
{colors.map((color) => (
<button
key={color}
type="button"
onClick={() => setFormData(prev => ({ ...prev, color }))}
className={`w-full h-12 rounded-lg transition-all ${
formData.color === color ? 'ring-2 ring-surface-400 ring-offset-2 dark:ring-offset-surface-900' : ''
}`}
style={{ backgroundColor: color }}
/>
))}
</div>
</div>
{/* Action Buttons */}
<div className="flex space-x-3">
<button
type="submit"
disabled={!formData.name.trim() || createMutation.isPending || updateMutation.isPending}
className="flex-1 bg-gradient-to-r from-primary-500 to-accent-500 hover:from-primary-600 hover:to-accent-600 disabled:from-surface-400 disabled:to-surface-400 text-white font-semibold py-4 px-6 rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl disabled:shadow-none transform hover:-translate-y-0.5 disabled:transform-none"
>
{(createMutation.isPending || updateMutation.isPending) ? (
<div className="flex items-center justify-center">
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{tagId ? 'Updating...' : 'Creating...'}
</div>
) : (
tagId ? 'Update Tag' : 'Create Tag'
)}
</button>
{tagId && (
<button
type="button"
onClick={handleDelete}
disabled={deleteMutation.isPending}
className="px-6 py-4 bg-red-500 hover:bg-red-600 disabled:bg-red-400 text-white font-semibold rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl disabled:shadow-none transform hover:-translate-y-0.5 disabled:transform-none"
>
{deleteMutation.isPending ? (
<div className="flex items-center justify-center">
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Deleting...
</div>
) : (
'Delete'
)}
</button>
)}
</div>
</form>
</div>
</div>
</div>
</div>
);
};
export default TagManager;

View file

@ -1,26 +0,0 @@
// API Configuration for All-Vercel Setup
const API_CONFIG = {
development: {
baseURL: 'http://localhost:3000/api/v1', // Local Vercel dev server
},
production: {
baseURL: '/api/v1', // Relative URL for deployed Vercel functions
}
};
const environment = process.env.NODE_ENV as 'development' | 'production';
export const API_BASE_URL = API_CONFIG[environment].baseURL;
// Helper function to build full API URLs
export const buildApiUrl = (endpoint: string) => {
return `${API_BASE_URL}${endpoint.startsWith('/') ? endpoint : `/${endpoint}`}`;
};
// Export for use in services
const apiConfig = {
baseURL: API_BASE_URL,
buildUrl: buildApiUrl,
};
export default apiConfig;

View file

@ -1,158 +0,0 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
interface User {
id: number;
username: string;
email: string;
firstName?: string;
lastName?: string;
roles: string[];
permissions: string[];
lastLogin?: string;
}
interface AuthContextType {
user: User | null;
token: string | null;
login: (username: string, password: string) => Promise<{ success: boolean; error?: string }>;
register: (userData: RegisterData) => Promise<{ success: boolean; error?: string }>;
logout: () => void;
isLoading: boolean;
hasRole: (role: string) => boolean;
hasPermission: (permission: string) => boolean;
isAdmin: () => boolean;
}
interface RegisterData {
username: string;
email: string;
password: string;
firstName?: string;
lastName?: string;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
interface AuthProviderProps {
children: ReactNode;
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Load user from localStorage on mount
useEffect(() => {
const savedToken = localStorage.getItem('tcg_vault_token');
const savedUser = localStorage.getItem('tcg_vault_user');
if (savedToken && savedUser) {
try {
const parsedUser = JSON.parse(savedUser);
setToken(savedToken);
setUser(parsedUser);
} catch (error) {
console.error('Error parsing saved user data:', error);
localStorage.removeItem('tcg_vault_token');
localStorage.removeItem('tcg_vault_user');
}
}
setIsLoading(false);
}, []);
const login = async (username: string, password: string) => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
});
const data = await response.json();
if (data.success) {
setUser(data.user);
setToken(data.token);
localStorage.setItem('tcg_vault_token', data.token);
localStorage.setItem('tcg_vault_user', JSON.stringify(data.user));
return { success: true };
} else {
return { success: false, error: data.error || 'Login failed' };
}
} catch (error) {
console.error('Login error:', error);
return { success: false, error: 'Network error. Please try again.' };
}
};
const register = async (userData: RegisterData) => {
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(userData),
});
const data = await response.json();
if (data.success) {
setUser(data.user);
setToken(data.token);
localStorage.setItem('tcg_vault_token', data.token);
localStorage.setItem('tcg_vault_user', JSON.stringify(data.user));
return { success: true };
} else {
return { success: false, error: data.error || 'Registration failed' };
}
} catch (error) {
console.error('Registration error:', error);
return { success: false, error: 'Network error. Please try again.' };
}
};
const logout = () => {
setUser(null);
setToken(null);
localStorage.removeItem('tcg_vault_token');
localStorage.removeItem('tcg_vault_user');
};
const hasRole = (role: string): boolean => {
return user?.roles?.includes(role) || false;
};
const hasPermission = (permission: string): boolean => {
return user?.permissions?.includes(permission) || false;
};
const isAdmin = (): boolean => {
return hasRole('admin');
};
const value: AuthContextType = {
user,
token,
login,
register,
logout,
isLoading,
hasRole,
hasPermission,
isAdmin,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

View file

@ -1,105 +0,0 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextType {
theme: Theme;
effectiveTheme: 'light' | 'dark';
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const useTheme = () => {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
interface ThemeProviderProps {
children: React.ReactNode;
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const [theme, setTheme] = useState<Theme>(() => {
const savedTheme = localStorage.getItem('tcg-vault-theme') as Theme;
return savedTheme || 'system';
});
const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>('light');
useEffect(() => {
const updateEffectiveTheme = () => {
if (theme === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
setEffectiveTheme(prefersDark ? 'dark' : 'light');
} else {
setEffectiveTheme(theme);
}
};
updateEffectiveTheme();
// Listen for system theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
if (theme === 'system') {
updateEffectiveTheme();
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme]);
useEffect(() => {
// Apply theme class to document
const root = document.documentElement;
if (effectiveTheme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
// Update meta theme-color for mobile browsers
const metaThemeColor = document.querySelector('meta[name="theme-color"]');
if (metaThemeColor) {
metaThemeColor.setAttribute('content', effectiveTheme === 'dark' ? '#0f172a' : '#8b5cf6');
}
// Store theme preference
localStorage.setItem('tcg-vault-theme', theme);
}, [theme, effectiveTheme]);
const handleSetTheme = (newTheme: Theme) => {
setTheme(newTheme);
};
const toggleTheme = () => {
if (theme === 'light') {
setTheme('dark');
} else if (theme === 'dark') {
setTheme('system');
} else {
setTheme('light');
}
};
const value: ThemeContextType = {
theme,
effectiveTheme,
setTheme: handleSetTheme,
toggleTheme,
};
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
};
export default ThemeProvider;

View file

@ -1,502 +0,0 @@
[
{
"id": 1,
"name": "Lightning Bolt",
"set_name": "Alpha",
"set_code": "LEA",
"card_number": null,
"rarity": "Common",
"game": "MTG",
"mana_cost": "{R}",
"cmc": 1,
"card_type": "Instant",
"colors": [
"Red"
],
"oracle_text": "Lightning Bolt deals 3 damage to any target.",
"flavor_text": null,
"power": null,
"toughness": null,
"loyalty": null,
"artist": null,
"image_url": "/images/cards/mtg/lightning-bolt-alpha.jpg",
"scryfall_id": null,
"tcg_player_id": null,
"current_price": 15.99,
"market_price": 14.5,
"low_price": null,
"high_price": null,
"price_last_updated": null,
"ocr_confidence": null,
"ocr_raw_text": null,
"image_path": null,
"verified": 1,
"created_at": "2025-07-21 02:35:37",
"updated_at": "2025-07-21 15:10:46",
"stock_image_url": "/images/cards/mtg/lightning-bolt-alpha.jpg",
"artwork_crop_coords": {
"x": 23,
"y": 34,
"width": 265,
"height": 190
}
},
{
"id": 2,
"name": "Black Lotus",
"set_name": "Alpha",
"set_code": "LEA",
"card_number": null,
"rarity": "Rare",
"game": "MTG",
"mana_cost": "{0}",
"cmc": 0,
"card_type": "Artifact",
"colors": [],
"oracle_text": "{T}, Sacrifice Black Lotus: Add three mana of any one color.",
"flavor_text": null,
"power": null,
"toughness": null,
"loyalty": null,
"artist": null,
"image_url": "/images/cards/mtg/black-lotus-alpha.jpg",
"scryfall_id": null,
"tcg_player_id": null,
"current_price": 25000.0,
"market_price": 24500.0,
"low_price": null,
"high_price": null,
"price_last_updated": null,
"ocr_confidence": null,
"ocr_raw_text": null,
"image_path": null,
"verified": 1,
"created_at": "2025-07-21 02:35:37",
"updated_at": "2025-07-21 15:10:46",
"stock_image_url": "/images/cards/mtg/black-lotus-alpha.jpg",
"artwork_crop_coords": {
"x": 23,
"y": 34,
"width": 265,
"height": 190
}
},
{
"id": 3,
"name": "Pikachu",
"set_name": "Base Set",
"set_code": "BS1",
"card_number": null,
"rarity": "Common",
"game": "POKEMON",
"mana_cost": null,
"cmc": null,
"card_type": "Pokemon",
"colors": null,
"oracle_text": "When several of these Pokemon gather, their electricity could build and cause lightning storms.",
"flavor_text": null,
"power": null,
"toughness": null,
"loyalty": null,
"artist": null,
"image_url": "/images/cards/pokemon/pikachu-base-set.jpg",
"scryfall_id": null,
"tcg_player_id": null,
"current_price": 5.99,
"market_price": 6.5,
"low_price": null,
"high_price": null,
"price_last_updated": null,
"ocr_confidence": null,
"ocr_raw_text": null,
"image_path": null,
"verified": 1,
"created_at": "2025-07-21 02:35:37",
"updated_at": "2025-07-21 15:10:46",
"stock_image_url": "/images/cards/pokemon/pikachu-base-set.jpg",
"artwork_crop_coords": {
"x": 20,
"y": 30,
"width": 200,
"height": 140
}
},
{
"id": 4,
"name": "Mickey Mouse - Brave Little Tailor",
"set_name": "The First Chapter",
"set_code": "TFC",
"card_number": "001",
"rarity": "Legendary",
"game": "LORCANA",
"mana_cost": "8",
"cmc": 8,
"card_type": "Character - Storyborn Hero",
"colors": [
"Amber"
],
"oracle_text": "Evasive (Only characters with Evasive can challenge this character.) Support (Whenever this character quests, you may add their Lore to another chosen character's Lore this turn.)",
"flavor_text": null,
"power": "5",
"toughness": "8",
"loyalty": null,
"artist": null,
"image_url": "/images/cards/lorcana/mickey-brave-tailor-tfc.jpg",
"scryfall_id": null,
"tcg_player_id": null,
"current_price": 15.99,
"market_price": 14.5,
"low_price": null,
"high_price": null,
"price_last_updated": null,
"ocr_confidence": null,
"ocr_raw_text": null,
"image_path": null,
"verified": 1,
"created_at": "2025-07-21 02:50:54",
"updated_at": "2025-07-21 15:10:46",
"stock_image_url": "/images/cards/lorcana/mickey-brave-tailor-tfc.jpg",
"artwork_crop_coords": {
"x": 25,
"y": 40,
"width": 260,
"height": 180
}
},
{
"id": 5,
"name": "Elsa - Snow Queen",
"set_name": "The First Chapter",
"set_code": "TFC",
"card_number": "216",
"rarity": "Super Rare",
"game": "LORCANA",
"mana_cost": "8",
"cmc": 8,
"card_type": "Character - Storyborn Queen Sorcerer",
"colors": [
"Sapphire"
],
"oracle_text": "Deep Freeze - Exert chosen opposing character. They can't ready at the start of their next turn.",
"flavor_text": null,
"power": "4",
"toughness": "6",
"loyalty": null,
"artist": null,
"image_url": "/images/cards/lorcana/elsa-snow-queen-tfc.jpg",
"scryfall_id": null,
"tcg_player_id": null,
"current_price": 8.99,
"market_price": 9.5,
"low_price": null,
"high_price": null,
"price_last_updated": null,
"ocr_confidence": null,
"ocr_raw_text": null,
"image_path": null,
"verified": 1,
"created_at": "2025-07-21 02:50:54",
"updated_at": "2025-07-21 15:10:46",
"stock_image_url": "/images/cards/lorcana/elsa-snow-queen-tfc.jpg",
"artwork_crop_coords": {
"x": 25,
"y": 40,
"width": 260,
"height": 180
}
},
{
"id": 6,
"name": "Be Prepared",
"set_name": "The First Chapter",
"set_code": "TFC",
"card_number": "087",
"rarity": "Rare",
"game": "LORCANA",
"mana_cost": "3",
"cmc": 3,
"card_type": "Action - Song",
"colors": [
"Emerald"
],
"oracle_text": "(A character with cost 3 or more can sing this song for free.) Deal 2 damage to chosen character.",
"flavor_text": null,
"power": null,
"toughness": null,
"loyalty": null,
"artist": null,
"image_url": "/images/cards/lorcana/be-prepared-tfc.jpg",
"scryfall_id": null,
"tcg_player_id": null,
"current_price": 2.99,
"market_price": 3.25,
"low_price": null,
"high_price": null,
"price_last_updated": null,
"ocr_confidence": null,
"ocr_raw_text": null,
"image_path": null,
"verified": 1,
"created_at": "2025-07-21 02:50:54",
"updated_at": "2025-07-21 15:10:46",
"stock_image_url": "/images/cards/lorcana/be-prepared-tfc.jpg",
"artwork_crop_coords": {
"x": 25,
"y": 40,
"width": 260,
"height": 180
}
},
{
"id": 7,
"name": "Mickey Mouse - Steamboat Pilot",
"set_name": "The First Chapter",
"set_code": "TFC",
"card_number": "019",
"rarity": "Common",
"game": "LORCANA",
"mana_cost": "2",
"cmc": 2,
"card_type": "Character - Storyborn Captain",
"colors": [
"Amber"
],
"oracle_text": "Shift 4 (You may pay 4 ink to play this on top of another Mickey Mouse character.)",
"flavor_text": null,
"power": "2",
"toughness": "2",
"loyalty": null,
"artist": null,
"image_url": "/images/cards/lorcana/mickey-steamboat-pilot-tfc.jpg",
"scryfall_id": null,
"tcg_player_id": null,
"current_price": 0.25,
"market_price": 0.3,
"low_price": null,
"high_price": null,
"price_last_updated": null,
"ocr_confidence": null,
"ocr_raw_text": null,
"image_path": null,
"verified": 1,
"created_at": "2025-07-21 02:50:54",
"updated_at": "2025-07-21 15:10:46",
"stock_image_url": "/images/cards/lorcana/mickey-steamboat-pilot-tfc.jpg",
"artwork_crop_coords": {
"x": 25,
"y": 40,
"width": 260,
"height": 180
}
},
{
"id": 8,
"name": "Ancestral Recall",
"set_name": "Alpha",
"set_code": "LEA",
"card_number": "048",
"rarity": "Rare",
"game": "MTG",
"mana_cost": "U",
"cmc": 1,
"card_type": "Instant",
"colors": [
"Blue"
],
"oracle_text": "Target player draws three cards.",
"flavor_text": null,
"power": null,
"toughness": null,
"loyalty": null,
"artist": null,
"image_url": null,
"scryfall_id": null,
"tcg_player_id": null,
"current_price": 8000.0,
"market_price": 7500.0,
"low_price": null,
"high_price": null,
"price_last_updated": null,
"ocr_confidence": null,
"ocr_raw_text": null,
"image_path": null,
"verified": 1,
"created_at": "2025-07-21 15:26:08",
"updated_at": null,
"stock_image_url": "/images/cards/mtg/ancestral-recall-alpha.jpg",
"artwork_crop_coords": {
"x": 23,
"y": 34,
"width": 265,
"height": 190
}
},
{
"id": 9,
"name": "Charizard",
"set_name": "Base Set",
"set_code": "BS",
"card_number": "4",
"rarity": "Super Rare",
"game": "POKEMON",
"mana_cost": null,
"cmc": null,
"card_type": "Fire Pok\u00e9mon",
"colors": [
"Fire"
],
"oracle_text": "Discard 2 Energy cards attached to Charizard in order to use this attack.",
"flavor_text": null,
"power": "120",
"toughness": null,
"loyalty": null,
"artist": null,
"image_url": null,
"scryfall_id": null,
"tcg_player_id": null,
"current_price": 350.0,
"market_price": 320.0,
"low_price": null,
"high_price": null,
"price_last_updated": null,
"ocr_confidence": null,
"ocr_raw_text": null,
"image_path": null,
"verified": 1,
"created_at": "2025-07-21 15:26:09",
"updated_at": null,
"stock_image_url": "/images/cards/pokemon/charizard-base-set.jpg",
"artwork_crop_coords": {
"x": 20,
"y": 30,
"width": 200,
"height": 140
}
},
{
"id": 10,
"name": "Moxen - Time Twister",
"set_name": "Alpha",
"set_code": "LEA",
"card_number": "295",
"rarity": "Mythic",
"game": "MTG",
"mana_cost": "2U",
"cmc": 3,
"card_type": "Sorcery",
"colors": [
"Blue"
],
"oracle_text": "Each player shuffles their hand and graveyard into their library, then draws seven cards.",
"flavor_text": null,
"power": null,
"toughness": null,
"loyalty": null,
"artist": null,
"image_url": null,
"scryfall_id": null,
"tcg_player_id": null,
"current_price": 3500.0,
"market_price": 3200.0,
"low_price": null,
"high_price": null,
"price_last_updated": null,
"ocr_confidence": null,
"ocr_raw_text": null,
"image_path": null,
"verified": 1,
"created_at": "2025-07-21 15:26:09",
"updated_at": null,
"stock_image_url": "/images/cards/mtg/timetwister-alpha.jpg",
"artwork_crop_coords": {
"x": 23,
"y": 34,
"width": 265,
"height": 190
}
},
{
"id": 11,
"name": "Belle - Hidden Archer",
"set_name": "Rise of the Floodborn",
"set_code": "ROF",
"card_number": "011",
"rarity": "Legendary",
"game": "LORCANA",
"mana_cost": "4",
"cmc": 4,
"card_type": "Character - Storyborn Hero",
"colors": [
"Amber"
],
"oracle_text": "Support (Whenever this character quests, you may add their Lore to another chosen character's Lore this turn.) Challenger +2 (While challenging, this character gets +2 Strength.)",
"flavor_text": null,
"power": "3",
"toughness": "5",
"loyalty": null,
"artist": null,
"image_url": null,
"scryfall_id": null,
"tcg_player_id": null,
"current_price": 45.99,
"market_price": 42.5,
"low_price": null,
"high_price": null,
"price_last_updated": null,
"ocr_confidence": null,
"ocr_raw_text": null,
"image_path": null,
"verified": 1,
"created_at": "2025-07-21 15:26:09",
"updated_at": null,
"stock_image_url": "/images/cards/lorcana/belle-hidden-archer-rof.jpg",
"artwork_crop_coords": {
"x": 25,
"y": 40,
"width": 260,
"height": 180
}
},
{
"id": 12,
"name": "Shining Gyarados",
"set_name": "Neo Revelation",
"set_code": "N1",
"card_number": "65",
"rarity": "Super Rare",
"game": "POKEMON",
"mana_cost": null,
"cmc": null,
"card_type": "Water Pok\u00e9mon",
"colors": [
"Water"
],
"oracle_text": "Whenever Shining Gyarados takes damage, flip a coin. If tails, Shining Gyarados does 10 damage to itself.",
"flavor_text": null,
"power": "130",
"toughness": null,
"loyalty": null,
"artist": null,
"image_url": null,
"scryfall_id": null,
"tcg_player_id": null,
"current_price": 180.0,
"market_price": 165.0,
"low_price": null,
"high_price": null,
"price_last_updated": null,
"ocr_confidence": null,
"ocr_raw_text": null,
"image_path": null,
"verified": 1,
"created_at": "2025-07-21 15:26:09",
"updated_at": null,
"stock_image_url": "/images/cards/pokemon/shining-gyarados-neo.jpg",
"artwork_crop_coords": {
"x": 20,
"y": 30,
"width": 200,
"height": 140
}
}
]

View file

@ -1,113 +0,0 @@
import { useState, useEffect, useRef, useCallback } from 'react';
interface TiltOptions {
maxTilt?: number;
perspective?: number;
scale?: number;
speed?: number;
reset?: boolean;
easing?: string;
}
interface TiltState {
tiltX: number;
tiltY: number;
scale: number;
transform: string;
}
export const use3DTilt = (options: TiltOptions = {}) => {
const {
maxTilt = 15,
perspective = 1000,
scale = 1.05,
speed = 600,
reset = true,
easing = 'cubic-bezier(0.25, 0.46, 0.45, 0.94)'
} = options;
const elementRef = useRef<HTMLDivElement>(null);
const [tiltState, setTiltState] = useState<TiltState>({
tiltX: 0,
tiltY: 0,
scale: 1,
transform: '',
});
const updateTilt = useCallback((x: number, y: number, rect: DOMRect) => {
// Calculate the center of the element
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
// Calculate the tilt based on mouse position relative to center
const tiltX = ((y - centerY) / (rect.height / 2)) * maxTilt;
const tiltY = ((centerX - x) / (rect.width / 2)) * maxTilt;
// Create the transform string
const transform = `perspective(${perspective}px) rotateX(${tiltX}deg) rotateY(${tiltY}deg) scale(${scale})`;
setTiltState({
tiltX,
tiltY,
scale,
transform
});
}, [maxTilt, perspective, scale]);
const resetTilt = useCallback(() => {
setTiltState({
tiltX: 0,
tiltY: 0,
scale: 1,
transform: `perspective(${perspective}px) rotateX(0deg) rotateY(0deg) scale(1)`
});
}, [perspective]);
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!elementRef.current) return;
const rect = elementRef.current.getBoundingClientRect();
updateTilt(e.clientX, e.clientY, rect);
}, [updateTilt]);
const handleMouseEnter = useCallback(() => {
if (!elementRef.current) return;
elementRef.current.addEventListener('mousemove', handleMouseMove);
}, [handleMouseMove]);
const handleMouseLeave = useCallback(() => {
if (!elementRef.current) return;
elementRef.current.removeEventListener('mousemove', handleMouseMove);
if (reset) {
resetTilt();
}
}, [handleMouseMove, reset, resetTilt]);
useEffect(() => {
const element = elementRef.current;
if (!element) return;
element.addEventListener('mouseenter', handleMouseEnter);
element.addEventListener('mouseleave', handleMouseLeave);
return () => {
element.removeEventListener('mouseenter', handleMouseEnter);
element.removeEventListener('mouseleave', handleMouseLeave);
element.removeEventListener('mousemove', handleMouseMove);
};
}, [handleMouseEnter, handleMouseLeave, handleMouseMove]);
// Generate CSS styles for the tilt effect
const tiltStyles = {
transform: tiltState.transform,
transformStyle: 'preserve-3d' as const,
transition: reset ? `transform ${speed}ms ${easing}` : 'none',
};
return {
ref: elementRef,
tiltState,
tiltStyles,
resetTilt,
};
};

View file

@ -1,121 +0,0 @@
import { useState, useEffect, useRef, useCallback } from 'react';
interface MouseGlowOptions {
glowRadius?: number;
glowIntensity?: number;
updateThrottle?: number;
enabled?: boolean;
}
interface MouseGlowState {
glowX: number;
glowY: number;
isHovering: boolean;
}
export const useMouseGlow = (options: MouseGlowOptions = {}) => {
const {
glowRadius = 150,
glowIntensity = 0.6,
updateThrottle = 33, // ~30fps for better performance
enabled = true
} = options;
const elementRef = useRef<HTMLDivElement>(null);
const throttleRef = useRef<number | undefined>(undefined);
const [glowState, setGlowState] = useState<MouseGlowState>({
glowX: 50,
glowY: 50,
isHovering: false,
});
const updateGlow = useCallback((clientX: number, clientY: number) => {
if (!elementRef.current || !enabled) return;
const rect = elementRef.current.getBoundingClientRect();
const glowX = ((clientX - rect.left) / rect.width) * 100;
const glowY = ((clientY - rect.top) / rect.height) * 100;
// Clamp values to ensure glow stays within bounds
const clampedX = Math.max(0, Math.min(100, glowX));
const clampedY = Math.max(0, Math.min(100, glowY));
setGlowState(prev => ({
...prev,
glowX: clampedX,
glowY: clampedY,
}));
// Update CSS custom properties directly for better performance
if (elementRef.current) {
elementRef.current.style.setProperty('--glow-x', `${clampedX}%`);
elementRef.current.style.setProperty('--glow-y', `${clampedY}%`);
elementRef.current.style.setProperty('--glow-intensity', glowIntensity.toString());
}
}, [enabled, glowIntensity]);
const handleMouseMove = useCallback((e: MouseEvent) => {
if (throttleRef.current) {
clearTimeout(throttleRef.current);
}
throttleRef.current = window.setTimeout(() => {
updateGlow(e.clientX, e.clientY);
}, updateThrottle);
}, [updateGlow, updateThrottle]);
const handleMouseEnter = useCallback((e: MouseEvent) => {
setGlowState(prev => ({
...prev,
isHovering: true,
}));
updateGlow(e.clientX, e.clientY);
}, [updateGlow]);
const handleMouseLeave = useCallback(() => {
setGlowState(prev => ({
...prev,
isHovering: false,
}));
// Reset to center position
if (elementRef.current) {
elementRef.current.style.setProperty('--glow-x', '50%');
elementRef.current.style.setProperty('--glow-y', '50%');
}
}, []);
useEffect(() => {
const element = elementRef.current;
if (!element || !enabled) return;
element.addEventListener('mousemove', handleMouseMove, { passive: true });
element.addEventListener('mouseenter', handleMouseEnter, { passive: true });
element.addEventListener('mouseleave', handleMouseLeave, { passive: true });
return () => {
element.removeEventListener('mousemove', handleMouseMove);
element.removeEventListener('mouseenter', handleMouseEnter);
element.removeEventListener('mouseleave', handleMouseLeave);
if (throttleRef.current) {
clearTimeout(throttleRef.current);
}
};
}, [handleMouseMove, handleMouseEnter, handleMouseLeave, enabled]);
// Generate CSS styles for the glow effect
const glowStyles = {
'--glow-x': `${glowState.glowX}%`,
'--glow-y': `${glowState.glowY}%`,
'--glow-intensity': glowIntensity.toString(),
'--glow-radius': `${glowRadius}px`,
} as React.CSSProperties;
return {
ref: elementRef,
glowState,
glowStyles,
isHovering: glowState.isHovering,
};
};

View file

@ -1,77 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Safe area classes for mobile PWA */
.safe-area-inset-top {
padding-top: env(safe-area-inset-top);
}
.safe-area-inset-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
.safe-area-inset-left {
padding-left: env(safe-area-inset-left);
}
.safe-area-inset-right {
padding-right: env(safe-area-inset-right);
}
/* Custom animations */
@keyframes slide-down {
from {
opacity: 0;
transform: translate(-50%, -20px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
.animate-slide-down {
animation: slide-down 0.3s ease-out;
}
/* PWA specific styles */
@supports (padding: env(safe-area-inset-top)) {
.min-h-screen-safe {
min-height: calc(100vh + env(safe-area-inset-top) + env(safe-area-inset-bottom));
}
}
/* Fallback for browsers that don't support env() */
.min-h-screen-safe {
min-height: 100vh;
}
/* Touch optimization */
.active\:scale-98:active {
transform: scale(0.98);
}
/* Import custom card effects */
@import './styles/cardEffects.css';
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-tap-highlight-color: transparent; /* Remove tap highlight on iOS */
overscroll-behavior: none; /* Prevent pull to refresh */
}
/* Prevent zoom on iOS */
input, select, textarea {
font-size: 16px !important;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View file

@ -1,32 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// Register service worker for PWA
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then((registration) => {
console.log('SW registered: ', registration);
})
.catch((registrationError) => {
console.log('SW registration failed: ', registrationError);
});
});
}
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -1,474 +0,0 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { tcgApi } from '../services/tcgApi';
import CardSearch from '../components/cards/CardSearch';
import CardDatabaseBrowser from '../components/cards/CardDatabaseBrowser';
import CardManager from '../components/cards/CardManager';
import CardImageDisplay from '../components/CardImageDisplay';
import GlowingCard from '../components/GlowingCard';
import type { UserCard, CardFilters, Card } from '../types';
const Cards: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('');
const [filters, setFilters] = useState<CardFilters>({
game: '',
rarity: '',
status: 'all',
search: '',
});
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
const [showCardSearch, setShowCardSearch] = useState(false);
const [showDatabaseBrowser, setShowDatabaseBrowser] = useState(false);
const [editingCard, setEditingCard] = useState<string | null>(null);
const { data: userCards = [], isLoading, error } = useQuery({
queryKey: ['user-cards', { ...filters, search: searchTerm }],
queryFn: () => tcgApi.cards.getUserCards({ ...filters, search: searchTerm }),
});
// Filter cards based on current filters
const filteredCards = userCards.filter((userCard: UserCard) => {
const card = userCard.card;
if (!card) return false;
if (filters.rarity && card.rarity !== filters.rarity) return false;
if (filters.game && card.game !== filters.game) return false;
if (filters.status && filters.status !== 'all' && userCard.status !== filters.status) return false;
return true;
});
const getGameBadgeColor = (game: string) => {
switch (game) {
case 'MTG': return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300';
case 'POKEMON': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
case 'LORCANA': return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300';
case 'YUGIOH': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
default: return 'bg-surface-100 text-surface-800 dark:bg-surface-700 dark:text-surface-300';
}
};
const getRarityBadgeColor = (rarity: string) => {
switch (rarity.toLowerCase()) {
case 'common': return 'bg-surface-100 text-surface-800 dark:bg-surface-700 dark:text-surface-300';
case 'uncommon': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
case 'rare': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
case 'super rare': return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300';
case 'legendary': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
case 'mythic': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
default: return 'bg-surface-100 text-surface-800 dark:bg-surface-700 dark:text-surface-300';
}
};
const getStatusBadgeColor = (status: string) => {
switch (status) {
case 'owned': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
case 'wanted': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
default: return 'bg-surface-100 text-surface-800 dark:bg-surface-700 dark:text-surface-300';
}
};
const getConditionBadgeColor = (condition: string) => {
switch (condition) {
case 'mint': return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300';
case 'near_mint': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
case 'excellent': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
case 'good': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
case 'light_played': return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300';
case 'played': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
case 'poor': return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300';
default: return 'bg-surface-100 text-surface-800 dark:bg-surface-700 dark:text-surface-300';
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-surface-900 dark:text-white">My Cards</h1>
<p className="text-surface-600 dark:text-surface-400 mt-1">
{filteredCards.length} cards in your collection
</p>
</div>
<div className="flex space-x-3">
<button
onClick={() => setShowCardSearch(true)}
className="bg-gradient-to-r from-primary-500 to-accent-500 hover:from-primary-600 hover:to-accent-600 text-white px-4 py-2 rounded-xl font-medium transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5"
>
<svg className="w-4 h-4 mr-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Add Cards
</button>
<button
onClick={() => setShowDatabaseBrowser(true)}
className="bg-gradient-to-r from-accent-500 to-primary-500 hover:from-accent-600 hover:to-primary-600 text-white px-4 py-2 rounded-xl font-medium transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5"
>
<svg className="w-4 h-4 mr-2 inline" 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>
Browse Database
</button>
</div>
</div>
{/* Search and Filter Bar */}
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-4">
<div className="space-y-4">
{/* Search Bar */}
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-surface-400" 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>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search your cards..."
className="w-full pl-10 pr-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors"
/>
</div>
{/* Filters and View Toggle */}
<div className="flex items-center justify-between">
<div className="flex space-x-2 overflow-x-auto pb-2">
<select
value={filters.game}
onChange={(e) => setFilters(prev => ({ ...prev, game: e.target.value }))}
className="px-3 py-2 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors whitespace-nowrap"
>
<option value="">All Games</option>
<option value="MTG">Magic: The Gathering</option>
<option value="POKEMON">Pokémon</option>
<option value="LORCANA">Disney Lorcana</option>
<option value="YUGIOH">Yu-Gi-Oh!</option>
</select>
<select
value={filters.rarity}
onChange={(e) => setFilters(prev => ({ ...prev, rarity: e.target.value }))}
className="px-3 py-2 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors whitespace-nowrap"
>
<option value="">All Rarities</option>
<option value="Common">Common</option>
<option value="Uncommon">Uncommon</option>
<option value="Rare">Rare</option>
<option value="Super Rare">Super Rare</option>
<option value="Legendary">Legendary</option>
<option value="Mythic">Mythic</option>
</select>
<select
value={filters.status}
onChange={(e) => setFilters(prev => ({ ...prev, status: e.target.value as any }))}
className="px-3 py-2 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors whitespace-nowrap"
>
<option value="all">All Cards</option>
<option value="owned">Owned</option>
<option value="wanted">Wanted</option>
</select>
</div>
{/* View Toggle */}
<div className="flex bg-surface-100 dark:bg-surface-700 rounded-lg p-1">
<button
onClick={() => setViewMode('card')}
className={`p-2 rounded-md transition-colors ${
viewMode === 'card'
? 'bg-white dark:bg-surface-600 text-surface-900 dark:text-white shadow-sm'
: 'text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white'
}`}
>
<svg className="w-4 h-4" 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-md transition-colors ${
viewMode === 'list'
? 'bg-white dark:bg-surface-600 text-surface-900 dark:text-white shadow-sm'
: 'text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white'
}`}
>
<svg className="w-4 h-4" 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>
</div>
{/* Loading State */}
{isLoading && (
<div className="flex items-center justify-center py-12">
<div className="flex flex-col items-center">
<div className="w-8 h-8 bg-gradient-to-r from-primary-500 to-accent-500 rounded-full flex items-center justify-center animate-bounce-subtle mb-3">
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<p className="text-surface-600 dark:text-surface-400 text-sm">Loading your cards...</p>
</div>
</div>
)}
{/* Error State */}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4 mb-6">
<p className="text-red-800 dark:text-red-400">Error loading cards. Please try again.</p>
</div>
)}
{/* Cards Display */}
{!isLoading && !error && (
<>
{filteredCards.length === 0 ? (
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-8">
<div className="text-center">
<div className="w-16 h-16 bg-surface-100 dark:bg-surface-700 rounded-2xl flex items-center justify-center mx-auto mb-6">
<svg className="w-8 h-8 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h3 className="text-xl font-semibold text-surface-900 dark:text-white mb-2">
{userCards.length === 0 ? 'No cards yet' : 'No matching cards'}
</h3>
<p className="text-surface-600 dark:text-surface-400 mb-6">
{userCards.length === 0
? 'Start building your collection by adding cards'
: 'Try adjusting your search or filters'
}
</p>
{userCards.length === 0 && (
<div className="flex flex-col sm:flex-row gap-3 items-center justify-center">
<button
onClick={() => setShowCardSearch(true)}
className="bg-gradient-to-r from-primary-500 to-accent-500 hover:from-primary-600 hover:to-accent-600 text-white px-6 py-3 rounded-xl font-medium transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5"
>
Browse Cards
</button>
<button className="bg-surface-100 dark:bg-surface-700 hover:bg-surface-200 dark:hover:bg-surface-600 text-surface-800 dark:text-surface-300 px-6 py-3 rounded-xl font-medium transition-colors">
Scan Cards
</button>
</div>
)}
</div>
</div>
) : viewMode === 'card' ? (
/* Card View */
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredCards.map((userCard: UserCard) => {
const card = userCard.card;
if (!card) return null;
return (
<GlowingCard key={userCard.id} rarity={card.rarity} className="bg-white dark:bg-surface-800 rounded-xl shadow overflow-hidden hover:shadow-lg transition-all duration-200">
{/* Card Image */}
<div className="flex justify-center p-4 bg-surface-50 dark:bg-surface-700/50">
<div className="relative">
<CardImageDisplay
card={card}
size="large"
className="mx-auto"
/>
{/* Status Badge */}
<div className="absolute -top-2 -right-2">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusBadgeColor(userCard.status)}`}>
{userCard.status === 'owned' ? '✅' : '❤️'}
</span>
</div>
{/* Quantity Badge */}
{userCard.quantity > 1 && (
<div className="absolute -bottom-2 -right-2">
<span className="bg-surface-800 dark:bg-surface-200 text-white dark:text-surface-900 text-xs font-bold px-2 py-1 rounded-full">
{userCard.quantity}x
</span>
</div>
)}
</div>
</div>
{/* Card Info */}
<div className="p-4">
{/* Header */}
<div className="flex justify-between items-start mb-3">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-surface-900 dark:text-white truncate" title={card.name}>
{card.name}
</h3>
<p className="text-sm text-surface-600 dark:text-surface-400 truncate">{card.set_name}</p>
</div>
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${getGameBadgeColor(card.game)}`}>
{card.game}
</span>
</div>
{/* Quick Stats */}
<div className="space-y-1 mb-3">
{card.card_type && (
<p className="text-xs text-surface-600 dark:text-surface-400 truncate" title={card.card_type}>
{card.card_type}
</p>
)}
<div className="flex justify-between items-center">
{card.mana_cost && (
<span className="text-xs text-surface-600 dark:text-surface-400">
Cost: {card.mana_cost}
</span>
)}
{(card.power && card.toughness) && (
<span className="text-xs text-surface-600 dark:text-surface-400">
{card.power}/{card.toughness}
</span>
)}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between mb-3">
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${getRarityBadgeColor(card.rarity)}`}>
{card.rarity}
</span>
{card.current_price && (
<span className="text-sm font-medium text-green-600 dark:text-green-400">
${card.current_price.toFixed(2)}
</span>
)}
</div>
{/* Condition Badge */}
{userCard.condition && userCard.status === 'owned' && (
<div className="mb-3">
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${getConditionBadgeColor(userCard.condition)}`}>
{userCard.condition.replace('_', ' ').toUpperCase()}
</span>
</div>
)}
{/* Edit Button */}
<button
onClick={() => setEditingCard(userCard.id)}
className="w-full bg-surface-100 dark:bg-surface-700 hover:bg-surface-200 dark:hover:bg-surface-600 text-surface-800 dark:text-surface-300 py-2 px-3 rounded-lg text-sm font-medium transition-colors"
>
Edit Card
</button>
</div>
</GlowingCard>
);
})}
</div>
) : (
/* List View */
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 overflow-hidden">
<div className="space-y-1">
{filteredCards.map((userCard: UserCard) => {
const card = userCard.card;
if (!card) return null;
return (
<div key={userCard.id} className="flex items-center p-4 hover:bg-surface-50 dark:hover:bg-surface-700/50 transition-colors">
{/* Card Image */}
<div className="w-12 h-16 bg-surface-200 dark:bg-surface-700 rounded-lg flex items-center justify-center flex-shrink-0 mr-4">
{card.image_url ? (
<img
src={card.image_url}
alt={card.name}
className="w-full h-full object-cover rounded-lg"
/>
) : (
<svg className="w-4 h-4 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
)}
</div>
{/* Card Info */}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-surface-900 dark:text-white truncate">
{card.name}
</h3>
<p className="text-sm text-surface-600 dark:text-surface-400 truncate">
{card.set_name}
</p>
<div className="flex items-center space-x-2 mt-1">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getGameBadgeColor(card.game)}`}>
{card.game}
</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getRarityBadgeColor(card.rarity)}`}>
{card.rarity}
</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusBadgeColor(userCard.status)}`}>
{userCard.status}
</span>
</div>
</div>
{/* Quantity and Price */}
<div className="text-right mr-4">
<div className="text-sm font-medium text-surface-900 dark:text-white">
{userCard.quantity}x
</div>
{card.current_price && (
<div className="text-sm text-green-600 dark:text-green-400">
${(card.current_price * userCard.quantity).toFixed(2)}
</div>
)}
</div>
{/* Edit Button */}
<button
onClick={() => setEditingCard(userCard.id)}
className="p-2 text-surface-400 hover:text-surface-600 dark:hover:text-surface-300 rounded-lg transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
</div>
);
})}
</div>
</div>
)}
</>
)}
{/* Card Search Modal */}
<CardSearch
isOpen={showCardSearch}
onClose={() => setShowCardSearch(false)}
/>
{/* Card Manager Modal */}
{editingCard && (
<CardManager
isOpen={!!editingCard}
onClose={() => setEditingCard(null)}
cardId={editingCard}
/>
)}
{/* Database Browser Modal */}
<CardDatabaseBrowser
isOpen={showDatabaseBrowser}
onClose={() => setShowDatabaseBrowser(false)}
onCardSelect={(card: Card) => {
// Handle card selection from database browser
console.log('Selected card from database:', card);
setShowDatabaseBrowser(false);
}}
/>
</div>
);
};
export default Cards;

View file

@ -1,320 +0,0 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { tcgApi } from '../services/tcgApi';
import CollectionManager from '../components/collections/CollectionManager';
import type { Collection, CollectionFilters } from '../types';
const Collections: React.FC = () => {
const queryClient = useQueryClient();
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingCollection, setEditingCollection] = useState<string | null>(null);
const [filters, setFilters] = useState<CollectionFilters>({
search: '',
game: '',
isFavorite: false,
});
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
// Queries
const { data: collections = [], isLoading } = useQuery({
queryKey: ['collections', filters],
queryFn: () => tcgApi.collections.getCollections(filters),
});
// Mutations
const deleteMutation = useMutation({
mutationFn: tcgApi.collections.deleteCollection,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['collections'] });
},
});
const toggleFavoriteMutation = useMutation({
mutationFn: ({ id, isFavorite }: { id: string; isFavorite: boolean }) =>
tcgApi.collections.updateCollection(id, { isFavorite }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['collections'] });
},
});
const handleDeleteCollection = (id: string, name: string) => {
if (window.confirm(`Are you sure you want to delete "${name}"? This action cannot be undone.`)) {
deleteMutation.mutate(id);
}
};
const handleToggleFavorite = (collection: Collection) => {
toggleFavoriteMutation.mutate({
id: collection.id,
isFavorite: !collection.isFavorite
});
};
const getGameBadgeColor = (game: string) => {
switch (game) {
case 'MTG': return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300';
case 'POKEMON': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
case 'LORCANA': return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300';
case 'YUGIOH': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
default: return 'bg-surface-100 text-surface-800 dark:bg-surface-700 dark:text-surface-300';
}
};
const filteredCollections = collections.filter(collection => {
const matchesSearch = !filters.search ||
collection.name.toLowerCase().includes(filters.search.toLowerCase()) ||
collection.description?.toLowerCase().includes(filters.search.toLowerCase());
const matchesGame = !filters.game || collection.game === filters.game;
const matchesFavorite = !filters.isFavorite || collection.isFavorite;
return matchesSearch && matchesGame && matchesFavorite;
});
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-surface-900 dark:text-white">Collections</h1>
<p className="text-surface-600 dark:text-surface-400 mt-1">
Organize and manage your card collections
</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="bg-gradient-to-r from-primary-500 to-accent-500 hover:from-primary-600 hover:to-accent-600 text-white px-4 py-2 rounded-xl font-medium transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5"
>
<svg className="w-4 h-4 mr-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
New Collection
</button>
</div>
{/* Search and Filters */}
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-4">
<div className="space-y-4">
{/* Search Bar */}
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-surface-400" 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>
<input
type="text"
value={filters.search}
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
placeholder="Search collections..."
className="w-full pl-10 pr-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors"
/>
</div>
{/* Filters and View Toggle */}
<div className="flex items-center justify-between">
<div className="flex space-x-2 overflow-x-auto pb-2">
<select
value={filters.game}
onChange={(e) => setFilters(prev => ({ ...prev, game: e.target.value }))}
className="px-3 py-2 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors whitespace-nowrap"
>
<option value="">All Games</option>
<option value="MTG">Magic: The Gathering</option>
<option value="POKEMON">Pokémon</option>
<option value="LORCANA">Disney Lorcana</option>
<option value="YUGIOH">Yu-Gi-Oh!</option>
<option value="OTHER">Other</option>
</select>
<button
onClick={() => setFilters(prev => ({ ...prev, isFavorite: !prev.isFavorite }))}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
filters.isFavorite
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300'
: 'bg-surface-50 dark:bg-surface-700 text-surface-700 dark:text-surface-300 border border-surface-300 dark:border-surface-600'
}`}
>
Favorites
</button>
</div>
{/* View Mode Toggle */}
<div className="flex bg-surface-100 dark:bg-surface-700 rounded-lg p-1">
<button
onClick={() => setViewMode('grid')}
className={`p-2 rounded-md transition-colors ${
viewMode === 'grid'
? 'bg-white dark:bg-surface-600 text-surface-900 dark:text-white shadow-sm'
: 'text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white'
}`}
>
<svg className="w-4 h-4" 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-md transition-colors ${
viewMode === 'list'
? 'bg-white dark:bg-surface-600 text-surface-900 dark:text-white shadow-sm'
: 'text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white'
}`}
>
<svg className="w-4 h-4" 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>
</div>
{/* Collections List */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="flex flex-col items-center">
<div className="w-8 h-8 bg-gradient-to-r from-primary-500 to-accent-500 rounded-full flex items-center justify-center animate-bounce-subtle mb-3">
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<p className="text-surface-600 dark:text-surface-400 text-sm">Loading collections...</p>
</div>
</div>
) : filteredCollections.length === 0 ? (
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-8">
<div className="text-center">
<div className="w-16 h-16 bg-surface-100 dark:bg-surface-700 rounded-2xl flex items-center justify-center mx-auto mb-6">
<svg className="w-8 h-8 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h3 className="text-xl font-semibold text-surface-900 dark:text-white mb-2">
{collections.length === 0 ? 'No collections yet' : 'No matching collections'}
</h3>
<p className="text-surface-600 dark:text-surface-400 mb-6">
{collections.length === 0
? 'Create your first collection to start organizing your cards'
: 'Try adjusting your search or filters'
}
</p>
{collections.length === 0 && (
<button
onClick={() => setShowCreateModal(true)}
className="bg-gradient-to-r from-primary-500 to-accent-500 hover:from-primary-600 hover:to-accent-600 text-white px-6 py-3 rounded-xl font-medium transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5"
>
Create Collection
</button>
)}
</div>
</div>
) : (
<div className={viewMode === 'grid' ? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4' : 'space-y-4'}>
{filteredCollections.map((collection) => (
<div
key={collection.id}
className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-4 transition-all duration-200 hover:shadow-md"
>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center space-x-3">
<div
className="w-12 h-12 rounded-xl flex items-center justify-center text-white text-xl font-semibold shadow-md"
style={{ backgroundColor: collection.color || '#8b5cf6' }}
>
{collection.icon || '📚'}
</div>
<div className="flex-1">
<h3 className="font-semibold text-surface-900 dark:text-white">
{collection.name}
</h3>
{collection.description && (
<p className="text-sm text-surface-600 dark:text-surface-400 line-clamp-2">
{collection.description}
</p>
)}
</div>
</div>
{/* Favorite Button */}
<button
onClick={() => handleToggleFavorite(collection)}
className={`p-2 rounded-lg transition-colors ${
collection.isFavorite
? 'text-yellow-500 hover:text-yellow-600'
: 'text-surface-400 hover:text-yellow-500'
}`}
>
<svg className="w-5 h-5" fill={collection.isFavorite ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</button>
</div>
{/* Stats */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-4 text-sm text-surface-600 dark:text-surface-400">
<span>{collection.cardCount || 0} cards</span>
{collection.totalValue && (
<span className="text-green-600 dark:text-green-400 font-medium">
${collection.totalValue.toFixed(2)}
</span>
)}
</div>
{collection.game && (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getGameBadgeColor(collection.game)}`}>
{collection.game}
</span>
)}
</div>
{/* Actions */}
<div className="flex items-center space-x-2">
<button
onClick={() => {/* Navigate to collection detail */}}
className="flex-1 bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300 py-2 px-3 rounded-lg text-sm font-medium hover:bg-primary-100 dark:hover:bg-primary-900/30 transition-colors"
>
View Cards
</button>
<button
onClick={() => setEditingCollection(collection.id)}
className="p-2 text-surface-400 hover:text-surface-600 dark:hover:text-surface-300 rounded-lg transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDeleteCollection(collection.id, collection.name)}
className="p-2 text-red-400 hover:text-red-600 rounded-lg transition-colors"
disabled={deleteMutation.isPending}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
))}
</div>
)}
{/* Collection Manager Modal */}
<CollectionManager
isOpen={showCreateModal || !!editingCollection}
onClose={() => {
setShowCreateModal(false);
setEditingCollection(null);
}}
collectionId={editingCollection || undefined}
/>
</div>
);
};
export default Collections;

View file

@ -1,182 +0,0 @@
import React from 'react';
import { useAuth } from '../contexts/AuthContext';
import { Link } from 'react-router-dom';
const Dashboard: React.FC = () => {
const { user } = useAuth();
const statsCards = [
{
title: 'Collections',
value: '0',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
color: 'primary'
},
{
title: 'Decks',
value: '0',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
color: 'accent'
},
{
title: 'Total Cards',
value: '0',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2M7 4H5a2 2 0 00-2 2v10a2 2 0 002 2h14a2 2 0 002-2V6a2 2 0 00-2-2h-2M7 4v6l2-2 2 2V4" />
</svg>
),
color: 'primary'
},
{
title: 'Total Value',
value: '$0',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
</svg>
),
color: 'accent'
},
];
const quickActions = [
{
title: 'Scan Cards',
description: 'Use OCR to quickly add cards',
href: '/scanner',
icon: (
<svg className="w-8 h-8" 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>
),
gradient: 'from-primary-500 to-accent-500',
},
{
title: 'Browse Cards',
description: 'Explore your collection',
href: '/cards',
icon: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
gradient: 'from-accent-500 to-primary-500',
},
{
title: 'Manage Collections',
description: 'Organize your cards',
href: '/collections',
icon: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
gradient: 'from-primary-600 to-accent-400',
},
];
return (
<div className="space-y-6">
{/* Welcome Section */}
<div className="bg-gradient-to-br from-primary-500 via-primary-600 to-accent-500 p-6 rounded-2xl text-white shadow-lg">
<h1 className="text-2xl font-bold mb-2">
Welcome back, {user?.firstName || user?.username}! 👋
</h1>
<p className="text-primary-100">
Ready to manage your card collection?
</p>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-2 gap-4">
{statsCards.map((stat, index) => (
<div
key={stat.title}
className="bg-white dark:bg-surface-800 p-4 rounded-xl shadow-sm border border-surface-200 dark:border-surface-700 transition-colors"
>
<div className={`inline-flex items-center justify-center w-10 h-10 rounded-lg mb-3 ${
stat.color === 'primary'
? 'bg-primary-100 text-primary-600 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-accent-100 text-accent-600 dark:bg-accent-900/30 dark:text-accent-400'
}`}>
{stat.icon}
</div>
<p className="text-2xl font-bold text-surface-900 dark:text-white">
{stat.value}
</p>
<p className="text-sm text-surface-600 dark:text-surface-400">
{stat.title}
</p>
</div>
))}
</div>
{/* Quick Actions */}
<div className="space-y-4">
<h2 className="text-lg font-semibold text-surface-900 dark:text-white">
Quick Actions
</h2>
{quickActions.map((action, index) => (
<Link
key={action.title}
to={action.href}
className="block bg-white dark:bg-surface-800 p-4 rounded-xl shadow-sm border border-surface-200 dark:border-surface-700 hover:shadow-md transition-all duration-200 active:scale-98"
>
<div className="flex items-center space-x-4">
<div className={`w-12 h-12 bg-gradient-to-r ${action.gradient} rounded-xl flex items-center justify-center text-white shadow-md`}>
{action.icon}
</div>
<div className="flex-1">
<h3 className="font-semibold text-surface-900 dark:text-white">
{action.title}
</h3>
<p className="text-sm text-surface-600 dark:text-surface-400">
{action.description}
</p>
</div>
<svg className="w-5 h-5 text-surface-400 dark:text-surface-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</Link>
))}
</div>
{/* Recent Activity */}
<div className="space-y-4">
<h2 className="text-lg font-semibold text-surface-900 dark:text-white">
Recent Activity
</h2>
<div className="bg-white dark:bg-surface-800 p-6 rounded-xl shadow-sm border border-surface-200 dark:border-surface-700 transition-colors">
<div className="text-center">
<div className="w-16 h-16 bg-surface-100 dark:bg-surface-700 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-surface-400 dark:text-surface-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<p className="text-surface-600 dark:text-surface-400 mb-4">
No recent activity yet
</p>
<p className="text-sm text-surface-500 dark:text-surface-500">
Start by scanning some cards or creating a collection!
</p>
</div>
</div>
</div>
</div>
);
};
export default Dashboard;

View file

@ -1,372 +0,0 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { tcgApi } from '../services/tcgApi';
import DeckManager from '../components/decks/DeckManager';
import type { Deck, DeckFilters } from '../types';
const Decks: React.FC = () => {
const queryClient = useQueryClient();
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingDeck, setEditingDeck] = useState<string | null>(null);
const [filters, setFilters] = useState<DeckFilters>({
search: '',
game: '',
format: '',
isFavorite: false,
});
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
// Queries
const { data: decks = [], isLoading } = useQuery({
queryKey: ['decks', filters],
queryFn: () => tcgApi.decks.getDecks(filters),
});
// Mutations
const deleteMutation = useMutation({
mutationFn: tcgApi.decks.deleteDeck,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['decks'] });
},
});
const toggleFavoriteMutation = useMutation({
mutationFn: ({ id, isFavorite }: { id: string; isFavorite: boolean }) =>
tcgApi.decks.updateDeck(id, { isFavorite }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['decks'] });
},
});
const handleDeleteDeck = (id: string, name: string) => {
if (window.confirm(`Are you sure you want to delete "${name}"? This action cannot be undone.`)) {
deleteMutation.mutate(id);
}
};
const handleToggleFavorite = (deck: Deck) => {
toggleFavoriteMutation.mutate({
id: deck.id,
isFavorite: !deck.isFavorite
});
};
const getGameBadgeColor = (game: string) => {
switch (game) {
case 'MTG': return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300';
case 'POKEMON': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
case 'LORCANA': return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300';
case 'YUGIOH': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
default: return 'bg-surface-100 text-surface-800 dark:bg-surface-700 dark:text-surface-300';
}
};
const getFormatBadgeColor = (format: string) => {
switch (format.toLowerCase()) {
case 'standard': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
case 'modern': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
case 'commander': return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300';
case 'pioneer': return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300';
case 'legacy': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
case 'vintage': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
default: return 'bg-surface-100 text-surface-800 dark:bg-surface-700 dark:text-surface-300';
}
};
const filteredDecks = decks.filter(deck => {
const matchesSearch = !filters.search ||
deck.name.toLowerCase().includes(filters.search.toLowerCase()) ||
deck.description?.toLowerCase().includes(filters.search.toLowerCase());
const matchesGame = !filters.game || deck.game === filters.game;
const matchesFormat = !filters.format || deck.format === filters.format;
const matchesFavorite = !filters.isFavorite || deck.isFavorite;
return matchesSearch && matchesGame && matchesFormat && matchesFavorite;
});
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-surface-900 dark:text-white">My Decks</h1>
<p className="text-surface-600 dark:text-surface-400 mt-1">
Build and manage your card decks
</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="bg-gradient-to-r from-primary-500 to-accent-500 hover:from-primary-600 hover:to-accent-600 text-white px-4 py-2 rounded-xl font-medium transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5"
>
<svg className="w-4 h-4 mr-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
New Deck
</button>
</div>
{/* Search and Filter Bar */}
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-4">
<div className="space-y-4">
{/* Search Bar */}
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-surface-400" 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>
<input
type="text"
value={filters.search}
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
placeholder="Search decks..."
className="w-full pl-10 pr-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors"
/>
</div>
{/* Filters and View Toggle */}
<div className="flex items-center justify-between">
<div className="flex space-x-2 overflow-x-auto pb-2">
<select
value={filters.game}
onChange={(e) => setFilters(prev => ({ ...prev, game: e.target.value }))}
className="px-3 py-2 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors whitespace-nowrap"
>
<option value="">All Games</option>
<option value="MTG">Magic: The Gathering</option>
<option value="POKEMON">Pokémon</option>
<option value="LORCANA">Disney Lorcana</option>
<option value="YUGIOH">Yu-Gi-Oh!</option>
</select>
<select
value={filters.format}
onChange={(e) => setFilters(prev => ({ ...prev, format: e.target.value }))}
className="px-3 py-2 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors whitespace-nowrap"
>
<option value="">All Formats</option>
<option value="standard">Standard</option>
<option value="modern">Modern</option>
<option value="commander">Commander</option>
<option value="pioneer">Pioneer</option>
<option value="legacy">Legacy</option>
<option value="vintage">Vintage</option>
</select>
<button
onClick={() => setFilters(prev => ({ ...prev, isFavorite: !prev.isFavorite }))}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
filters.isFavorite
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300'
: 'bg-surface-50 dark:bg-surface-700 text-surface-700 dark:text-surface-300 border border-surface-300 dark:border-surface-600'
}`}
>
Favorites
</button>
</div>
{/* View Mode Toggle */}
<div className="flex bg-surface-100 dark:bg-surface-700 rounded-lg p-1">
<button
onClick={() => setViewMode('grid')}
className={`p-2 rounded-md transition-colors ${
viewMode === 'grid'
? 'bg-white dark:bg-surface-600 text-surface-900 dark:text-white shadow-sm'
: 'text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white'
}`}
>
<svg className="w-4 h-4" 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-md transition-colors ${
viewMode === 'list'
? 'bg-white dark:bg-surface-600 text-surface-900 dark:text-white shadow-sm'
: 'text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white'
}`}
>
<svg className="w-4 h-4" 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>
</div>
{/* Decks List */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="flex flex-col items-center">
<div className="w-8 h-8 bg-gradient-to-r from-primary-500 to-accent-500 rounded-full flex items-center justify-center animate-bounce-subtle mb-3">
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<p className="text-surface-600 dark:text-surface-400 text-sm">Loading decks...</p>
</div>
</div>
) : filteredDecks.length === 0 ? (
<div className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-8">
<div className="text-center">
<div className="w-16 h-16 bg-surface-100 dark:bg-surface-700 rounded-2xl flex items-center justify-center mx-auto mb-6">
<svg className="w-8 h-8 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h3 className="text-xl font-semibold text-surface-900 dark:text-white mb-2">
{decks.length === 0 ? 'No decks yet' : 'No matching decks'}
</h3>
<p className="text-surface-600 dark:text-surface-400 mb-6">
{decks.length === 0
? 'Start building your first deck from your collection'
: 'Try adjusting your search or filters'
}
</p>
{decks.length === 0 && (
<button
onClick={() => setShowCreateModal(true)}
className="bg-gradient-to-r from-primary-500 to-accent-500 hover:from-primary-600 hover:to-accent-600 text-white px-6 py-3 rounded-xl font-medium transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5"
>
Create Deck
</button>
)}
</div>
</div>
) : (
<div className={viewMode === 'grid' ? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4' : 'space-y-4'}>
{filteredDecks.map((deck) => (
<div
key={deck.id}
className="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-4 transition-all duration-200 hover:shadow-md"
>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center space-x-3">
<div
className="w-12 h-12 rounded-xl flex items-center justify-center text-white text-xl font-semibold shadow-md"
style={{ backgroundColor: deck.color || '#8b5cf6' }}
>
🎴
</div>
<div className="flex-1">
<h3 className="font-semibold text-surface-900 dark:text-white">
{deck.name}
</h3>
{deck.description && (
<p className="text-sm text-surface-600 dark:text-surface-400 line-clamp-2">
{deck.description}
</p>
)}
</div>
</div>
{/* Favorite Button */}
<button
onClick={() => handleToggleFavorite(deck)}
className={`p-2 rounded-lg transition-colors ${
deck.isFavorite
? 'text-yellow-500 hover:text-yellow-600'
: 'text-surface-400 hover:text-yellow-500'
}`}
>
<svg className="w-5 h-5" fill={deck.isFavorite ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</button>
</div>
{/* Stats */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-4 text-sm text-surface-600 dark:text-surface-400">
<span>{deck.totalCards || 0} cards</span>
{deck.totalValue && (
<span className="text-green-600 dark:text-green-400 font-medium">
${deck.totalValue.toFixed(2)}
</span>
)}
</div>
<div className="flex items-center space-x-2">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getGameBadgeColor(deck.game)}`}>
{deck.game}
</span>
{deck.format && (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getFormatBadgeColor(deck.format)}`}>
{deck.format}
</span>
)}
</div>
</div>
{/* Deck Status */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
{deck.isLegal !== undefined && (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
deck.isLegal
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'
}`}>
{deck.isLegal ? '✅ Legal' : '❌ Illegal'}
</span>
)}
{deck.averageManaValue && (
<span className="px-2 py-1 bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300 rounded-full text-xs font-medium">
{deck.averageManaValue.toFixed(1)} CMC
</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center space-x-2">
<button
onClick={() => {/* Navigate to deck detail */}}
className="flex-1 bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300 py-2 px-3 rounded-lg text-sm font-medium hover:bg-primary-100 dark:hover:bg-primary-900/30 transition-colors"
>
View Deck
</button>
<button
onClick={() => setEditingDeck(deck.id)}
className="p-2 text-surface-400 hover:text-surface-600 dark:hover:text-surface-300 rounded-lg transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDeleteDeck(deck.id, deck.name)}
className="p-2 text-red-400 hover:text-red-600 rounded-lg transition-colors"
disabled={deleteMutation.isPending}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
))}
</div>
)}
{/* Deck Manager Modal */}
<DeckManager
isOpen={showCreateModal || !!editingDeck}
onClose={() => {
setShowCreateModal(false);
setEditingDeck(null);
}}
deckId={editingDeck || undefined}
/>
</div>
);
};
export default Decks;

View file

@ -1,192 +0,0 @@
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { useNavigate } from 'react-router-dom';
const Login: React.FC = () => {
const [isLogin, setIsLogin] = useState(true);
const [formData, setFormData] = useState({
username: '',
password: '',
email: '',
full_name: '',
});
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
if (isLogin) {
// Use real API for login
const { authService } = await import('../services/api');
const response = await authService.login(formData.username, formData.password);
// Get user info after successful login
const userInfo = await authService.getCurrentUser();
// Store token and user info
login(response.access_token, userInfo);
navigate('/');
} else {
// Registration flow
const { authService } = await import('../services/api');
await authService.register(formData);
// Auto-login after registration
const response = await authService.login(formData.username, formData.password);
const userInfo = await authService.getCurrentUser();
login(response.access_token, userInfo);
navigate('/');
}
} catch (err: any) {
console.error('Authentication error:', err);
if (err.response?.status === 401) {
setError('Invalid username or password. Try demo/demo123 for the sample account.');
} else if (err.response?.status === 400) {
setError(err.response.data?.detail || 'Registration failed. Please check your information.');
} else {
setError('Authentication failed. Please try again.');
}
} finally {
setIsLoading(false);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
return (
<div className="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div className="text-center">
<span className="text-6xl">🃏</span>
<h2 className="mt-6 text-3xl font-extrabold text-gray-900">
{isLogin ? 'Sign in to your account' : 'Create your account'}
</h2>
<p className="mt-2 text-sm text-gray-600">
{isLogin ? 'Welcome back to TCG Vault' : 'Join TCG Vault today'}
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="space-y-4">
{!isLogin && (
<>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
name="email"
type="email"
required={!isLogin}
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Email address"
value={formData.email}
onChange={handleInputChange}
/>
</div>
<div>
<label htmlFor="full_name" className="block text-sm font-medium text-gray-700">
Full Name
</label>
<input
id="full_name"
name="full_name"
type="text"
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Full name"
value={formData.full_name}
onChange={handleInputChange}
/>
</div>
</>
)}
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
Username
</label>
<input
id="username"
name="username"
type="text"
required
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Username"
value={formData.username}
onChange={handleInputChange}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Password"
value={formData.password}
onChange={handleInputChange}
/>
</div>
</div>
{error && (
<div className="text-red-600 text-sm text-center bg-red-50 p-3 rounded-md">
{error}
</div>
)}
<div>
<button
type="submit"
disabled={isLoading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Please wait...' : (isLogin ? 'Sign in' : 'Sign up')}
</button>
</div>
<div className="text-center">
<button
type="button"
onClick={() => setIsLogin(!isLogin)}
className="text-sm text-indigo-600 hover:text-indigo-500"
>
{isLogin ? "Don't have an account? Sign up" : 'Already have an account? Sign in'}
</button>
</div>
{isLogin && (
<div className="bg-blue-50 p-4 rounded-md">
<p className="text-sm text-blue-800">
<strong>Demo credentials:</strong><br />
Username: demo<br />
Password: demo123
</p>
</div>
)}
</form>
</div>
</div>
);
};
export default Login;

View file

@ -1,8 +0,0 @@
import React from 'react';
import ScannerWizard from '../components/scanner/ScannerWizard';
const Scanner: React.FC = () => {
return <ScannerWizard />;
};
export default Scanner;

View file

@ -1,496 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext';
interface UserPreferences {
defaultView: string;
itemsPerPage: number;
enableAnimations: boolean;
enableOcr: boolean;
theme: string;
privacySettings: {
collections_public: boolean;
decks_public: boolean;
};
ocrSettings: {
preferred_service: 'openai' | 'ollama';
openai_api_key: string;
ollama_url: string;
auto_add_to_collection: boolean;
confidence_threshold: number;
};
}
const Settings: React.FC = () => {
const { user } = useAuth();
const [preferences, setPreferences] = useState<UserPreferences | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [activeTab, setActiveTab] = useState('general');
const [isTestingOpenAI, setIsTestingOpenAI] = useState(false);
const [isTestingOllama, setIsTestingOllama] = useState(false);
const [testResults, setTestResults] = useState<{ openai?: string; ollama?: string }>({});
useEffect(() => {
loadPreferences();
}, [user]); // eslint-disable-line react-hooks/exhaustive-deps
const loadPreferences = async () => {
if (!user) return;
try {
const token = localStorage.getItem('tcg_vault_token');
const response = await fetch('/api/user/preferences', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
setPreferences(data.preferences);
} else {
setMessage({ type: 'error', text: 'Failed to load preferences' });
}
} catch (error) {
console.error('Error loading preferences:', error);
setMessage({ type: 'error', text: 'Error loading preferences' });
} finally {
setIsLoading(false);
}
};
const savePreferences = async () => {
if (!preferences) return;
setIsSaving(true);
try {
const token = localStorage.getItem('tcg_vault_token');
const response = await fetch('/api/user/preferences', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(preferences)
});
const data = await response.json();
if (response.ok) {
setMessage({ type: 'success', text: 'Settings saved successfully!' });
setPreferences(data.preferences);
} else {
setMessage({ type: 'error', text: data.error || 'Failed to save settings' });
}
} catch (error) {
console.error('Error saving preferences:', error);
setMessage({ type: 'error', text: 'Error saving settings' });
} finally {
setIsSaving(false);
}
};
const testOpenAI = async () => {
if (!preferences?.ocrSettings.openai_api_key) {
setTestResults(prev => ({ ...prev, openai: '❌ API key required' }));
return;
}
setIsTestingOpenAI(true);
try {
const response = await fetch('https://api.openai.com/v1/models', {
headers: {
'Authorization': `Bearer ${preferences.ocrSettings.openai_api_key}`,
},
});
if (response.ok) {
setTestResults(prev => ({ ...prev, openai: '✅ API key valid' }));
} else {
setTestResults(prev => ({ ...prev, openai: `❌ API error: ${response.status}` }));
}
} catch (error: any) {
setTestResults(prev => ({ ...prev, openai: `❌ Connection failed: ${error.message}` }));
} finally {
setIsTestingOpenAI(false);
}
};
const testOllama = async () => {
if (!preferences?.ocrSettings.ollama_url) return;
setIsTestingOllama(true);
try {
const response = await fetch(`${preferences.ocrSettings.ollama_url}/api/tags`);
if (response.ok) {
const data = await response.json();
const hasVisionModel = data.models?.some((model: any) =>
model.name.includes('llava') || model.name.includes('vision')
);
if (hasVisionModel) {
setTestResults(prev => ({ ...prev, ollama: '✅ Ollama with vision models available' }));
} else {
setTestResults(prev => ({ ...prev, ollama: '⚠️ Ollama running but no vision models found' }));
}
} else {
setTestResults(prev => ({ ...prev, ollama: `❌ Ollama error: ${response.status}` }));
}
} catch (error: any) {
setTestResults(prev => ({ ...prev, ollama: `❌ Cannot reach Ollama: ${error.message}` }));
} finally {
setIsTestingOllama(false);
}
};
const updatePreference = (key: keyof UserPreferences, value: any) => {
if (!preferences) return;
setPreferences({ ...preferences, [key]: value });
};
const updateOcrSetting = (key: keyof UserPreferences['ocrSettings'], value: any) => {
if (!preferences) return;
setPreferences({
...preferences,
ocrSettings: { ...preferences.ocrSettings, [key]: value }
});
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
);
}
if (!preferences) {
return (
<div className="text-center py-8">
<p className="text-red-600">Failed to load preferences</p>
<button onClick={loadPreferences} className="mt-4 text-indigo-600 hover:underline">
Try Again
</button>
</div>
);
}
return (
<div className="max-w-4xl mx-auto p-6">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Settings</h1>
<p className="text-gray-600">Manage your account preferences and OCR configuration</p>
</div>
{/* Message Display */}
{message && (
<div className={`mb-6 p-4 rounded-lg ${
message.type === 'success' ? 'bg-green-50 text-green-800 border border-green-200' :
'bg-red-50 text-red-800 border border-red-200'
}`}>
{message.text}
</div>
)}
{/* Tab Navigation */}
<div className="border-b border-gray-200 mb-6">
<nav className="-mb-px flex space-x-8">
{[
{ id: 'general', label: 'General', icon: '⚙️' },
{ id: 'ocr', label: 'AI OCR', icon: '🤖' },
{ id: 'privacy', label: 'Privacy', icon: '🔒' }
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === tab.id
? 'border-indigo-500 text-indigo-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<span className="mr-2">{tab.icon}</span>
{tab.label}
</button>
))}
</nav>
</div>
{/* Tab Content */}
<div className="space-y-6">
{activeTab === 'general' && (
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h3 className="text-lg font-medium mb-4">General Preferences</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Default View
</label>
<select
value={preferences.defaultView}
onChange={(e) => updatePreference('defaultView', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="card">Card View</option>
<option value="table">Table View</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Items per Page
</label>
<select
value={preferences.itemsPerPage}
onChange={(e) => updatePreference('itemsPerPage', parseInt(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500"
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Theme
</label>
<select
value={preferences.theme}
onChange={(e) => updatePreference('theme', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
</div>
<div className="space-y-3">
<label className="flex items-center">
<input
type="checkbox"
checked={preferences.enableAnimations}
onChange={(e) => updatePreference('enableAnimations', e.target.checked)}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="ml-2 text-sm text-gray-700">Enable animations</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={preferences.enableOcr}
onChange={(e) => updatePreference('enableOcr', e.target.checked)}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="ml-2 text-sm text-gray-700">Enable OCR scanning</span>
</label>
</div>
</div>
</div>
)}
{activeTab === 'ocr' && (
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h3 className="text-lg font-medium mb-4">AI OCR Configuration</h3>
{/* Service Selection */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-3">
Preferred OCR Service
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
value="openai"
checked={preferences.ocrSettings.preferred_service === 'openai'}
onChange={(e) => updateOcrSetting('preferred_service', e.target.value as 'openai')}
className="text-indigo-600 focus:ring-indigo-500"
/>
<span className="ml-2 text-sm text-gray-700">OpenAI Vision API (Recommended)</span>
</label>
<label className="flex items-center">
<input
type="radio"
value="ollama"
checked={preferences.ocrSettings.preferred_service === 'ollama'}
onChange={(e) => updateOcrSetting('preferred_service', e.target.value as 'ollama')}
className="text-indigo-600 focus:ring-indigo-500"
/>
<span className="ml-2 text-sm text-gray-700">Ollama (Local/Private)</span>
</label>
</div>
</div>
{/* OpenAI Settings */}
<div className="border rounded-lg p-4 mb-6">
<h4 className="font-medium text-gray-900 mb-3">OpenAI Configuration</h4>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
API Key
</label>
<input
type="password"
value={preferences.ocrSettings.openai_api_key}
onChange={(e) => updateOcrSetting('openai_api_key', e.target.value)}
placeholder="sk-..."
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500"
/>
<p className="text-xs text-gray-500 mt-1">
Get your API key from{' '}
<a
href="https://platform.openai.com/api-keys"
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 hover:underline"
>
OpenAI Platform
</a>
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={testOpenAI}
disabled={isTestingOpenAI || !preferences.ocrSettings.openai_api_key}
className="bg-blue-600 text-white px-3 py-1 rounded text-sm disabled:bg-gray-400 hover:bg-blue-700"
>
{isTestingOpenAI ? 'Testing...' : 'Test API Key'}
</button>
{testResults.openai && (
<span className="text-sm">{testResults.openai}</span>
)}
</div>
</div>
</div>
{/* Ollama Settings */}
<div className="border rounded-lg p-4 mb-6">
<h4 className="font-medium text-gray-900 mb-3">Ollama Configuration</h4>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Ollama URL
</label>
<input
type="text"
value={preferences.ocrSettings.ollama_url}
onChange={(e) => updateOcrSetting('ollama_url', e.target.value)}
placeholder="http://localhost:11434"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500"
/>
<p className="text-xs text-gray-500 mt-1">
Requires LLaVA or similar vision model:{' '}
<code className="bg-gray-100 px-1 rounded">ollama pull llava</code>
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={testOllama}
disabled={isTestingOllama}
className="bg-green-600 text-white px-3 py-1 rounded text-sm disabled:bg-gray-400 hover:bg-green-700"
>
{isTestingOllama ? 'Testing...' : 'Test Ollama'}
</button>
{testResults.ollama && (
<span className="text-sm">{testResults.ollama}</span>
)}
</div>
</div>
</div>
{/* OCR Options */}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Confidence Threshold ({preferences.ocrSettings.confidence_threshold}%)
</label>
<input
type="range"
min="0"
max="100"
value={preferences.ocrSettings.confidence_threshold}
onChange={(e) => updateOcrSetting('confidence_threshold', parseInt(e.target.value))}
className="w-full"
/>
<p className="text-xs text-gray-500 mt-1">
Minimum confidence required to accept OCR results
</p>
</div>
<label className="flex items-center">
<input
type="checkbox"
checked={preferences.ocrSettings.auto_add_to_collection}
onChange={(e) => updateOcrSetting('auto_add_to_collection', e.target.checked)}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="ml-2 text-sm text-gray-700">
Automatically add scanned cards to selected collection
</span>
</label>
</div>
</div>
)}
{activeTab === 'privacy' && (
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h3 className="text-lg font-medium mb-4">Privacy Settings</h3>
<div className="space-y-4">
<label className="flex items-center">
<input
type="checkbox"
checked={preferences.privacySettings.collections_public}
onChange={(e) => updatePreference('privacySettings', {
...preferences.privacySettings,
collections_public: e.target.checked
})}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="ml-2 text-sm text-gray-700">
Make collections public by default
</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={preferences.privacySettings.decks_public}
onChange={(e) => updatePreference('privacySettings', {
...preferences.privacySettings,
decks_public: e.target.checked
})}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="ml-2 text-sm text-gray-700">
Make decks public by default
</span>
</label>
</div>
</div>
)}
</div>
{/* Save Button */}
<div className="mt-8 flex justify-end">
<button
onClick={savePreferences}
disabled={isSaving}
className="bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 disabled:bg-gray-400 flex items-center gap-2"
>
{isSaving && <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>}
{isSaving ? 'Saving...' : 'Save Settings'}
</button>
</div>
</div>
);
};
export default Settings;

View file

@ -1 +0,0 @@
/// <reference types="react-scripts" />

View file

@ -1,15 +0,0 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View file

@ -1,300 +0,0 @@
interface CardOCRResult {
cardName: string;
setName?: string;
setCode?: string;
cardType?: string;
rarity?: string;
hp?: string;
attacks?: string[];
abilities?: string[];
rawText: string;
confidence: number;
game: 'MTG' | 'POKEMON' | 'LORCANA' | 'UNKNOWN';
}
class AICardOCR {
private apiKey: string | null = null;
constructor() {
// In production, this would come from environment variables
// For now, we'll make it configurable
this.apiKey = process.env.REACT_APP_OPENAI_API_KEY || null;
}
// Set API key dynamically (for testing)
setApiKey(key: string) {
this.apiKey = key;
}
async analyzeCard(imageDataUrl: string): Promise<CardOCRResult> {
if (!this.apiKey) {
throw new Error('OpenAI API key not configured');
}
try {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4o-mini', // Cheaper vision model
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: 'Analyze this trading card image and extract information. Respond with ONLY valid JSON, no markdown formatting or code blocks:\n\n{"cardName": "exact card name", "setName": "set name if visible", "setCode": "3-4 letter set code if visible", "cardType": "creature, spell, pokemon, etc", "rarity": "common, uncommon, rare, etc", "hp": "HP value for Pokemon", "attacks": ["attack names"], "abilities": ["ability names"], "rawText": "all visible text on the card", "game": "MTG or POKEMON or LORCANA", "confidence": 95}\n\nIMPORTANT: Return ONLY the JSON object, no markdown code blocks. Focus on accuracy, leave fields empty if unclear. For the cardName, use the exact name as printed on the card.'
},
{
type: 'image_url',
image_url: {
url: imageDataUrl,
detail: 'high'
}
}
]
}
],
max_tokens: 1000,
temperature: 0.1 // Low temperature for consistency
})
});
if (!response.ok) {
throw new Error(`OpenAI API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
const content = data.choices[0]?.message?.content;
if (!content) {
throw new Error('No content received from OpenAI');
}
// Clean and parse JSON response
try {
// Remove markdown code blocks if present
let cleanContent = content.trim();
if (cleanContent.startsWith('```json')) {
cleanContent = cleanContent.replace(/^```json\s*/, '').replace(/\s*```$/, '');
} else if (cleanContent.startsWith('```')) {
cleanContent = cleanContent.replace(/^```\s*/, '').replace(/\s*```$/, '');
}
const result = JSON.parse(cleanContent);
return {
cardName: result.cardName || '',
setName: result.setName || undefined,
setCode: result.setCode || undefined,
cardType: result.cardType || undefined,
rarity: result.rarity || undefined,
hp: result.hp || undefined,
attacks: result.attacks || [],
abilities: result.abilities || [],
rawText: result.rawText || content,
confidence: result.confidence || 85,
game: result.game || 'UNKNOWN'
};
} catch (parseError) {
console.log('JSON parsing failed, trying text extraction:', parseError);
console.log('Raw content:', content);
// If JSON parsing fails, try to extract data from the raw text
const extractedData = this.extractDataFromRawResponse(content);
return {
cardName: extractedData.cardName || 'Unknown Card',
setName: extractedData.setName,
setCode: extractedData.setCode,
cardType: extractedData.cardType,
rarity: extractedData.rarity,
hp: extractedData.hp,
attacks: extractedData.attacks || [],
abilities: extractedData.abilities || [],
rawText: content,
confidence: 70,
game: extractedData.game || 'UNKNOWN'
};
}
} catch (error: any) {
console.error('AI OCR error:', error);
throw new Error(`AI OCR failed: ${error.message}`);
}
}
// Fallback: Extract card name from raw text response
private extractCardNameFromText(text: string): string {
const lines = text.split('\n').map(line => line.trim()).filter(line => line.length > 0);
// Look for card name patterns
for (const line of lines) {
// Skip common non-name patterns
if (line.match(/^(card name|name:|the card|this is)/i)) continue;
if (line.length > 3 && line.length < 50) {
return line;
}
}
return lines[0] || 'Unknown Card';
}
// Extract data from raw AI response when JSON parsing fails
private extractDataFromRawResponse(text: string): Partial<CardOCRResult> {
const lines = text.split('\n').map(line => line.trim()).filter(line => line.length > 0);
let cardName = '';
let setName = '';
let setCode = '';
let cardType = '';
let rarity = '';
let hp = '';
let attacks: string[] = [];
let abilities: string[] = [];
for (const line of lines) {
// Skip markdown and formatting
if (line.startsWith('```') || line.startsWith('#')) continue;
// Look for key-value patterns
if (line.match(/card\s*name[:]?\s*(.+)/i)) {
cardName = line.replace(/card\s*name[:]?\s*/i, '').replace(/['"]/g, '');
} else if (line.match(/name[:]?\s*(.+)/i) && !cardName) {
cardName = line.replace(/name[:]?\s*/i, '').replace(/['"]/g, '');
} else if (line.match(/set[:]?\s*(.+)/i)) {
setName = line.replace(/set[:]?\s*/i, '').replace(/['"]/g, '');
} else if (line.match(/hp[:]?\s*(\d+)/i)) {
hp = line.match(/hp[:]?\s*(\d+)/i)?.[1] || '';
} else if (line.match(/type[:]?\s*(.+)/i)) {
cardType = line.replace(/type[:]?\s*/i, '').replace(/['"]/g, '');
} else if (line.match(/rarity[:]?\s*(.+)/i)) {
rarity = line.replace(/rarity[:]?\s*/i, '').replace(/['"]/g, '');
}
}
// If no structured data found, use the first substantial line as card name
if (!cardName) {
cardName = this.extractCardNameFromText(text);
}
return {
cardName: cardName || 'Unknown Card',
setName: setName || undefined,
setCode: setCode || undefined,
cardType: cardType || undefined,
rarity: rarity || undefined,
hp: hp || undefined,
attacks: attacks.length > 0 ? attacks : undefined,
abilities: abilities.length > 0 ? abilities : undefined,
game: this.detectGameFromText(text)
};
}
// Detect game type from text
private detectGameFromText(text: string): 'MTG' | 'POKEMON' | 'LORCANA' | 'UNKNOWN' {
const lowerText = text.toLowerCase();
if (lowerText.includes('pokemon') || lowerText.includes('hp') || lowerText.includes('evolves')) {
return 'POKEMON';
}
if (lowerText.includes('mana') || lowerText.includes('creature') || lowerText.includes('instant')) {
return 'MTG';
}
if (lowerText.includes('lorcana') || lowerText.includes('ink')) {
return 'LORCANA';
}
return 'UNKNOWN';
}
}
// Alternative: Ollama Vision Service
class OllamaVisionOCR {
private baseUrl: string;
constructor(baseUrl: string = 'http://localhost:11434') {
this.baseUrl = baseUrl;
}
async analyzeCard(imageDataUrl: string): Promise<CardOCRResult> {
try {
// Convert data URL to base64
const base64Image = imageDataUrl.split(',')[1];
const response = await fetch(`${this.baseUrl}/api/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'llava:latest', // or 'llava:7b', 'llava:13b'
prompt: `Analyze this trading card image and extract:
1. Card name (exact spelling)
2. Set name and code
3. Game type (Magic: The Gathering, Pokemon, or Lorcana)
4. All visible text
5. Card type and rarity if visible
Provide accurate information only. Format as JSON with fields: cardName, setName, setCode, game, cardType, rarity, rawText`,
images: [base64Image],
stream: false
})
});
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status}`);
}
const data = await response.json();
const responseText = data.response;
// Try to parse as JSON, fallback to text processing
try {
const result = JSON.parse(responseText);
return {
cardName: result.cardName || '',
setName: result.setName,
setCode: result.setCode,
cardType: result.cardType,
rarity: result.rarity,
rawText: result.rawText || responseText,
confidence: 85,
game: result.game?.toUpperCase() || 'UNKNOWN'
};
} catch {
return {
cardName: this.extractCardNameFromText(responseText),
rawText: responseText,
confidence: 75,
game: this.detectGameFromText(responseText)
};
}
} catch (error: any) {
console.error('Ollama OCR error:', error);
throw new Error(`Ollama OCR failed: ${error.message}`);
}
}
private extractCardNameFromText(text: string): string {
const lines = text.split('\n').map(line => line.trim()).filter(line => line.length > 0);
return lines.find(line => line.length > 3 && line.length < 50) || 'Unknown Card';
}
private detectGameFromText(text: string): 'MTG' | 'POKEMON' | 'LORCANA' | 'UNKNOWN' {
const lowerText = text.toLowerCase();
if (lowerText.includes('pokemon') || lowerText.includes('hp')) return 'POKEMON';
if (lowerText.includes('mana') || lowerText.includes('creature')) return 'MTG';
if (lowerText.includes('lorcana')) return 'LORCANA';
return 'UNKNOWN';
}
}
// Export instances
export const aiCardOCR = new AICardOCR();
export const ollamaCardOCR = new OllamaVisionOCR();
// Export types
export type { CardOCRResult };

View file

@ -1,110 +0,0 @@
import axios from 'axios';
import { API_BASE_URL } from '../config/api';
// Create axios instance with base configuration
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Add request interceptor to include auth token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('tcg_vault_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// API service functions
export const cardService = {
getAllCards: async (params?: { game?: string; search?: string; skip?: number; limit?: number }) => {
const response = await api.get('/cards/', { params });
return response.data;
},
getCard: async (cardId: number) => {
const response = await api.get(`/cards/${cardId}`);
return response.data;
},
searchCardsByName: async (cardName: string, game?: string) => {
const response = await api.get(`/cards/search/name/${cardName}`, {
params: game ? { game } : {}
});
return response.data;
},
getSets: async (game: string) => {
const response = await api.get(`/cards/sets/${game}`);
return response.data;
},
};
export const collectionService = {
getMyCollections: async () => {
const response = await api.get('/collections/');
return response.data;
},
getCollection: async (collectionId: number) => {
const response = await api.get(`/collections/${collectionId}`);
return response.data;
},
getCollectionCards: async (collectionId: number) => {
const response = await api.get(`/collections/${collectionId}/cards`);
return response.data;
},
};
export const deckService = {
getMyDecks: async () => {
const response = await api.get('/decks/');
return response.data;
},
getDeck: async (deckId: number) => {
const response = await api.get(`/decks/${deckId}`);
return response.data;
},
getDeckCards: async (deckId: number) => {
const response = await api.get(`/decks/${deckId}/cards`);
return response.data;
},
};
export const authService = {
login: async (username: string, password: string) => {
const formData = new FormData();
formData.append('username', username);
formData.append('password', password);
const response = await api.post('/auth/token', formData, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
return response.data;
},
register: async (userData: any) => {
const response = await api.post('/auth/register', userData);
return response.data;
},
getCurrentUser: async () => {
const response = await api.get('/auth/me');
return response.data;
},
};
export default api;

View file

@ -1,305 +0,0 @@
interface CardData {
cardName?: string;
setName?: string;
rarity?: string;
game?: string;
cardType?: string;
manaCost?: string;
power?: string;
toughness?: string;
element?: string;
series?: string;
}
interface AutoTagResult {
tags: string[];
confidence: number;
reasoning: string;
}
export class AutoTagger {
/**
* Generate auto-tags based on card data
*/
static generateTags(cardData: CardData): AutoTagResult {
const tags: string[] = [];
let confidence = 0.8;
const reasons: string[] = [];
// Game-specific tags
if (cardData.game) {
const gameTag = this.normalizeGame(cardData.game);
tags.push(gameTag);
reasons.push(`Game: ${gameTag}`);
}
// Rarity-based tags
if (cardData.rarity) {
const rarityTag = this.normalizeRarity(cardData.rarity);
tags.push(rarityTag);
reasons.push(`Rarity: ${rarityTag}`);
// Special rarity indicators
if (this.isHighValueRarity(cardData.rarity)) {
tags.push('High Value');
reasons.push('High-value rarity detected');
}
}
// Set-based tags
if (cardData.setName) {
// Add set abbreviation if recognizable
const setTag = this.generateSetTag(cardData.setName);
if (setTag) {
tags.push(setTag);
reasons.push(`Set: ${setTag}`);
}
// Detect special sets
const specialSetTags = this.detectSpecialSets(cardData.setName);
tags.push(...specialSetTags);
if (specialSetTags.length > 0) {
reasons.push(`Special sets: ${specialSetTags.join(', ')}`);
}
}
// Card type tags (MTG specific)
if (cardData.cardType) {
const typeTag = this.normalizeCardType(cardData.cardType);
if (typeTag) {
tags.push(typeTag);
reasons.push(`Type: ${typeTag}`);
}
// Detect creature specifics
if (cardData.power && cardData.toughness) {
tags.push('Creature');
const powerLevel = this.categorizeCreaturePower(cardData.power, cardData.toughness);
if (powerLevel) {
tags.push(powerLevel);
reasons.push(`Power level: ${powerLevel}`);
}
}
}
// Mana cost analysis (MTG)
if (cardData.manaCost) {
const manaTags = this.analyzeManaColor(cardData.manaCost);
tags.push(...manaTags);
if (manaTags.length > 0) {
reasons.push(`Mana colors: ${manaTags.join(', ')}`);
}
}
// Element analysis (Pokemon)
if (cardData.element) {
const elementTag = this.normalizeElement(cardData.element);
if (elementTag) {
tags.push(elementTag);
reasons.push(`Element: ${elementTag}`);
}
}
// Card name analysis
if (cardData.cardName) {
const nameTags = this.analyzeCardName(cardData.cardName);
tags.push(...nameTags);
if (nameTags.length > 0) {
reasons.push(`Name analysis: ${nameTags.join(', ')}`);
}
}
// Remove duplicates and clean up
const uniqueTags = Array.from(new Set(tags)).filter(tag => tag.length > 0);
return {
tags: uniqueTags,
confidence,
reasoning: reasons.join('; ')
};
}
private static normalizeGame(game: string): string {
const gameMap: Record<string, string> = {
'magic the gathering': 'MTG',
'magic': 'MTG',
'mtg': 'MTG',
'pokemon': 'Pokemon',
'pokémon': 'Pokemon',
'lorcana': 'Lorcana',
'disney lorcana': 'Lorcana'
};
return gameMap[game.toLowerCase()] || game;
}
private static normalizeRarity(rarity: string): string {
const rarityMap: Record<string, string> = {
'c': 'Common',
'common': 'Common',
'u': 'Uncommon',
'uncommon': 'Uncommon',
'r': 'Rare',
'rare': 'Rare',
'm': 'Mythic',
'mythic': 'Mythic',
'mythic rare': 'Mythic',
'sr': 'Super Rare',
'super rare': 'Super Rare',
'ultra rare': 'Ultra Rare',
'secret rare': 'Secret Rare',
'legendary': 'Legendary',
'enchanted': 'Enchanted'
};
return rarityMap[rarity.toLowerCase()] || rarity;
}
private static isHighValueRarity(rarity: string): boolean {
const highValueRarities = ['mythic', 'super rare', 'ultra rare', 'secret rare', 'legendary', 'enchanted'];
return highValueRarities.some(hvr => rarity.toLowerCase().includes(hvr));
}
private static generateSetTag(setName: string): string | null {
// Common set abbreviations
const setAbbreviations: Record<string, string> = {
'dominaria united': 'DMU',
'brothers war': 'BRO',
'phyrexia all will be one': 'ONE',
'march of the machine': 'MOM',
'battle for zendikar': 'BFZ',
'sword shield': 'SWSH',
'sun moon': 'SM',
'the first chapter': 'TFC'
};
const lowerSet = setName.toLowerCase();
return setAbbreviations[lowerSet] || null;
}
private static detectSpecialSets(setName: string): string[] {
const tags: string[] = [];
const lowerSet = setName.toLowerCase();
if (lowerSet.includes('promo')) tags.push('Promo');
if (lowerSet.includes('prerelease')) tags.push('Prerelease');
if (lowerSet.includes('foil')) tags.push('Foil');
if (lowerSet.includes('alternate art')) tags.push('Alt Art');
if (lowerSet.includes('full art')) tags.push('Full Art');
if (lowerSet.includes('borderless')) tags.push('Borderless');
if (lowerSet.includes('showcase')) tags.push('Showcase');
if (lowerSet.includes('collector')) tags.push('Collector');
return tags;
}
private static normalizeCardType(cardType: string): string | null {
const typeMap: Record<string, string> = {
'creature': 'Creature',
'instant': 'Instant',
'sorcery': 'Sorcery',
'artifact': 'Artifact',
'enchantment': 'Enchantment',
'planeswalker': 'Planeswalker',
'land': 'Land',
'basic land': 'Basic Land',
'legendary': 'Legendary'
};
const lowerType = cardType.toLowerCase();
for (const [key, value] of Object.entries(typeMap)) {
if (lowerType.includes(key)) {
return value;
}
}
return null;
}
private static categorizeCreaturePower(power: string, toughness: string): string | null {
const powerNum = parseInt(power);
const toughnessNum = parseInt(toughness);
if (isNaN(powerNum) || isNaN(toughnessNum)) return null;
const totalStats = powerNum + toughnessNum;
if (totalStats >= 10) return 'High Power';
if (totalStats >= 6) return 'Mid Power';
if (totalStats >= 3) return 'Low Power';
return 'Utility';
}
private static analyzeManaColor(manaCost: string): string[] {
const colors: string[] = [];
if (manaCost.includes('W') || manaCost.includes('white')) colors.push('White');
if (manaCost.includes('U') || manaCost.includes('blue')) colors.push('Blue');
if (manaCost.includes('B') || manaCost.includes('black')) colors.push('Black');
if (manaCost.includes('R') || manaCost.includes('red')) colors.push('Red');
if (manaCost.includes('G') || manaCost.includes('green')) colors.push('Green');
// Multi-color detection
if (colors.length > 1) {
colors.push('Multicolor');
} else if (colors.length === 0 && manaCost.match(/\d+/)) {
colors.push('Colorless');
}
return colors;
}
private static normalizeElement(element: string): string | null {
const elementMap: Record<string, string> = {
'fire': 'Fire',
'water': 'Water',
'grass': 'Grass',
'electric': 'Electric',
'psychic': 'Psychic',
'fighting': 'Fighting',
'darkness': 'Dark',
'metal': 'Steel',
'fairy': 'Fairy',
'dragon': 'Dragon',
'colorless': 'Colorless'
};
return elementMap[element.toLowerCase()] || element;
}
private static analyzeCardName(cardName: string): string[] {
const tags: string[] = [];
const lowerName = cardName.toLowerCase();
// Legendary indicators
if (lowerName.includes('legendary') || this.isLegendaryName(lowerName)) {
tags.push('Legendary');
}
// Foil indicators in name
if (lowerName.includes('foil') || lowerName.includes('holo')) {
tags.push('Foil');
}
// Special card indicators
if (lowerName.includes('ex') || lowerName.includes('gx') || lowerName.includes('vmax')) {
tags.push('Special');
}
return tags;
}
private static isLegendaryName(name: string): boolean {
// Common legendary name patterns
const legendaryPatterns = [
/\b(jace|chandra|garruk|liliana|ajani|elspeth|vraska|nissa|gideon|teferi)\b/,
/\b(pikachu|charizard|mewtwo|mew|rayquaza|arceus)\b/,
/\b(mickey|minnie|donald|goofy|elsa|anna|simba)\b/
];
return legendaryPatterns.some(pattern => pattern.test(name));
}
}
export default AutoTagger;

View file

@ -1,872 +0,0 @@
import type { Card } from '../types';
// API Configuration
const SCRYFALL_BASE_URL = 'https://api.scryfall.com';
const POKEMON_API_BASE_URL = 'https://api.pokemontcg.io/v2';
const YUGIOH_API_BASE_URL = 'https://db.ygoprodeck.com/api/v7';
const LORCAST_API_BASE_URL = 'https://api.lorcast.com/v0';
const LORCANA_API_BASE_URL = 'https://api.lorcana-api.com';
const PROXY_API_BASE_URL = 'https://tcg-vault.vercel.app/api/proxy/lorcana';
// Rate limiting utilities
class RateLimiter {
private requests: number[] = [];
private maxRequests: number;
private timeWindow: number;
constructor(maxRequests: number, timeWindow: number) {
this.maxRequests = maxRequests;
this.timeWindow = timeWindow;
}
async waitForSlot(): Promise<void> {
const now = Date.now();
this.requests = this.requests.filter(time => now - time < this.timeWindow);
if (this.requests.length >= this.maxRequests) {
const oldestRequest = this.requests[0];
const waitTime = this.timeWindow - (now - oldestRequest);
await new Promise(resolve => setTimeout(resolve, waitTime));
}
this.requests.push(now);
}
}
// Rate limiters for each API
const scryfallLimiter = new RateLimiter(10, 1000); // 10 requests per second
const pokemonLimiter = new RateLimiter(5, 1000); // 5 requests per second
const lorcastLimiter = new RateLimiter(10, 1000); // 10 requests per second (50-100ms delay)
// ============================================================================
// MAGIC: THE GATHERING (Scryfall API)
// ============================================================================
export const mtgService = {
async searchCards(query: string, page = 1): Promise<Card[]> {
await scryfallLimiter.waitForSlot();
try {
const response = await fetch(
`${SCRYFALL_BASE_URL}/cards/search?q=${encodeURIComponent(query)}&page=${page}`
);
if (!response.ok) {
throw new Error(`Scryfall API error: ${response.status}`);
}
const data = await response.json();
return data.data.map((card: any) => ({
id: card.id,
name: card.name,
set_name: card.set_name,
set_code: card.set,
card_number: card.collector_number,
rarity: card.rarity,
game: 'MTG',
mana_cost: card.mana_cost,
cmc: card.cmc,
card_type: card.type_line,
colors: card.colors,
oracle_text: card.oracle_text,
power: card.power,
toughness: card.toughness,
image_url: card.image_uris?.normal,
stock_image_url: card.image_uris?.normal,
current_price: card.prices?.usd ? parseFloat(card.prices.usd) : undefined,
market_price: card.prices?.usd_foil ? parseFloat(card.prices.usd_foil) : undefined,
verified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
} catch (error) {
console.error('Error fetching MTG cards:', error);
return [];
}
},
async getCardByName(name: string): Promise<Card | null> {
await scryfallLimiter.waitForSlot();
try {
const response = await fetch(
`${SCRYFALL_BASE_URL}/cards/named?fuzzy=${encodeURIComponent(name)}`
);
if (!response.ok) {
return null;
}
const card = await response.json();
return {
id: card.id,
name: card.name,
set_name: card.set_name,
set_code: card.set,
card_number: card.collector_number,
rarity: card.rarity,
game: 'MTG',
mana_cost: card.mana_cost,
cmc: card.cmc,
card_type: card.type_line,
colors: card.colors,
oracle_text: card.oracle_text,
power: card.power,
toughness: card.toughness,
image_url: card.image_uris?.normal,
stock_image_url: card.image_uris?.normal,
current_price: card.prices?.usd ? parseFloat(card.prices.usd) : undefined,
market_price: card.prices?.usd_foil ? parseFloat(card.prices.usd_foil) : undefined,
verified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
} catch (error) {
console.error('Error fetching MTG card:', error);
return null;
}
},
async getRandomCards(count = 20): Promise<Card[]> {
await scryfallLimiter.waitForSlot();
try {
const response = await fetch(`${SCRYFALL_BASE_URL}/cards/random?q=game:paper&page_size=${count}`);
if (!response.ok) {
throw new Error(`Scryfall API error: ${response.status}`);
}
const data = await response.json();
const cards = Array.isArray(data) ? data : [data];
return cards.map((card: any) => ({
id: card.id,
name: card.name,
set_name: card.set_name,
set_code: card.set,
card_number: card.collector_number,
rarity: card.rarity,
game: 'MTG',
mana_cost: card.mana_cost,
cmc: card.cmc,
card_type: card.type_line,
colors: card.colors,
oracle_text: card.oracle_text,
power: card.power,
toughness: card.toughness,
image_url: card.image_uris?.normal,
stock_image_url: card.image_uris?.normal,
current_price: card.prices?.usd ? parseFloat(card.prices.usd) : undefined,
market_price: card.prices?.usd_foil ? parseFloat(card.prices.usd_foil) : undefined,
verified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
} catch (error) {
console.error('Error fetching random MTG cards:', error);
return [];
}
}
};
// ============================================================================
// POKÉMON TCG API
// ============================================================================
export const pokemonService = {
async searchCards(query: string, page = 1): Promise<Card[]> {
await pokemonLimiter.waitForSlot();
try {
const response = await fetch(
`${POKEMON_API_BASE_URL}/cards?q=name:${encodeURIComponent(query)}&page=${page}&pageSize=20`
);
if (!response.ok) {
throw new Error(`Pokémon API error: ${response.status}`);
}
const data = await response.json();
return data.data.map((card: any) => ({
id: card.id,
name: card.name,
set_name: card.set.name,
set_code: card.set.id,
card_number: card.number,
rarity: card.rarity,
game: 'POKEMON',
mana_cost: card.convertedRetreatCost?.toString(),
cmc: card.convertedRetreatCost,
card_type: card.supertype,
colors: card.types || [],
oracle_text: card.attacks?.map((attack: any) => `${attack.name}: ${attack.text}`).join('\n'),
power: card.hp,
toughness: null,
image_url: card.images.small,
stock_image_url: card.images.large,
current_price: card.cardmarket?.prices?.averageSellPrice || undefined,
market_price: card.cardmarket?.prices?.lowPrice || undefined,
verified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
} catch (error) {
console.error('Error fetching Pokémon cards:', error);
return [];
}
},
async getCardByName(name: string): Promise<Card | null> {
const cards = await this.searchCards(name, 1);
return cards.length > 0 ? cards[0] : null;
}
};
// ============================================================================
// YU-GI-OH! API
// ============================================================================
export const yugiohService = {
async searchCards(query: string): Promise<Card[]> {
try {
const response = await fetch(
`${YUGIOH_API_BASE_URL}/cardinfo.php?fname=${encodeURIComponent(query)}`
);
if (!response.ok) {
throw new Error(`Yu-Gi-Oh! API error: ${response.status}`);
}
const data = await response.json();
return data.data.map((card: any) => ({
id: card.id,
name: card.name,
set_name: card.set_name,
set_code: card.set_code,
card_number: card.num,
rarity: card.rarity,
game: 'YUGIOH',
mana_cost: card.level?.toString(),
cmc: card.level,
card_type: card.type,
colors: [card.attribute],
oracle_text: card.desc,
power: card.atk?.toString(),
toughness: card.def?.toString(),
image_url: card.card_images?.[0]?.image_url,
stock_image_url: card.card_images?.[0]?.image_url_small,
current_price: card.card_prices?.[0]?.amazon_price ? parseFloat(card.card_prices[0].amazon_price) : undefined,
market_price: card.card_prices?.[0]?.cardmarket_price ? parseFloat(card.card_prices[0].cardmarket_price) : undefined,
verified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
} catch (error) {
console.error('Error fetching Yu-Gi-Oh! cards:', error);
return [];
}
},
async getCardByName(name: string): Promise<Card | null> {
const cards = await this.searchCards(name);
return cards.length > 0 ? cards[0] : null;
}
};
// ============================================================================
// DISNEY LORCANA API (Lorcast)
// ============================================================================
export const lorcanaService = {
async searchCards(query: string): Promise<Card[]> {
await lorcastLimiter.waitForSlot();
try {
// Try proxy API first (handles CORS)
try {
const response = await fetch(`${PROXY_API_BASE_URL}?search=${encodeURIComponent(query)}&api=lorcana`);
if (response.ok) {
const proxyData = await response.json();
console.log('🔍 Proxy API response:', JSON.stringify(proxyData, null, 2));
if (proxyData.success && proxyData.data) {
const cards = proxyData.data.cards || proxyData.data.data || proxyData.data || [];
if (cards.length > 0) {
console.log(`✅ Found ${cards.length} Lorcana cards via proxy (${proxyData.source})`);
console.log('🔍 First card structure:', JSON.stringify(cards[0], null, 2));
return cards.map((card: any) => ({
id: card.id || card.uuid || card.card_id || `lorcana-${Math.random()}`,
name: card.name || card.card_name || card.title || '',
set_name: card.set?.name || card.set_name || card.set_name || '',
set_code: card.set?.code || card.set_code || card.set_code || '',
card_number: card.number || card.card_number || card.card_num || '',
rarity: card.rarity || card.rarity_name || '',
game: 'LORCANA',
mana_cost: card.cost?.toString() || card.mana_cost || card.ink_cost?.toString() || '',
cmc: card.cost || card.cmc || card.ink_cost || 0,
card_type: card.type || card.card_type || card.type_name || '',
colors: card.colors || card.ink || [],
oracle_text: card.text || card.oracle_text || card.description || card.effect || '',
power: card.strength?.toString() || card.power || card.attack?.toString() || '',
toughness: card.willpower?.toString() || card.toughness || card.defense?.toString() || '',
image_url: card.image_url || card.images?.small || card.images?.png || card.image || '',
stock_image_url: card.image_url || card.images?.small || card.images?.png || card.image || '',
current_price: card.price?.market || card.current_price || undefined,
market_price: card.price?.low || card.market_price || undefined,
verified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
}
}
}
} catch (e) {
console.log('❌ Proxy API failed, trying direct APIs');
}
// Fallback to direct APIs
try {
// Use correct search syntax for Lorcana API
const response = await fetch(`${LORCANA_API_BASE_URL}/cards/fetch?search=name~${encodeURIComponent(query)}`);
if (response.ok) {
const data = await response.json();
console.log('🔍 Direct API response:', JSON.stringify(data, null, 2));
const cards = data.cards || data.data || data || [];
if (cards.length > 0) {
console.log(`✅ Found ${cards.length} Lorcana cards from direct Lorcana API`);
console.log('🔍 First card structure:', JSON.stringify(cards[0], null, 2));
return cards.map((card: any) => ({
id: card.id || card.uuid || card.card_id || `lorcana-${Math.random()}`,
name: card.name || card.card_name || card.title || '',
set_name: card.set?.name || card.set_name || card.set_name || '',
set_code: card.set?.code || card.set_code || card.set_code || '',
card_number: card.number || card.card_number || card.card_num || '',
rarity: card.rarity || card.rarity_name || '',
game: 'LORCANA',
mana_cost: card.cost?.toString() || card.mana_cost || card.ink_cost?.toString() || '',
cmc: card.cost || card.cmc || card.ink_cost || 0,
card_type: card.type || card.card_type || card.type_name || '',
colors: card.colors || card.ink || [],
oracle_text: card.text || card.oracle_text || card.description || card.effect || '',
power: card.strength?.toString() || card.power || card.attack?.toString() || '',
toughness: card.willpower?.toString() || card.toughness || card.defense?.toString() || '',
image_url: card.image_url || card.images?.small || card.images?.png || card.image || '',
stock_image_url: card.image_url || card.images?.small || card.images?.png || card.image || '',
current_price: card.price?.market || card.current_price || undefined,
market_price: card.price?.low || card.market_price || undefined,
verified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
}
}
} catch (e) {
console.log('❌ Direct Lorcana API failed');
}
// Final fallback to Lorcast API
const searchEndpoints = [
`/cards?q=${encodeURIComponent(query)}`,
`/cards?search=${encodeURIComponent(query)}`,
`/cards?query=${encodeURIComponent(query)}`
];
for (const endpoint of searchEndpoints) {
try {
const response = await fetch(`${LORCAST_API_BASE_URL}${endpoint}`);
if (response.ok) {
const data = await response.json();
console.log(`🔍 Lorcast API response from ${endpoint}:`, JSON.stringify(data, null, 2));
const cards = data.cards || data.data || data || [];
if (cards.length > 0) {
console.log(`✅ Found ${cards.length} Lorcana cards from Lorcast ${endpoint}`);
console.log('🔍 First card structure:', JSON.stringify(cards[0], null, 2));
return cards.map((card: any) => ({
id: card.id || card.uuid || card.card_id || `lorcana-${Math.random()}`,
name: card.name || card.card_name || card.title || '',
set_name: card.set?.name || card.set_name || card.set_name || '',
set_code: card.set?.code || card.set_code || card.set_code || '',
card_number: card.number || card.card_number || card.card_num || '',
rarity: card.rarity || card.rarity_name || '',
game: 'LORCANA',
mana_cost: card.cost?.toString() || card.mana_cost || card.ink_cost?.toString() || '',
cmc: card.cost || card.cmc || card.ink_cost || 0,
card_type: card.type || card.card_type || card.type_name || '',
colors: card.colors || card.ink || [],
oracle_text: card.text || card.oracle_text || card.description || card.effect || '',
power: card.strength?.toString() || card.power || card.attack?.toString() || '',
toughness: card.willpower?.toString() || card.toughness || card.defense?.toString() || '',
image_url: card.image_url || card.images?.small || card.images?.png || card.image || '',
stock_image_url: card.image_url || card.images?.small || card.images?.png || card.image || '',
current_price: card.price?.market || card.current_price || undefined,
market_price: card.price?.low || card.market_price || undefined,
verified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
}
}
} catch (e) {
console.log(`❌ Failed to search from Lorcast ${endpoint}:`, e);
continue;
}
}
console.log('⚠️ No Lorcana cards found from any API');
return [];
} catch (error) {
console.error('Error searching Lorcana cards:', error);
return [];
}
},
async getCardByName(name: string): Promise<Card | null> {
await lorcastLimiter.waitForSlot();
try {
// Try proxy API first (handles CORS)
try {
const response = await fetch(`${PROXY_API_BASE_URL}?search=${encodeURIComponent(name)}&api=lorcana`);
if (response.ok) {
const proxyData = await response.json();
if (proxyData.success && proxyData.data) {
const cards = proxyData.data.cards || proxyData.data.data || proxyData.data || [];
if (cards.length > 0) {
const card = cards[0];
console.log(`✅ Found Lorcana card "${name}" via proxy (${proxyData.source})`);
return {
id: card.id || card.uuid || card.card_id || `lorcana-${Math.random()}`,
name: card.name || card.card_name || card.title || '',
set_name: card.set?.name || card.set_name || card.set_name || '',
set_code: card.set?.code || card.set_code || card.set_code || '',
card_number: card.number || card.card_number || card.card_num || '',
rarity: card.rarity || card.rarity_name || '',
game: 'LORCANA',
mana_cost: card.cost?.toString() || card.mana_cost || card.ink_cost?.toString() || '',
cmc: card.cost || card.cmc || card.ink_cost || 0,
card_type: card.type || card.card_type || card.type_name || '',
colors: card.colors || card.ink || [],
oracle_text: card.text || card.oracle_text || card.description || card.effect || '',
power: card.strength?.toString() || card.power || card.attack?.toString() || '',
toughness: card.willpower?.toString() || card.toughness || card.defense?.toString() || '',
image_url: card.image_url || card.images?.small || card.images?.png || card.image || '',
stock_image_url: card.image_url || card.images?.small || card.images?.png || card.image || '',
current_price: card.price?.market || card.current_price || undefined,
market_price: card.price?.low || card.market_price || undefined,
verified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
}
}
} catch (e) {
console.log('❌ Proxy API failed for card lookup, trying direct APIs');
}
// Fallback to direct Lorcana API
try {
const response = await fetch(`${LORCANA_API_BASE_URL}/cards/fetch?search=name~${encodeURIComponent(name)}`);
if (response.ok) {
const data = await response.json();
const cards = data.cards || data.data || data || [];
if (cards.length > 0) {
const card = cards[0];
console.log(`✅ Found Lorcana card "${name}" from direct Lorcana API`);
return {
id: card.id || card.uuid || card.card_id || `lorcana-${Math.random()}`,
name: card.name || card.card_name || card.title || '',
set_name: card.set?.name || card.set_name || card.set_name || '',
set_code: card.set?.code || card.set_code || card.set_code || '',
card_number: card.number || card.card_number || card.card_num || '',
rarity: card.rarity || card.rarity_name || '',
game: 'LORCANA',
mana_cost: card.cost?.toString() || card.mana_cost || card.ink_cost?.toString() || '',
cmc: card.cost || card.cmc || card.ink_cost || 0,
card_type: card.type || card.card_type || card.type_name || '',
colors: card.colors || card.ink || [],
oracle_text: card.text || card.oracle_text || card.description || card.effect || '',
power: card.strength?.toString() || card.power || card.attack?.toString() || '',
toughness: card.willpower?.toString() || card.toughness || card.defense?.toString() || '',
image_url: card.image_url || card.images?.small || card.images?.png || card.image || '',
stock_image_url: card.image_url || card.images?.small || card.images?.png || card.image || '',
current_price: card.price?.market || card.current_price || undefined,
market_price: card.price?.low || card.market_price || undefined,
verified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
}
} catch (e) {
console.log('❌ Direct Lorcana API failed for card lookup');
}
// Final fallback to Lorcast API
const searchEndpoints = [
`/cards?q=name:${encodeURIComponent(name)}`,
`/cards?search=${encodeURIComponent(name)}`,
`/cards?query=${encodeURIComponent(name)}`
];
for (const endpoint of searchEndpoints) {
try {
const response = await fetch(`${LORCAST_API_BASE_URL}${endpoint}`);
if (response.ok) {
const data = await response.json();
const cards = data.cards || data.data || data || [];
if (cards.length > 0) {
const card = cards[0];
console.log(`✅ Found Lorcana card "${name}" from Lorcast ${endpoint}`);
return {
id: card.id || card.uuid || card.card_id || `lorcana-${Math.random()}`,
name: card.name || card.card_name || card.title || '',
set_name: card.set?.name || card.set_name || card.set_name || '',
set_code: card.set?.code || card.set_code || card.set_code || '',
card_number: card.number || card.card_number || card.card_num || '',
rarity: card.rarity || card.rarity_name || '',
game: 'LORCANA',
mana_cost: card.cost?.toString() || card.mana_cost || card.ink_cost?.toString() || '',
cmc: card.cost || card.cmc || card.ink_cost || 0,
card_type: card.type || card.card_type || card.type_name || '',
colors: card.colors || card.ink || [],
oracle_text: card.text || card.oracle_text || card.description || card.effect || '',
power: card.strength?.toString() || card.power || card.attack?.toString() || '',
toughness: card.willpower?.toString() || card.toughness || card.defense?.toString() || '',
image_url: card.image_url || card.images?.small || card.images?.png || card.image || '',
stock_image_url: card.image_url || card.images?.small || card.images?.png || card.image || '',
current_price: card.price?.market || card.current_price || undefined,
market_price: card.price?.low || card.market_price || undefined,
verified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
}
} catch (e) {
console.log(`❌ Failed to fetch card from Lorcast ${endpoint}:`, e);
continue;
}
}
console.log(`⚠️ Lorcana card "${name}" not found from any API`);
return null;
} catch (error) {
console.error('Error fetching Lorcana card:', error);
return null;
}
},
async getRandomCards(count = 20): Promise<Card[]> {
await lorcastLimiter.waitForSlot();
try {
// Try proxy API first (handles CORS)
try {
const response = await fetch(`${PROXY_API_BASE_URL}?limit=${count}&api=lorcana`);
if (response.ok) {
const proxyData = await response.json();
if (proxyData.success && proxyData.data) {
const allCards = proxyData.data.cards || proxyData.data.data || proxyData.data || [];
if (allCards.length > 0) {
console.log(`✅ Found ${allCards.length} cards via proxy (${proxyData.source})`);
// Randomly select cards
const shuffled = allCards.sort(() => 0.5 - Math.random());
const selectedCards = shuffled.slice(0, count);
return selectedCards.map((card: any) => ({
id: card.id || card.uuid || `lorcana-${Math.random()}`,
name: card.name,
set_name: card.set?.name || card.set_name || '',
set_code: card.set?.code || card.set_code || '',
card_number: card.number || card.card_number || '',
rarity: card.rarity,
game: 'LORCANA',
mana_cost: card.cost?.toString() || card.mana_cost,
cmc: card.cost || card.cmc,
card_type: card.type || card.card_type,
colors: card.colors || [],
oracle_text: card.text || card.oracle_text || '',
power: card.strength?.toString() || card.power,
toughness: card.willpower?.toString() || card.toughness,
image_url: card.image_url || card.images?.small || card.images?.png,
stock_image_url: card.image_url || card.images?.small || card.images?.png,
current_price: card.price?.market || card.current_price || undefined,
market_price: card.price?.low || card.market_price || undefined,
verified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
}
}
}
} catch (e) {
console.log('❌ Proxy API failed for random cards, trying direct APIs');
}
// Fallback to direct Lorcana API
try {
const response = await fetch(`${LORCANA_API_BASE_URL}/cards/fetch?pagesize=${count}`);
if (response.ok) {
const data = await response.json();
const allCards = data.cards || data.data || data || [];
if (allCards.length > 0) {
console.log(`✅ Found ${allCards.length} cards from direct Lorcana API`);
// Randomly select cards
const shuffled = allCards.sort(() => 0.5 - Math.random());
const selectedCards = shuffled.slice(0, count);
return selectedCards.map((card: any) => ({
id: card.id || card.uuid || `lorcana-${Math.random()}`,
name: card.name,
set_name: card.set?.name || card.set_name || '',
set_code: card.set?.code || card.set_code || '',
card_number: card.number || card.card_number || '',
rarity: card.rarity,
game: 'LORCANA',
mana_cost: card.cost?.toString() || card.mana_cost,
cmc: card.cost || card.cmc,
card_type: card.type || card.card_type,
colors: card.colors || [],
oracle_text: card.text || card.oracle_text || '',
power: card.strength?.toString() || card.power,
toughness: card.willpower?.toString() || card.toughness,
image_url: card.image_url || card.images?.small || card.images?.png,
stock_image_url: card.image_url || card.images?.small || card.images?.png,
current_price: card.price?.market || card.current_price || undefined,
market_price: card.price?.low || card.market_price || undefined,
verified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
}
}
} catch (e) {
console.log('❌ Direct Lorcana API failed for random cards');
}
// Final fallback to Lorcast API
const endpoints = [
'/cards',
'/api/cards',
'/v0/cards'
];
let allCards: any[] = [];
for (const endpoint of endpoints) {
try {
const response = await fetch(`${LORCAST_API_BASE_URL}${endpoint}`);
if (response.ok) {
const data = await response.json();
allCards = data.cards || data.data || data || [];
console.log(`✅ Found ${allCards.length} cards from Lorcast ${endpoint}`);
break;
}
} catch (e) {
console.log(`❌ Failed to fetch from Lorcast ${endpoint}:`, e);
continue;
}
}
if (allCards.length === 0) {
console.log('⚠️ No cards found from any endpoint, returning empty array');
return [];
}
// Randomly select cards
const shuffled = allCards.sort(() => 0.5 - Math.random());
const selectedCards = shuffled.slice(0, count);
return selectedCards.map((card: any) => ({
id: card.id || card.uuid || `lorcana-${Math.random()}`,
name: card.name,
set_name: card.set?.name || card.set_name || '',
set_code: card.set?.code || card.set_code || '',
card_number: card.number || card.card_number || '',
rarity: card.rarity,
game: 'LORCANA',
mana_cost: card.cost?.toString() || card.mana_cost,
cmc: card.cost || card.cmc,
card_type: card.type || card.card_type,
colors: card.colors || [],
oracle_text: card.text || card.oracle_text || '',
power: card.strength?.toString() || card.power,
toughness: card.willpower?.toString() || card.toughness,
image_url: card.image_url || card.images?.small || card.images?.png,
stock_image_url: card.image_url || card.images?.small || card.images?.png,
current_price: card.price?.market || card.current_price || undefined,
market_price: card.price?.low || card.market_price || undefined,
verified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
} catch (error) {
console.error('Error fetching random Lorcana cards:', error);
return [];
}
}
};
// ============================================================================
// UNIFIED CARD SEARCH
// ============================================================================
export const cardDataService = {
async searchCards(query: string, game?: string): Promise<Card[]> {
console.log(`🔍 Database search: "${query}" for game: "${game}"`);
try {
const params = new URLSearchParams({
q: query,
limit: '50'
});
if (game && game !== 'ALL') {
params.append('game', game);
}
const response = await fetch(`https://tcg-vault.vercel.app/api/cards?${params}`);
if (!response.ok) {
throw new Error(`Database search failed: ${response.status}`);
}
const data = await response.json();
if (data.success && data.data) {
console.log(`✅ Found ${data.data.length} cards from database`);
return data.data.map((card: any) => ({
id: card.id,
name: card.name,
set_name: card.set_name,
set_code: card.set_code,
card_number: card.card_number,
rarity: card.rarity,
game: card.game,
mana_cost: card.mana_cost,
cmc: card.cmc,
card_type: card.card_type,
colors: card.colors || [],
oracle_text: card.oracle_text,
power: card.power,
toughness: card.toughness,
image_url: card.image_url,
stock_image_url: card.stock_image_url,
current_price: card.current_price,
market_price: card.market_price,
verified: card.verified,
createdAt: card.createdAt,
updatedAt: card.updatedAt,
}));
}
return [];
} catch (error) {
console.error('Error in database card search:', error);
return [];
}
},
async getCardByName(name: string, game?: string): Promise<Card | null> {
try {
if (game === 'MTG') {
return await mtgService.getCardByName(name);
}
if (game === 'POKEMON') {
return await pokemonService.getCardByName(name);
}
if (game === 'YUGIOH') {
return await yugiohService.getCardByName(name);
}
if (game === 'LORCANA') {
return await lorcanaService.getCardByName(name);
}
// Try all games if no specific game is specified
const mtgCard = await mtgService.getCardByName(name);
if (mtgCard) return mtgCard;
const pokemonCard = await pokemonService.getCardByName(name);
if (pokemonCard) return pokemonCard;
const yugiohCard = await yugiohService.getCardByName(name);
if (yugiohCard) return yugiohCard;
const lorcanaCard = await lorcanaService.getCardByName(name);
if (lorcanaCard) return lorcanaCard;
return null;
} catch (error) {
console.error('Error in unified card search by name:', error);
return null;
}
},
async getRandomCards(count = 20, game?: string): Promise<Card[]> {
const results: Card[] = [];
console.log(`🎲 Getting ${count} random cards for game: "${game}"`);
try {
if (!game || game === 'MTG') {
console.log('🎲 Getting random MTG cards...');
const mtgCards = await mtgService.getRandomCards(Math.ceil(count / 4));
console.log(`✅ Found ${mtgCards.length} random MTG cards`);
results.push(...mtgCards);
}
if (!game || game === 'LORCANA') {
console.log('🎲 Getting random Lorcana cards...');
const lorcanaCards = await lorcanaService.getRandomCards(Math.ceil(count / 4));
console.log(`✅ Found ${lorcanaCards.length} random Lorcana cards`);
results.push(...lorcanaCards);
}
// Note: Pokémon and Yu-Gi-Oh! APIs don't have random card endpoints
// You could implement random selection from popular cards lists
const finalResults = results.slice(0, count);
console.log(`🎯 Returning ${finalResults.length} random cards`);
return finalResults;
} catch (error) {
console.error('Error getting random cards:', error);
return [];
}
}
};
export default cardDataService;

View file

@ -1,212 +0,0 @@
import { cardService } from './api';
interface CardMatchResult {
card: any;
confidence: number;
matchType: 'exact' | 'fuzzy' | 'partial';
}
interface OCRCardData {
name: string;
set?: string;
ocrText: string;
confidence: number;
}
class CardMatcherService {
private cardCache: any[] = [];
private lastCacheUpdate = 0;
private readonly CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
// Get all cards (with caching)
private async getAllCards(): Promise<any[]> {
const now = Date.now();
if (this.cardCache.length === 0 || now - this.lastCacheUpdate > this.CACHE_DURATION) {
try {
this.cardCache = await cardService.getAllCards();
this.lastCacheUpdate = now;
} catch (error) {
console.error('Failed to fetch cards for matching:', error);
return [];
}
}
return this.cardCache;
}
// Calculate string similarity using Levenshtein distance
private calculateSimilarity(str1: string, str2: string): number {
const s1 = str1.toLowerCase().trim();
const s2 = str2.toLowerCase().trim();
if (s1 === s2) return 1.0;
if (s1.length === 0 || s2.length === 0) return 0;
const matrix = Array(s2.length + 1).fill(null).map(() => Array(s1.length + 1).fill(null));
for (let i = 0; i <= s1.length; i++) {
matrix[0][i] = i;
}
for (let j = 0; j <= s2.length; j++) {
matrix[j][0] = j;
}
for (let j = 1; j <= s2.length; j++) {
for (let i = 1; i <= s1.length; i++) {
if (s1[i - 1] === s2[j - 1]) {
matrix[j][i] = matrix[j - 1][i - 1];
} else {
matrix[j][i] = Math.min(
matrix[j - 1][i - 1] + 1, // substitution
matrix[j][i - 1] + 1, // insertion
matrix[j - 1][i] + 1 // deletion
);
}
}
}
const maxLength = Math.max(s1.length, s2.length);
return (maxLength - matrix[s2.length][s1.length]) / maxLength;
}
// Clean card name for better matching
private cleanCardName(name: string): string {
return name
.toLowerCase()
.replace(/[^\w\s]/g, ' ') // Replace non-word chars with spaces
.replace(/\s+/g, ' ') // Normalize whitespace
.trim();
}
// Extract potential card names from OCR text
private extractPotentialNames(ocrText: string): string[] {
const lines = ocrText.split('\n').map(line => line.trim()).filter(line => line.length > 0);
const potentialNames: string[] = [];
for (const line of lines) {
// Skip lines that are too short or too long
if (line.length < 3 || line.length > 50) continue;
// Skip lines with mostly numbers or symbols
if (/^[\d\s\W]+$/.test(line)) continue;
// Skip common card text patterns
if (/^(hp|©|tap|untap|mana|legendary|instant|sorcery|creature|artifact|enchantment|planeswalker)$/i.test(line)) continue;
// Clean and add potential names
const cleaned = this.cleanCardName(line);
if (cleaned.length >= 3) {
potentialNames.push(cleaned);
// Also try without common prefixes/suffixes
const withoutCommonWords = cleaned.replace(/\b(the|of|and|or|a|an)\b/g, '').trim();
if (withoutCommonWords.length >= 3 && withoutCommonWords !== cleaned) {
potentialNames.push(withoutCommonWords);
}
}
}
return Array.from(new Set(potentialNames)); // Remove duplicates
}
// Match OCR data against card database
async matchCard(ocrData: OCRCardData): Promise<CardMatchResult[]> {
const cards = await this.getAllCards();
if (cards.length === 0) return [];
const results: CardMatchResult[] = [];
const potentialNames = this.extractPotentialNames(ocrData.ocrText);
// Add the provided name if it exists
if (ocrData.name && ocrData.name.trim().length > 0) {
potentialNames.unshift(this.cleanCardName(ocrData.name));
}
console.log('Potential card names extracted:', potentialNames);
// Try to match each potential name against all cards
for (const potentialName of potentialNames) {
for (const card of cards) {
const cardName = this.cleanCardName(card.name);
const similarity = this.calculateSimilarity(potentialName, cardName);
// Determine match type and minimum confidence
let matchType: 'exact' | 'fuzzy' | 'partial' = 'fuzzy';
let minConfidence = 0.6;
if (similarity === 1.0) {
matchType = 'exact';
minConfidence = 1.0;
} else if (similarity >= 0.9) {
matchType = 'fuzzy';
minConfidence = 0.9;
} else if (similarity >= 0.7) {
matchType = 'fuzzy';
minConfidence = 0.7;
} else if (potentialName.includes(cardName) || cardName.includes(potentialName)) {
matchType = 'partial';
minConfidence = 0.6;
}
// Add result if confidence is high enough
if (similarity >= minConfidence) {
// Bonus for set matching (if available)
let finalConfidence = similarity;
if (ocrData.set && card.set_name) {
const setMatch = this.calculateSimilarity(ocrData.set, card.set_name);
if (setMatch > 0.7) {
finalConfidence = Math.min(1.0, finalConfidence + 0.1);
}
}
results.push({
card,
confidence: finalConfidence,
matchType
});
}
}
}
// Remove duplicates and sort by confidence
const uniqueResults = results
.filter((result, index, arr) =>
arr.findIndex(r => r.card.id === result.card.id) === index
)
.sort((a, b) => b.confidence - a.confidence)
.slice(0, 5); // Top 5 matches
console.log('Card matching results:', uniqueResults);
return uniqueResults;
}
// Quick search for testing - search by name only
async quickSearch(name: string): Promise<CardMatchResult[]> {
const cards = await this.getAllCards();
const cleanName = this.cleanCardName(name);
const results: CardMatchResult[] = [];
for (const card of cards) {
const cardName = this.cleanCardName(card.name);
const similarity = this.calculateSimilarity(cleanName, cardName);
if (similarity >= 0.6) {
let matchType: 'exact' | 'fuzzy' | 'partial' = 'fuzzy';
if (similarity === 1.0) matchType = 'exact';
else if (cardName.includes(cleanName) || cleanName.includes(cardName)) matchType = 'partial';
results.push({
card,
confidence: similarity,
matchType
});
}
}
return results
.sort((a, b) => b.confidence - a.confidence)
.slice(0, 10);
}
}
export const cardMatcher = new CardMatcherService();

View file

@ -1,410 +0,0 @@
import axios from 'axios';
import type {
Card,
UserCard,
Collection,
Deck,
Tag,
CreateCardData,
CreateCollectionData,
CreateDeckData,
CreateTagData,
CardFilters,
CollectionFilters,
DeckFilters,
UserStats,
} from '../types';
const API_BASE_URL = process.env.REACT_APP_API_URL || 'https://tcg-vault.vercel.app';
// Create axios instance with default config
const api = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor for auth token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('tcg-vault-token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Response interceptor for error handling
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('tcg-vault-token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
// ============================================================================
// CARD SERVICES
// ============================================================================
export const cardService = {
// Get all cards from database (for browsing/searching)
getAllCards: async (filters?: CardFilters): Promise<Card[]> => {
const response = await api.get('/api/cards', { params: filters });
return response.data.data;
},
// Get single card from database
getCard: async (id: string): Promise<Card> => {
const response = await api.get(`/api/cards/${id}`);
return response.data.data;
},
// Search cards in database
searchCards: async (query: string, filters?: Omit<CardFilters, 'search'>): Promise<Card[]> => {
const response = await api.get('/api/cards/search', {
params: { q: query, ...filters }
});
return response.data.data;
},
// Get user's cards (owned/wanted)
getUserCards: async (filters?: CardFilters): Promise<UserCard[]> => {
const response = await api.get('/api/user-cards', { params: filters });
return response.data.data;
},
// Get single user card
getUserCard: async (id: string): Promise<UserCard> => {
const response = await api.get(`/api/user-cards/${id}`);
return response.data.data;
},
// Add card to user collection
addCard: async (data: CreateCardData): Promise<UserCard> => {
const response = await api.post('/api/user-cards', data);
return response.data.data;
},
// Update user card
updateCard: async (id: string, data: Partial<CreateCardData>): Promise<UserCard> => {
const response = await api.put(`/api/user-cards/${id}`, data);
return response.data.data;
},
// Delete user card
deleteCard: async (id: string): Promise<void> => {
await api.delete(`/api/user-cards/${id}`);
},
// Bulk operations
bulkAddCards: async (cards: CreateCardData[]): Promise<UserCard[]> => {
const response = await api.post('/api/user-cards/bulk', { cards });
return response.data.data;
},
bulkUpdateCards: async (updates: { id: string; data: Partial<CreateCardData> }[]): Promise<UserCard[]> => {
const response = await api.put('/api/user-cards/bulk', { updates });
return response.data.data;
},
bulkDeleteCards: async (ids: string[]): Promise<void> => {
await api.delete('/api/user-cards/bulk', { data: { ids } });
},
// Move cards between collections/decks
moveCardsToCollection: async (cardIds: string[], collectionId: string): Promise<void> => {
await api.post('/api/user-cards/move-to-collection', { cardIds, collectionId });
},
moveCardsToDeck: async (cardIds: string[], deckId: string): Promise<void> => {
await api.post('/api/user-cards/move-to-deck', { cardIds, deckId });
},
};
// ============================================================================
// COLLECTION SERVICES
// ============================================================================
export const collectionService = {
// Get all user collections
getCollections: async (filters?: CollectionFilters): Promise<Collection[]> => {
const response = await api.get('/api/collections', { params: filters });
return response.data.data;
},
// Get single collection with cards
getCollection: async (id: string, includeCards = true): Promise<Collection> => {
const response = await api.get(`/api/collections/${id}`, {
params: { include_cards: includeCards }
});
return response.data.data;
},
// Create new collection
createCollection: async (data: CreateCollectionData): Promise<Collection> => {
const response = await api.post('/api/collections', data);
return response.data.data;
},
// Update collection
updateCollection: async (id: string, data: Partial<CreateCollectionData>): Promise<Collection> => {
const response = await api.put(`/api/collections/${id}`, data);
return response.data.data;
},
// Delete collection
deleteCollection: async (id: string): Promise<void> => {
await api.delete(`/api/collections/${id}`);
},
// Collection card management
addCardsToCollection: async (collectionId: string, cardIds: string[]): Promise<void> => {
await api.post(`/api/collections/${collectionId}/cards`, { cardIds });
},
removeCardsFromCollection: async (collectionId: string, cardIds: string[]): Promise<void> => {
await api.delete(`/api/collections/${collectionId}/cards`, { data: { cardIds } });
},
// Collection stats
getCollectionStats: async (id: string): Promise<{
cardCount: number;
totalValue: number;
gameBreakdown: Record<string, number>;
rarityBreakdown: Record<string, number>;
}> => {
const response = await api.get(`/api/collections/${id}/stats`);
return response.data.data;
},
// Duplicate collection
duplicateCollection: async (id: string, newName: string): Promise<Collection> => {
const response = await api.post(`/api/collections/${id}/duplicate`, { name: newName });
return response.data.data;
},
// Export collection
exportCollection: async (id: string, format: 'csv' | 'json' | 'txt'): Promise<Blob> => {
const response = await api.get(`/api/collections/${id}/export`, {
params: { format },
responseType: 'blob'
});
return response.data;
},
};
// ============================================================================
// DECK SERVICES
// ============================================================================
export const deckService = {
// Get all user decks
getDecks: async (filters?: DeckFilters): Promise<Deck[]> => {
const response = await api.get('/api/decks', { params: filters });
return response.data.data;
},
// Get single deck with cards
getDeck: async (id: string): Promise<Deck> => {
const response = await api.get(`/api/decks/${id}`);
return response.data.data;
},
// Create new deck
createDeck: async (data: CreateDeckData): Promise<Deck> => {
const response = await api.post('/api/decks', data);
return response.data.data;
},
// Update deck
updateDeck: async (id: string, data: Partial<CreateDeckData>): Promise<Deck> => {
const response = await api.put(`/api/decks/${id}`, data);
return response.data.data;
},
// Delete deck
deleteDeck: async (id: string): Promise<void> => {
await api.delete(`/api/decks/${id}`);
},
// Deck card management
addCardToDeck: async (deckId: string, cardId: string, quantity: number, board: 'mainboard' | 'sideboard' = 'mainboard'): Promise<void> => {
await api.post(`/api/decks/${deckId}/cards`, { cardId, quantity, board });
},
updateDeckCard: async (deckId: string, cardId: string, quantity: number, board: 'mainboard' | 'sideboard' = 'mainboard'): Promise<void> => {
await api.put(`/api/decks/${deckId}/cards/${cardId}`, { quantity, board });
},
removeDeckCard: async (deckId: string, cardId: string): Promise<void> => {
await api.delete(`/api/decks/${deckId}/cards/${cardId}`);
},
// Deck validation
validateDeck: async (id: string): Promise<{
isLegal: boolean;
errors: string[];
warnings: string[];
}> => {
const response = await api.get(`/api/decks/${id}/validate`);
return response.data.data;
},
// Deck stats
getDeckStats: async (id: string): Promise<{
totalCards: number;
totalValue: number;
averageManaValue: number;
colorBreakdown: Record<string, number>;
typeBreakdown: Record<string, number>;
manaCurve: Record<number, number>;
ownedCards: number;
neededCards: number;
}> => {
const response = await api.get(`/api/decks/${id}/stats`);
return response.data.data;
},
// Import/Export decks
importDeck: async (data: { name: string; format: string; decklist: string }): Promise<Deck> => {
const response = await api.post('/api/decks/import', data);
return response.data.data;
},
exportDeck: async (id: string, format: 'mtgo' | 'arena' | 'txt' | 'json'): Promise<string> => {
const response = await api.get(`/api/decks/${id}/export`, { params: { format } });
return response.data.data;
},
// Duplicate deck
duplicateDeck: async (id: string, newName: string): Promise<Deck> => {
const response = await api.post(`/api/decks/${id}/duplicate`, { name: newName });
return response.data.data;
},
};
// ============================================================================
// TAG SERVICES
// ============================================================================
export const tagService = {
// Get all user tags
getTags: async (): Promise<Tag[]> => {
const response = await api.get('/api/tags');
return response.data.data;
},
// Create new tag
createTag: async (data: CreateTagData): Promise<Tag> => {
const response = await api.post('/api/tags', data);
return response.data.data;
},
// Update tag
updateTag: async (id: string, data: Partial<CreateTagData>): Promise<Tag> => {
const response = await api.put(`/api/tags/${id}`, data);
return response.data.data;
},
// Delete tag
deleteTag: async (id: string): Promise<void> => {
await api.delete(`/api/tags/${id}`);
},
// Get entities by tag
getEntitiesByTag: async (tagId: string): Promise<{
cards: UserCard[];
collections: Collection[];
decks: Deck[];
}> => {
const response = await api.get(`/api/tags/${tagId}/entities`);
return response.data.data;
},
};
// ============================================================================
// STATS AND ANALYTICS
// ============================================================================
export const statsService = {
// Get user statistics
getUserStats: async (): Promise<UserStats> => {
const response = await api.get('/api/stats');
return response.data.data;
},
// Get price alerts
getPriceAlerts: async (): Promise<{
increases: Array<{ card: Card; oldPrice: number; newPrice: number; change: number }>;
decreases: Array<{ card: Card; oldPrice: number; newPrice: number; change: number }>;
}> => {
const response = await api.get('/api/stats/price-alerts');
return response.data.data;
},
};
// ============================================================================
// SCANNER INTEGRATION
// ============================================================================
export const scannerService = {
// Process OCR scan result
processOCR: async (imageData: string): Promise<{
cards: Array<{
card: Card;
confidence: number;
}>;
}> => {
const response = await api.post('/api/scanner/ocr', { imageData });
return response.data.data;
},
// Add scanned cards to collection
addScannedCards: async (cards: CreateCardData[]): Promise<UserCard[]> => {
const response = await api.post('/api/scanner/add-cards', { cards });
return response.data.data;
},
};
// ============================================================================
// UTILITY FUNCTIONS
// ============================================================================
export const utilityService = {
// Health check
healthCheck: async (): Promise<{ status: string; timestamp: string }> => {
const response = await api.get('/api/health');
return response.data;
},
// Upload image
uploadImage: async (file: File): Promise<{ url: string }> => {
const formData = new FormData();
formData.append('image', file);
const response = await api.post('/api/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data.data;
},
};
// Export all services
export const tcgApi = {
cards: cardService,
collections: collectionService,
decks: deckService,
tags: tagService,
stats: statsService,
scanner: scannerService,
utils: utilityService,
};
export default tcgApi;

View file

@ -1,5 +0,0 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

View file

@ -1,345 +0,0 @@
/* 3D Card Effects */
.card-3d {
perspective: 1000px;
transform-style: preserve-3d;
}
.card-3d-inner {
transition: transform 0.3s cubic-bezier(0.23, 1, 0.320, 1);
transform-style: preserve-3d;
will-change: transform;
}
/* Foil Shimmer Effects */
.foil-card {
position: relative;
overflow: hidden;
}
.foil-card::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.4),
transparent
);
border-radius: inherit;
transition: left 0.6s ease;
pointer-events: none;
z-index: 1;
}
.foil-card:hover::before {
left: 100%;
}
.foil-rainbow {
position: relative;
overflow: hidden;
}
.foil-rainbow::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
45deg,
transparent 30%,
rgba(255, 0, 150, 0.1) 40%,
rgba(0, 255, 255, 0.1) 50%,
rgba(255, 255, 0, 0.1) 60%,
transparent 70%
);
background-size: 200% 200%;
animation: foil-shine 3s ease-in-out infinite;
border-radius: inherit;
pointer-events: none;
z-index: 1;
}
@keyframes foil-shine {
0%, 100% {
background-position: 0% 0%;
opacity: 0.3;
}
50% {
background-position: 100% 100%;
opacity: 0.7;
}
}
/* Gradient Border Effects */
.rarity-border-common {
position: relative;
border: 2px solid transparent;
background:
linear-gradient(white, white) padding-box,
linear-gradient(145deg,
rgba(107, 114, 128, 0.4) 0%,
rgba(156, 163, 175, 0.7) 50%,
rgba(107, 114, 128, 0.4) 100%) border-box;
box-shadow: 0 0 20px rgba(107, 114, 128, 0.2);
}
.rarity-border-uncommon {
position: relative;
border: 2px solid transparent;
background:
linear-gradient(white, white) padding-box,
linear-gradient(145deg,
rgba(34, 197, 94, 0.4) 0%,
rgba(74, 222, 128, 0.7) 50%,
rgba(34, 197, 94, 0.4) 100%) border-box;
box-shadow: 0 0 20px rgba(34, 197, 94, 0.2);
}
.rarity-border-rare {
position: relative;
border: 2px solid transparent;
background:
linear-gradient(white, white) padding-box,
linear-gradient(145deg,
rgba(59, 130, 246, 0.4) 0%,
rgba(99, 102, 241, 0.7) 50%,
rgba(59, 130, 246, 0.4) 100%) border-box;
box-shadow: 0 0 20px rgba(59, 130, 246, 0.3);
}
.rarity-border-superrare {
position: relative;
border: 2px solid transparent;
background:
linear-gradient(white, white) padding-box,
linear-gradient(145deg,
rgba(147, 51, 234, 0.5) 0%,
rgba(168, 85, 247, 0.8) 50%,
rgba(147, 51, 234, 0.5) 100%) border-box;
box-shadow: 0 0 20px rgba(147, 51, 234, 0.3);
}
.rarity-border-legendary {
position: relative;
border: 2px solid transparent;
background:
linear-gradient(white, white) padding-box,
linear-gradient(145deg,
rgba(245, 158, 11, 0.5) 0%,
rgba(251, 191, 36, 0.8) 30%,
rgba(254, 240, 138, 1.0) 50%,
rgba(251, 191, 36, 0.8) 70%,
rgba(245, 158, 11, 0.5) 100%) border-box;
box-shadow: 0 0 30px rgba(245, 158, 11, 0.5);
}
.rarity-border-mythic {
position: relative;
border: 2px solid transparent;
background:
linear-gradient(white, white) padding-box,
linear-gradient(145deg,
rgba(239, 68, 68, 0.5) 0%,
rgba(248, 113, 113, 0.8) 30%,
rgba(254, 202, 202, 1.0) 50%,
rgba(248, 113, 113, 0.8) 70%,
rgba(239, 68, 68, 0.5) 100%) border-box;
box-shadow: 0 0 30px rgba(239, 68, 68, 0.5);
}
/* Mouse glow tracking removed - performance optimization */
/* Enhanced gradient border animations - no individual transforms */
.rarity-border-common,
.rarity-border-uncommon,
.rarity-border-rare,
.rarity-border-superrare,
.rarity-border-legendary,
.rarity-border-mythic {
/* Main container handles all transforms */
}
/* Remove individual rarity border scaling - main container handles this */
.rarity-border-common:hover,
.rarity-border-uncommon:hover,
.rarity-border-rare:hover {
/* No transforms - handled by main container */
}
/* Remove continuous pulse for Super Rare - keep only for Legendary/Mythic */
.rarity-border-superrare:hover {
/* No pulse animation - better performance */
}
.rarity-border-legendary:hover,
.rarity-border-mythic:hover {
animation: pulse-glow 2s ease-in-out infinite;
/* No transforms - handled by main container */
}
@keyframes pulse-glow {
0%, 100% {
box-shadow:
0 0 25px rgba(147, 51, 234, 0.4),
0 0 45px rgba(147, 51, 234, 0.2);
}
50% {
box-shadow:
0 0 35px rgba(147, 51, 234, 0.5),
0 0 65px rgba(147, 51, 234, 0.3);
}
}
/* Table row glow effects (simplified) */
.table-row-wrapper {
display: table-row;
}
/* Animated Border Glow */
.rarity-border-glow {
position: relative;
animation: border-glow 2s ease-in-out infinite alternate;
}
@keyframes border-glow {
0% {
box-shadow: 0 0 5px var(--glow-color, rgba(107, 114, 128, 0.2));
}
100% {
box-shadow: 0 0 20px var(--glow-color, rgba(107, 114, 128, 0.4));
}
}
/* Holographic Effect for Mythic/Legendary Cards */
.holographic {
position: relative;
background: linear-gradient(
45deg,
#ff0080,
#ff8000,
#ffff00,
#80ff00,
#00ffff,
#8000ff,
#ff0080
);
background-size: 400%;
animation: holographic 4s ease-in-out infinite;
}
.holographic::before {
content: '';
position: absolute;
top: 2px;
left: 2px;
right: 2px;
bottom: 2px;
background: white;
border-radius: inherit;
z-index: -1;
}
@keyframes holographic {
0%, 100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}
/* Transition improvements with smooth expansion */
.card-transition {
transition: all 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94);
transform-origin: center center;
}
.card-transition:hover {
transition: all 0.8s cubic-bezier(0.23, 1, 0.320, 1);
}
/* Scale effect on hover with layered timing */
.hover-scale {
transition: transform 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.hover-scale:hover {
transform: scale(1.03);
transition: transform 0.8s cubic-bezier(0.23, 1, 0.320, 1);
}
/* Card content - no individual transforms */
.card-content {
/* No transforms - main container handles expansion */
}
/* Card image container - no scaling, just static */
.card-image-container {
/* No transforms - main container handles expansion */
}
/* Card text content - no transforms */
.card-text-content {
/* No transforms - main container handles expansion */
}
/* Optimized organic expansion - main container only */
.card-expansion-organic {
transition: transform 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94);
transform-origin: center center;
will-change: transform;
transform: translate3d(0, 0, 0); /* Force hardware acceleration */
}
.card-expansion-organic:hover {
transform: translate3d(0, 0, 0) scale(1.02);
transition: transform 0.8s cubic-bezier(0.23, 1, 0.320, 1);
}
/* Optimized premium card animations */
.premium-card-animation {
transition: transform 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94);
transform-origin: center center;
will-change: transform;
transform: translate3d(0, 0, 0); /* Force hardware acceleration */
}
.premium-card-animation:hover {
transform: translate3d(0, 0, 0) scale(1.025);
transition: transform 0.9s cubic-bezier(0.23, 1, 0.320, 1);
}
/* Optimized breathe-like expansion for mythic cards */
.mythic-expansion {
transition: transform 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
transform-origin: center center;
will-change: transform;
transform: translate3d(0, 0, 0); /* Force hardware acceleration */
}
.mythic-expansion:hover {
transform: translate3d(0, 0, 0) scale(1.035);
transition: transform 1.1s cubic-bezier(0.23, 1, 0.320, 1);
}
/* Depth shadow with smooth expansion */
.card-depth {
box-shadow:
0 4px 8px rgba(0, 0, 0, 0.1),
0 6px 20px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.card-depth:hover {
box-shadow:
0 12px 24px rgba(0, 0, 0, 0.15),
0 16px 48px rgba(0, 0, 0, 0.12);
transition: box-shadow 0.9s cubic-bezier(0.23, 1, 0.320, 1);
}

View file

@ -1,295 +0,0 @@
// Core entity interfaces for TCG Vault
export interface User {
id: string;
username: string;
email: string;
firstName?: string;
lastName?: string;
roles: string[];
createdAt: string;
updatedAt: string;
}
export interface Tag {
id: string;
name: string;
color: string;
userId: string;
createdAt: string;
}
export interface Card {
id: string;
// Card database info
name: string;
set_name: string;
set_code: string;
card_number: string;
rarity: string;
game: 'MTG' | 'POKEMON' | 'LORCANA' | 'YUGIOH' | 'OTHER';
card_type: string;
// Game-specific attributes
mana_cost?: string;
cmc?: number;
colors?: string[];
oracle_text?: string;
power?: string;
toughness?: string;
// Pricing
current_price?: number;
market_price?: number;
price_history?: PricePoint[];
// Images
image_url?: string;
stock_image_url?: string;
artwork_crop_coords?: {
x: number;
y: number;
width: number;
height: number;
};
// System fields
verified: boolean;
createdAt: string;
updatedAt: string;
}
export interface PricePoint {
date: string;
price: number;
source: string;
}
export interface UserCard {
id: string;
userId: string;
cardId: string;
card?: Card; // Populated card data
// Ownership status
status: 'owned' | 'wanted';
quantity: number;
condition?: 'mint' | 'near_mint' | 'excellent' | 'good' | 'light_played' | 'played' | 'poor';
// Organization
tags: string[]; // Tag IDs
notes?: string;
// Acquisition info
acquired_date?: string;
acquired_price?: number;
acquired_from?: string;
// Collection associations
collectionIds: string[];
deckIds: string[];
createdAt: string;
updatedAt: string;
}
export interface Collection {
id: string;
userId: string;
// Basic info
name: string;
description?: string;
game?: 'MTG' | 'POKEMON' | 'LORCANA' | 'YUGIOH' | 'OTHER';
// Organization
tags: string[]; // Tag IDs
color?: string;
icon?: string;
// Status
isPublic: boolean;
isFavorite: boolean;
// Stats (calculated)
cardCount?: number;
totalValue?: number;
completionPercentage?: number;
// Metadata
createdAt: string;
updatedAt: string;
// Populated data
cards?: UserCard[];
userTags?: Tag[];
}
export interface Deck {
id: string;
userId: string;
// Basic info
name: string;
description?: string;
game: 'MTG' | 'POKEMON' | 'LORCANA' | 'YUGIOH' | 'OTHER';
format?: string; // Standard, Commander, etc.
// Organization
tags: string[]; // Tag IDs
color?: string;
// Status
isPublic: boolean;
isFavorite: boolean;
isLegal?: boolean;
// Deck composition
mainboard: DeckCard[];
sideboard?: DeckCard[];
// Stats (calculated)
totalCards?: number;
totalValue?: number;
averageManaValue?: number;
colorIdentity?: string[];
// Metadata
createdAt: string;
updatedAt: string;
// Populated data
userTags?: Tag[];
}
export interface DeckCard {
cardId: string;
card?: Card; // Populated card data
quantity: number;
category?: string; // For organization within deck
notes?: string;
// Override card status for deck building
status: 'owned' | 'needed' | 'considering';
}
// API Response types
export interface ApiResponse<T> {
data: T;
message?: string;
success: boolean;
}
export interface PaginatedResponse<T> {
data: T[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
success: boolean;
}
// Filter and Search types
export interface CardFilters {
game?: string;
rarity?: string;
cardType?: string;
colors?: string[];
manaCost?: {
min?: number;
max?: number;
};
price?: {
min?: number;
max?: number;
};
search?: string;
tags?: string[];
status?: 'owned' | 'wanted' | 'all';
}
export interface CollectionFilters {
game?: string;
tags?: string[];
search?: string;
isPublic?: boolean;
isFavorite?: boolean;
}
export interface DeckFilters {
game?: string;
format?: string;
tags?: string[];
search?: string;
isPublic?: boolean;
isFavorite?: boolean;
isLegal?: boolean;
}
// Form types for creation/editing
export interface CreateCardData {
cardId: string; // Reference to card in database
status: 'owned' | 'wanted';
quantity: number;
condition?: string;
notes?: string;
tags?: string[];
collectionIds?: string[];
deckIds?: string[];
acquired_date?: string;
acquired_price?: number;
acquired_from?: string;
}
export interface CreateCollectionData {
name: string;
description?: string;
game?: string;
tags?: string[];
color?: string;
icon?: string;
isPublic?: boolean;
isFavorite?: boolean;
}
export interface CreateDeckData {
name: string;
description?: string;
game: string;
format?: string;
tags?: string[];
color?: string;
isPublic?: boolean;
isFavorite?: boolean;
}
export interface CreateTagData {
name: string;
color: string;
}
// Statistics and Analytics
export interface UserStats {
totalCards: number;
totalCollections: number;
totalDecks: number;
totalValue: number;
cardsByGame: Record<string, number>;
cardsByRarity: Record<string, number>;
cardsByStatus: {
owned: number;
wanted: number;
};
recentActivity: ActivityItem[];
}
export interface ActivityItem {
id: string;
type: 'card_added' | 'collection_created' | 'deck_created' | 'card_moved';
description: string;
entityId: string;
entityType: 'card' | 'collection' | 'deck';
createdAt: string;
}

3
styles/globals.css Normal file
View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

@ -1,88 +1,12 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
darkMode: 'class',
theme: {
extend: {
colors: {
// Purple primary palette
primary: {
50: '#faf5ff',
100: '#f3e8ff',
200: '#e9d5ff',
300: '#d8b4fe',
400: '#c084fc',
500: '#a855f7',
600: '#9333ea',
700: '#7c3aed',
800: '#6b21a8',
900: '#581c87',
950: '#3b0764',
},
// Purple accent variations
accent: {
50: '#f5f3ff',
100: '#ede9fe',
200: '#ddd6fe',
300: '#c4b5fd',
400: '#a78bfa',
500: '#8b5cf6',
600: '#7c3aed',
700: '#6d28d9',
800: '#5b21b6',
900: '#4c1d95',
},
// Custom surface colors for dark/light modes
surface: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
},
},
screens: {
'xs': '475px',
},
spacing: {
'18': '4.5rem',
'88': '22rem',
'92': '23rem',
'128': '32rem',
},
minHeight: {
'screen-safe': '100dvh',
},
fontSize: {
'xxs': '0.625rem',
},
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'bounce-subtle': 'bounceSubtle 0.6s ease-in-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(20px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
bounceSubtle: {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-5px)' },
},
},
},
extend: {},
},
plugins: [],
}

View file

@ -1,30 +1,27 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"lib": ["dom", "dom.iterable", "es6"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"incremental": true
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"src"
],
"exclude": [
"node_modules"
]
}
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}