Restructure into full project layout
This commit is contained in:
@@ -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