✨ Features Added: - User registration and login with JWT authentication - Role-based access control (user/admin) - Comprehensive admin panel with user management - Password hashing with bcrypt - Session management and token storage - Protected routes and public routes - Modern login/register forms with validation 🗄️ Database Schema: - Users table with profile information - Roles and permissions system - User-role junction tables - Session management tables - Database triggers and indexes 🎨 UI/UX Improvements: - Updated navigation with user menu - Admin badge and access controls - Responsive authentication forms - Loading states and error handling - Role-based UI elements 🔧 API Endpoints: - /api/auth/login - User authentication - /api/auth/register - User registration - /api/admin/users - User management (admin only) - /api/setup-auth - Database schema setup 🚀 Admin Panel Features: - User listing with search and pagination - Role assignment (user/admin) - User activation/deactivation - System dashboard with stats - Card management placeholder - Real-time user management
319 lines
No EOL
10 KiB
TypeScript
319 lines
No EOL
10 KiB
TypeScript
import React, { useState, useRef, useEffect } from 'react';
|
||
import Tesseract from 'tesseract.js';
|
||
|
||
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;
|
||
setIsStreaming(true);
|
||
}
|
||
} catch (err) {
|
||
console.error('Camera access error:', err);
|
||
onError('Unable to access camera. Please check permissions.');
|
||
}
|
||
};
|
||
|
||
// 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 Tesseract OCR
|
||
const processImage = async (imageData: string) => {
|
||
setIsProcessing(true);
|
||
setScanResult(null);
|
||
|
||
try {
|
||
const { data: { text, confidence } } = await Tesseract.recognize(imageData, 'eng', {
|
||
logger: m => console.log(m) // Optional: log progress
|
||
});
|
||
|
||
console.log('OCR Result:', text);
|
||
console.log('Confidence:', confidence);
|
||
|
||
// Extract card information from OCR text
|
||
const cardInfo = extractCardInfo(text);
|
||
|
||
const result: ScanResult = {
|
||
text: text.trim(),
|
||
confidence,
|
||
cardName: cardInfo.name,
|
||
setName: cardInfo.set
|
||
};
|
||
|
||
setScanResult(result);
|
||
|
||
// If we found card info, try to match against database
|
||
if (cardInfo.name) {
|
||
onCardScanned({
|
||
name: cardInfo.name,
|
||
set: cardInfo.set,
|
||
ocrText: text,
|
||
confidence: confidence
|
||
});
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('OCR processing error:', error);
|
||
onError('Failed to process image. Please try again.');
|
||
} finally {
|
||
setIsProcessing(false);
|
||
}
|
||
};
|
||
|
||
// Extract card name and set from OCR text
|
||
const extractCardInfo = (text: string) => {
|
||
const lines = text.split('\n').map(line => line.trim()).filter(line => line.length > 0);
|
||
|
||
// Common patterns for different card games
|
||
const patterns = {
|
||
// Magic: The Gathering patterns
|
||
mtg: {
|
||
cardName: /^[A-Z][a-zA-Z\s,'-]+(?=\s|$)/,
|
||
setInfo: /\b([A-Z]{3}|[A-Z]{4})\b/, // 3-4 letter set codes
|
||
},
|
||
// Pokemon patterns
|
||
pokemon: {
|
||
cardName: /^[A-Z][a-zA-Z\s]+(?=\s+[0-9])/,
|
||
hpPattern: /HP\s*[0-9]+/,
|
||
},
|
||
// Lorcana patterns
|
||
lorcana: {
|
||
cardName: /^[A-Z][a-zA-Z\s,'-]+(?=\s*-)/,
|
||
subtitle: /-\s*([A-Z][a-zA-Z\s]+)/,
|
||
}
|
||
};
|
||
|
||
let cardName = '';
|
||
let setName = '';
|
||
|
||
// Try to find the card name (usually the first substantial line)
|
||
for (const line of lines) {
|
||
if (line.length > 3 && line.length < 50) { // Reasonable card name length
|
||
// Skip common non-name text
|
||
if (!line.match(/^(hp|©|legendary|instant|sorcery|creature|artifact|enchantment)$/i)) {
|
||
if (!cardName || line.length > cardName.length) {
|
||
cardName = line;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Try to extract set information
|
||
const setText = lines.find(line => patterns.mtg.setInfo.test(line));
|
||
if (setText) {
|
||
const setMatch = setText.match(patterns.mtg.setInfo);
|
||
if (setMatch) {
|
||
setName = setMatch[1];
|
||
}
|
||
}
|
||
|
||
return {
|
||
name: cardName || '',
|
||
set: setName || ''
|
||
};
|
||
};
|
||
|
||
// 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 */}
|
||
{isStreaming && (
|
||
<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"
|
||
/>
|
||
|
||
{/* Scan Guide Overlay */}
|
||
<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>
|
||
</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;
|