Files
Commander-Deck-App-backup/frontend/src/pages/decks/DeckViewPage.tsx
T

379 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}