Restructure into full project layout

This commit is contained in:
2026-06-16 23:06:16 -06:00
parent de4862b2d1
commit 57765496a6
74 changed files with 4441 additions and 3 deletions
+378
View File
@@ -0,0 +1,378 @@
import React, { useState } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
ArrowLeft, Trash2, ChevronDown, ChevronUp, ExternalLink,
Swords, Zap, Sparkles, Shield, Cog, Star, Mountain, X
} from 'lucide-react'
import { decksApi } from '@/api'
import { Button, Badge, Spinner, Panel, EmptyState } from '@/components/ui'
import type { Deck, DeckCard, CardSlot } from '@/types'
// ─── Slot metadata ────────────────────────────────────────────────────────────
const SLOT_META: Record<CardSlot, { label: string; icon: React.ReactNode; color: string }> = {
creature: { label: 'Creatures', icon: <Swords className="w-4 h-4" />, color: 'text-green-400' },
instant: { label: 'Instants', icon: <Zap className="w-4 h-4" />, color: 'text-blue-400' },
sorcery: { label: 'Sorceries', icon: <Sparkles className="w-4 h-4" />, color: 'text-purple-400' },
enchantment: { label: 'Enchantments', icon: <Star className="w-4 h-4" />, color: 'text-yellow-400' },
artifact: { label: 'Artifacts', icon: <Cog className="w-4 h-4" />, color: 'text-slate-400' },
planeswalker: { label: 'Planeswalkers', icon: <Shield className="w-4 h-4" />, color: 'text-pink-400' },
land: { label: 'Lands', icon: <Mountain className="w-4 h-4" />, color: 'text-amber-600' },
battle: { label: 'Battles', icon: <Swords className="w-4 h-4" />, color: 'text-red-400' },
}
const SLOT_ORDER: CardSlot[] = [
'creature', 'instant', 'sorcery', 'enchantment', 'artifact', 'planeswalker', 'land', 'battle',
]
// ─── Card image helper ────────────────────────────────────────────────────────
function cardImageUrl(card: DeckCard, size: 'small' | 'normal' = 'small'): string | null {
const sf = card.scryfall_data
if (!sf) return null
const uris = sf.image_uris
if (uris) return uris[size] ?? null
const faces = sf.card_faces
if (faces?.length) return faces[0].image_uris?.[size] ?? null
return null
}
// ─── Reasoning drawer ─────────────────────────────────────────────────────────
function ReasoningDrawer({
card,
onClose,
}: {
card: DeckCard
onClose: () => void
}) {
const img = cardImageUrl(card, 'normal')
return (
<div className="fixed inset-0 z-50 flex items-end md:items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
onClick={onClose}
/>
<div className="relative bg-bg-surface border border-bg-border rounded-xl shadow-card w-full max-w-lg animate-slide-up">
<div className="flex items-start gap-4 p-5">
{img && (
<img
src={img}
alt={card.card_name}
className="w-24 rounded-lg shadow-card flex-shrink-0 hidden sm:block"
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-3">
<div>
<h3 className="font-display text-base text-text-primary">{card.card_name}</h3>
{card.scryfall_data?.type_line && (
<p className="text-xs text-text-muted mt-0.5">{card.scryfall_data.type_line}</p>
)}
</div>
<button onClick={onClose} className="text-text-muted hover:text-text-primary flex-shrink-0">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex flex-wrap gap-2 mb-3">
<Badge variant={card.is_owned ? 'owned' : 'unowned'}>
{card.is_owned ? 'Owned' : 'Unowned'}
</Badge>
<Badge variant="slot">{SLOT_META[card.slot]?.label ?? card.slot}</Badge>
{card.scryfall_data?.prices?.usd && (
<Badge variant="default">${card.scryfall_data.prices.usd}</Badge>
)}
</div>
{card.ai_reasoning && (
<div className="bg-bg-elevated rounded-md p-3 text-sm text-text-secondary leading-relaxed">
{card.ai_reasoning}
</div>
)}
</div>
</div>
{card.scryfall_id && (
<div className="px-5 pb-4">
<a
href={`https://scryfall.com/card/${card.scryfall_id}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-text-muted hover:text-accent-violet-light flex items-center gap-1"
>
View on Scryfall <ExternalLink className="w-3 h-3" />
</a>
</div>
)}
</div>
</div>
)
}
// ─── Card tile ────────────────────────────────────────────────────────────────
function CardTile({ card, onClick }: { card: DeckCard; onClick: () => void }) {
const img = cardImageUrl(card, 'small')
const price = card.scryfall_data?.prices?.usd
return (
<button
onClick={onClick}
className="group flex items-center gap-3 bg-bg-elevated hover:bg-bg-border/50 border border-bg-border hover:border-accent-violet/40 rounded-md px-3 py-2 text-left transition-all w-full"
>
{img ? (
<img
src={img}
alt={card.card_name}
className="w-8 h-10 rounded object-cover flex-shrink-0 opacity-90 group-hover:opacity-100"
/>
) : (
<div className="w-8 h-10 rounded bg-bg-border flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="text-sm text-text-primary truncate group-hover:text-accent-violet-light transition-colors">
{card.quantity > 1 && <span className="text-text-muted mr-1.5">{card.quantity}×</span>}
{card.card_name}
</p>
{card.scryfall_data?.mana_cost && (
<p className="text-xs text-text-muted truncate">{card.scryfall_data.mana_cost}</p>
)}
</div>
<div className="flex flex-col items-end gap-1 flex-shrink-0">
{!card.is_owned && (
<span className="w-1.5 h-1.5 rounded-full bg-unowned" title="Unowned" />
)}
{price && <span className="text-xs text-text-muted">${price}</span>}
</div>
</button>
)
}
// ─── Slot group ───────────────────────────────────────────────────────────────
function SlotGroup({ slot, cards }: { slot: CardSlot; cards: DeckCard[] }) {
const [open, setOpen] = useState(true)
const [selectedCard, setSelectedCard] = useState<DeckCard | null>(null)
const meta = SLOT_META[slot]
const totalCount = cards.reduce((s, c) => s + (c.quantity || 1), 0)
return (
<div className="mb-4">
<button
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-2 w-full mb-2 group"
>
<span className={`${meta.color}`}>{meta.icon}</span>
<span className="text-sm font-semibold text-text-primary">{meta.label}</span>
<span className="text-xs text-text-muted">({totalCount})</span>
<div className="flex-1 h-px bg-bg-border ml-2" />
{open ? (
<ChevronUp className="w-3.5 h-3.5 text-text-muted" />
) : (
<ChevronDown className="w-3.5 h-3.5 text-text-muted" />
)}
</button>
{open && (
<div className="flex flex-col gap-1 animate-fade-in">
{cards.map((card) => (
<CardTile key={card.id} card={card} onClick={() => setSelectedCard(card)} />
))}
</div>
)}
{selectedCard && (
<ReasoningDrawer card={selectedCard} onClose={() => setSelectedCard(null)} />
)}
</div>
)
}
// ─── Cuts section ─────────────────────────────────────────────────────────────
function CutsSection({ cuts }: { cuts: { name: string; reasoning: string }[] }) {
const [open, setOpen] = useState(false)
if (!cuts.length) return null
return (
<Panel className="p-5">
<button onClick={() => setOpen((o) => !o)} className="flex items-center justify-between w-full mb-1">
<div className="flex items-center gap-2">
<X className="w-4 h-4 text-danger" />
<span className="font-display text-sm text-text-primary">Suggested cuts ({cuts.length})</span>
</div>
{open ? <ChevronUp className="w-4 h-4 text-text-muted" /> : <ChevronDown className="w-4 h-4 text-text-muted" />}
</button>
{open && (
<div className="flex flex-col gap-2 mt-3 animate-fade-in">
{cuts.map((cut, i) => (
<div key={i} className="flex gap-3 bg-danger/5 border border-danger/20 rounded-md p-3">
<span className="text-xs text-danger font-medium mt-0.5 flex-shrink-0">#{i + 1}</span>
<div>
<p className="text-sm font-medium text-text-primary">{cut.name}</p>
<p className="text-xs text-text-secondary mt-0.5 leading-relaxed">{cut.reasoning}</p>
</div>
</div>
))}
</div>
)}
</Panel>
)
}
// ─── Main page ────────────────────────────────────────────────────────────────
export function DeckViewPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const { data: deck, isLoading, error } = useQuery<Deck>({
queryKey: ['deck', id],
queryFn: async () => (await decksApi.get(Number(id))).data,
enabled: !!id,
})
const deleteMutation = useMutation({
mutationFn: () => decksApi.delete(Number(id)),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['decks'] })
navigate('/decks')
},
})
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Spinner size="lg" />
</div>
)
}
if (error || !deck) {
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<EmptyState title="Deck not found" description="This deck may have been deleted." />
</div>
)
}
// Group cards by slot (excluding commander)
const nonCommanderCards = deck.cards.filter((c) => !c.is_commander)
const commander = deck.cards.find((c) => c.is_commander)
const bySlot = SLOT_ORDER.reduce<Record<CardSlot, DeckCard[]>>(
(acc, slot) => {
acc[slot] = nonCommanderCards.filter((c) => c.slot === slot)
return acc
},
{} as Record<CardSlot, DeckCard[]>
)
const ownedCount = nonCommanderCards.filter((c) => c.is_owned).length
const totalNonBasics = nonCommanderCards.filter((c) => c.slot !== 'land').length
const totalCards = nonCommanderCards.reduce((s, c) => s + (c.quantity || 1), 0)
const cuts = deck.ai_reasoning?.cuts ?? []
const unresolved = deck.ai_reasoning?.unresolved_cards ?? []
return (
<div className="max-w-3xl mx-auto px-4 py-8">
{/* Header */}
<div className="flex items-start justify-between gap-4 mb-6">
<div className="flex items-start gap-3">
<Link to="/decks" className="text-text-muted hover:text-text-primary mt-1">
<ArrowLeft className="w-5 h-5" />
</Link>
<div>
<h1 className="font-display text-2xl text-text-primary">{deck.name}</h1>
<div className="flex flex-wrap items-center gap-2 mt-1">
<Badge variant="mode">{deck.mode}</Badge>
<span className="text-text-muted text-sm"></span>
<span className="text-text-secondary text-sm">{deck.commander}</span>
{deck.playstyle && (
<>
<span className="text-text-muted text-sm"></span>
<span className="text-text-muted text-sm">{deck.playstyle}</span>
</>
)}
</div>
</div>
</div>
<Button
variant="danger"
size="sm"
icon={<Trash2 className="w-4 h-4" />}
loading={deleteMutation.isPending}
onClick={() => {
if (confirm('Delete this deck?')) deleteMutation.mutate()
}}
>
Delete
</Button>
</div>
{/* Stats bar */}
<div className="grid grid-cols-3 gap-3 mb-6">
{[
{ label: 'Total cards', value: totalCards + 1 },
{ label: 'Owned', value: `${ownedCount}/${totalCards}` },
{ label: 'Non-lands', value: totalNonBasics },
].map((stat) => (
<Panel key={stat.label} className="p-3 text-center">
<p className="text-xl font-semibold text-text-primary">{stat.value}</p>
<p className="text-xs text-text-muted mt-0.5">{stat.label}</p>
</Panel>
))}
</div>
{/* Strategy summary */}
{deck.ai_reasoning?.strategy_summary && (
<Panel className="p-5 mb-6">
<p className="text-xs font-medium text-text-secondary uppercase tracking-wider mb-2">Strategy</p>
<p className="text-sm text-text-secondary leading-relaxed">{deck.ai_reasoning.strategy_summary}</p>
</Panel>
)}
{/* Commander */}
{commander && (
<div className="mb-6">
<p className="text-xs font-medium text-text-secondary uppercase tracking-wider mb-2">Commander</p>
<div className="bg-accent-violet/10 border border-accent-violet/30 rounded-lg px-4 py-3 flex items-center gap-3">
<span className="font-display text-base text-accent-violet-light">{commander.card_name}</span>
<Badge variant={commander.is_owned ? 'owned' : 'unowned'}>
{commander.is_owned ? 'Owned' : 'Unowned'}
</Badge>
</div>
</div>
)}
{/* Unresolved cards warning */}
{unresolved.length > 0 && (
<div className="bg-unowned/10 border border-unowned/30 rounded-lg p-4 mb-6 flex gap-3">
<span className="text-unowned flex-shrink-0"></span>
<div>
<p className="text-sm font-medium text-unowned">Unresolved cards ({unresolved.length})</p>
<p className="text-xs text-text-secondary mt-1">
These card names could not be verified on Scryfall:{' '}
{unresolved.join(', ')}
</p>
</div>
</div>
)}
{/* Card grid by slot */}
<div>
{SLOT_ORDER.map((slot) => {
const cards = bySlot[slot]
if (!cards.length) return null
return <SlotGroup key={slot} slot={slot} cards={cards} />
})}
</div>
{/* Cuts */}
{cuts.length > 0 && <CutsSection cuts={cuts} />}
</div>
)
}