Clean slate: Remove React traces and create pure Next.js setup
This commit is contained in:
parent
2d4c4963b8
commit
7be41b4490
93 changed files with 58 additions and 15830 deletions
|
|
@ -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
27
package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
50
package.json
50
package.json
|
|
@ -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",
|
||||
|
|
|
|||
5
pages/_app.js
Normal file
5
pages/_app.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import '../styles/globals.css'
|
||||
|
||||
export default function App({ Component, pageProps }) {
|
||||
return <Component {...pageProps} />
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
});
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export default function handler(req, res) {
|
||||
res.status(200).json({ message: 'Hello from Next.js API!' })
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
});
|
||||
}
|
||||
|
|
@ -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'
|
||||
});
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
14
pages/index.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
38
src/App.css
38
src/App.css
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
211
src/App.tsx
211
src/App.tsx
|
|
@ -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're looking for doesn'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;
|
||||
|
|
@ -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 "Start Camera" 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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'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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>;
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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 |
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import React from 'react';
|
||||
import ScannerWizard from '../components/scanner/ScannerWizard';
|
||||
|
||||
const Scanner: React.FC = () => {
|
||||
return <ScannerWizard />;
|
||||
};
|
||||
|
||||
export default Scanner;
|
||||
|
|
@ -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;
|
||||
1
src/react-app-env.d.ts
vendored
1
src/react-app-env.d.ts
vendored
|
|
@ -1 +0,0 @@
|
|||
/// <reference types="react-scripts" />
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
|
|
@ -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;
|
||||
|
|
@ -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';
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
3
styles/globals.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
|
@ -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: [],
|
||||
}
|
||||
|
|
@ -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
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Loading…
Reference in a new issue