🚀 Convert to mobile-first PWA with purple theme
- Add PWA configuration (manifest.json, service worker) - Implement light/dark theme system with purple accents - Replace desktop navbar with mobile-first bottom navigation - Redesign auth forms with mobile-optimized UI - Update Dashboard with gradient cards and touch-friendly actions - Add safe area support for iOS devices - Implement theme context with system preference detection - Optimize touch targets and mobile interactions - Add offline functionality through service worker
This commit is contained in:
parent
8fe34cc10f
commit
0a03f0ed14
12 changed files with 1238 additions and 582 deletions
|
|
@ -3,41 +3,40 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#8b5cf6" />
|
||||||
<meta
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
name="description"
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
content="Web site created using create-react-app"
|
<meta name="apple-mobile-web-app-title" content="TCG Vault" />
|
||||||
/>
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
|
||||||
<!--
|
|
||||||
manifest.json provides metadata used when your web app is installed on a
|
|
||||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
|
||||||
-->
|
|
||||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
|
||||||
<!--
|
|
||||||
Notice the use of %PUBLIC_URL% in the tags above.
|
|
||||||
It will be replaced with the URL of the `public` folder during the build.
|
|
||||||
Only files inside the `public` folder can be referenced from the HTML.
|
|
||||||
|
|
||||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
<!-- PWA Meta Tags -->
|
||||||
work correctly both with client-side routing and a non-root public URL.
|
<meta name="application-name" content="TCG Vault" />
|
||||||
Learn how to configure a non-root public URL by running `npm run build`.
|
<meta name="msapplication-TileColor" content="#8b5cf6" />
|
||||||
-->
|
<meta name="msapplication-config" content="%PUBLIC_URL%/browserconfig.xml" />
|
||||||
<title>React App</title>
|
|
||||||
|
<!-- iOS Splash Screen -->
|
||||||
|
<link rel="apple-touch-startup-image" href="%PUBLIC_URL%/logo512.png" />
|
||||||
|
|
||||||
|
<meta name="description" content="Mobile-first PWA for managing your trading card game collections, decks, and scanning cards with AI-powered OCR" />
|
||||||
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
|
<title>TCG Vault</title>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Prevent flash of unstyled content */
|
||||||
|
html {
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
html {
|
||||||
|
background-color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<!--
|
|
||||||
This HTML file is a template.
|
|
||||||
If you open it directly in the browser, you will see an empty page.
|
|
||||||
|
|
||||||
You can add webfonts, meta tags, or analytics to this file.
|
|
||||||
The build step will place the bundled scripts into the <body> tag.
|
|
||||||
|
|
||||||
To begin the development, run `npm start` or `yarn start`.
|
|
||||||
To create a production bundle, use `npm run build` or `yarn build`.
|
|
||||||
-->
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"short_name": "React App",
|
"short_name": "TCG Vault",
|
||||||
"name": "Create React App Sample",
|
"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": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "favicon.ico",
|
"src": "favicon.ico",
|
||||||
|
|
@ -15,11 +16,15 @@
|
||||||
{
|
{
|
||||||
"src": "logo512.png",
|
"src": "logo512.png",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"sizes": "512x512"
|
"sizes": "512x512",
|
||||||
|
"purpose": "any maskable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"start_url": ".",
|
"start_url": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"theme_color": "#000000",
|
"orientation": "portrait-primary",
|
||||||
"background_color": "#ffffff"
|
"theme_color": "#8b5cf6",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"categories": ["entertainment", "lifestyle", "utilities"],
|
||||||
|
"prefer_related_applications": false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
87
public/sw.js
Normal file
87
public/sw.js
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
const CACHE_NAME = 'tcg-vault-v1';
|
||||||
|
const urlsToCache = [
|
||||||
|
'/',
|
||||||
|
'/static/js/bundle.js',
|
||||||
|
'/static/css/main.css',
|
||||||
|
'/manifest.json',
|
||||||
|
'/favicon.ico',
|
||||||
|
'/logo192.png',
|
||||||
|
'/logo512.png'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Install event
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME)
|
||||||
|
.then((cache) => cache.addAll(urlsToCache))
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to cache resources:', error);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch event
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request)
|
||||||
|
.then((response) => {
|
||||||
|
// Return cached version or fetch from network
|
||||||
|
return response || fetch(event.request);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Fetch failed:', error);
|
||||||
|
// Return offline page if available
|
||||||
|
return caches.match('/');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate event
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((cacheNames) => {
|
||||||
|
return Promise.all(
|
||||||
|
cacheNames.map((cacheName) => {
|
||||||
|
if (cacheName !== CACHE_NAME) {
|
||||||
|
return caches.delete(cacheName);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Background sync for offline actions
|
||||||
|
self.addEventListener('sync', (event) => {
|
||||||
|
if (event.tag === 'background-sync') {
|
||||||
|
event.waitUntil(
|
||||||
|
// Handle offline actions when back online
|
||||||
|
console.log('Background sync triggered')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Push notifications (for future use)
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
if (event.data) {
|
||||||
|
const data = event.data.json();
|
||||||
|
const options = {
|
||||||
|
body: data.body,
|
||||||
|
icon: '/logo192.png',
|
||||||
|
badge: '/favicon.ico',
|
||||||
|
vibrate: [200, 100, 200]
|
||||||
|
};
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(data.title, options)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notification click
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
event.waitUntil(
|
||||||
|
clients.openWindow('/')
|
||||||
|
);
|
||||||
|
});
|
||||||
230
src/App.tsx
230
src/App.tsx
|
|
@ -4,7 +4,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { Analytics } from '@vercel/analytics/react';
|
import { Analytics } from '@vercel/analytics/react';
|
||||||
import { SpeedInsights } from '@vercel/speed-insights/react';
|
import { SpeedInsights } from '@vercel/speed-insights/react';
|
||||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||||
import Navbar from './components/Navbar';
|
import { ThemeProvider } from './contexts/ThemeContext';
|
||||||
|
import MobileNavbar from './components/MobileNavbar';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import Collections from './pages/Collections';
|
import Collections from './pages/Collections';
|
||||||
import Decks from './pages/Decks';
|
import Decks from './pages/Decks';
|
||||||
|
|
@ -30,8 +31,15 @@ const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen-safe flex items-center justify-center bg-white dark:bg-surface-900">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -49,8 +57,15 @@ const PublicRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen-safe flex items-center justify-center bg-white dark:bg-surface-900">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -62,112 +77,141 @@ const PublicRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Layout Component
|
// Mobile Layout Component
|
||||||
const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
const MobileLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen-safe bg-surface-50 dark:bg-surface-900 transition-colors">
|
||||||
<Navbar />
|
<MobileNavbar />
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="pt-16 pb-20 px-4 max-w-md mx-auto">
|
||||||
{children}
|
<div className="animate-fade-in">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthProvider>
|
<ThemeProvider>
|
||||||
<Router>
|
<AuthProvider>
|
||||||
<Routes>
|
<Router>
|
||||||
{/* Public Routes */}
|
<Routes>
|
||||||
<Route path="/login" element={
|
{/* Public Routes */}
|
||||||
<PublicRoute>
|
<Route path="/login" element={
|
||||||
<LoginForm />
|
<PublicRoute>
|
||||||
</PublicRoute>
|
<AuthLayout>
|
||||||
} />
|
<LoginForm />
|
||||||
<Route path="/register" element={
|
</AuthLayout>
|
||||||
<PublicRoute>
|
</PublicRoute>
|
||||||
<RegisterForm />
|
} />
|
||||||
</PublicRoute>
|
<Route path="/register" element={
|
||||||
} />
|
<PublicRoute>
|
||||||
|
<AuthLayout>
|
||||||
|
<RegisterForm />
|
||||||
|
</AuthLayout>
|
||||||
|
</PublicRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
{/* Protected Routes */}
|
{/* Protected Routes */}
|
||||||
<Route path="/dashboard" element={
|
<Route path="/dashboard" element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<Layout>
|
<MobileLayout>
|
||||||
<Dashboard />
|
<Dashboard />
|
||||||
</Layout>
|
</MobileLayout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
<Route path="/collections" element={
|
<Route path="/collections" element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<Layout>
|
<MobileLayout>
|
||||||
<Collections />
|
<Collections />
|
||||||
</Layout>
|
</MobileLayout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
<Route path="/decks" element={
|
<Route path="/decks" element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<Layout>
|
<MobileLayout>
|
||||||
<Decks />
|
<Decks />
|
||||||
</Layout>
|
</MobileLayout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
<Route path="/cards" element={
|
<Route path="/cards" element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<Layout>
|
<MobileLayout>
|
||||||
<Cards />
|
<Cards />
|
||||||
</Layout>
|
</MobileLayout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
<Route path="/scanner" element={
|
<Route path="/scanner" element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<Layout>
|
<MobileLayout>
|
||||||
<Scanner />
|
<Scanner />
|
||||||
</Layout>
|
</MobileLayout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
<Route path="/settings" element={
|
<Route path="/settings" element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<Layout>
|
<MobileLayout>
|
||||||
<Settings />
|
<Settings />
|
||||||
</Layout>
|
</MobileLayout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
{/* Admin Routes */}
|
{/* Admin Routes */}
|
||||||
<Route path="/admin" element={
|
<Route path="/admin" element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<AdminPanel />
|
<MobileLayout>
|
||||||
</ProtectedRoute>
|
<AdminPanel />
|
||||||
} />
|
</MobileLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
{/* Default redirect */}
|
{/* Default redirect */}
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
|
||||||
{/* 404 fallback */}
|
{/* 404 fallback */}
|
||||||
<Route path="*" element={
|
<Route path="*" element={
|
||||||
<Layout>
|
<MobileLayout>
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">404 - Page Not Found</h1>
|
<div className="w-16 h-16 bg-surface-200 dark:bg-surface-700 rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||||
<p className="text-gray-600 mb-8">The page you're looking for doesn't exist.</p>
|
<svg className="w-8 h-8 text-surface-500 dark:text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<a href="/dashboard" className="bg-indigo-600 text-white px-6 py-3 rounded-lg hover:bg-indigo-700 transition-colors">
|
<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" />
|
||||||
Go to Dashboard
|
</svg>
|
||||||
</a>
|
</div>
|
||||||
</div>
|
<h1 className="text-2xl font-bold text-surface-900 dark:text-white mb-2">Page Not Found</h1>
|
||||||
</Layout>
|
<p className="text-surface-600 dark:text-surface-400 mb-6">The page you're looking for doesn't exist.</p>
|
||||||
} />
|
<button
|
||||||
</Routes>
|
onClick={() => window.history.back()}
|
||||||
<Analytics />
|
className="bg-primary-600 hover:bg-primary-700 text-white px-6 py-3 rounded-xl font-medium transition-colors"
|
||||||
<SpeedInsights />
|
>
|
||||||
</Router>
|
Go Back
|
||||||
</AuthProvider>
|
</button>
|
||||||
|
</div>
|
||||||
|
</MobileLayout>
|
||||||
|
} />
|
||||||
|
</Routes>
|
||||||
|
<Analytics />
|
||||||
|
<SpeedInsights />
|
||||||
|
</Router>
|
||||||
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
258
src/components/MobileNavbar.tsx
Normal file
258
src/components/MobileNavbar.tsx
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
|
|
||||||
|
interface NavigationItem {
|
||||||
|
name: string;
|
||||||
|
href: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
activeIcon?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 navigationItems: NavigationItem[] = [
|
||||||
|
{
|
||||||
|
name: 'Dashboard',
|
||||||
|
href: '/dashboard',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-6 h-6" 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>
|
||||||
|
),
|
||||||
|
activeIcon: (
|
||||||
|
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path 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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cards',
|
||||||
|
href: '/cards',
|
||||||
|
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>
|
||||||
|
),
|
||||||
|
activeIcon: (
|
||||||
|
<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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Scanner',
|
||||||
|
href: '/scanner',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
activeIcon: (
|
||||||
|
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path fillRule="evenodd" d="M1.5 6a2.25 2.25 0 012.25-2.25h1.386c.17 0 .318.114.362.278l.558 2.047c.25.895 1.03 1.425 1.944 1.425H19.5a2.25 2.25 0 012.25 2.25v6.75a2.25 2.25 0 01-2.25 2.25H4.5A2.25 2.25 0 012.25 18V9.75a2.25 2.25 0 012.25-2.25H1.5V6zM12 15a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Collections',
|
||||||
|
href: '/collections',
|
||||||
|
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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'More',
|
||||||
|
href: '/menu',
|
||||||
|
icon: (
|
||||||
|
<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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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="grid grid-cols-5 px-2 py-2">
|
||||||
|
{navigationItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
to={item.href === '/menu' ? '#' : item.href}
|
||||||
|
onClick={item.href === '/menu' ? (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowUserMenu(true);
|
||||||
|
} : undefined}
|
||||||
|
className={`flex flex-col items-center justify-center p-2 rounded-xl transition-all duration-200 ${
|
||||||
|
isActive(item.href) && item.href !== '/menu'
|
||||||
|
? 'text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/30'
|
||||||
|
: 'text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white hover:bg-surface-100 dark:hover:bg-surface-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="mb-1">
|
||||||
|
{isActive(item.href) && item.activeIcon ? item.activeIcon : item.icon}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium">{item.name}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</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,143 +1,152 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
const LoginForm: React.FC = () => {
|
const LoginForm: React.FC = () => {
|
||||||
|
const { login } = useAuth();
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
username: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
});
|
});
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { login } = useAuth();
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const { name, value } = e.target;
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
[name]: value
|
|
||||||
}));
|
|
||||||
// Clear error when user starts typing
|
|
||||||
if (error) setError('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError('');
|
setError(null);
|
||||||
|
|
||||||
if (!formData.username.trim() || !formData.password) {
|
try {
|
||||||
setError('Please fill in all fields');
|
await login(formData.email, formData.password);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Login failed');
|
||||||
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const result = await login(formData.username, formData.password);
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFormData(prev => ({
|
||||||
if (result.success) {
|
...prev,
|
||||||
navigate('/dashboard');
|
[e.target.name]: e.target.value
|
||||||
} else {
|
}));
|
||||||
setError(result.error || 'Login failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4 sm:px-6 lg:px-8">
|
<div className="bg-white dark:bg-surface-800 p-8 rounded-2xl shadow-xl border border-surface-200 dark:border-surface-700 transition-colors">
|
||||||
<div className="max-w-md w-full space-y-8">
|
{/* 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>
|
<div>
|
||||||
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-indigo-100">
|
<label htmlFor="email" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
|
||||||
<svg className="h-8 w-8 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
Email Address
|
||||||
<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" />
|
</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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
|
||||||
Welcome to TCG Vault
|
|
||||||
</h2>
|
|
||||||
<p className="mt-2 text-center text-sm text-gray-600">
|
|
||||||
Sign in to your account
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
<div>
|
||||||
<div className="rounded-md shadow-sm space-y-4">
|
<label htmlFor="password" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
|
||||||
<div>
|
Password
|
||||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
|
</label>
|
||||||
Username or Email
|
<div className="relative">
|
||||||
</label>
|
<input
|
||||||
<input
|
type={showPassword ? 'text' : 'password'}
|
||||||
id="username"
|
id="password"
|
||||||
name="username"
|
name="password"
|
||||||
type="text"
|
value={formData.password}
|
||||||
required
|
onChange={handleChange}
|
||||||
className="appearance-none rounded-lg relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
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 username or email"
|
placeholder="Enter your password"
|
||||||
value={formData.username}
|
required
|
||||||
onChange={handleChange}
|
/>
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
className="appearance-none rounded-lg relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
|
||||||
placeholder="Enter your password"
|
|
||||||
value={formData.password}
|
|
||||||
onChange={handleChange}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="button"
|
||||||
disabled={isLoading}
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-lg 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 transition-colors duration-200"
|
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"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{showPassword ? (
|
||||||
<>
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" 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" />
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
</svg>
|
||||||
<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...
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
'Sign in'
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="text-center">
|
<button
|
||||||
<p className="text-sm text-gray-600">
|
type="submit"
|
||||||
Don't have an account?{' '}
|
disabled={isLoading}
|
||||||
<Link
|
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"
|
||||||
to="/register"
|
>
|
||||||
className="font-medium text-indigo-600 hover:text-indigo-500 transition-colors duration-200"
|
{isLoading ? (
|
||||||
>
|
<div className="flex items-center justify-center">
|
||||||
Sign up here
|
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
|
||||||
</Link>
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
</p>
|
<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>
|
||||||
</div>
|
</svg>
|
||||||
</form>
|
Signing In...
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,262 +1,247 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
const RegisterForm: React.FC = () => {
|
const RegisterForm: React.FC = () => {
|
||||||
|
const { register } = useAuth();
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
username: '',
|
username: '',
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
firstName: '',
|
|
||||||
lastName: '',
|
|
||||||
});
|
});
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { register } = useAuth();
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const navigate = useNavigate();
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const { name, value } = e.target;
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
[name]: value
|
|
||||||
}));
|
|
||||||
// Clear error when user starts typing
|
|
||||||
if (error) setError('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateForm = () => {
|
|
||||||
if (!formData.username.trim()) {
|
|
||||||
setError('Username is required');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.username.length < 3) {
|
|
||||||
setError('Username must be at least 3 characters long');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.email.trim()) {
|
|
||||||
setError('Email is required');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
if (!emailRegex.test(formData.email)) {
|
|
||||||
setError('Please enter a valid email address');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.password) {
|
|
||||||
setError('Password is required');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.password.length < 6) {
|
|
||||||
setError('Password must be at least 6 characters long');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.password !== formData.confirmPassword) {
|
|
||||||
setError('Passwords do not match');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError('');
|
setError(null);
|
||||||
|
|
||||||
if (!validateForm()) {
|
// Validate passwords match
|
||||||
|
if (formData.password !== formData.confirmPassword) {
|
||||||
|
setError('Passwords do not match');
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const registerData = {
|
try {
|
||||||
username: formData.username.trim(),
|
await register(formData);
|
||||||
email: formData.email.trim(),
|
} catch (err) {
|
||||||
password: formData.password,
|
setError(err instanceof Error ? err.message : 'Registration failed');
|
||||||
firstName: formData.firstName.trim() || undefined,
|
} finally {
|
||||||
lastName: formData.lastName.trim() || undefined,
|
setIsLoading(false);
|
||||||
};
|
|
||||||
|
|
||||||
const result = await register(registerData);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
navigate('/dashboard');
|
|
||||||
} else {
|
|
||||||
setError(result.error || 'Registration failed');
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
setIsLoading(false);
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[e.target.name]: e.target.value
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 to-emerald-100 py-12 px-4 sm:px-6 lg:px-8">
|
<div className="bg-white dark:bg-surface-800 p-8 rounded-2xl shadow-xl border border-surface-200 dark:border-surface-700 transition-colors">
|
||||||
<div className="max-w-md w-full space-y-8">
|
{/* Logo Section */}
|
||||||
<div>
|
<div className="text-center mb-8">
|
||||||
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-emerald-100">
|
<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="h-8 w-8 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
|
<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>
|
</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>
|
||||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
|
||||||
Join TCG Vault
|
|
||||||
</h2>
|
|
||||||
<p className="mt-2 text-center text-sm text-gray-600">
|
|
||||||
Create your account to get started
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
<div>
|
||||||
<div className="space-y-4">
|
<label htmlFor="username" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
Username
|
||||||
<div>
|
</label>
|
||||||
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700 mb-1">
|
<input
|
||||||
First Name
|
type="text"
|
||||||
</label>
|
id="username"
|
||||||
<input
|
name="username"
|
||||||
id="firstName"
|
value={formData.username}
|
||||||
name="firstName"
|
onChange={handleChange}
|
||||||
type="text"
|
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"
|
||||||
className="appearance-none rounded-lg relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm"
|
placeholder="johndoe"
|
||||||
placeholder="First name"
|
required
|
||||||
value={formData.firstName}
|
/>
|
||||||
onChange={handleChange}
|
</div>
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700 mb-1">
|
<label htmlFor="email" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
|
||||||
Last Name
|
Email Address
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div className="relative">
|
||||||
id="lastName"
|
<input
|
||||||
name="lastName"
|
type="email"
|
||||||
type="text"
|
id="email"
|
||||||
className="appearance-none rounded-lg relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm"
|
name="email"
|
||||||
placeholder="Last name"
|
value={formData.email}
|
||||||
value={formData.lastName}
|
onChange={handleChange}
|
||||||
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"
|
||||||
disabled={isLoading}
|
placeholder="john@example.com"
|
||||||
/>
|
required
|
||||||
</div>
|
/>
|
||||||
</div>
|
<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" />
|
||||||
<div>
|
</svg>
|
||||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Username *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="username"
|
|
||||||
name="username"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
className="appearance-none rounded-lg relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm"
|
|
||||||
placeholder="Choose a username"
|
|
||||||
value={formData.username}
|
|
||||||
onChange={handleChange}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Email Address *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
className="appearance-none rounded-lg relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm"
|
|
||||||
placeholder="Enter your email"
|
|
||||||
value={formData.email}
|
|
||||||
onChange={handleChange}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Password *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
className="appearance-none rounded-lg relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm"
|
|
||||||
placeholder="Create a password"
|
|
||||||
value={formData.password}
|
|
||||||
onChange={handleChange}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Confirm Password *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="confirmPassword"
|
|
||||||
name="confirmPassword"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
className="appearance-none rounded-lg relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm"
|
|
||||||
placeholder="Confirm your password"
|
|
||||||
value={formData.confirmPassword}
|
|
||||||
onChange={handleChange}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
<div>
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
|
<label htmlFor="password" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
|
||||||
{error}
|
Password
|
||||||
</div>
|
</label>
|
||||||
)}
|
<div className="relative">
|
||||||
|
<input
|
||||||
<div>
|
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
|
<button
|
||||||
type="submit"
|
type="button"
|
||||||
disabled={isLoading}
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-lg text-white bg-emerald-600 hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
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"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{showPassword ? (
|
||||||
<>
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" 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" />
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
</svg>
|
||||||
<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...
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
'Create Account'
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="text-center">
|
<div>
|
||||||
<p className="text-sm text-gray-600">
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
|
||||||
Already have an account?{' '}
|
Confirm Password
|
||||||
<Link
|
</label>
|
||||||
to="/login"
|
<div className="relative">
|
||||||
className="font-medium text-emerald-600 hover:text-emerald-500 transition-colors duration-200"
|
<input
|
||||||
>
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
Sign in here
|
id="confirmPassword"
|
||||||
</Link>
|
name="confirmPassword"
|
||||||
</p>
|
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>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
105
src/contexts/ThemeContext.tsx
Normal file
105
src/contexts/ThemeContext.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
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;
|
||||||
|
|
@ -2,6 +2,23 @@
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@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 */
|
/* Custom animations */
|
||||||
@keyframes slide-down {
|
@keyframes slide-down {
|
||||||
from {
|
from {
|
||||||
|
|
@ -18,6 +35,23 @@
|
||||||
animation: slide-down 0.3s ease-out;
|
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 custom card effects */
|
||||||
@import './styles/cardEffects.css';
|
@import './styles/cardEffects.css';
|
||||||
|
|
||||||
|
|
@ -28,6 +62,13 @@ body {
|
||||||
sans-serif;
|
sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-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 {
|
code {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,19 @@ root.render(
|
||||||
</React.StrictMode>
|
</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
|
// If you want to start measuring performance in your app, pass a function
|
||||||
// to log results (for example: reportWebVitals(console.log))
|
// to log results (for example: reportWebVitals(console.log))
|
||||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||||
|
|
|
||||||
|
|
@ -5,125 +5,174 @@ import { Link } from 'react-router-dom';
|
||||||
const Dashboard: React.FC = () => {
|
const Dashboard: React.FC = () => {
|
||||||
const { user } = useAuth();
|
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 (
|
return (
|
||||||
<div>
|
<div className="space-y-6">
|
||||||
<div className="mb-8">
|
{/* Welcome Section */}
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
<div className="bg-gradient-to-br from-primary-500 via-primary-600 to-accent-500 p-6 rounded-2xl text-white shadow-lg">
|
||||||
Welcome back, {user?.username}!
|
<h1 className="text-2xl font-bold mb-2">
|
||||||
|
Welcome back, {user?.firstName || user?.username}! 👋
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600">
|
<p className="text-primary-100">
|
||||||
Here's an overview of your trading card collection.
|
Ready to manage your card collection?
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Stats */}
|
{/* Quick Stats */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
{statsCards.map((stat, index) => (
|
||||||
<div className="flex items-center">
|
<div
|
||||||
<div className="flex-shrink-0">
|
key={stat.title}
|
||||||
<span className="text-2xl">📚</span>
|
className="bg-white dark:bg-surface-800 p-4 rounded-xl shadow-sm border border-surface-200 dark:border-surface-700 transition-colors"
|
||||||
</div>
|
>
|
||||||
<div className="ml-4">
|
<div className={`inline-flex items-center justify-center w-10 h-10 rounded-lg mb-3 ${
|
||||||
<p className="text-sm font-medium text-gray-600">Collections</p>
|
stat.color === 'primary'
|
||||||
<p className="text-2xl font-bold text-gray-900">0</p>
|
? '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>
|
</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>
|
||||||
</div>
|
))}
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<span className="text-2xl">🎴</span>
|
|
||||||
</div>
|
|
||||||
<div className="ml-4">
|
|
||||||
<p className="text-sm font-medium text-gray-600">Decks</p>
|
|
||||||
<p className="text-2xl font-bold text-gray-900">0</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<span className="text-2xl">🃏</span>
|
|
||||||
</div>
|
|
||||||
<div className="ml-4">
|
|
||||||
<p className="text-sm font-medium text-gray-600">Total Cards</p>
|
|
||||||
<p className="text-2xl font-bold text-gray-900">0</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<span className="text-2xl">💰</span>
|
|
||||||
</div>
|
|
||||||
<div className="ml-4">
|
|
||||||
<p className="text-sm font-medium text-gray-600">Total Value</p>
|
|
||||||
<p className="text-2xl font-bold text-gray-900">$0</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="space-y-4">
|
||||||
<Link
|
<h2 className="text-lg font-semibold text-surface-900 dark:text-white">
|
||||||
to="/scanner"
|
Quick Actions
|
||||||
className="bg-white rounded-lg shadow p-6 border border-gray-200 hover:shadow-md transition-shadow"
|
</h2>
|
||||||
>
|
|
||||||
<div className="text-center">
|
|
||||||
<span className="text-4xl mb-4 block">📸</span>
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
Scan Cards
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
Use OCR to quickly add cards to your collection
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
{quickActions.map((action, index) => (
|
||||||
to="/collections"
|
<Link
|
||||||
className="bg-white rounded-lg shadow p-6 border border-gray-200 hover:shadow-md transition-shadow"
|
key={action.title}
|
||||||
>
|
to={action.href}
|
||||||
<div className="text-center">
|
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"
|
||||||
<span className="text-4xl mb-4 block">📚</span>
|
>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<div className="flex items-center space-x-4">
|
||||||
Manage Collections
|
<div className={`w-12 h-12 bg-gradient-to-r ${action.gradient} rounded-xl flex items-center justify-center text-white shadow-md`}>
|
||||||
</h3>
|
{action.icon}
|
||||||
<p className="text-gray-600">
|
</div>
|
||||||
Organize and track your card collections
|
<div className="flex-1">
|
||||||
</p>
|
<h3 className="font-semibold text-surface-900 dark:text-white">
|
||||||
</div>
|
{action.title}
|
||||||
</Link>
|
</h3>
|
||||||
|
<p className="text-sm text-surface-600 dark:text-surface-400">
|
||||||
<Link
|
{action.description}
|
||||||
to="/decks"
|
</p>
|
||||||
className="bg-white rounded-lg shadow p-6 border border-gray-200 hover:shadow-md transition-shadow"
|
</div>
|
||||||
>
|
<svg className="w-5 h-5 text-surface-400 dark:text-surface-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<div className="text-center">
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
<span className="text-4xl mb-4 block">🎴</span>
|
</svg>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
</div>
|
||||||
Build Decks
|
</Link>
|
||||||
</h3>
|
))}
|
||||||
<p className="text-gray-600">
|
|
||||||
Create and optimize decks with AI assistance
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Activity (placeholder) */}
|
{/* Recent Activity */}
|
||||||
<div className="mt-8">
|
<div className="space-y-4">
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">
|
<h2 className="text-lg font-semibold text-surface-900 dark:text-white">
|
||||||
Recent Activity
|
Recent Activity
|
||||||
</h2>
|
</h2>
|
||||||
<div className="bg-white rounded-lg shadow border border-gray-200 p-6">
|
|
||||||
<p className="text-gray-500 text-center py-8">
|
<div className="bg-white dark:bg-surface-800 p-6 rounded-xl shadow-sm border border-surface-200 dark:border-surface-700 transition-colors">
|
||||||
No recent activity. Start by scanning some cards or creating a collection!
|
<div className="text-center">
|
||||||
</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,24 +3,85 @@ module.exports = {
|
||||||
content: [
|
content: [
|
||||||
"./src/**/*.{js,jsx,ts,tsx}",
|
"./src/**/*.{js,jsx,ts,tsx}",
|
||||||
],
|
],
|
||||||
|
darkMode: 'class',
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
|
// Purple primary palette
|
||||||
primary: {
|
primary: {
|
||||||
50: '#eff6ff',
|
50: '#faf5ff',
|
||||||
100: '#dbeafe',
|
100: '#f3e8ff',
|
||||||
500: '#3b82f6',
|
200: '#e9d5ff',
|
||||||
600: '#2563eb',
|
300: '#d8b4fe',
|
||||||
700: '#1d4ed8',
|
400: '#c084fc',
|
||||||
|
500: '#a855f7',
|
||||||
|
600: '#9333ea',
|
||||||
|
700: '#7c3aed',
|
||||||
|
800: '#6b21a8',
|
||||||
|
900: '#581c87',
|
||||||
|
950: '#3b0764',
|
||||||
},
|
},
|
||||||
secondary: {
|
// Purple accent variations
|
||||||
50: '#fdf7ef',
|
accent: {
|
||||||
100: '#fcefc3',
|
50: '#f5f3ff',
|
||||||
500: '#f59e0b',
|
100: '#ede9fe',
|
||||||
600: '#d97706',
|
200: '#ddd6fe',
|
||||||
700: '#b45309',
|
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)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue