Restructure into full project layout

This commit is contained in:
2026-06-16 23:06:16 -06:00
parent de4862b2d1
commit 57765496a6
74 changed files with 4441 additions and 3 deletions
+14
View File
@@ -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;"]
+16
View File
@@ -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>
+20
View File
@@ -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;
}
+36
View File
@@ -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"
}
}
+3
View File
@@ -0,0 +1,3 @@
export default {
plugins: { tailwindcss: {}, autoprefixer: {} },
}
+43
View File
@@ -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>
)
}
+145
View File
@@ -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>
)
}
+46
View File
@@ -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 />
}
+297
View File
@@ -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}`}
/>
)
}
+57
View File
@@ -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;
}
}
+36
View File
@@ -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>
)
+96
View File
@@ -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>
)
}
+258
View File
@@ -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>
)
}
+228
View File
@@ -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>
)
}
+378
View File
@@ -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 3060 seconds
</p>
)}
</div>
</div>
</div>
)
}
+110
View File
@@ -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>
)
}
+378
View File
@@ -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>
)
}
+75
View File
@@ -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
}
},
}))
+71
View File
@@ -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,
}),
}))
+178
View File
@@ -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 }>
}
+48
View File
@@ -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: [],
}
+23
View File
@@ -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" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+19
View File
@@ -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,
},
},
},
})