🚀 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>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<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.
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" />
<meta name="theme-color" content="#8b5cf6" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="TCG Vault" />
<meta name="mobile-web-app-capable" content="yes" />
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
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`.
-->
<title>React App</title>
<!-- 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="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>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<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>
</html>

View file

@ -1,6 +1,7 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"short_name": "TCG Vault",
"name": "TCG Vault - Trading Card Game Collection Manager",
"description": "Mobile-first PWA for managing your trading card game collections, decks, and scanning cards with AI-powered OCR",
"icons": [
{
"src": "favicon.ico",
@ -15,11 +16,15 @@
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
"sizes": "512x512",
"purpose": "any maskable"
}
],
"start_url": ".",
"start_url": "/",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
"orientation": "portrait-primary",
"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 { SpeedInsights } from '@vercel/speed-insights/react';
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 Collections from './pages/Collections';
import Decks from './pages/Decks';
@ -30,8 +31,15 @@ const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) =
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
<div className="min-h-screen-safe flex items-center justify-center bg-white dark:bg-surface-900">
<div className="flex flex-col items-center space-y-4">
<div className="w-12 h-12 bg-gradient-to-r from-primary-500 to-accent-500 rounded-xl flex items-center justify-center animate-bounce-subtle">
<svg className="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<p className="text-surface-600 dark:text-surface-400 text-sm">Loading TCG Vault...</p>
</div>
</div>
);
}
@ -49,8 +57,15 @@ const PublicRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
<div className="min-h-screen-safe flex items-center justify-center bg-white dark:bg-surface-900">
<div className="flex flex-col items-center space-y-4">
<div className="w-12 h-12 bg-gradient-to-r from-primary-500 to-accent-500 rounded-xl flex items-center justify-center animate-bounce-subtle">
<svg className="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<p className="text-surface-600 dark:text-surface-400 text-sm">Loading TCG Vault...</p>
</div>
</div>
);
}
@ -62,89 +77,109 @@ const PublicRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return <>{children}</>;
};
// Layout Component
const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
// Mobile Layout Component
const MobileLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return (
<div className="min-h-screen bg-gray-50">
<Navbar />
<main className="container mx-auto px-4 py-8">
<div className="min-h-screen-safe bg-surface-50 dark:bg-surface-900 transition-colors">
<MobileNavbar />
<main className="pt-16 pb-20 px-4 max-w-md mx-auto">
<div className="animate-fade-in">
{children}
</div>
</main>
</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() {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<AuthProvider>
<Router>
<Routes>
{/* Public Routes */}
<Route path="/login" element={
<PublicRoute>
<AuthLayout>
<LoginForm />
</AuthLayout>
</PublicRoute>
} />
<Route path="/register" element={
<PublicRoute>
<AuthLayout>
<RegisterForm />
</AuthLayout>
</PublicRoute>
} />
{/* Protected Routes */}
<Route path="/dashboard" element={
<ProtectedRoute>
<Layout>
<MobileLayout>
<Dashboard />
</Layout>
</MobileLayout>
</ProtectedRoute>
} />
<Route path="/collections" element={
<ProtectedRoute>
<Layout>
<MobileLayout>
<Collections />
</Layout>
</MobileLayout>
</ProtectedRoute>
} />
<Route path="/decks" element={
<ProtectedRoute>
<Layout>
<MobileLayout>
<Decks />
</Layout>
</MobileLayout>
</ProtectedRoute>
} />
<Route path="/cards" element={
<ProtectedRoute>
<Layout>
<MobileLayout>
<Cards />
</Layout>
</MobileLayout>
</ProtectedRoute>
} />
<Route path="/scanner" element={
<ProtectedRoute>
<Layout>
<MobileLayout>
<Scanner />
</Layout>
</MobileLayout>
</ProtectedRoute>
} />
<Route path="/settings" element={
<ProtectedRoute>
<Layout>
<MobileLayout>
<Settings />
</Layout>
</MobileLayout>
</ProtectedRoute>
} />
{/* Admin Routes */}
<Route path="/admin" element={
<ProtectedRoute>
<MobileLayout>
<AdminPanel />
</MobileLayout>
</ProtectedRoute>
} />
@ -153,21 +188,30 @@ function App() {
{/* 404 fallback */}
<Route path="*" element={
<Layout>
<MobileLayout>
<div className="text-center py-12">
<h1 className="text-4xl font-bold text-gray-900 mb-4">404 - Page Not Found</h1>
<p className="text-gray-600 mb-8">The page you're looking for doesn't exist.</p>
<a href="/dashboard" className="bg-indigo-600 text-white px-6 py-3 rounded-lg hover:bg-indigo-700 transition-colors">
Go to Dashboard
</a>
<div className="w-16 h-16 bg-surface-200 dark:bg-surface-700 rounded-2xl flex items-center justify-center mx-auto mb-6">
<svg className="w-8 h-8 text-surface-500 dark:text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
</Layout>
<h1 className="text-2xl font-bold text-surface-900 dark:text-white mb-2">Page Not Found</h1>
<p className="text-surface-600 dark:text-surface-400 mb-6">The page you're looking for doesn't exist.</p>
<button
onClick={() => window.history.back()}
className="bg-primary-600 hover:bg-primary-700 text-white px-6 py-3 rounded-xl font-medium transition-colors"
>
Go Back
</button>
</div>
</MobileLayout>
} />
</Routes>
<Analytics />
<SpeedInsights />
</Router>
</AuthProvider>
</ThemeProvider>
</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,144 +1,153 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { useNavigate, Link } from 'react-router-dom';
const LoginForm: React.FC = () => {
const { login } = useAuth();
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
});
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { login } = useAuth();
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 [error, setError] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
setError(null);
if (!formData.username.trim() || !formData.password) {
setError('Please fill in all fields');
try {
await login(formData.email, formData.password);
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setIsLoading(false);
return;
}
};
const result = await login(formData.username, formData.password);
if (result.success) {
navigate('/dashboard');
} else {
setError(result.error || 'Login failed');
}
setIsLoading(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({
...prev,
[e.target.name]: e.target.value
}));
};
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="max-w-md w-full space-y-8">
<div>
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-indigo-100">
<svg className="h-8 w-8 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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" />
<div className="bg-white dark:bg-surface-800 p-8 rounded-2xl shadow-xl border border-surface-200 dark:border-surface-700 transition-colors">
{/* Logo Section */}
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-r from-primary-500 to-accent-500 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
<svg className="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<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
<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>
<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 className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{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>
<button
type="submit"
disabled={isLoading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-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"
>
{isLoading ? (
<>
<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">
<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>
<label htmlFor="email" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Email Address
</label>
<div className="relative">
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors"
placeholder="Enter your email"
required
/>
<svg className="absolute right-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-surface-400 dark:text-surface-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207" />
</svg>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Password
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
id="password"
name="password"
value={formData.password}
onChange={handleChange}
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors pr-12"
placeholder="Enter your password"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300 transition-colors"
>
{showPassword ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
</svg>
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>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-gradient-to-r from-primary-500 to-accent-500 hover:from-primary-600 hover:to-accent-600 disabled:from-surface-400 disabled:to-surface-400 text-white font-semibold py-3 px-4 rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl disabled:shadow-none transform hover:-translate-y-0.5 disabled:transform-none"
>
{isLoading ? (
<div className="flex items-center justify-center">
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Signing In...
</div>
) : (
'Sign In'
)}
</button>
<div className="text-center">
<p className="text-sm text-gray-600">
<p className="text-surface-600 dark:text-surface-400">
Don't have an account?{' '}
<Link
to="/register"
className="font-medium text-indigo-600 hover:text-indigo-500 transition-colors duration-200"
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-medium transition-colors"
>
Sign up here
Sign up
</Link>
</p>
</div>
</form>
</div>
</div>
);
};

View file

@ -1,263 +1,248 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { useNavigate, Link } from 'react-router-dom';
const RegisterForm: React.FC = () => {
const { register } = useAuth();
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
username: '',
email: '',
password: '',
confirmPassword: '',
firstName: '',
lastName: '',
});
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { register } = useAuth();
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 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 [error, setError] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
setError(null);
if (!validateForm()) {
// Validate passwords match
if (formData.password !== formData.confirmPassword) {
setError('Passwords do not match');
setIsLoading(false);
return;
}
const registerData = {
username: formData.username.trim(),
email: formData.email.trim(),
password: formData.password,
firstName: formData.firstName.trim() || undefined,
lastName: formData.lastName.trim() || undefined,
try {
await register(formData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed');
} finally {
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 (
<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="max-w-md w-full space-y-8">
<div>
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-emerald-100">
<svg className="h-8 w-8 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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" />
<div className="bg-white dark:bg-surface-800 p-8 rounded-2xl shadow-xl border border-surface-200 dark:border-surface-700 transition-colors">
{/* Logo Section */}
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-r from-primary-500 to-accent-500 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
<svg className="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
<h1 className="text-2xl font-bold text-surface-900 dark:text-white mb-2">
Join TCG Vault
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
</h1>
<p className="text-surface-600 dark:text-surface-400">
Create your account to get started
</p>
</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>
<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>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{error}
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl">
<div className="flex items-center">
<svg className="w-5 h-5 text-red-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<span className="text-red-700 dark:text-red-400 text-sm">{error}</span>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="firstName" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
First Name
</label>
<input
type="text"
id="firstName"
name="firstName"
value={formData.firstName}
onChange={handleChange}
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors"
placeholder="John"
required
/>
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Last Name
</label>
<input
type="text"
id="lastName"
name="lastName"
value={formData.lastName}
onChange={handleChange}
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors"
placeholder="Doe"
required
/>
</div>
</div>
<div>
<label htmlFor="username" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Username
</label>
<input
type="text"
id="username"
name="username"
value={formData.username}
onChange={handleChange}
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors"
placeholder="johndoe"
required
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Email Address
</label>
<div className="relative">
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors"
placeholder="john@example.com"
required
/>
<svg className="absolute right-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-surface-400 dark:text-surface-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207" />
</svg>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Password
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
id="password"
name="password"
value={formData.password}
onChange={handleChange}
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors pr-12"
placeholder="Create a strong password"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300 transition-colors"
>
{showPassword ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
Confirm Password
</label>
<div className="relative">
<input
type={showConfirmPassword ? 'text' : 'password'}
id="confirmPassword"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleChange}
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors pr-12"
placeholder="Confirm your password"
required
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300 transition-colors"
>
{showConfirmPassword ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="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="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 ? (
<>
<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">
<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...
</>
Creating Account...
</div>
) : (
'Create Account'
)}
</button>
</div>
<div className="text-center">
<p className="text-sm text-gray-600">
<p className="text-surface-600 dark:text-surface-400">
Already have an account?{' '}
<Link
to="/login"
className="font-medium text-emerald-600 hover:text-emerald-500 transition-colors duration-200"
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-medium transition-colors"
>
Sign in here
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 utilities;
/* Safe area classes for mobile PWA */
.safe-area-inset-top {
padding-top: env(safe-area-inset-top);
}
.safe-area-inset-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
.safe-area-inset-left {
padding-left: env(safe-area-inset-left);
}
.safe-area-inset-right {
padding-right: env(safe-area-inset-right);
}
/* Custom animations */
@keyframes slide-down {
from {
@ -18,6 +35,23 @@
animation: slide-down 0.3s ease-out;
}
/* PWA specific styles */
@supports (padding: env(safe-area-inset-top)) {
.min-h-screen-safe {
min-height: calc(100vh + env(safe-area-inset-top) + env(safe-area-inset-bottom));
}
}
/* Fallback for browsers that don't support env() */
.min-h-screen-safe {
min-height: 100vh;
}
/* Touch optimization */
.active\:scale-98:active {
transform: scale(0.98);
}
/* Import custom card effects */
@import './styles/cardEffects.css';
@ -28,6 +62,13 @@ body {
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-tap-highlight-color: transparent; /* Remove tap highlight on iOS */
overscroll-behavior: none; /* Prevent pull to refresh */
}
/* Prevent zoom on iOS */
input, select, textarea {
font-size: 16px !important;
}
code {

View file

@ -13,6 +13,19 @@ root.render(
</React.StrictMode>
);
// Register service worker for PWA
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then((registration) => {
console.log('SW registered: ', registration);
})
.catch((registrationError) => {
console.log('SW registration failed: ', registrationError);
});
});
}
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals

View file

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

View file

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