Restructure into full project layout
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user