Restructure into full project layout
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
# ── Build stage ────────────────────────────────────────────────────────────────
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ── Serve stage ────────────────────────────────────────────────────────────────
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Commander Forge</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,20 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Static assets with long cache
|
||||
location ~* \.(js|css|woff2?|png|jpg|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Gzip
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/javascript application/json;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "mtg-deck-builder-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.28.0",
|
||||
"axios": "^1.6.8",
|
||||
"lucide-react": "^0.368.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.5.0",
|
||||
"@typescript-eslint/parser": "^7.5.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.6",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.4.3",
|
||||
"vite": "^5.2.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react'
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { RequireAuth, RequireAdmin, GuestOnly } from '@/components/layout/Guards'
|
||||
import { LoginPage, RegisterPage, PendingPage } from '@/pages/auth/AuthPages'
|
||||
import { DeckListPage } from '@/pages/decks/DeckListPage'
|
||||
import { DeckViewPage } from '@/pages/decks/DeckViewPage'
|
||||
import { BuildDeckPage } from '@/pages/decks/BuildDeckPage'
|
||||
import { CollectionPage } from '@/pages/collection/CollectionPage'
|
||||
import { AdminPage } from '@/pages/admin/AdminPage'
|
||||
import { ProfilePage } from '@/pages/ProfilePage'
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<Routes>
|
||||
{/* Public / guest routes */}
|
||||
<Route element={<GuestOnly />}>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Pending holding page — accessible when logged in but pending */}
|
||||
<Route path="/pending" element={<PendingPage />} />
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route path="/decks" element={<DeckListPage />} />
|
||||
<Route path="/decks/:id" element={<DeckViewPage />} />
|
||||
<Route path="/build" element={<BuildDeckPage />} />
|
||||
<Route path="/collection" element={<CollectionPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
|
||||
{/* Admin-only */}
|
||||
<Route element={<RequireAdmin />}>
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Default redirect */}
|
||||
<Route path="/" element={<Navigate to="/decks" replace />} />
|
||||
<Route path="*" element={<Navigate to="/decks" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
|
||||
import type { AuthTokens } from '@/types'
|
||||
|
||||
const BASE_URL = '/api'
|
||||
|
||||
// ─── Token storage ────────────────────────────────────────────────────────────
|
||||
|
||||
const ACCESS_KEY = 'mtg_access_token'
|
||||
const REFRESH_KEY = 'mtg_refresh_token'
|
||||
|
||||
export const tokenStorage = {
|
||||
getAccess: () => localStorage.getItem(ACCESS_KEY),
|
||||
getRefresh: () => localStorage.getItem(REFRESH_KEY),
|
||||
set: (tokens: AuthTokens) => {
|
||||
localStorage.setItem(ACCESS_KEY, tokens.access_token)
|
||||
localStorage.setItem(REFRESH_KEY, tokens.refresh_token)
|
||||
},
|
||||
clear: () => {
|
||||
localStorage.removeItem(ACCESS_KEY)
|
||||
localStorage.removeItem(REFRESH_KEY)
|
||||
},
|
||||
}
|
||||
|
||||
// ─── Axios instance ───────────────────────────────────────────────────────────
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
// Attach access token to every request
|
||||
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const token = tokenStorage.getAccess()
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// Track in-flight refresh to avoid multiple simultaneous refresh calls
|
||||
let refreshPromise: Promise<string> | null = null
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
async (error: AxiosError) => {
|
||||
const original = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||
|
||||
if (error.response?.status !== 401 || original._retry) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
original._retry = true
|
||||
|
||||
try {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = (async () => {
|
||||
const refreshToken = tokenStorage.getRefresh()
|
||||
if (!refreshToken) throw new Error('No refresh token')
|
||||
|
||||
const res = await axios.post<AuthTokens>(`${BASE_URL}/auth/refresh`, {
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
tokenStorage.set(res.data)
|
||||
return res.data.access_token
|
||||
})().finally(() => { refreshPromise = null })
|
||||
}
|
||||
|
||||
const newToken = await refreshPromise
|
||||
if (original.headers) original.headers.Authorization = `Bearer ${newToken}`
|
||||
return api(original)
|
||||
} catch {
|
||||
tokenStorage.clear()
|
||||
window.location.href = '/login'
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ─── Auth endpoints ───────────────────────────────────────────────────────────
|
||||
|
||||
export const authApi = {
|
||||
register: (email: string, password: string, display_name?: string) =>
|
||||
api.post('/auth/register', { email, password, display_name }),
|
||||
|
||||
login: (email: string, password: string) =>
|
||||
api.post<AuthTokens>('/auth/login', { email, password }),
|
||||
|
||||
refresh: (refresh_token: string) =>
|
||||
api.post<AuthTokens>('/auth/refresh', { refresh_token }),
|
||||
}
|
||||
|
||||
// ─── User endpoints ───────────────────────────────────────────────────────────
|
||||
|
||||
export const usersApi = {
|
||||
me: () => api.get('/users/me'),
|
||||
updateMe: (data: { display_name?: string; password?: string }) =>
|
||||
api.patch('/users/me', data),
|
||||
}
|
||||
|
||||
// ─── Deck endpoints ───────────────────────────────────────────────────────────
|
||||
|
||||
export const decksApi = {
|
||||
list: (page = 1, pageSize = 20) =>
|
||||
api.get('/decks/', { params: { page, page_size: pageSize } }),
|
||||
|
||||
get: (id: number) => api.get(`/decks/${id}`),
|
||||
|
||||
generate: (data: object) => api.post('/decks/generate', data),
|
||||
complete: (data: object) => api.post('/decks/complete', data),
|
||||
cull: (data: object) => api.post('/decks/cull', data),
|
||||
|
||||
delete: (id: number) => api.delete(`/decks/${id}`),
|
||||
}
|
||||
|
||||
// ─── Collection endpoints ─────────────────────────────────────────────────────
|
||||
|
||||
export const collectionApi = {
|
||||
import: (source: 'archidekt' | 'manabox', file: File, replace = false) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return api.post(`/collection/import/${source}?replace=${replace}`, form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
},
|
||||
|
||||
list: (search = '', page = 1, pageSize = 50) =>
|
||||
api.get('/collection/', { params: { search, page, page_size: pageSize } }),
|
||||
|
||||
stats: () => api.get('/collection/stats'),
|
||||
|
||||
clear: () => api.delete('/collection/'),
|
||||
|
||||
deleteCard: (id: number) => api.delete(`/collection/${id}`),
|
||||
}
|
||||
|
||||
// ─── Admin endpoints ──────────────────────────────────────────────────────────
|
||||
|
||||
export const adminApi = {
|
||||
queue: () => api.get('/admin/queue'),
|
||||
users: (role?: string) => api.get('/admin/users', { params: { role } }),
|
||||
approve: (id: number) => api.post(`/admin/users/${id}/approve`),
|
||||
deny: (id: number) => api.post(`/admin/users/${id}/deny`),
|
||||
updateUser: (id: number, data: object) => api.patch(`/admin/users/${id}`, data),
|
||||
deleteUser: (id: number) => api.delete(`/admin/users/${id}`),
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Layers,
|
||||
Package,
|
||||
ShieldCheck,
|
||||
LogOut,
|
||||
User,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Menu,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
|
||||
// Mana pentagon SVG — the signature UI element
|
||||
function ManaPentagon({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 40 40" className={className} fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* Pentagon segments representing 5 mana colors */}
|
||||
<polygon points="20,2 38,14 31,34 9,34 2,14" stroke="#7C3AED" strokeWidth="1.5" fill="none" opacity="0.6" />
|
||||
<circle cx="20" cy="14" r="3" fill="#F9D71C" opacity="0.8" /> {/* White */}
|
||||
<circle cx="29" cy="21" r="3" fill="#4A90D9" opacity="0.8" /> {/* Blue */}
|
||||
<circle cx="25" cy="30" r="3" fill="#1a1a1a" stroke="#555" strokeWidth="1" /> {/* Black */}
|
||||
<circle cx="15" cy="30" r="3" fill="#C0392B" opacity="0.8" /> {/* Red */}
|
||||
<circle cx="11" cy="21" r="3" fill="#27AE60" opacity="0.8" /> {/* Green */}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
path: string
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
adminOnly?: boolean
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ path: '/decks', label: 'My Decks', icon: <Layers className="w-5 h-5" /> },
|
||||
{ path: '/build', label: 'Build Deck', icon: <span className="w-5 h-5 flex items-center justify-center text-lg leading-none">✦</span> },
|
||||
{ path: '/collection', label: 'Collection', icon: <Package className="w-5 h-5" /> },
|
||||
{ path: '/admin', label: 'Admin', icon: <ShieldCheck className="w-5 h-5" />, adminOnly: true },
|
||||
]
|
||||
|
||||
export function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuthStore()
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const visibleItems = NAV_ITEMS.filter(
|
||||
(item) => !item.adminOnly || user?.role === 'admin'
|
||||
)
|
||||
|
||||
const Sidebar = ({ mobile = false }: { mobile?: boolean }) => (
|
||||
<aside
|
||||
className={`
|
||||
flex flex-col bg-bg-surface border-r border-bg-border h-full
|
||||
transition-all duration-300
|
||||
${mobile ? 'w-64' : collapsed ? 'w-16' : 'w-56'}
|
||||
`}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className={`flex items-center gap-3 p-4 border-b border-bg-border ${collapsed && !mobile ? 'justify-center' : ''}`}>
|
||||
<ManaPentagon className="w-8 h-8 flex-shrink-0" />
|
||||
{(!collapsed || mobile) && (
|
||||
<span className="font-display text-sm font-semibold text-text-primary tracking-wide whitespace-nowrap">
|
||||
Commander Forge
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 py-4 px-2 flex flex-col gap-1">
|
||||
{visibleItems.map((item) => {
|
||||
const active = location.pathname.startsWith(item.path)
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={`
|
||||
flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium
|
||||
transition-all duration-150
|
||||
${active
|
||||
? 'bg-accent-violet/20 text-accent-violet-light border border-accent-violet/30'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-bg-elevated'
|
||||
}
|
||||
${collapsed && !mobile ? 'justify-center px-0' : ''}
|
||||
`}
|
||||
title={collapsed && !mobile ? item.label : undefined}
|
||||
>
|
||||
<span className={`flex-shrink-0 ${active ? 'text-accent-violet-light' : ''}`}>
|
||||
{item.icon}
|
||||
</span>
|
||||
{(!collapsed || mobile) && <span>{item.label}</span>}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User section */}
|
||||
<div className={`p-3 border-t border-bg-border flex flex-col gap-1 ${collapsed && !mobile ? 'items-center' : ''}`}>
|
||||
<Link
|
||||
to="/profile"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={`
|
||||
flex items-center gap-3 rounded-md px-3 py-2 text-sm
|
||||
text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-all
|
||||
${collapsed && !mobile ? 'justify-center px-0' : ''}
|
||||
`}
|
||||
title={collapsed && !mobile ? 'Profile' : undefined}
|
||||
>
|
||||
<User className="w-4 h-4 flex-shrink-0" />
|
||||
{(!collapsed || mobile) && (
|
||||
<span className="truncate">{user?.display_name || user?.email}</span>
|
||||
)}
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={`
|
||||
flex items-center gap-3 rounded-md px-3 py-2 text-sm w-full
|
||||
text-text-secondary hover:text-danger hover:bg-danger/10 transition-all
|
||||
${collapsed && !mobile ? 'justify-center px-0' : ''}
|
||||
`}
|
||||
title={collapsed && !mobile ? 'Sign out' : undefined}
|
||||
>
|
||||
<LogOut className="w-4 h-4 flex-shrink-0" />
|
||||
{(!collapsed || mobile) && <span>Sign out</span>}
|
||||
</button>
|
||||
|
||||
{!mobile && (
|
||||
<button
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
className="mt-1 flex items-center justify-center w-full py-1.5 rounded text-text-muted hover:text-text-secondary hover:bg-bg-elevated transition-all"
|
||||
>
|
||||
{collapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-bg-base overflow-hidden">
|
||||
{/* Desktop sidebar */}
|
||||
<div className="hidden md:flex flex-shrink-0">
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
{/* Mobile overlay */}
|
||||
{mobileOpen && (
|
||||
<div className="md:hidden fixed inset-0 z-50 flex">
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
<div className="relative z-10 animate-slide-up">
|
||||
<Sidebar mobile />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="absolute top-4 right-4 z-20 text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
{/* Mobile topbar */}
|
||||
<header className="md:hidden flex items-center gap-3 px-4 py-3 border-b border-bg-border bg-bg-surface">
|
||||
<button onClick={() => setMobileOpen(true)} className="text-text-secondary hover:text-text-primary">
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<ManaPentagon className="w-6 h-6" />
|
||||
<span className="font-display text-sm font-semibold text-text-primary">Commander Forge</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react'
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { AppLayout } from '@/components/layout/AppLayout'
|
||||
import { Spinner } from '@/components/ui'
|
||||
|
||||
// ─── Auth guard: must be logged in and approved ───────────────────────────────
|
||||
|
||||
export function RequireAuth() {
|
||||
const { user, initialized } = useAuthStore()
|
||||
const location = useLocation()
|
||||
|
||||
if (!initialized) {
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-base flex items-center justify-center">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) return <Navigate to="/login" state={{ from: location }} replace />
|
||||
if (user.role === 'pending') return <Navigate to="/pending" replace />
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<Outlet />
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Admin guard ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function RequireAdmin() {
|
||||
const { user } = useAuthStore()
|
||||
if (user?.role !== 'admin') return <Navigate to="/decks" replace />
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
// ─── Redirect if already logged in ───────────────────────────────────────────
|
||||
|
||||
export function GuestOnly() {
|
||||
const { user, initialized } = useAuthStore()
|
||||
if (!initialized) return null
|
||||
if (user && user.role !== 'pending') return <Navigate to="/decks" replace />
|
||||
return <Outlet />
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import React from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
// ─── Button ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger'
|
||||
type ButtonSize = 'sm' | 'md' | 'lg'
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant
|
||||
size?: ButtonSize
|
||||
loading?: boolean
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
const variantClasses: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
'bg-accent-violet hover:bg-accent-violet-light text-white shadow-glow hover:shadow-lg transition-all',
|
||||
secondary:
|
||||
'bg-bg-elevated border border-bg-border text-text-primary hover:border-accent-violet hover:text-accent-violet-light transition-all',
|
||||
ghost: 'text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-all',
|
||||
danger: 'bg-danger/20 border border-danger/50 text-danger hover:bg-danger/30 transition-all',
|
||||
}
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading,
|
||||
icon,
|
||||
children,
|
||||
disabled,
|
||||
className = '',
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
disabled={disabled || loading}
|
||||
className={`
|
||||
inline-flex items-center gap-2 rounded-md font-medium
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${variantClasses[variant]} ${sizeClasses[size]} ${className}
|
||||
`}
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : icon}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Input ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
hint?: string
|
||||
}
|
||||
|
||||
export function Input({ label, error, hint, className = '', ...props }: InputProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{label && (
|
||||
<label className="text-xs font-medium text-text-secondary uppercase tracking-wider">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
{...props}
|
||||
className={`
|
||||
bg-bg-elevated border rounded-md px-3 py-2 text-sm text-text-primary
|
||||
placeholder:text-text-muted
|
||||
focus:outline-none focus:ring-2 focus:ring-accent-violet focus:border-transparent
|
||||
${error ? 'border-danger' : 'border-bg-border'}
|
||||
${className}
|
||||
`}
|
||||
/>
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
{hint && !error && <p className="text-xs text-text-muted">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Textarea ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
hint?: string
|
||||
}
|
||||
|
||||
export function Textarea({ label, error, hint, className = '', ...props }: TextareaProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{label && (
|
||||
<label className="text-xs font-medium text-text-secondary uppercase tracking-wider">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
{...props}
|
||||
className={`
|
||||
bg-bg-elevated border rounded-md px-3 py-2 text-sm text-text-primary
|
||||
placeholder:text-text-muted resize-none
|
||||
focus:outline-none focus:ring-2 focus:ring-accent-violet focus:border-transparent
|
||||
${error ? 'border-danger' : 'border-bg-border'}
|
||||
${className}
|
||||
`}
|
||||
/>
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
{hint && !error && <p className="text-xs text-text-muted">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Select ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function Select({ label, error, className = '', children, ...props }: SelectProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{label && (
|
||||
<label className="text-xs font-medium text-text-secondary uppercase tracking-wider">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
{...props}
|
||||
className={`
|
||||
bg-bg-elevated border rounded-md px-3 py-2 text-sm text-text-primary
|
||||
focus:outline-none focus:ring-2 focus:ring-accent-violet focus:border-transparent
|
||||
${error ? 'border-danger' : 'border-bg-border'}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Toggle ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ToggleProps {
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
label?: string
|
||||
description?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function Toggle({ checked, onChange, label, description, disabled }: ToggleProps) {
|
||||
return (
|
||||
<label className="flex items-start gap-3 cursor-pointer select-none group">
|
||||
<div className="relative mt-0.5 flex-shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div
|
||||
className={`
|
||||
w-10 h-6 rounded-full transition-colors duration-200
|
||||
${checked ? 'bg-accent-violet' : 'bg-bg-border'}
|
||||
${disabled ? 'opacity-50' : ''}
|
||||
`}
|
||||
/>
|
||||
<div
|
||||
className={`
|
||||
absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform duration-200
|
||||
${checked ? 'translate-x-4' : 'translate-x-0'}
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
{(label || description) && (
|
||||
<div className="flex flex-col">
|
||||
{label && <span className="text-sm font-medium text-text-primary">{label}</span>}
|
||||
{description && <span className="text-xs text-text-muted">{description}</span>}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Badge ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type BadgeVariant = 'owned' | 'unowned' | 'default' | 'mode' | 'slot'
|
||||
|
||||
interface BadgeProps {
|
||||
variant?: BadgeVariant
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const badgeVariants: Record<BadgeVariant, string> = {
|
||||
owned: 'bg-owned/20 text-owned border border-owned/30',
|
||||
unowned: 'bg-unowned/20 text-unowned border border-unowned/30',
|
||||
default: 'bg-bg-elevated text-text-secondary border border-bg-border',
|
||||
mode: 'bg-accent-violet/20 text-accent-violet-light border border-accent-violet/30',
|
||||
slot: 'bg-bg-surface text-text-secondary border border-bg-border',
|
||||
}
|
||||
|
||||
export function Badge({ variant = 'default', children, className = '' }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${badgeVariants[variant]} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Card (panel) ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface PanelProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
glow?: boolean
|
||||
}
|
||||
|
||||
export function Panel({ children, className = '', glow }: PanelProps) {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
bg-bg-surface rounded-lg border border-bg-border
|
||||
${glow ? 'shadow-glow' : 'shadow-card'}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Spinner ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function Spinner({ size = 'md', className = '' }: { size?: 'sm' | 'md' | 'lg'; className?: string }) {
|
||||
const s = { sm: 'w-4 h-4', md: 'w-6 h-6', lg: 'w-10 h-10' }[size]
|
||||
return <Loader2 className={`${s} animate-spin text-accent-violet ${className}`} />
|
||||
}
|
||||
|
||||
// ─── Empty state ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
icon?: React.ReactNode
|
||||
title: string
|
||||
description?: string
|
||||
action?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-4 text-center">
|
||||
{icon && <div className="text-text-muted w-12 h-12">{icon}</div>}
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-text-primary font-medium">{title}</p>
|
||||
{description && <p className="text-text-muted text-sm max-w-sm">{description}</p>}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Error message ────────────────────────────────────────────────────────────
|
||||
|
||||
export function ErrorMessage({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="bg-danger/10 border border-danger/30 rounded-md p-3 text-sm text-danger">
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Skeleton loader ──────────────────────────────────────────────────────────
|
||||
|
||||
export function Skeleton({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded bg-gradient-to-r from-bg-elevated via-bg-border to-bg-elevated bg-[length:200%_100%] animate-shimmer ${className}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-bg-base text-text-primary font-body;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-bg-base;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-bg-border rounded-full;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-text-muted;
|
||||
}
|
||||
|
||||
/* Focus visible for accessibility */
|
||||
:focus-visible {
|
||||
@apply outline-2 outline-offset-2 outline-accent-violet;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Card frame aesthetic — the signature element */
|
||||
.card-frame {
|
||||
@apply bg-bg-surface border border-bg-border rounded-lg;
|
||||
background-image: linear-gradient(
|
||||
135deg,
|
||||
rgba(124, 58, 237, 0.03) 0%,
|
||||
transparent 50%
|
||||
);
|
||||
}
|
||||
|
||||
/* Mana symbol decorative text */
|
||||
.mana-text {
|
||||
font-family: 'Cinzel', serif;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { App } from './App'
|
||||
import { useAuthStore } from './store/authStore'
|
||||
import './index.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 2, // 2 minutes
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function Root() {
|
||||
const initialize = useAuthStore((s) => s.initialize)
|
||||
|
||||
useEffect(() => {
|
||||
initialize()
|
||||
}, [initialize])
|
||||
|
||||
return <App />
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<Root />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { usersApi } from '@/api'
|
||||
import { Button, Input, Panel, ErrorMessage } from '@/components/ui'
|
||||
import { AxiosError } from 'axios'
|
||||
|
||||
export function ProfilePage() {
|
||||
const { user, fetchMe } = useAuthStore()
|
||||
const [displayName, setDisplayName] = useState(user?.display_name ?? '')
|
||||
const [currentPw, setCurrentPw] = useState('')
|
||||
const [newPw, setNewPw] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: { display_name?: string; password?: string }) =>
|
||||
usersApi.updateMe(data),
|
||||
onSuccess: () => {
|
||||
setSuccess('Profile updated.')
|
||||
setCurrentPw('')
|
||||
setNewPw('')
|
||||
fetchMe()
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof AxiosError) {
|
||||
setError(err.response?.data?.detail ?? 'Update failed.')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSuccess('')
|
||||
setError('')
|
||||
const payload: { display_name?: string; password?: string } = {}
|
||||
if (displayName !== user?.display_name) payload.display_name = displayName
|
||||
if (newPw) payload.password = newPw
|
||||
if (!Object.keys(payload).length) return
|
||||
updateMutation.mutate(payload)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto px-4 py-8">
|
||||
<h1 className="font-display text-2xl text-text-primary mb-8">Profile</h1>
|
||||
|
||||
<Panel className="p-6">
|
||||
<div className="mb-6 pb-6 border-b border-bg-border">
|
||||
<p className="text-xs font-medium text-text-secondary uppercase tracking-wider mb-1">Account</p>
|
||||
<p className="text-sm text-text-primary">{user?.email}</p>
|
||||
<p className="text-xs text-text-muted mt-1 capitalize">{user?.role}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
{error && <ErrorMessage message={error} />}
|
||||
{success && (
|
||||
<div className="bg-owned/10 border border-owned/30 rounded-md p-3 text-sm text-owned">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Display name"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Your name"
|
||||
/>
|
||||
|
||||
<div className="pt-2 border-t border-bg-border">
|
||||
<p className="text-xs font-medium text-text-secondary uppercase tracking-wider mb-3">
|
||||
Change password
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Input
|
||||
label="New password"
|
||||
type="password"
|
||||
value={newPw}
|
||||
onChange={(e) => setNewPw(e.target.value)}
|
||||
placeholder="Leave blank to keep current"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
loading={updateMutation.isPending}
|
||||
className="w-full mt-2"
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { CheckCircle, XCircle, ShieldCheck, Trash2, Users, Clock } from 'lucide-react'
|
||||
import { adminApi } from '@/api'
|
||||
import { Button, Badge, Panel, Spinner, EmptyState } from '@/components/ui'
|
||||
import type { User, UserRole } from '@/types'
|
||||
|
||||
// ─── Approval queue ───────────────────────────────────────────────────────────
|
||||
|
||||
function ApprovalQueue() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: queue, isLoading } = useQuery<User[]>({
|
||||
queryKey: ['admin-queue'],
|
||||
queryFn: async () => (await adminApi.queue()).data,
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (id: number) => adminApi.approve(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-queue'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] })
|
||||
},
|
||||
})
|
||||
|
||||
const denyMutation = useMutation({
|
||||
mutationFn: (id: number) => adminApi.deny(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-queue'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] })
|
||||
},
|
||||
})
|
||||
|
||||
const pending = queue ?? []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Clock className="w-4 h-4 text-unowned" />
|
||||
<h2 className="font-display text-base text-text-primary">Pending approvals</h2>
|
||||
{pending.length > 0 && (
|
||||
<span className="bg-unowned/20 text-unowned text-xs px-2 py-0.5 rounded-full">
|
||||
{pending.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8"><Spinner /></div>
|
||||
) : pending.length === 0 ? (
|
||||
<Panel className="py-2">
|
||||
<EmptyState
|
||||
icon={<CheckCircle className="w-full h-full text-owned" />}
|
||||
title="No pending approvals"
|
||||
description="All registrations have been reviewed."
|
||||
/>
|
||||
</Panel>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{pending.map((user) => (
|
||||
<Panel key={user.id} className="p-4 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
{user.display_name || user.email}
|
||||
</p>
|
||||
{user.display_name && (
|
||||
<p className="text-xs text-text-muted">{user.email}</p>
|
||||
)}
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
Registered {new Date(user.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<CheckCircle className="w-4 h-4 text-owned" />}
|
||||
loading={approveMutation.isPending}
|
||||
onClick={() => approveMutation.mutate(user.id)}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
icon={<XCircle className="w-4 h-4" />}
|
||||
loading={denyMutation.isPending}
|
||||
onClick={() => denyMutation.mutate(user.id)}
|
||||
>
|
||||
Deny
|
||||
</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Role badge ───────────────────────────────────────────────────────────────
|
||||
|
||||
const ROLE_STYLE: Record<UserRole, string> = {
|
||||
admin: 'bg-accent-violet/20 text-accent-violet-light border-accent-violet/30',
|
||||
approved: 'bg-owned/20 text-owned border-owned/30',
|
||||
pending: 'bg-unowned/20 text-unowned border-unowned/30',
|
||||
}
|
||||
|
||||
function RoleBadge({ role }: { role: UserRole }) {
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border capitalize ${ROLE_STYLE[role]}`}>
|
||||
{role}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── User table ───────────────────────────────────────────────────────────────
|
||||
|
||||
function UserTable() {
|
||||
const queryClient = useQueryClient()
|
||||
const [roleFilter, setRoleFilter] = useState<string>('all')
|
||||
|
||||
const { data: users, isLoading } = useQuery<User[]>({
|
||||
queryKey: ['admin-users', roleFilter],
|
||||
queryFn: async () =>
|
||||
(await adminApi.users(roleFilter === 'all' ? undefined : roleFilter)).data,
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => adminApi.deleteUser(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin-users'] }),
|
||||
})
|
||||
|
||||
const promoteMutation = useMutation({
|
||||
mutationFn: ({ id, role }: { id: number; role: string }) =>
|
||||
adminApi.updateUser(id, { role }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin-users'] }),
|
||||
})
|
||||
|
||||
const allUsers = users ?? []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-4 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-text-muted" />
|
||||
<h2 className="font-display text-base text-text-primary">All users</h2>
|
||||
<span className="text-sm text-text-muted">({allUsers.length})</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{['all', 'pending', 'approved', 'admin'].map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setRoleFilter(r)}
|
||||
className={`text-xs px-3 py-1.5 rounded capitalize ${
|
||||
roleFilter === r
|
||||
? 'bg-accent-violet text-white'
|
||||
: 'bg-bg-elevated text-text-muted hover:text-text-primary border border-bg-border'
|
||||
}`}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8"><Spinner /></div>
|
||||
) : allUsers.length === 0 ? (
|
||||
<Panel className="py-2">
|
||||
<EmptyState title="No users found" />
|
||||
</Panel>
|
||||
) : (
|
||||
<Panel className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-bg-border">
|
||||
{['User', 'Role', 'Joined', 'Status', ''].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="px-4 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-bg-border">
|
||||
{allUsers.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-bg-elevated transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium text-text-primary">{user.display_name || '—'}</p>
|
||||
<p className="text-xs text-text-muted">{user.email}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<RoleBadge role={user.role} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-muted text-xs">
|
||||
{new Date(user.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs ${user.is_active ? 'text-owned' : 'text-danger'}`}>
|
||||
{user.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
{user.role !== 'admin' && (
|
||||
<button
|
||||
onClick={() => promoteMutation.mutate({ id: user.id, role: 'admin' })}
|
||||
className="text-xs text-text-muted hover:text-accent-violet-light transition-colors"
|
||||
title="Promote to admin"
|
||||
>
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Delete ${user.email}? This removes all their data.`)) {
|
||||
deleteMutation.mutate(user.id)
|
||||
}
|
||||
}}
|
||||
className="text-text-muted hover:text-danger transition-colors"
|
||||
title="Delete user"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function AdminPage() {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="font-display text-2xl text-text-primary">Admin panel</h1>
|
||||
<p className="text-text-muted text-sm mt-1">Manage user access and permissions</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-10">
|
||||
<ApprovalQueue />
|
||||
<UserTable />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { Button, Input, ErrorMessage } from '@/components/ui'
|
||||
import { AxiosError } from 'axios'
|
||||
|
||||
// ─── Shared card wrapper ──────────────────────────────────────────────────────
|
||||
|
||||
function AuthCard({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-base flex items-center justify-center p-4">
|
||||
{/* Subtle background texture */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-5"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'radial-gradient(circle at 25% 25%, #7C3AED 0%, transparent 50%), radial-gradient(circle at 75% 75%, #4C1D95 0%, transparent 50%)',
|
||||
}}
|
||||
/>
|
||||
<div className="relative w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="flex flex-col items-center mb-8 gap-2">
|
||||
<svg viewBox="0 0 60 60" className="w-14 h-14" fill="none">
|
||||
<polygon
|
||||
points="30,3 57,21 46.5,51 13.5,51 3,21"
|
||||
stroke="#7C3AED"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
/>
|
||||
<polygon
|
||||
points="30,12 48,24 41,44 19,44 12,24"
|
||||
stroke="#9F67FF"
|
||||
strokeWidth="1"
|
||||
fill="rgba(124,58,237,0.1)"
|
||||
/>
|
||||
<circle cx="30" cy="28" r="5" fill="#7C3AED" opacity="0.8" />
|
||||
</svg>
|
||||
<h1 className="font-display text-2xl text-text-primary tracking-wide">Commander Forge</h1>
|
||||
<p className="text-text-muted text-sm">AI-powered deck building for Commander</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-bg-surface border border-bg-border rounded-xl p-8 shadow-card">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getErrorMessage(err: unknown): string {
|
||||
if (err instanceof AxiosError) {
|
||||
const detail = err.response?.data?.detail
|
||||
if (typeof detail === 'string') return detail
|
||||
if (Array.isArray(detail)) return detail.map((d) => d.msg).join('. ')
|
||||
}
|
||||
return 'Something went wrong. Please try again.'
|
||||
}
|
||||
|
||||
// ─── Login ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const { login, loading } = useAuthStore()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
try {
|
||||
await login(email, password)
|
||||
navigate('/decks')
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthCard>
|
||||
<h2 className="font-display text-lg text-text-primary mb-6">Sign in</h2>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
{error && <ErrorMessage message={error} />}
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
<Button type="submit" loading={loading} className="w-full mt-2" size="lg">
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
<p className="text-center text-sm text-text-muted mt-6">
|
||||
No account?{' '}
|
||||
<Link to="/register" className="text-accent-violet-light hover:underline">
|
||||
Request access
|
||||
</Link>
|
||||
</p>
|
||||
</AuthCard>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Register ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function RegisterPage() {
|
||||
const navigate = useNavigate()
|
||||
const { register, loading } = useAuthStore()
|
||||
const [email, setEmail] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
if (password !== confirm) {
|
||||
setError('Passwords do not match.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await register(email, password, displayName || undefined)
|
||||
navigate('/pending')
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthCard>
|
||||
<h2 className="font-display text-lg text-text-primary mb-1">Request access</h2>
|
||||
<p className="text-text-muted text-sm mb-6">
|
||||
Accounts require admin approval before you can start building.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
{error && <ErrorMessage message={error} />}
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Display name (optional)"
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Your name"
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="At least 8 characters"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Confirm password"
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
<Button type="submit" loading={loading} className="w-full mt-2" size="lg">
|
||||
Request access
|
||||
</Button>
|
||||
</form>
|
||||
<p className="text-center text-sm text-text-muted mt-6">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-accent-violet-light hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</AuthCard>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Pending ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function PendingPage() {
|
||||
const { logout } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<AuthCard>
|
||||
<div className="text-center flex flex-col items-center gap-4">
|
||||
<div className="w-16 h-16 rounded-full bg-accent-violet/20 border border-accent-violet/40 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-accent-violet-light" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M12 6v6l4 2M12 2a10 10 0 100 20 10 10 0 000-20z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-display text-lg text-text-primary mb-2">Access pending</h2>
|
||||
<p className="text-text-secondary text-sm leading-relaxed">
|
||||
Your account is awaiting admin approval. You'll be able to sign in once approved.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { logout(); navigate('/login') }}
|
||||
className="text-sm text-text-muted hover:text-text-secondary transition-colors"
|
||||
>
|
||||
Back to sign in
|
||||
</button>
|
||||
</div>
|
||||
</AuthCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import React, { useState, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Upload, Package, Search, Trash2, RefreshCw, X, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { collectionApi } from '@/api'
|
||||
import { Button, Input, Panel, Badge, Spinner, EmptyState, Skeleton, ErrorMessage } from '@/components/ui'
|
||||
import type { CollectionCard, CollectionStats } from '@/types'
|
||||
import { AxiosError } from 'axios'
|
||||
|
||||
// ─── Import panel ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ImportPanel({ onSuccess }: { onSuccess: () => void }) {
|
||||
const [source, setSource] = useState<'archidekt' | 'manabox'>('archidekt')
|
||||
const [replace, setReplace] = useState(false)
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: () => collectionApi.import(source, file!, replace),
|
||||
onSuccess: (res) => {
|
||||
setSuccess(`Imported ${res.data.imported ?? 'cards'} successfully.`)
|
||||
setFile(null)
|
||||
setError('')
|
||||
onSuccess()
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof AxiosError) {
|
||||
setError(err.response?.data?.detail ?? 'Import failed.')
|
||||
} else {
|
||||
setError('Import failed.')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Panel className="p-5 flex flex-col gap-4">
|
||||
<h2 className="font-display text-base text-text-primary">Import collection</h2>
|
||||
|
||||
{/* Source selector */}
|
||||
<div className="flex gap-2">
|
||||
{(['archidekt', 'manabox'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSource(s)}
|
||||
className={`flex-1 py-2 rounded text-sm font-medium transition-all capitalize ${
|
||||
source === s
|
||||
? 'bg-accent-violet text-white'
|
||||
: 'bg-bg-elevated text-text-muted hover:text-text-primary border border-bg-border'
|
||||
}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* File drop zone */}
|
||||
<div
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className={`
|
||||
border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-all
|
||||
${file ? 'border-accent-violet/50 bg-accent-violet/5' : 'border-bg-border hover:border-accent-violet/40 hover:bg-bg-elevated'}
|
||||
`}
|
||||
>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".csv,.json"
|
||||
className="hidden"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
{file ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span className="text-sm text-accent-violet-light">{file.name}</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setFile(null) }}
|
||||
className="text-text-muted hover:text-danger"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 text-text-muted">
|
||||
<Upload className="w-6 h-6" />
|
||||
<p className="text-sm">Click to choose {source === 'archidekt' ? 'CSV or JSON' : 'CSV'} file</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={replace}
|
||||
onChange={(e) => setReplace(e.target.checked)}
|
||||
className="rounded border-bg-border"
|
||||
/>
|
||||
<span className="text-sm text-text-secondary">Replace existing collection</span>
|
||||
</label>
|
||||
|
||||
{error && <ErrorMessage message={error} />}
|
||||
{success && (
|
||||
<div className="bg-owned/10 border border-owned/30 rounded-md p-3 text-sm text-owned">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
loading={importMutation.isPending}
|
||||
disabled={!file}
|
||||
icon={<Upload className="w-4 h-4" />}
|
||||
onClick={() => importMutation.mutate()}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Stats panel ──────────────────────────────────────────────────────────────
|
||||
|
||||
function StatsPanel() {
|
||||
const { data } = useQuery<CollectionStats>({
|
||||
queryKey: ['collection-stats'],
|
||||
queryFn: async () => (await collectionApi.stats()).data,
|
||||
})
|
||||
|
||||
if (!data) return null
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: 'Total cards', value: data.total_cards.toLocaleString() },
|
||||
{ label: 'Unique cards', value: data.unique_cards.toLocaleString() },
|
||||
{ label: 'Foil', value: data.foil_cards.toLocaleString() },
|
||||
{
|
||||
label: 'Est. value',
|
||||
value: data.estimated_value != null ? `$${data.estimated_value.toFixed(2)}` : '—',
|
||||
},
|
||||
].map((s) => (
|
||||
<Panel key={s.label} className="p-3 text-center">
|
||||
<p className="text-xl font-semibold text-text-primary">{s.value}</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">{s.label}</p>
|
||||
</Panel>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Card row ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function CollectionCardRow({
|
||||
card,
|
||||
onDelete,
|
||||
}: {
|
||||
card: CollectionCard
|
||||
onDelete: (id: number) => void
|
||||
}) {
|
||||
const img = card.scryfall_data?.image_uris?.small
|
||||
const price = card.scryfall_data?.prices?.usd
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-2.5 px-3 hover:bg-bg-elevated rounded-md transition-colors group">
|
||||
{img ? (
|
||||
<img src={img} alt={card.card_name} className="w-7 h-9 rounded object-cover flex-shrink-0" />
|
||||
) : (
|
||||
<div className="w-7 h-9 bg-bg-border rounded flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-text-primary truncate">{card.card_name}</p>
|
||||
<div className="flex gap-2 items-center mt-0.5">
|
||||
{card.set_code && (
|
||||
<span className="text-xs text-text-muted uppercase">{card.set_code}</span>
|
||||
)}
|
||||
{card.collector_number && (
|
||||
<span className="text-xs text-text-muted">#{card.collector_number}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<div className="text-right">
|
||||
{card.quantity > 0 && (
|
||||
<span className="text-xs text-text-secondary">{card.quantity}×</span>
|
||||
)}
|
||||
{card.foil_quantity > 0 && (
|
||||
<span className="text-xs text-unowned ml-1.5">{card.foil_quantity}✦</span>
|
||||
)}
|
||||
</div>
|
||||
{price && <span className="text-xs text-text-muted">${price}</span>}
|
||||
<button
|
||||
onClick={() => onDelete(card.id)}
|
||||
className="text-text-muted hover:text-danger opacity-0 group-hover:opacity-100 transition-all"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function CollectionPage() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['collection', search, page],
|
||||
queryFn: async () => (await collectionApi.list(search, page)).data,
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => collectionApi.deleteCard(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['collection'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['collection-stats'] })
|
||||
},
|
||||
})
|
||||
|
||||
const clearMutation = useMutation({
|
||||
mutationFn: () => collectionApi.clear(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['collection'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['collection-stats'] })
|
||||
},
|
||||
})
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['collection'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['collection-stats'] })
|
||||
}
|
||||
|
||||
const cards: CollectionCard[] = data?.items ?? []
|
||||
const total: number = data?.total ?? 0
|
||||
const pages: number = data?.pages ?? 1
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="font-display text-2xl text-text-primary">Collection</h1>
|
||||
{total > 0 && (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
icon={<Trash2 className="w-4 h-4" />}
|
||||
loading={clearMutation.isPending}
|
||||
onClick={() => {
|
||||
if (confirm('Clear your entire collection? This cannot be undone.')) {
|
||||
clearMutation.mutate()
|
||||
}
|
||||
}}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<ImportPanel onSuccess={invalidate} />
|
||||
<StatsPanel />
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
|
||||
placeholder="Search collection…"
|
||||
className="w-full bg-bg-elevated border border-bg-border rounded-md pl-9 pr-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent-violet focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Card list */}
|
||||
<Panel className="overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-4 flex flex-col gap-2">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<div key={n} className="flex gap-3 items-center">
|
||||
<Skeleton className="w-7 h-9 rounded" />
|
||||
<div className="flex-1">
|
||||
<Skeleton className="h-4 w-40 mb-1.5" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : cards.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Package className="w-full h-full" />}
|
||||
title={search ? 'No matching cards' : 'Collection is empty'}
|
||||
description={search ? 'Try a different search.' : 'Import your collection above.'}
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-y divide-bg-border px-1">
|
||||
{cards.map((card) => (
|
||||
<CollectionCardRow
|
||||
key={card.id}
|
||||
card={card}
|
||||
onDelete={(id) => deleteMutation.mutate(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
{/* Pagination */}
|
||||
{pages > 1 && (
|
||||
<div className="flex items-center justify-between text-sm text-text-muted">
|
||||
<span>{total} cards</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="p-1 hover:text-text-primary disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span>Page {page} of {pages}</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(pages, p + 1))}
|
||||
disabled={page === pages}
|
||||
className="p-1 hover:text-text-primary disabled:opacity-30"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Wand2, Puzzle, Scissors, Plus, X, ChevronDown, ChevronUp, AlertCircle } from 'lucide-react'
|
||||
import { useDeckBuilderStore } from '@/store/deckBuilderStore'
|
||||
import { decksApi } from '@/api'
|
||||
import { Button, Input, Textarea, Toggle, Panel, ErrorMessage, Badge, Select } from '@/components/ui'
|
||||
import type { DeckMode, CardSlot } from '@/types'
|
||||
import { AxiosError } from 'axios'
|
||||
|
||||
// ─── Mode config ──────────────────────────────────────────────────────────────
|
||||
|
||||
const MODES: { id: DeckMode; label: string; icon: React.ReactNode; description: string }[] = [
|
||||
{
|
||||
id: 'generate',
|
||||
label: 'Generate',
|
||||
icon: <Wand2 className="w-5 h-5" />,
|
||||
description: 'Build a full 99-card deck from scratch',
|
||||
},
|
||||
{
|
||||
id: 'complete',
|
||||
label: 'Complete',
|
||||
icon: <Puzzle className="w-5 h-5" />,
|
||||
description: 'Fill the remaining slots in a partial decklist',
|
||||
},
|
||||
{
|
||||
id: 'cull',
|
||||
label: 'Cull',
|
||||
icon: <Scissors className="w-5 h-5" />,
|
||||
description: 'Cut an oversized deck down to 99',
|
||||
},
|
||||
]
|
||||
|
||||
const CARD_SLOTS: CardSlot[] = [
|
||||
'creature', 'instant', 'sorcery', 'enchantment', 'artifact', 'planeswalker', 'land', 'battle',
|
||||
]
|
||||
|
||||
const PLAYSTYLE_SUGGESTIONS = [
|
||||
'Aggro', 'Control', 'Combo', 'Midrange', 'Stax', 'Pillowfort',
|
||||
'Tokens', 'Reanimator', 'Aristocrats', 'Voltron', 'Group Hug', 'Superfriends',
|
||||
]
|
||||
|
||||
// ─── Existing card input (Complete / Cull modes) ──────────────────────────────
|
||||
|
||||
function ExistingCardsInput() {
|
||||
const { existingCards, setExistingCards } = useDeckBuilderStore()
|
||||
const [raw, setRaw] = useState('')
|
||||
const [pasteMode, setPasteMode] = useState(true)
|
||||
|
||||
const parseList = () => {
|
||||
const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean)
|
||||
const parsed = lines.map((line) => {
|
||||
// Handle "1x Card Name" or "Card Name" or "1 Card Name"
|
||||
const match = line.match(/^(\d+)[x\s]+(.+)$/)
|
||||
if (match) return { card_name: match[2].trim(), quantity: parseInt(match[1]) }
|
||||
return { card_name: line, quantity: 1 }
|
||||
})
|
||||
setExistingCards(parsed)
|
||||
}
|
||||
|
||||
const removeCard = (idx: number) => {
|
||||
setExistingCards(existingCards.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPasteMode(true)}
|
||||
className={`text-xs px-3 py-1.5 rounded ${pasteMode ? 'bg-accent-violet text-white' : 'bg-bg-elevated text-text-muted hover:text-text-primary'}`}
|
||||
>
|
||||
Paste list
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPasteMode(false)}
|
||||
className={`text-xs px-3 py-1.5 rounded ${!pasteMode ? 'bg-accent-violet text-white' : 'bg-bg-elevated text-text-muted hover:text-text-primary'}`}
|
||||
>
|
||||
View cards ({existingCards.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{pasteMode ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Textarea
|
||||
value={raw}
|
||||
onChange={(e) => setRaw(e.target.value)}
|
||||
placeholder={"1x Sol Ring\n1x Arcane Signet\n2x Island\n..."}
|
||||
rows={8}
|
||||
hint="One card per line. Quantities like '1x' or '1 ' are optional."
|
||||
/>
|
||||
<Button variant="secondary" size="sm" onClick={parseList} disabled={!raw.trim()}>
|
||||
Import {raw.split('\n').filter((l) => l.trim()).length} cards
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5 max-h-64 overflow-y-auto pr-1">
|
||||
{existingCards.length === 0 ? (
|
||||
<p className="text-text-muted text-sm py-4 text-center">No cards added yet</p>
|
||||
) : (
|
||||
existingCards.map((card, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between bg-bg-elevated rounded px-3 py-2">
|
||||
<span className="text-sm text-text-primary truncate">
|
||||
{card.quantity && card.quantity > 1 && (
|
||||
<span className="text-text-muted mr-2">{card.quantity}×</span>
|
||||
)}
|
||||
{card.card_name}
|
||||
</span>
|
||||
<button onClick={() => removeCard(idx)} className="text-text-muted hover:text-danger ml-2 flex-shrink-0">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Constraints panel ────────────────────────────────────────────────────────
|
||||
|
||||
function ConstraintsPanel() {
|
||||
const { constraints, setConstraints } = useDeckBuilderStore()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="border border-bg-border rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 bg-bg-elevated hover:bg-bg-border/50 transition-colors"
|
||||
>
|
||||
<span className="text-sm font-medium text-text-primary">Constraints</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{constraints.prefer_owned && <Badge variant="owned">Prefer owned</Badge>}
|
||||
{constraints.budget_enabled && (
|
||||
<Badge variant="unowned">${constraints.budget_amount}</Badge>
|
||||
)}
|
||||
{open ? <ChevronUp className="w-4 h-4 text-text-muted" /> : <ChevronDown className="w-4 h-4 text-text-muted" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="px-4 py-4 flex flex-col gap-4 bg-bg-surface border-t border-bg-border animate-fade-in">
|
||||
<Toggle
|
||||
checked={constraints.prefer_owned}
|
||||
onChange={(v) => setConstraints({ prefer_owned: v })}
|
||||
label="Prefer owned cards"
|
||||
description="Cards you own will be prioritised; others marked [UNOWNED]"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<Toggle
|
||||
checked={constraints.budget_enabled}
|
||||
onChange={(v) => setConstraints({ budget_enabled: v })}
|
||||
label="Budget limit"
|
||||
description="Cap the total card cost"
|
||||
/>
|
||||
|
||||
{constraints.budget_enabled && (
|
||||
<div className="flex gap-3 pl-13 animate-fade-in">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="100"
|
||||
value={constraints.budget_amount ?? ''}
|
||||
onChange={(e) => setConstraints({ budget_amount: e.target.value ? Number(e.target.value) : null })}
|
||||
className="w-32"
|
||||
/>
|
||||
<Select
|
||||
value={constraints.budget_scope}
|
||||
onChange={(e) => setConstraints({ budget_scope: e.target.value as 'purchase' | 'total' })}
|
||||
>
|
||||
<option value="purchase">Purchases only</option>
|
||||
<option value="total">Total deck</option>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BuildDeckPage() {
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
mode, setMode, commander, setCommander, playstyle, setPlaystyle,
|
||||
deckName, setDeckName, existingCards, targetCount, setTargetCount,
|
||||
isBuilding, setBuilding, lastError, setError,
|
||||
} = useDeckBuilderStore()
|
||||
|
||||
const handleBuild = async () => {
|
||||
if (!commander.trim()) {
|
||||
setError('Commander name is required.')
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
setBuilding(true)
|
||||
try {
|
||||
const constraints = useDeckBuilderStore.getState().constraints
|
||||
let res
|
||||
|
||||
if (mode === 'generate') {
|
||||
res = await decksApi.generate({
|
||||
commander: commander.trim(),
|
||||
playstyle: playstyle || undefined,
|
||||
name: deckName || undefined,
|
||||
constraints,
|
||||
})
|
||||
} else if (mode === 'complete') {
|
||||
res = await decksApi.complete({
|
||||
commander: commander.trim(),
|
||||
playstyle: playstyle || undefined,
|
||||
name: deckName || undefined,
|
||||
existing_cards: existingCards,
|
||||
constraints,
|
||||
})
|
||||
} else {
|
||||
res = await decksApi.cull({
|
||||
commander: commander.trim(),
|
||||
name: deckName || undefined,
|
||||
existing_cards: existingCards,
|
||||
target_count: targetCount,
|
||||
constraints,
|
||||
})
|
||||
}
|
||||
|
||||
navigate(`/decks/${res.data.id}`)
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
const detail = err.response?.data?.detail
|
||||
setError(typeof detail === 'string' ? detail : 'Build failed. Please try again.')
|
||||
} else {
|
||||
setError('Build failed. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
setBuilding(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="font-display text-2xl text-text-primary mb-1">Deck Builder</h1>
|
||||
<p className="text-text-muted text-sm">Forge your Commander deck with AI assistance</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Mode selector */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-secondary uppercase tracking-wider mb-3">Mode</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{MODES.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => setMode(m.id)}
|
||||
className={`
|
||||
flex flex-col items-center gap-2 p-4 rounded-lg border text-center transition-all
|
||||
${mode === m.id
|
||||
? 'border-accent-violet bg-accent-violet/10 text-accent-violet-light shadow-glow'
|
||||
: 'border-bg-border bg-bg-surface text-text-secondary hover:border-accent-violet/50 hover:text-text-primary'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{m.icon}
|
||||
<div>
|
||||
<p className="text-sm font-medium">{m.label}</p>
|
||||
<p className="text-xs mt-0.5 opacity-70 leading-tight">{m.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Panel className="p-5 flex flex-col gap-5">
|
||||
{/* Commander */}
|
||||
<Input
|
||||
label="Commander"
|
||||
value={commander}
|
||||
onChange={(e) => setCommander(e.target.value)}
|
||||
placeholder="e.g. Atraxa, Praetors' Voice"
|
||||
hint="Enter the exact card name of your commander"
|
||||
/>
|
||||
|
||||
{/* Playstyle (not for cull) */}
|
||||
{mode !== 'cull' && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
label="Playstyle (optional)"
|
||||
value={playstyle}
|
||||
onChange={(e) => setPlaystyle(e.target.value)}
|
||||
placeholder="e.g. Proliferate counters, Superfriends..."
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{PLAYSTYLE_SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setPlaystyle(s)}
|
||||
className={`text-xs px-2.5 py-1 rounded-full border transition-all ${
|
||||
playstyle === s
|
||||
? 'bg-accent-violet/20 border-accent-violet/50 text-accent-violet-light'
|
||||
: 'bg-bg-elevated border-bg-border text-text-muted hover:text-text-secondary hover:border-bg-border'
|
||||
}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deck name */}
|
||||
<Input
|
||||
label="Deck name (optional)"
|
||||
value={deckName}
|
||||
onChange={(e) => setDeckName(e.target.value)}
|
||||
placeholder="AI will name it if left blank"
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
{/* Existing cards input for complete/cull */}
|
||||
{(mode === 'complete' || mode === 'cull') && (
|
||||
<Panel className="p-5">
|
||||
<p className="text-xs font-medium text-text-secondary uppercase tracking-wider mb-3">
|
||||
{mode === 'cull' ? 'Current decklist' : 'Existing cards'}
|
||||
</p>
|
||||
<ExistingCardsInput />
|
||||
{mode === 'cull' && (
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<p className="text-sm text-text-secondary">Trim to</p>
|
||||
<Input
|
||||
type="number"
|
||||
value={targetCount}
|
||||
onChange={(e) => setTargetCount(Number(e.target.value))}
|
||||
className="w-20"
|
||||
min={1}
|
||||
max={99}
|
||||
/>
|
||||
<p className="text-sm text-text-secondary">cards</p>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{/* Constraints */}
|
||||
<ConstraintsPanel />
|
||||
|
||||
{/* Error */}
|
||||
{lastError && <ErrorMessage message={lastError} />}
|
||||
|
||||
{/* Build button */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
size="lg"
|
||||
loading={isBuilding}
|
||||
onClick={handleBuild}
|
||||
className="w-full"
|
||||
icon={!isBuilding ? <Wand2 className="w-5 h-5" /> : undefined}
|
||||
>
|
||||
{isBuilding
|
||||
? 'Forging your deck…'
|
||||
: mode === 'generate'
|
||||
? 'Generate deck'
|
||||
: mode === 'complete'
|
||||
? 'Complete deck'
|
||||
: 'Cull deck'}
|
||||
</Button>
|
||||
{isBuilding && (
|
||||
<p className="text-center text-xs text-text-muted animate-pulse">
|
||||
This usually takes 30–60 seconds…
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Plus, Layers, Trash2, ChevronRight } from 'lucide-react'
|
||||
import { decksApi } from '@/api'
|
||||
import { Button, Badge, Spinner, Panel, EmptyState, Skeleton } from '@/components/ui'
|
||||
import type { DeckSummary, DeckMode } from '@/types'
|
||||
|
||||
const MODE_LABELS: Record<DeckMode, string> = {
|
||||
generate: 'Generated',
|
||||
complete: 'Completed',
|
||||
cull: 'Culled',
|
||||
}
|
||||
|
||||
function DeckCard({ deck }: { deck: DeckSummary }) {
|
||||
const navigate = useNavigate()
|
||||
const date = new Date(deck.created_at).toLocaleDateString(undefined, {
|
||||
month: 'short', day: 'numeric', year: 'numeric',
|
||||
})
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => navigate(`/decks/${deck.id}`)}
|
||||
className="group w-full text-left bg-bg-surface border border-bg-border hover:border-accent-violet/50 rounded-lg p-4 transition-all hover:shadow-glow"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-display text-base text-text-primary group-hover:text-accent-violet-light transition-colors truncate">
|
||||
{deck.name}
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary mt-0.5 truncate">{deck.commander}</p>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-text-muted group-hover:text-accent-violet-light transition-colors flex-shrink-0 mt-1" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 mt-3">
|
||||
<Badge variant="mode">{MODE_LABELS[deck.mode]}</Badge>
|
||||
{deck.playstyle && (
|
||||
<Badge variant="default">{deck.playstyle}</Badge>
|
||||
)}
|
||||
<span className="text-xs text-text-muted ml-auto">{date}</span>
|
||||
</div>
|
||||
|
||||
{deck.card_count != null && (
|
||||
<p className="text-xs text-text-muted mt-2">{deck.card_count} cards</p>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function DeckCardSkeleton() {
|
||||
return (
|
||||
<div className="bg-bg-surface border border-bg-border rounded-lg p-4">
|
||||
<Skeleton className="h-5 w-48 mb-2" />
|
||||
<Skeleton className="h-4 w-32 mb-4" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-5 w-20 rounded-full" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DeckListPage() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['decks'],
|
||||
queryFn: async () => (await decksApi.list()).data,
|
||||
})
|
||||
|
||||
const decks: DeckSummary[] = data?.items ?? []
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl text-text-primary">My Decks</h1>
|
||||
{!isLoading && (
|
||||
<p className="text-text-muted text-sm mt-1">{decks.length} deck{decks.length !== 1 ? 's' : ''}</p>
|
||||
)}
|
||||
</div>
|
||||
<Link to="/build">
|
||||
<Button icon={<Plus className="w-4 h-4" />}>New deck</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{[1, 2, 3].map((n) => <DeckCardSkeleton key={n} />)}
|
||||
</div>
|
||||
) : decks.length === 0 ? (
|
||||
<Panel className="py-4">
|
||||
<EmptyState
|
||||
icon={<Layers className="w-full h-full" />}
|
||||
title="No decks yet"
|
||||
description="Build your first Commander deck with AI assistance."
|
||||
action={
|
||||
<Link to="/build">
|
||||
<Button icon={<Plus className="w-4 h-4" />}>Build a deck</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</Panel>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{decks.map((deck) => <DeckCard key={deck.id} deck={deck} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
ArrowLeft, Trash2, ChevronDown, ChevronUp, ExternalLink,
|
||||
Swords, Zap, Sparkles, Shield, Cog, Star, Mountain, X
|
||||
} from 'lucide-react'
|
||||
import { decksApi } from '@/api'
|
||||
import { Button, Badge, Spinner, Panel, EmptyState } from '@/components/ui'
|
||||
import type { Deck, DeckCard, CardSlot } from '@/types'
|
||||
|
||||
// ─── Slot metadata ────────────────────────────────────────────────────────────
|
||||
|
||||
const SLOT_META: Record<CardSlot, { label: string; icon: React.ReactNode; color: string }> = {
|
||||
creature: { label: 'Creatures', icon: <Swords className="w-4 h-4" />, color: 'text-green-400' },
|
||||
instant: { label: 'Instants', icon: <Zap className="w-4 h-4" />, color: 'text-blue-400' },
|
||||
sorcery: { label: 'Sorceries', icon: <Sparkles className="w-4 h-4" />, color: 'text-purple-400' },
|
||||
enchantment: { label: 'Enchantments', icon: <Star className="w-4 h-4" />, color: 'text-yellow-400' },
|
||||
artifact: { label: 'Artifacts', icon: <Cog className="w-4 h-4" />, color: 'text-slate-400' },
|
||||
planeswalker: { label: 'Planeswalkers', icon: <Shield className="w-4 h-4" />, color: 'text-pink-400' },
|
||||
land: { label: 'Lands', icon: <Mountain className="w-4 h-4" />, color: 'text-amber-600' },
|
||||
battle: { label: 'Battles', icon: <Swords className="w-4 h-4" />, color: 'text-red-400' },
|
||||
}
|
||||
|
||||
const SLOT_ORDER: CardSlot[] = [
|
||||
'creature', 'instant', 'sorcery', 'enchantment', 'artifact', 'planeswalker', 'land', 'battle',
|
||||
]
|
||||
|
||||
// ─── Card image helper ────────────────────────────────────────────────────────
|
||||
|
||||
function cardImageUrl(card: DeckCard, size: 'small' | 'normal' = 'small'): string | null {
|
||||
const sf = card.scryfall_data
|
||||
if (!sf) return null
|
||||
const uris = sf.image_uris
|
||||
if (uris) return uris[size] ?? null
|
||||
const faces = sf.card_faces
|
||||
if (faces?.length) return faces[0].image_uris?.[size] ?? null
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Reasoning drawer ─────────────────────────────────────────────────────────
|
||||
|
||||
function ReasoningDrawer({
|
||||
card,
|
||||
onClose,
|
||||
}: {
|
||||
card: DeckCard
|
||||
onClose: () => void
|
||||
}) {
|
||||
const img = cardImageUrl(card, 'normal')
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end md:items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="relative bg-bg-surface border border-bg-border rounded-xl shadow-card w-full max-w-lg animate-slide-up">
|
||||
<div className="flex items-start gap-4 p-5">
|
||||
{img && (
|
||||
<img
|
||||
src={img}
|
||||
alt={card.card_name}
|
||||
className="w-24 rounded-lg shadow-card flex-shrink-0 hidden sm:block"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2 mb-3">
|
||||
<div>
|
||||
<h3 className="font-display text-base text-text-primary">{card.card_name}</h3>
|
||||
{card.scryfall_data?.type_line && (
|
||||
<p className="text-xs text-text-muted mt-0.5">{card.scryfall_data.type_line}</p>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onClose} className="text-text-muted hover:text-text-primary flex-shrink-0">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
<Badge variant={card.is_owned ? 'owned' : 'unowned'}>
|
||||
{card.is_owned ? 'Owned' : 'Unowned'}
|
||||
</Badge>
|
||||
<Badge variant="slot">{SLOT_META[card.slot]?.label ?? card.slot}</Badge>
|
||||
{card.scryfall_data?.prices?.usd && (
|
||||
<Badge variant="default">${card.scryfall_data.prices.usd}</Badge>
|
||||
)}
|
||||
</div>
|
||||
{card.ai_reasoning && (
|
||||
<div className="bg-bg-elevated rounded-md p-3 text-sm text-text-secondary leading-relaxed">
|
||||
{card.ai_reasoning}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{card.scryfall_id && (
|
||||
<div className="px-5 pb-4">
|
||||
<a
|
||||
href={`https://scryfall.com/card/${card.scryfall_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-text-muted hover:text-accent-violet-light flex items-center gap-1"
|
||||
>
|
||||
View on Scryfall <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Card tile ────────────────────────────────────────────────────────────────
|
||||
|
||||
function CardTile({ card, onClick }: { card: DeckCard; onClick: () => void }) {
|
||||
const img = cardImageUrl(card, 'small')
|
||||
const price = card.scryfall_data?.prices?.usd
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="group flex items-center gap-3 bg-bg-elevated hover:bg-bg-border/50 border border-bg-border hover:border-accent-violet/40 rounded-md px-3 py-2 text-left transition-all w-full"
|
||||
>
|
||||
{img ? (
|
||||
<img
|
||||
src={img}
|
||||
alt={card.card_name}
|
||||
className="w-8 h-10 rounded object-cover flex-shrink-0 opacity-90 group-hover:opacity-100"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-10 rounded bg-bg-border flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-text-primary truncate group-hover:text-accent-violet-light transition-colors">
|
||||
{card.quantity > 1 && <span className="text-text-muted mr-1.5">{card.quantity}×</span>}
|
||||
{card.card_name}
|
||||
</p>
|
||||
{card.scryfall_data?.mana_cost && (
|
||||
<p className="text-xs text-text-muted truncate">{card.scryfall_data.mana_cost}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
||||
{!card.is_owned && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-unowned" title="Unowned" />
|
||||
)}
|
||||
{price && <span className="text-xs text-text-muted">${price}</span>}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Slot group ───────────────────────────────────────────────────────────────
|
||||
|
||||
function SlotGroup({ slot, cards }: { slot: CardSlot; cards: DeckCard[] }) {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [selectedCard, setSelectedCard] = useState<DeckCard | null>(null)
|
||||
const meta = SLOT_META[slot]
|
||||
const totalCount = cards.reduce((s, c) => s + (c.quantity || 1), 0)
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex items-center gap-2 w-full mb-2 group"
|
||||
>
|
||||
<span className={`${meta.color}`}>{meta.icon}</span>
|
||||
<span className="text-sm font-semibold text-text-primary">{meta.label}</span>
|
||||
<span className="text-xs text-text-muted">({totalCount})</span>
|
||||
<div className="flex-1 h-px bg-bg-border ml-2" />
|
||||
{open ? (
|
||||
<ChevronUp className="w-3.5 h-3.5 text-text-muted" />
|
||||
) : (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-text-muted" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="flex flex-col gap-1 animate-fade-in">
|
||||
{cards.map((card) => (
|
||||
<CardTile key={card.id} card={card} onClick={() => setSelectedCard(card)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedCard && (
|
||||
<ReasoningDrawer card={selectedCard} onClose={() => setSelectedCard(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Cuts section ─────────────────────────────────────────────────────────────
|
||||
|
||||
function CutsSection({ cuts }: { cuts: { name: string; reasoning: string }[] }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
if (!cuts.length) return null
|
||||
|
||||
return (
|
||||
<Panel className="p-5">
|
||||
<button onClick={() => setOpen((o) => !o)} className="flex items-center justify-between w-full mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<X className="w-4 h-4 text-danger" />
|
||||
<span className="font-display text-sm text-text-primary">Suggested cuts ({cuts.length})</span>
|
||||
</div>
|
||||
{open ? <ChevronUp className="w-4 h-4 text-text-muted" /> : <ChevronDown className="w-4 h-4 text-text-muted" />}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="flex flex-col gap-2 mt-3 animate-fade-in">
|
||||
{cuts.map((cut, i) => (
|
||||
<div key={i} className="flex gap-3 bg-danger/5 border border-danger/20 rounded-md p-3">
|
||||
<span className="text-xs text-danger font-medium mt-0.5 flex-shrink-0">#{i + 1}</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-primary">{cut.name}</p>
|
||||
<p className="text-xs text-text-secondary mt-0.5 leading-relaxed">{cut.reasoning}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function DeckViewPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: deck, isLoading, error } = useQuery<Deck>({
|
||||
queryKey: ['deck', id],
|
||||
queryFn: async () => (await decksApi.get(Number(id))).data,
|
||||
enabled: !!id,
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => decksApi.delete(Number(id)),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['decks'] })
|
||||
navigate('/decks')
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !deck) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||
<EmptyState title="Deck not found" description="This deck may have been deleted." />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Group cards by slot (excluding commander)
|
||||
const nonCommanderCards = deck.cards.filter((c) => !c.is_commander)
|
||||
const commander = deck.cards.find((c) => c.is_commander)
|
||||
const bySlot = SLOT_ORDER.reduce<Record<CardSlot, DeckCard[]>>(
|
||||
(acc, slot) => {
|
||||
acc[slot] = nonCommanderCards.filter((c) => c.slot === slot)
|
||||
return acc
|
||||
},
|
||||
{} as Record<CardSlot, DeckCard[]>
|
||||
)
|
||||
|
||||
const ownedCount = nonCommanderCards.filter((c) => c.is_owned).length
|
||||
const totalNonBasics = nonCommanderCards.filter((c) => c.slot !== 'land').length
|
||||
const totalCards = nonCommanderCards.reduce((s, c) => s + (c.quantity || 1), 0)
|
||||
|
||||
const cuts = deck.ai_reasoning?.cuts ?? []
|
||||
const unresolved = deck.ai_reasoning?.unresolved_cards ?? []
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4 mb-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Link to="/decks" className="text-text-muted hover:text-text-primary mt-1">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="font-display text-2xl text-text-primary">{deck.name}</h1>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||
<Badge variant="mode">{deck.mode}</Badge>
|
||||
<span className="text-text-muted text-sm">•</span>
|
||||
<span className="text-text-secondary text-sm">{deck.commander}</span>
|
||||
{deck.playstyle && (
|
||||
<>
|
||||
<span className="text-text-muted text-sm">•</span>
|
||||
<span className="text-text-muted text-sm">{deck.playstyle}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
icon={<Trash2 className="w-4 h-4" />}
|
||||
loading={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
if (confirm('Delete this deck?')) deleteMutation.mutate()
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-6">
|
||||
{[
|
||||
{ label: 'Total cards', value: totalCards + 1 },
|
||||
{ label: 'Owned', value: `${ownedCount}/${totalCards}` },
|
||||
{ label: 'Non-lands', value: totalNonBasics },
|
||||
].map((stat) => (
|
||||
<Panel key={stat.label} className="p-3 text-center">
|
||||
<p className="text-xl font-semibold text-text-primary">{stat.value}</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">{stat.label}</p>
|
||||
</Panel>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Strategy summary */}
|
||||
{deck.ai_reasoning?.strategy_summary && (
|
||||
<Panel className="p-5 mb-6">
|
||||
<p className="text-xs font-medium text-text-secondary uppercase tracking-wider mb-2">Strategy</p>
|
||||
<p className="text-sm text-text-secondary leading-relaxed">{deck.ai_reasoning.strategy_summary}</p>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{/* Commander */}
|
||||
{commander && (
|
||||
<div className="mb-6">
|
||||
<p className="text-xs font-medium text-text-secondary uppercase tracking-wider mb-2">Commander</p>
|
||||
<div className="bg-accent-violet/10 border border-accent-violet/30 rounded-lg px-4 py-3 flex items-center gap-3">
|
||||
<span className="font-display text-base text-accent-violet-light">{commander.card_name}</span>
|
||||
<Badge variant={commander.is_owned ? 'owned' : 'unowned'}>
|
||||
{commander.is_owned ? 'Owned' : 'Unowned'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unresolved cards warning */}
|
||||
{unresolved.length > 0 && (
|
||||
<div className="bg-unowned/10 border border-unowned/30 rounded-lg p-4 mb-6 flex gap-3">
|
||||
<span className="text-unowned flex-shrink-0">⚠</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-unowned">Unresolved cards ({unresolved.length})</p>
|
||||
<p className="text-xs text-text-secondary mt-1">
|
||||
These card names could not be verified on Scryfall:{' '}
|
||||
{unresolved.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Card grid by slot */}
|
||||
<div>
|
||||
{SLOT_ORDER.map((slot) => {
|
||||
const cards = bySlot[slot]
|
||||
if (!cards.length) return null
|
||||
return <SlotGroup key={slot} slot={slot} cards={cards} />
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Cuts */}
|
||||
{cuts.length > 0 && <CutsSection cuts={cuts} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { create } from 'zustand'
|
||||
import { authApi, tokenStorage, usersApi } from '@/api'
|
||||
import type { User } from '@/types'
|
||||
|
||||
interface AuthState {
|
||||
user: User | null
|
||||
loading: boolean
|
||||
initialized: boolean
|
||||
|
||||
// Actions
|
||||
login: (email: string, password: string) => Promise<void>
|
||||
register: (email: string, password: string, displayName?: string) => Promise<void>
|
||||
logout: () => void
|
||||
fetchMe: () => Promise<void>
|
||||
initialize: () => Promise<void>
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
loading: false,
|
||||
initialized: false,
|
||||
|
||||
initialize: async () => {
|
||||
const token = tokenStorage.getAccess()
|
||||
if (!token) {
|
||||
set({ initialized: true })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await usersApi.me()
|
||||
set({ user: res.data, initialized: true })
|
||||
} catch {
|
||||
tokenStorage.clear()
|
||||
set({ initialized: true })
|
||||
}
|
||||
},
|
||||
|
||||
login: async (email, password) => {
|
||||
set({ loading: true })
|
||||
try {
|
||||
const res = await authApi.login(email, password)
|
||||
tokenStorage.set(res.data)
|
||||
const meRes = await usersApi.me()
|
||||
set({ user: meRes.data, loading: false })
|
||||
} catch (err) {
|
||||
set({ loading: false })
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
register: async (email, password, displayName) => {
|
||||
set({ loading: true })
|
||||
try {
|
||||
await authApi.register(email, password, displayName)
|
||||
set({ loading: false })
|
||||
} catch (err) {
|
||||
set({ loading: false })
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
tokenStorage.clear()
|
||||
set({ user: null })
|
||||
},
|
||||
|
||||
fetchMe: async () => {
|
||||
try {
|
||||
const res = await usersApi.me()
|
||||
set({ user: res.data })
|
||||
} catch {
|
||||
// silently fail — interceptor handles 401
|
||||
}
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,71 @@
|
||||
import { create } from 'zustand'
|
||||
import type { DeckMode, DeckConstraints, ExistingCard, BudgetScope } from '@/types'
|
||||
|
||||
const DEFAULT_CONSTRAINTS: DeckConstraints = {
|
||||
prefer_owned: false,
|
||||
budget_enabled: false,
|
||||
budget_amount: null,
|
||||
budget_scope: 'purchase',
|
||||
}
|
||||
|
||||
interface DeckBuilderState {
|
||||
mode: DeckMode
|
||||
commander: string
|
||||
playstyle: string
|
||||
deckName: string
|
||||
constraints: DeckConstraints
|
||||
existingCards: ExistingCard[] // for complete/cull modes
|
||||
targetCount: number // for cull mode
|
||||
isBuilding: boolean
|
||||
lastError: string | null
|
||||
|
||||
// Actions
|
||||
setMode: (mode: DeckMode) => void
|
||||
setCommander: (name: string) => void
|
||||
setPlaystyle: (style: string) => void
|
||||
setDeckName: (name: string) => void
|
||||
setConstraints: (partial: Partial<DeckConstraints>) => void
|
||||
setBudgetScope: (scope: BudgetScope) => void
|
||||
setExistingCards: (cards: ExistingCard[]) => void
|
||||
setTargetCount: (n: number) => void
|
||||
setBuilding: (v: boolean) => void
|
||||
setError: (msg: string | null) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const useDeckBuilderStore = create<DeckBuilderState>((set) => ({
|
||||
mode: 'generate',
|
||||
commander: '',
|
||||
playstyle: '',
|
||||
deckName: '',
|
||||
constraints: { ...DEFAULT_CONSTRAINTS },
|
||||
existingCards: [],
|
||||
targetCount: 99,
|
||||
isBuilding: false,
|
||||
lastError: null,
|
||||
|
||||
setMode: (mode) => set({ mode }),
|
||||
setCommander: (commander) => set({ commander }),
|
||||
setPlaystyle: (playstyle) => set({ playstyle }),
|
||||
setDeckName: (deckName) => set({ deckName }),
|
||||
setConstraints: (partial) =>
|
||||
set((s) => ({ constraints: { ...s.constraints, ...partial } })),
|
||||
setBudgetScope: (scope) =>
|
||||
set((s) => ({ constraints: { ...s.constraints, budget_scope: scope } })),
|
||||
setExistingCards: (existingCards) => set({ existingCards }),
|
||||
setTargetCount: (targetCount) => set({ targetCount }),
|
||||
setBuilding: (isBuilding) => set({ isBuilding }),
|
||||
setError: (lastError) => set({ lastError }),
|
||||
reset: () =>
|
||||
set({
|
||||
mode: 'generate',
|
||||
commander: '',
|
||||
playstyle: '',
|
||||
deckName: '',
|
||||
constraints: { ...DEFAULT_CONSTRAINTS },
|
||||
existingCards: [],
|
||||
targetCount: 99,
|
||||
isBuilding: false,
|
||||
lastError: null,
|
||||
}),
|
||||
}))
|
||||
@@ -0,0 +1,178 @@
|
||||
// ─── Auth & Users ────────────────────────────────────────────────────────────
|
||||
|
||||
export type UserRole = 'pending' | 'approved' | 'admin'
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
email: string
|
||||
display_name: string | null
|
||||
role: UserRole
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AuthTokens {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
token_type: string
|
||||
}
|
||||
|
||||
// ─── Decks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type DeckMode = 'generate' | 'complete' | 'cull'
|
||||
|
||||
export type CardSlot =
|
||||
| 'creature'
|
||||
| 'instant'
|
||||
| 'sorcery'
|
||||
| 'enchantment'
|
||||
| 'artifact'
|
||||
| 'planeswalker'
|
||||
| 'land'
|
||||
| 'battle'
|
||||
|
||||
export type BudgetScope = 'purchase' | 'total'
|
||||
|
||||
export interface DeckConstraints {
|
||||
prefer_owned: boolean
|
||||
budget_enabled: boolean
|
||||
budget_amount: number | null
|
||||
budget_scope: BudgetScope
|
||||
}
|
||||
|
||||
export interface DeckCard {
|
||||
id: number
|
||||
deck_id: number
|
||||
card_name: string
|
||||
slot: CardSlot
|
||||
quantity: number
|
||||
is_owned: boolean
|
||||
is_commander: boolean
|
||||
ai_reasoning: string | null
|
||||
scryfall_id: string
|
||||
scryfall_data: ScryfallCard | null
|
||||
}
|
||||
|
||||
export interface DeckCut {
|
||||
name: string
|
||||
reasoning: string
|
||||
}
|
||||
|
||||
export interface Deck {
|
||||
id: number
|
||||
owner_id: number
|
||||
name: string
|
||||
commander: string
|
||||
description: string | null
|
||||
mode: DeckMode
|
||||
playstyle: string | null
|
||||
prefer_owned: boolean
|
||||
budget_enabled: boolean
|
||||
budget_amount: number | null
|
||||
budget_scope: BudgetScope
|
||||
created_at: string
|
||||
updated_at: string
|
||||
cards: DeckCard[]
|
||||
ai_reasoning: {
|
||||
strategy_summary: string
|
||||
unresolved_cards: string[]
|
||||
cuts: DeckCut[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface DeckSummary {
|
||||
id: number
|
||||
name: string
|
||||
commander: string
|
||||
mode: DeckMode
|
||||
playstyle: string | null
|
||||
created_at: string
|
||||
card_count: number
|
||||
}
|
||||
|
||||
// ─── Deck Builder Requests ───────────────────────────────────────────────────
|
||||
|
||||
export interface ExistingCard {
|
||||
card_name: string
|
||||
slot?: CardSlot
|
||||
quantity?: number
|
||||
}
|
||||
|
||||
export interface GenerateRequest {
|
||||
commander: string
|
||||
playstyle?: string
|
||||
name?: string
|
||||
description?: string
|
||||
constraints: DeckConstraints
|
||||
}
|
||||
|
||||
export interface CompleteRequest {
|
||||
commander: string
|
||||
playstyle?: string
|
||||
name?: string
|
||||
existing_cards: ExistingCard[]
|
||||
constraints: DeckConstraints
|
||||
}
|
||||
|
||||
export interface CullRequest {
|
||||
commander: string
|
||||
name?: string
|
||||
existing_cards: ExistingCard[]
|
||||
target_count: number
|
||||
constraints: DeckConstraints
|
||||
}
|
||||
|
||||
// ─── Collection ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CollectionCard {
|
||||
id: number
|
||||
card_name: string
|
||||
set_code: string
|
||||
collector_number: string
|
||||
quantity: number
|
||||
foil_quantity: number
|
||||
scryfall_id: string
|
||||
scryfall_data: ScryfallCard | null
|
||||
}
|
||||
|
||||
export interface CollectionStats {
|
||||
total_cards: number
|
||||
unique_cards: number
|
||||
foil_cards: number
|
||||
estimated_value: number | null
|
||||
}
|
||||
|
||||
// ─── Scryfall ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ScryfallCard {
|
||||
id: string
|
||||
name: string
|
||||
mana_cost?: string
|
||||
cmc?: number
|
||||
type_line?: string
|
||||
oracle_text?: string
|
||||
colors?: string[]
|
||||
color_identity?: string[]
|
||||
legalities?: Record<string, string>
|
||||
prices?: { usd?: string; usd_foil?: string }
|
||||
image_uris?: { small?: string; normal?: string; large?: string; art_crop?: string }
|
||||
card_faces?: Array<{ image_uris?: { small?: string; normal?: string; art_crop?: string } }>
|
||||
set: string
|
||||
set_name?: string
|
||||
collector_number?: string
|
||||
rarity?: string
|
||||
}
|
||||
|
||||
// ─── API Responses ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
detail: string | Array<{ loc: string[]; msg: string; type: string }>
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
bg: {
|
||||
base: '#0D1117',
|
||||
surface: '#161B22',
|
||||
elevated: '#1F2937',
|
||||
border: '#2D3748',
|
||||
},
|
||||
accent: {
|
||||
violet: '#7C3AED',
|
||||
'violet-light': '#9F67FF',
|
||||
'violet-dim': '#4C1D95',
|
||||
},
|
||||
owned: '#10B981',
|
||||
unowned: '#F59E0B',
|
||||
danger: '#EF4444',
|
||||
text: {
|
||||
primary: '#E2E8F0',
|
||||
secondary: '#94A3B8',
|
||||
muted: '#475569',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
display: ['"Cinzel"', 'serif'],
|
||||
body: ['"Inter"', 'sans-serif'],
|
||||
},
|
||||
boxShadow: {
|
||||
card: '0 0 0 1px #2D3748, 0 4px 16px rgba(0,0,0,0.4)',
|
||||
glow: '0 0 20px rgba(124,58,237,0.3)',
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.2s ease-out',
|
||||
'slide-up': 'slideUp 0.25s ease-out',
|
||||
shimmer: 'shimmer 1.8s infinite linear',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: { from: { opacity: '0' }, to: { opacity: '1' } },
|
||||
slideUp: { from: { transform: 'translateY(8px)', opacity: '0' }, to: { transform: 'translateY(0)', opacity: '1' } },
|
||||
shimmer: { '0%': { backgroundPosition: '-200% 0' }, '100%': { backgroundPosition: '200% 0' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: { '@': path.resolve(__dirname, './src') },
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user