379 lines
15 KiB
TypeScript
379 lines
15 KiB
TypeScript
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>
|
||
)
|
||
}
|