🚀 Convert to mobile-first PWA with purple theme
- Add PWA configuration (manifest.json, service worker) - Implement light/dark theme system with purple accents - Replace desktop navbar with mobile-first bottom navigation - Redesign auth forms with mobile-optimized UI - Update Dashboard with gradient cards and touch-friendly actions - Add safe area support for iOS devices - Implement theme context with system preference detection - Optimize touch targets and mobile interactions - Add offline functionality through service worker
This commit is contained in:
parent
8fe34cc10f
commit
0a03f0ed14
12 changed files with 1238 additions and 582 deletions
|
|
@ -3,41 +3,40 @@
|
|||
<head>
|
||||
<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"
|
||||
/>
|
||||
<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" />
|
||||
|
||||
<!-- 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" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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
87
public/sw.js
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
const CACHE_NAME = 'tcg-vault-v1';
|
||||
const urlsToCache = [
|
||||
'/',
|
||||
'/static/js/bundle.js',
|
||||
'/static/css/main.css',
|
||||
'/manifest.json',
|
||||
'/favicon.ico',
|
||||
'/logo192.png',
|
||||
'/logo512.png'
|
||||
];
|
||||
|
||||
// Install event
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then((cache) => cache.addAll(urlsToCache))
|
||||
.catch((error) => {
|
||||
console.error('Failed to cache resources:', error);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch event
|
||||
self.addEventListener('fetch', (event) => {
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then((response) => {
|
||||
// Return cached version or fetch from network
|
||||
return response || fetch(event.request);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Fetch failed:', error);
|
||||
// Return offline page if available
|
||||
return caches.match('/');
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Activate event
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames.map((cacheName) => {
|
||||
if (cacheName !== CACHE_NAME) {
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Background sync for offline actions
|
||||
self.addEventListener('sync', (event) => {
|
||||
if (event.tag === 'background-sync') {
|
||||
event.waitUntil(
|
||||
// Handle offline actions when back online
|
||||
console.log('Background sync triggered')
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Push notifications (for future use)
|
||||
self.addEventListener('push', (event) => {
|
||||
if (event.data) {
|
||||
const data = event.data.json();
|
||||
const options = {
|
||||
body: data.body,
|
||||
icon: '/logo192.png',
|
||||
badge: '/favicon.ico',
|
||||
vibrate: [200, 100, 200]
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title, options)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Notification click
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
event.waitUntil(
|
||||
clients.openWindow('/')
|
||||
);
|
||||
});
|
||||
240
src/App.tsx
240
src/App.tsx
|
|
@ -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,112 +77,141 @@ 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">
|
||||
{children}
|
||||
<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}>
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
{/* Public Routes */}
|
||||
<Route path="/login" element={
|
||||
<PublicRoute>
|
||||
<LoginForm />
|
||||
</PublicRoute>
|
||||
} />
|
||||
<Route path="/register" element={
|
||||
<PublicRoute>
|
||||
<RegisterForm />
|
||||
</PublicRoute>
|
||||
} />
|
||||
<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>
|
||||
<Dashboard />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/collections" element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Collections />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/decks" element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Decks />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/cards" element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Cards />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/scanner" element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Scanner />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
{/* Protected Routes */}
|
||||
<Route path="/dashboard" element={
|
||||
<ProtectedRoute>
|
||||
<MobileLayout>
|
||||
<Dashboard />
|
||||
</MobileLayout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/collections" element={
|
||||
<ProtectedRoute>
|
||||
<MobileLayout>
|
||||
<Collections />
|
||||
</MobileLayout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/decks" element={
|
||||
<ProtectedRoute>
|
||||
<MobileLayout>
|
||||
<Decks />
|
||||
</MobileLayout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/cards" element={
|
||||
<ProtectedRoute>
|
||||
<MobileLayout>
|
||||
<Cards />
|
||||
</MobileLayout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/scanner" element={
|
||||
<ProtectedRoute>
|
||||
<MobileLayout>
|
||||
<Scanner />
|
||||
</MobileLayout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/settings" element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Settings />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/settings" element={
|
||||
<ProtectedRoute>
|
||||
<MobileLayout>
|
||||
<Settings />
|
||||
</MobileLayout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* Admin Routes */}
|
||||
<Route path="/admin" element={
|
||||
<ProtectedRoute>
|
||||
<AdminPanel />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
{/* Admin Routes */}
|
||||
<Route path="/admin" element={
|
||||
<ProtectedRoute>
|
||||
<MobileLayout>
|
||||
<AdminPanel />
|
||||
</MobileLayout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* Default redirect */}
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
|
||||
{/* 404 fallback */}
|
||||
<Route path="*" element={
|
||||
<Layout>
|
||||
<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>
|
||||
</Layout>
|
||||
} />
|
||||
</Routes>
|
||||
<Analytics />
|
||||
<SpeedInsights />
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
{/* Default redirect */}
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
|
||||
{/* 404 fallback */}
|
||||
<Route path="*" element={
|
||||
<MobileLayout>
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 bg-surface-200 dark:bg-surface-700 rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<svg className="w-8 h-8 text-surface-500 dark:text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-surface-900 dark:text-white mb-2">Page Not Found</h1>
|
||||
<p className="text-surface-600 dark:text-surface-400 mb-6">The page you're looking for doesn't exist.</p>
|
||||
<button
|
||||
onClick={() => 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
258
src/components/MobileNavbar.tsx
Normal file
258
src/components/MobileNavbar.tsx
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
interface NavigationItem {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
activeIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const MobileNavbar: React.FC = () => {
|
||||
const { user, logout, isAdmin } = useAuth();
|
||||
const { toggleTheme, effectiveTheme } = useTheme();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
|
||||
const navigationItems: NavigationItem[] = [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
href: '/dashboard',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
),
|
||||
activeIcon: (
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Cards',
|
||||
href: '/cards',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
),
|
||||
activeIcon: (
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Scanner',
|
||||
href: '/scanner',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
activeIcon: (
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fillRule="evenodd" d="M1.5 6a2.25 2.25 0 012.25-2.25h1.386c.17 0 .318.114.362.278l.558 2.047c.25.895 1.03 1.425 1.944 1.425H19.5a2.25 2.25 0 012.25 2.25v6.75a2.25 2.25 0 01-2.25 2.25H4.5A2.25 2.25 0 012.25 18V9.75a2.25 2.25 0 012.25-2.25H1.5V6zM12 15a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Collections',
|
||||
href: '/collections',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'More',
|
||||
href: '/menu',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
setShowUserMenu(false);
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const isActive = (path: string) => location.pathname === path;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Top Header */}
|
||||
<header className="fixed top-0 left-0 right-0 z-40 bg-white dark:bg-surface-900 border-b border-surface-200 dark:border-surface-700 px-4 py-3 safe-area-inset-top">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-gradient-to-r from-primary-500 to-accent-500 text-white p-2 rounded-xl">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-surface-900 dark:text-white">TCG Vault</h1>
|
||||
<p className="text-xs text-surface-600 dark:text-surface-400">
|
||||
Hi, {user?.firstName || user?.username}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-xl bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors"
|
||||
>
|
||||
{effectiveTheme === 'dark' ? (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* User Avatar */}
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
className="relative p-1"
|
||||
>
|
||||
<div className="w-8 h-8 bg-gradient-to-r from-primary-500 to-accent-500 rounded-xl flex items-center justify-center text-white font-semibold text-sm">
|
||||
{user?.firstName?.[0] || user?.username?.[0]?.toUpperCase() || 'U'}
|
||||
</div>
|
||||
{isAdmin() && (
|
||||
<div className="absolute -top-1 -right-1 w-3 h-3 bg-yellow-400 rounded-full border border-white dark:border-surface-900"></div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Bottom Navigation */}
|
||||
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-white dark:bg-surface-900 border-t border-surface-200 dark:border-surface-700 safe-area-inset-bottom">
|
||||
<div className="grid grid-cols-5 px-2 py-2">
|
||||
{navigationItems.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href === '/menu' ? '#' : item.href}
|
||||
onClick={item.href === '/menu' ? (e) => {
|
||||
e.preventDefault();
|
||||
setShowUserMenu(true);
|
||||
} : undefined}
|
||||
className={`flex flex-col items-center justify-center p-2 rounded-xl transition-all duration-200 ${
|
||||
isActive(item.href) && item.href !== '/menu'
|
||||
? 'text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/30'
|
||||
: 'text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white hover:bg-surface-100 dark:hover:bg-surface-800'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-1">
|
||||
{isActive(item.href) && item.activeIcon ? item.activeIcon : item.icon}
|
||||
</div>
|
||||
<span className="text-xs font-medium">{item.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* User Menu Modal */}
|
||||
{showUserMenu && (
|
||||
<div className="fixed inset-0 z-50 bg-black/50" onClick={() => setShowUserMenu(false)}>
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-surface-900 rounded-t-3xl p-6 safe-area-inset-bottom animate-slide-up">
|
||||
<div className="w-12 h-1.5 bg-surface-300 dark:bg-surface-600 rounded-full mx-auto mb-6"></div>
|
||||
|
||||
<div className="flex items-center space-x-4 mb-6 p-4 bg-surface-50 dark:bg-surface-800 rounded-2xl">
|
||||
<div className="w-12 h-12 bg-gradient-to-r from-primary-500 to-accent-500 rounded-xl flex items-center justify-center text-white font-semibold text-lg">
|
||||
{user?.firstName?.[0] || user?.username?.[0]?.toUpperCase() || 'U'}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-surface-900 dark:text-white">
|
||||
{user?.firstName && user?.lastName
|
||||
? `${user.firstName} ${user.lastName}`
|
||||
: user?.username}
|
||||
</p>
|
||||
<p className="text-sm text-surface-600 dark:text-surface-400">{user?.email}</p>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{user?.roles.map((role) => (
|
||||
<span
|
||||
key={role}
|
||||
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
role === 'admin'
|
||||
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
|
||||
: 'bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200'
|
||||
}`}
|
||||
>
|
||||
{role}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-6">
|
||||
<Link
|
||||
to="/decks"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
className="flex items-center space-x-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 text-surface-600 dark:text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
<span className="text-surface-900 dark:text-white font-medium">Decks</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/settings"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
className="flex items-center space-x-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 text-surface-600 dark:text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span className="text-surface-900 dark:text-white font-medium">Settings</span>
|
||||
</Link>
|
||||
|
||||
{isAdmin() && (
|
||||
<Link
|
||||
to="/admin"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
className="flex items-center space-x-3 p-3 rounded-xl hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 text-yellow-600 dark:text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<span className="text-yellow-700 dark:text-yellow-300 font-medium">Admin Panel</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center justify-center space-x-2 p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-xl hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors font-medium"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
<span>Sign Out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileNavbar;
|
||||
|
|
@ -1,143 +1,152 @@
|
|||
import React, { useState } from 'react';
|
||||
import { 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 className="bg-white dark:bg-surface-800 p-8 rounded-2xl shadow-xl border border-surface-200 dark:border-surface-700 transition-colors">
|
||||
{/* Logo Section */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-gradient-to-r from-primary-500 to-accent-500 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
|
||||
<svg className="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-surface-900 dark:text-white mb-2">
|
||||
Welcome Back
|
||||
</h1>
|
||||
<p className="text-surface-600 dark:text-surface-400">
|
||||
Sign in to your TCG Vault
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl">
|
||||
<div className="flex items-center">
|
||||
<svg className="w-5 h-5 text-red-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-red-700 dark:text-red-400 text-sm">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<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" />
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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="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"
|
||||
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"
|
||||
>
|
||||
{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>
|
||||
</svg>
|
||||
Signing in...
|
||||
</>
|
||||
{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>
|
||||
) : (
|
||||
'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>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Don't have an account?{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="font-medium text-indigo-600 hover:text-indigo-500 transition-colors duration-200"
|
||||
>
|
||||
Sign up here
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full bg-gradient-to-r from-primary-500 to-accent-500 hover:from-primary-600 hover:to-accent-600 disabled:from-surface-400 disabled:to-surface-400 text-white font-semibold py-3 px-4 rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl disabled:shadow-none transform hover:-translate-y-0.5 disabled:transform-none"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Signing In...
|
||||
</div>
|
||||
) : (
|
||||
'Sign In'
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-surface-600 dark:text-surface-400">
|
||||
Don't have an account?{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-medium transition-colors"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,262 +1,247 @@
|
|||
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,
|
||||
};
|
||||
|
||||
const result = await register(registerData);
|
||||
|
||||
if (result.success) {
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
setError(result.error || 'Registration failed');
|
||||
try {
|
||||
await register(formData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Registration failed');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
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 className="bg-white dark:bg-surface-800 p-8 rounded-2xl shadow-xl border border-surface-200 dark:border-surface-700 transition-colors">
|
||||
{/* Logo Section */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-gradient-to-r from-primary-500 to-accent-500 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
|
||||
<svg className="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-surface-900 dark:text-white mb-2">
|
||||
Join TCG Vault
|
||||
</h1>
|
||||
<p className="text-surface-600 dark:text-surface-400">
|
||||
Create your account to get started
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl">
|
||||
<div className="flex items-center">
|
||||
<svg className="w-5 h-5 text-red-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-red-700 dark:text-red-400 text-sm">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="firstName" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
|
||||
First Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
value={formData.firstName}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors"
|
||||
placeholder="John"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="lastName" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">
|
||||
Last Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
value={formData.lastName}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-700 border border-surface-300 dark:border-surface-600 rounded-xl text-surface-900 dark:text-white placeholder-surface-500 dark:placeholder-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors"
|
||||
placeholder="Doe"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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" />
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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="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"
|
||||
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"
|
||||
>
|
||||
{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>
|
||||
</svg>
|
||||
Creating account...
|
||||
</>
|
||||
{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>
|
||||
) : (
|
||||
'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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
to="/login"
|
||||
className="font-medium text-emerald-600 hover:text-emerald-500 transition-colors duration-200"
|
||||
>
|
||||
Sign in here
|
||||
</Link>
|
||||
</p>
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
105
src/contexts/ThemeContext.tsx
Normal file
105
src/contexts/ThemeContext.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
effectiveTheme: 'light' | 'dark';
|
||||
setTheme: (theme: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const savedTheme = localStorage.getItem('tcg-vault-theme') as Theme;
|
||||
return savedTheme || 'system';
|
||||
});
|
||||
|
||||
const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>('light');
|
||||
|
||||
useEffect(() => {
|
||||
const updateEffectiveTheme = () => {
|
||||
if (theme === 'system') {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
setEffectiveTheme(prefersDark ? 'dark' : 'light');
|
||||
} else {
|
||||
setEffectiveTheme(theme);
|
||||
}
|
||||
};
|
||||
|
||||
updateEffectiveTheme();
|
||||
|
||||
// Listen for system theme changes
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleChange = () => {
|
||||
if (theme === 'system') {
|
||||
updateEffectiveTheme();
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
// Apply theme class to document
|
||||
const root = document.documentElement;
|
||||
if (effectiveTheme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
|
||||
// Update meta theme-color for mobile browsers
|
||||
const metaThemeColor = document.querySelector('meta[name="theme-color"]');
|
||||
if (metaThemeColor) {
|
||||
metaThemeColor.setAttribute('content', effectiveTheme === 'dark' ? '#0f172a' : '#8b5cf6');
|
||||
}
|
||||
|
||||
// Store theme preference
|
||||
localStorage.setItem('tcg-vault-theme', theme);
|
||||
}, [theme, effectiveTheme]);
|
||||
|
||||
const handleSetTheme = (newTheme: Theme) => {
|
||||
setTheme(newTheme);
|
||||
};
|
||||
|
||||
const toggleTheme = () => {
|
||||
if (theme === 'light') {
|
||||
setTheme('dark');
|
||||
} else if (theme === 'dark') {
|
||||
setTheme('system');
|
||||
} else {
|
||||
setTheme('light');
|
||||
}
|
||||
};
|
||||
|
||||
const value: ThemeContextType = {
|
||||
theme,
|
||||
effectiveTheme,
|
||||
setTheme: handleSetTheme,
|
||||
toggleTheme,
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={value}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeProvider;
|
||||
|
|
@ -2,6 +2,23 @@
|
|||
@tailwind components;
|
||||
@tailwind 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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 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>
|
||||
|
||||
<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>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<Link
|
||||
to="/scanner"
|
||||
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">
|
||||
Scan Cards
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Use OCR to quickly add cards to your collection
|
||||
</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>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-surface-900 dark:text-white">
|
||||
Quick Actions
|
||||
</h2>
|
||||
|
||||
{quickActions.map((action, index) => (
|
||||
<Link
|
||||
key={action.title}
|
||||
to={action.href}
|
||||
className="block bg-white dark:bg-surface-800 p-4 rounded-xl shadow-sm border border-surface-200 dark:border-surface-700 hover:shadow-md transition-all duration-200 active:scale-98"
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className={`w-12 h-12 bg-gradient-to-r ${action.gradient} rounded-xl flex items-center justify-center text-white shadow-md`}>
|
||||
{action.icon}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-surface-900 dark:text-white">
|
||||
{action.title}
|
||||
</h3>
|
||||
<p className="text-sm text-surface-600 dark:text-surface-400">
|
||||
{action.description}
|
||||
</p>
|
||||
</div>
|
||||
<svg className="w-5 h-5 text-surface-400 dark:text-surface-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Recent Activity (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!
|
||||
</p>
|
||||
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
secondary: {
|
||||
50: '#fdf7ef',
|
||||
100: '#fcefc3',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
700: '#b45309',
|
||||
}
|
||||
}
|
||||
// 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)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
|
|
|
|||
Loading…
Reference in a new issue