🚀 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:
Randall Stillwell 2025-07-22 18:52:39 -05:00
parent 8fe34cc10f
commit 0a03f0ed14
12 changed files with 1238 additions and 582 deletions

View file

@ -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" />
<!-- PWA Meta Tags -->
<meta name="application-name" content="TCG Vault" />
<meta name="msapplication-TileColor" content="#8b5cf6" />
<meta name="msapplication-config" content="%PUBLIC_URL%/browserconfig.xml" />
<!-- 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="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" /> <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!-- <title>TCG Vault</title>
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build. <style>
Only files inside the `public` folder can be referenced from the HTML. /* Prevent flash of unstyled content */
html {
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will background-color: #ffffff;
work correctly both with client-side routing and a non-root public URL. }
Learn how to configure a non-root public URL by running `npm run build`. @media (prefers-color-scheme: dark) {
--> html {
<title>React App</title> 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>

View file

@ -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
View 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('/')
);
});

View file

@ -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>
); );
} }

View 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;

View file

@ -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 className="rounded-md shadow-sm space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
Username or Email
</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-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="Enter your username or email"
value={formData.username}
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>
<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="Enter your 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-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>
); );
}; };

View file

@ -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 className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-r from-primary-500 to-accent-500 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
<svg className="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h1 className="text-2xl font-bold text-surface-900 dark:text-white mb-2">
Join TCG Vault
</h1>
<p className="text-surface-600 dark:text-surface-400">
Create your account to get started
</p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl">
<div className="flex items-center">
<svg className="w-5 h-5 text-red-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<span className="text-red-700 dark:text-red-400 text-sm">{error}</span>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="firstName" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
First Name
</label>
<input
type="text"
id="firstName"
name="firstName"
value={formData.firstName}
onChange={handleChange}
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors"
placeholder="John"
required
/>
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Last Name
</label>
<input
type="text"
id="lastName"
name="lastName"
value={formData.lastName}
onChange={handleChange}
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors"
placeholder="Doe"
required
/>
</div>
</div>
<div> <div>
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-emerald-100"> <label htmlFor="username" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
<svg className="h-8 w-8 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"> Username
<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" /> </label>
<input
type="text"
id="username"
name="username"
value={formData.username}
onChange={handleChange}
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors"
placeholder="johndoe"
required
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Email Address
</label>
<div className="relative">
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors"
placeholder="john@example.com"
required
/>
<svg className="absolute right-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-surface-400 dark:text-surface-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207" />
</svg> </svg>
</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 className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700 mb-1">
First Name
</label>
<input
id="firstName"
name="firstName"
type="text"
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="First name"
value={formData.firstName}
onChange={handleChange}
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700 mb-1">
Last Name
</label>
<input
id="lastName"
name="lastName"
type="text"
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="Last name"
value={formData.lastName}
onChange={handleChange}
disabled={isLoading}
/>
</div>
</div>
<div> <div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor="password" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Username * Password
</label> </label>
<input <div className="relative">
id="username" <input
name="username" type={showPassword ? 'text' : 'password'}
type="text" id="password"
required name="password"
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" value={formData.password}
placeholder="Choose a username" onChange={handleChange}
value={formData.username} 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"
onChange={handleChange} placeholder="Create a strong password"
disabled={isLoading} required
/> />
</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>
{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-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>
); );
}; };

View 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;

View file

@ -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 {

View file

@ -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

View file

@ -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"> {quickActions.map((action, index) => (
<span className="text-4xl mb-4 block">📸</span> <Link
<h3 className="text-lg font-semibold text-gray-900 mb-2"> key={action.title}
Scan Cards to={action.href}
</h3> 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"
<p className="text-gray-600"> >
Use OCR to quickly add cards to your collection <div className="flex items-center space-x-4">
</p> <div className={`w-12 h-12 bg-gradient-to-r ${action.gradient} rounded-xl flex items-center justify-center text-white shadow-md`}>
</div> {action.icon}
</Link> </div>
<div className="flex-1">
<Link <h3 className="font-semibold text-surface-900 dark:text-white">
to="/collections" {action.title}
className="bg-white rounded-lg shadow p-6 border border-gray-200 hover:shadow-md transition-shadow" </h3>
> <p className="text-sm text-surface-600 dark:text-surface-400">
<div className="text-center"> {action.description}
<span className="text-4xl mb-4 block">📚</span> </p>
<h3 className="text-lg font-semibold text-gray-900 mb-2"> </div>
Manage Collections <svg className="w-5 h-5 text-surface-400 dark:text-surface-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</h3> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
<p className="text-gray-600"> </svg>
Organize and track your card collections </div>
</p> </Link>
</div> ))}
</Link>
<Link
to="/decks"
className="bg-white rounded-lg shadow p-6 border border-gray-200 hover:shadow-md transition-shadow"
>
<div className="text-center">
<span className="text-4xl mb-4 block">🎴</span>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Build Decks
</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>

View file

@ -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: [],