👁️
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

work on full goldfishing (messy)!

+1013 -57
+1 -1
src/components/CardImage.tsx
··· 14 14 } from "../lib/scryfall-types"; 15 15 import { type CardFaceType, getImageUri } from "../lib/scryfall-utils"; 16 16 17 - const PLACEHOLDER_STRIPES = `repeating-linear-gradient( 17 + export const PLACEHOLDER_STRIPES = `repeating-linear-gradient( 18 18 -45deg, 19 19 transparent, 20 20 transparent 8px,
+48 -27
src/components/deck/DeckActionsMenu.tsx
··· 1 1 import { useQueryClient } from "@tanstack/react-query"; 2 - import { MoreVertical, Trash2 } from "lucide-react"; 2 + import { Link } from "@tanstack/react-router"; 3 + import { MoreVertical, Play, Trash2 } from "lucide-react"; 3 4 import { useEffect, useRef, useState } from "react"; 4 5 import { toast } from "sonner"; 5 6 import { DeleteDeckDialog } from "@/components/deck/DeleteDeckDialog"; ··· 17 18 18 19 interface DeckActionsMenuProps { 19 20 deck: Deck; 21 + did: string; 20 22 rkey: Rkey; 21 - onUpdateDeck: (updater: (prev: Deck) => Deck) => Promise<void>; 23 + onUpdateDeck?: (updater: (prev: Deck) => Deck) => Promise<void>; 22 24 onCardsChanged?: (changedIds: Set<ScryfallId>) => void; 25 + readOnly?: boolean; 23 26 } 24 27 25 28 export function DeckActionsMenu({ 26 29 deck, 30 + did, 27 31 rkey, 28 32 onUpdateDeck, 29 33 onCardsChanged, 34 + readOnly = false, 30 35 }: DeckActionsMenuProps) { 31 36 const queryClient = useQueryClient(); 32 37 const [isOpen, setIsOpen] = useState(false); ··· 49 54 }, [isOpen]); 50 55 51 56 const handleSetAllToCheapest = async () => { 57 + if (!onUpdateDeck) return; 52 58 setIsOpen(false); 53 59 const toastId = toast.loading("Finding cheapest printings..."); 54 60 ··· 71 77 }; 72 78 73 79 const handleSetAllToBest = async () => { 80 + if (!onUpdateDeck) return; 74 81 setIsOpen(false); 75 82 const toastId = toast.loading("Finding best printings..."); 76 83 ··· 106 113 107 114 {isOpen && ( 108 115 <div className="absolute left-0 mt-2 w-48 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden z-50"> 109 - <button 110 - type="button" 111 - onClick={handleSetAllToCheapest} 112 - className="w-full text-left px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-900 dark:text-white text-sm" 116 + <Link 117 + to="/profile/$did/deck/$rkey/play" 118 + params={{ did, rkey }} 119 + className="w-full text-left px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-900 dark:text-white text-sm flex items-center gap-2" 120 + onClick={() => setIsOpen(false)} 113 121 > 114 - Set all to cheapest 115 - </button> 116 - <button 117 - type="button" 118 - onClick={handleSetAllToBest} 119 - className="w-full text-left px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-900 dark:text-white text-sm" 120 - > 121 - Set all to best 122 - </button> 123 - <div className="border-t border-gray-200 dark:border-gray-700" /> 124 - <button 125 - type="button" 126 - onClick={() => { 127 - setIsOpen(false); 128 - setShowDeleteDialog(true); 129 - }} 130 - className="w-full text-left px-4 py-3 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-red-600 dark:text-red-400 text-sm flex items-center gap-2" 131 - > 132 - <Trash2 size={14} /> 133 - Delete deck 134 - </button> 122 + <Play size={14} /> 123 + Playtest 124 + </Link> 125 + {!readOnly && ( 126 + <> 127 + <div className="border-t border-gray-200 dark:border-gray-700" /> 128 + <button 129 + type="button" 130 + onClick={handleSetAllToCheapest} 131 + className="w-full text-left px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-900 dark:text-white text-sm" 132 + > 133 + Set all to cheapest 134 + </button> 135 + <button 136 + type="button" 137 + onClick={handleSetAllToBest} 138 + className="w-full text-left px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-900 dark:text-white text-sm" 139 + > 140 + Set all to best 141 + </button> 142 + <div className="border-t border-gray-200 dark:border-gray-700" /> 143 + <button 144 + type="button" 145 + onClick={() => { 146 + setIsOpen(false); 147 + setShowDeleteDialog(true); 148 + }} 149 + className="w-full text-left px-4 py-3 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-red-600 dark:text-red-400 text-sm flex items-center gap-2" 150 + > 151 + <Trash2 size={14} /> 152 + Delete deck 153 + </button> 154 + </> 155 + )} 135 156 </div> 136 157 )} 137 158
+140
src/components/deck/GoldfishDragDropProvider.tsx
··· 1 + import { 2 + DndContext, 3 + type DragEndEvent, 4 + type DragMoveEvent, 5 + type DragOverEvent, 6 + DragOverlay, 7 + type DragStartEvent, 8 + KeyboardSensor, 9 + PointerSensor, 10 + TouchSensor, 11 + useSensor, 12 + useSensors, 13 + } from "@dnd-kit/core"; 14 + import { type ReactNode, useId, useRef, useState } from "react"; 15 + import { PLACEHOLDER_STRIPES } from "@/components/CardImage"; 16 + import type { CardInstance } from "@/lib/goldfish/types"; 17 + import { getImageUri } from "@/lib/scryfall-utils"; 18 + 19 + export interface DragPosition { 20 + translated: { left: number; top: number } | null; 21 + } 22 + 23 + interface GoldfishDragDropProviderProps { 24 + children: ReactNode; 25 + onDragStart?: (event: DragStartEvent) => void; 26 + onDragOver?: (event: DragOverEvent) => void; 27 + onDragEnd: (event: DragEndEvent, lastPosition: DragPosition | null) => void; 28 + } 29 + 30 + export function GoldfishDragDropProvider({ 31 + children, 32 + onDragStart, 33 + onDragOver, 34 + onDragEnd, 35 + }: GoldfishDragDropProviderProps) { 36 + const dndContextId = useId(); 37 + const [activeCard, setActiveCard] = useState<CardInstance | null>(null); 38 + // Track position during drag since rect.current.translated is null in onDragEnd 39 + // See: https://github.com/clauderic/dnd-kit/discussions/236 40 + const lastPositionRef = useRef<DragPosition | null>(null); 41 + 42 + const pointerSensor = useSensor(PointerSensor, { 43 + activationConstraint: { 44 + distance: 8, 45 + }, 46 + }); 47 + 48 + const touchSensor = useSensor(TouchSensor, { 49 + activationConstraint: { 50 + delay: 200, 51 + tolerance: 5, 52 + }, 53 + }); 54 + 55 + const keyboardSensor = useSensor(KeyboardSensor); 56 + 57 + const sensors = useSensors(pointerSensor, touchSensor, keyboardSensor); 58 + 59 + const handleDragStart = (event: DragStartEvent) => { 60 + const data = event.active.data.current as 61 + | { instance: CardInstance } 62 + | undefined; 63 + if (data?.instance) { 64 + setActiveCard(data.instance); 65 + } 66 + lastPositionRef.current = null; 67 + onDragStart?.(event); 68 + }; 69 + 70 + const handleDragMove = (event: DragMoveEvent) => { 71 + const rect = event.active.rect.current.translated; 72 + if (rect) { 73 + lastPositionRef.current = { 74 + translated: { left: rect.left, top: rect.top }, 75 + }; 76 + } 77 + }; 78 + 79 + const handleDragEnd = (event: DragEndEvent) => { 80 + const lastPosition = lastPositionRef.current; 81 + setActiveCard(null); 82 + lastPositionRef.current = null; 83 + onDragEnd(event, lastPosition); 84 + }; 85 + 86 + return ( 87 + <DndContext 88 + id={dndContextId} 89 + sensors={sensors} 90 + onDragStart={handleDragStart} 91 + onDragMove={handleDragMove} 92 + onDragOver={onDragOver} 93 + onDragEnd={handleDragEnd} 94 + > 95 + {children} 96 + <DragOverlay dropAnimation={null}> 97 + {activeCard && <DragPreview instance={activeCard} />} 98 + </DragOverlay> 99 + </DndContext> 100 + ); 101 + } 102 + 103 + function DragPreview({ instance }: { instance: CardInstance }) { 104 + const isFlipped = instance.faceIndex > 0; 105 + const imageSrc = instance.isFaceDown 106 + ? null 107 + : getImageUri(instance.cardId, "normal", isFlipped ? "back" : "front"); 108 + 109 + const counterEntries = Object.entries(instance.counters); 110 + 111 + return ( 112 + <div 113 + className={`relative pointer-events-none ${instance.isTapped ? "rotate-90" : ""}`} 114 + > 115 + {imageSrc ? ( 116 + <img 117 + src={imageSrc} 118 + alt="Dragging card" 119 + className="h-40 aspect-[5/7] rounded-[4.75%/3.5%] bg-gray-200 dark:bg-slate-700 shadow-2xl" 120 + style={{ backgroundImage: PLACEHOLDER_STRIPES }} 121 + draggable={false} 122 + /> 123 + ) : ( 124 + <div className="h-40 aspect-[5/7] rounded-[4.75%/3.5%] bg-amber-700 shadow-2xl" /> 125 + )} 126 + {counterEntries.length > 0 && ( 127 + <div className="absolute bottom-1 left-1 flex flex-wrap gap-1 max-w-full"> 128 + {counterEntries.map(([type, count]) => ( 129 + <span 130 + key={type} 131 + className="px-1.5 py-0.5 text-xs font-bold rounded bg-black/70 text-white" 132 + > 133 + {type === "+1/+1" ? `+${count}/+${count}` : `${count}`} 134 + </span> 135 + ))} 136 + </div> 137 + )} 138 + </div> 139 + ); 140 + }
+56
src/components/deck/goldfish/GoldfishBattlefield.tsx
··· 1 + import { useDroppable } from "@dnd-kit/core"; 2 + import { forwardRef } from "react"; 3 + import type { CardInstance } from "@/lib/goldfish/types"; 4 + import type { Card, ScryfallId } from "@/lib/scryfall-types"; 5 + import { GoldfishCard } from "./GoldfishCard"; 6 + 7 + interface GoldfishBattlefieldProps { 8 + cards: CardInstance[]; 9 + cardLookup?: (id: ScryfallId) => Card | undefined; 10 + onHover?: (instanceId: number | null) => void; 11 + onClick?: (instanceId: number) => void; 12 + } 13 + 14 + export const GoldfishBattlefield = forwardRef< 15 + HTMLDivElement, 16 + GoldfishBattlefieldProps 17 + >(function GoldfishBattlefield({ cards, cardLookup, onHover, onClick }, ref) { 18 + const { setNodeRef, isOver } = useDroppable({ 19 + id: "zone-battlefield", 20 + data: { zone: "battlefield" }, 21 + }); 22 + 23 + return ( 24 + <div ref={ref} className="flex-1 min-h-[300px]"> 25 + <div 26 + ref={setNodeRef} 27 + className={`isolate relative w-full h-full rounded-lg border-2 border-dashed transition-colors ${ 28 + isOver 29 + ? "border-green-500 bg-green-500/10" 30 + : "border-gray-300 dark:border-slate-600 bg-gray-50 dark:bg-slate-800/50" 31 + }`} 32 + > 33 + {cards.length === 0 && ( 34 + <div className="absolute inset-0 flex items-center justify-center text-gray-400 dark:text-gray-500 text-sm pointer-events-none"> 35 + Battlefield 36 + </div> 37 + )} 38 + {cards.map((instance) => ( 39 + <GoldfishCard 40 + key={instance.instanceId} 41 + instance={instance} 42 + card={cardLookup?.(instance.cardId)} 43 + onHover={onHover} 44 + onClick={onClick} 45 + positioning="absolute" 46 + style={{ 47 + left: instance.position?.x ?? 100, 48 + top: instance.position?.y ?? 100, 49 + zIndex: instance.zIndex, 50 + }} 51 + /> 52 + ))} 53 + </div> 54 + </div> 55 + ); 56 + });
+162
src/components/deck/goldfish/GoldfishBoard.tsx
··· 1 + import type { DragEndEvent } from "@dnd-kit/core"; 2 + import { useCallback, useRef } from "react"; 3 + import { CardImage } from "@/components/CardImage"; 4 + import type { DeckCard } from "@/lib/deck-types"; 5 + import { useGoldfishGame } from "@/lib/goldfish"; 6 + import type { CardInstance, Zone } from "@/lib/goldfish/types"; 7 + import type { Card, ScryfallId } from "@/lib/scryfall-types"; 8 + import { 9 + type DragPosition, 10 + GoldfishDragDropProvider, 11 + } from "../GoldfishDragDropProvider"; 12 + import { GoldfishBattlefield } from "./GoldfishBattlefield"; 13 + import { GoldfishHand } from "./GoldfishHand"; 14 + import { GoldfishSidebar } from "./GoldfishSidebar"; 15 + 16 + interface GoldfishBoardProps { 17 + deck: DeckCard[]; 18 + cardLookup: (id: ScryfallId) => Card | undefined; 19 + startingLife?: number; 20 + } 21 + 22 + export function GoldfishBoard({ 23 + deck, 24 + cardLookup, 25 + startingLife = 20, 26 + }: GoldfishBoardProps) { 27 + const { state, actions, SeedEmbed } = useGoldfishGame(deck, { 28 + startingLife, 29 + cardLookup, 30 + }); 31 + 32 + const battlefieldRef = useRef<HTMLDivElement>(null); 33 + 34 + const handleDragEnd = useCallback( 35 + (event: DragEndEvent, lastPosition: DragPosition | null) => { 36 + const { active, over, delta } = event; 37 + 38 + if (!over) return; 39 + 40 + const cardData = active.data.current as 41 + | { instance: CardInstance; fromLibrary?: boolean } 42 + | undefined; 43 + if (!cardData?.instance) return; 44 + 45 + const instance = cardData.instance; 46 + const instanceId = instance.instanceId; 47 + const fromLibrary = cardData.fromLibrary ?? false; 48 + const targetZone = (over.data.current as { zone: Zone } | undefined) 49 + ?.zone; 50 + 51 + if (!targetZone) return; 52 + 53 + if (targetZone === "battlefield") { 54 + const battlefieldRect = battlefieldRef.current?.getBoundingClientRect(); 55 + 56 + if (battlefieldRect) { 57 + let x: number; 58 + let y: number; 59 + 60 + if (instance.position) { 61 + // Card is already on battlefield - just add delta 62 + x = instance.position.x + delta.x; 63 + y = instance.position.y + delta.y; 64 + } else if (lastPosition?.translated) { 65 + // Card coming from another zone - use tracked translated position 66 + // (rect.current.translated is null in onDragEnd, so we track it in onDragMove) 67 + x = lastPosition.translated.left - battlefieldRect.left; 68 + y = lastPosition.translated.top - battlefieldRect.top; 69 + } else { 70 + // Fallback: center of battlefield 71 + x = battlefieldRect.width / 2; 72 + y = battlefieldRect.height / 2; 73 + } 74 + 75 + actions.moveCard(instanceId, targetZone, { 76 + position: { x, y }, 77 + faceDown: fromLibrary ? true : undefined, 78 + }); 79 + } else { 80 + actions.moveCard(instanceId, targetZone, { 81 + faceDown: fromLibrary ? true : undefined, 82 + }); 83 + } 84 + } else { 85 + actions.moveCard(instanceId, targetZone, { 86 + faceDown: fromLibrary ? true : undefined, 87 + }); 88 + } 89 + }, 90 + [actions], 91 + ); 92 + 93 + const hoveredCard = state.hoveredId 94 + ? [ 95 + ...state.hand, 96 + ...state.battlefield, 97 + ...state.graveyard, 98 + ...state.exile, 99 + ].find((c) => c.instanceId === state.hoveredId) 100 + : null; 101 + 102 + const hoveredCardData = hoveredCard ? cardLookup(hoveredCard.cardId) : null; 103 + 104 + return ( 105 + <GoldfishDragDropProvider onDragEnd={handleDragEnd}> 106 + <SeedEmbed /> 107 + <div className="flex h-full gap-4 p-4 bg-white dark:bg-slate-950"> 108 + {/* Left: Card Preview */} 109 + <div className="w-64 flex-shrink-0"> 110 + {hoveredCardData ? ( 111 + <CardImage 112 + card={hoveredCardData} 113 + size="large" 114 + className="w-full aspect-[5/7] rounded-lg shadow-lg" 115 + isFlipped={ 116 + hoveredCard?.faceIndex ? hoveredCard.faceIndex > 0 : false 117 + } 118 + /> 119 + ) : ( 120 + <div className="w-full aspect-[5/7] rounded-lg bg-gray-100 dark:bg-slate-800 flex items-center justify-center text-gray-400 dark:text-gray-500 text-sm"> 121 + Hover a card 122 + </div> 123 + )} 124 + </div> 125 + 126 + {/* Center: Battlefield + Hand */} 127 + <div className="flex-1 flex flex-col gap-4 min-w-0"> 128 + <GoldfishBattlefield 129 + ref={battlefieldRef} 130 + cards={state.battlefield} 131 + cardLookup={cardLookup} 132 + onHover={actions.setHoveredCard} 133 + onClick={actions.toggleTap} 134 + /> 135 + <GoldfishHand 136 + cards={state.hand} 137 + cardLookup={cardLookup} 138 + onHover={actions.setHoveredCard} 139 + onClick={(id) => actions.moveCard(id, "battlefield")} 140 + /> 141 + </div> 142 + 143 + {/* Right: Sidebar */} 144 + <GoldfishSidebar 145 + library={state.library} 146 + graveyard={state.graveyard} 147 + exile={state.exile} 148 + player={state.player} 149 + cardLookup={cardLookup} 150 + onHover={actions.setHoveredCard} 151 + onClick={actions.toggleTap} 152 + onDraw={actions.draw} 153 + onUntapAll={actions.untapAll} 154 + onMulligan={actions.mulligan} 155 + onReset={actions.reset} 156 + onAdjustLife={actions.adjustLife} 157 + onAdjustPoison={actions.adjustPoison} 158 + /> 159 + </div> 160 + </GoldfishDragDropProvider> 161 + ); 162 + }
+84
src/components/deck/goldfish/GoldfishCard.tsx
··· 1 + import { useDraggable } from "@dnd-kit/core"; 2 + import { PLACEHOLDER_STRIPES } from "@/components/CardImage"; 3 + import type { CardInstance } from "@/lib/goldfish/types"; 4 + import type { Card } from "@/lib/scryfall-types"; 5 + import { getImageUri } from "@/lib/scryfall-utils"; 6 + 7 + interface GoldfishCardProps { 8 + instance: CardInstance; 9 + card?: Card; 10 + onHover?: (instanceId: number | null) => void; 11 + onClick?: (instanceId: number) => void; 12 + size?: "tiny" | "small" | "normal"; 13 + positioning?: "relative" | "absolute"; 14 + className?: string; 15 + style?: React.CSSProperties; 16 + } 17 + 18 + export function GoldfishCard({ 19 + instance, 20 + card, 21 + onHover, 22 + onClick, 23 + size = "normal", 24 + positioning = "relative", 25 + className = "", 26 + style, 27 + }: GoldfishCardProps) { 28 + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ 29 + id: `card-${instance.instanceId}`, 30 + data: { instance }, 31 + }); 32 + 33 + const isFlipped = instance.faceIndex > 0; 34 + const imageSize = size === "normal" ? "normal" : "small"; 35 + const imageSrc = instance.isFaceDown 36 + ? null 37 + : getImageUri(instance.cardId, imageSize, isFlipped ? "back" : "front"); 38 + 39 + const counterEntries = Object.entries(instance.counters); 40 + const sizeClass = 41 + size === "tiny" ? "h-14" : size === "small" ? "h-24" : "h-40"; 42 + 43 + return ( 44 + <button 45 + type="button" 46 + ref={setNodeRef} 47 + className={`${positioning} select-none ${className} ${isDragging ? "opacity-0" : ""} ${instance.isTapped ? "rotate-90" : ""}`} 48 + style={style} 49 + onMouseEnter={() => onHover?.(instance.instanceId)} 50 + onMouseLeave={() => onHover?.(null)} 51 + onClick={() => onClick?.(instance.instanceId)} 52 + {...listeners} 53 + {...attributes} 54 + > 55 + {imageSrc ? ( 56 + <img 57 + src={imageSrc} 58 + alt={card?.name ?? "Card"} 59 + className={`rounded-[4.75%/3.5%] bg-gray-200 dark:bg-slate-700 ${sizeClass} aspect-[5/7]`} 60 + style={{ backgroundImage: PLACEHOLDER_STRIPES }} 61 + draggable={false} 62 + loading="lazy" 63 + /> 64 + ) : ( 65 + <div 66 + className={`rounded-[4.75%/3.5%] bg-amber-700 ${sizeClass} aspect-[5/7]`} 67 + /> 68 + )} 69 + {counterEntries.length > 0 && ( 70 + <div className="absolute bottom-1 left-1 flex flex-wrap gap-1 max-w-full"> 71 + {counterEntries.map(([type, count]) => ( 72 + <span 73 + key={type} 74 + className="px-1.5 py-0.5 text-xs font-bold rounded bg-black/70 text-white" 75 + title={type} 76 + > 77 + {type === "+1/+1" ? `+${count}/+${count}` : `${count}`} 78 + </span> 79 + ))} 80 + </div> 81 + )} 82 + </button> 83 + ); 84 + }
+51
src/components/deck/goldfish/GoldfishHand.tsx
··· 1 + import { useDroppable } from "@dnd-kit/core"; 2 + import type { CardInstance } from "@/lib/goldfish/types"; 3 + import type { Card, ScryfallId } from "@/lib/scryfall-types"; 4 + import { GoldfishCard } from "./GoldfishCard"; 5 + 6 + interface GoldfishHandProps { 7 + cards: CardInstance[]; 8 + cardLookup?: (id: ScryfallId) => Card | undefined; 9 + onHover?: (instanceId: number | null) => void; 10 + onClick?: (instanceId: number) => void; 11 + } 12 + 13 + export function GoldfishHand({ 14 + cards, 15 + cardLookup, 16 + onHover, 17 + onClick, 18 + }: GoldfishHandProps) { 19 + const { setNodeRef, isOver } = useDroppable({ 20 + id: "zone-hand", 21 + data: { zone: "hand" }, 22 + }); 23 + 24 + return ( 25 + <div 26 + ref={setNodeRef} 27 + className={`flex gap-2 overflow-x-auto p-2 min-h-[11rem] rounded-lg border-2 border-dashed transition-colors ${ 28 + isOver 29 + ? "border-blue-500 bg-blue-500/10" 30 + : "border-gray-300 dark:border-slate-600 bg-gray-50 dark:bg-slate-800/50" 31 + }`} 32 + > 33 + {cards.length === 0 ? ( 34 + <div className="flex items-center justify-center w-full text-gray-400 dark:text-gray-500 text-sm"> 35 + Hand is empty 36 + </div> 37 + ) : ( 38 + cards.map((instance) => ( 39 + <GoldfishCard 40 + key={instance.instanceId} 41 + instance={instance} 42 + card={cardLookup?.(instance.cardId)} 43 + onHover={onHover} 44 + onClick={onClick} 45 + className="flex-shrink-0" 46 + /> 47 + )) 48 + )} 49 + </div> 50 + ); 51 + }
+31
src/components/deck/goldfish/GoldfishLibraryCard.tsx
··· 1 + import { useDraggable } from "@dnd-kit/core"; 2 + import type { CardInstance } from "@/lib/goldfish/types"; 3 + 4 + interface GoldfishLibraryCardProps { 5 + topCard: CardInstance; 6 + onDraw: () => void; 7 + } 8 + 9 + export function GoldfishLibraryCard({ 10 + topCard, 11 + onDraw, 12 + }: GoldfishLibraryCardProps) { 13 + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ 14 + id: `library-top-${topCard.instanceId}`, 15 + data: { 16 + instance: { ...topCard, isFaceDown: true }, 17 + fromLibrary: true, 18 + }, 19 + }); 20 + 21 + return ( 22 + <button 23 + type="button" 24 + ref={setNodeRef} 25 + onClick={onDraw} 26 + className={`w-full aspect-[5/7] rounded-[4.75%/3.5%] bg-amber-700 shadow-md cursor-grab active:cursor-grabbing ${isDragging ? "opacity-0" : ""}`} 27 + {...listeners} 28 + {...attributes} 29 + /> 30 + ); 31 + }
+93
src/components/deck/goldfish/GoldfishPile.tsx
··· 1 + import { useDroppable } from "@dnd-kit/core"; 2 + import { ChevronDown, ChevronUp } from "lucide-react"; 3 + import { useState } from "react"; 4 + import type { CardInstance, Zone } from "@/lib/goldfish/types"; 5 + import type { Card, ScryfallId } from "@/lib/scryfall-types"; 6 + import { GoldfishCard } from "./GoldfishCard"; 7 + 8 + interface GoldfishPileProps { 9 + zone: Zone; 10 + label: string; 11 + cards: CardInstance[]; 12 + cardLookup?: (id: ScryfallId) => Card | undefined; 13 + onHover?: (instanceId: number | null) => void; 14 + onClick?: (instanceId: number) => void; 15 + } 16 + 17 + export function GoldfishPile({ 18 + zone, 19 + label, 20 + cards, 21 + cardLookup, 22 + onHover, 23 + onClick, 24 + }: GoldfishPileProps) { 25 + const [expanded, setExpanded] = useState(false); 26 + 27 + const { setNodeRef, isOver } = useDroppable({ 28 + id: `zone-${zone}`, 29 + data: { zone }, 30 + }); 31 + 32 + return ( 33 + <div 34 + ref={setNodeRef} 35 + className={`rounded-lg border-2 border-dashed transition-colors ${ 36 + isOver 37 + ? "border-purple-500 bg-purple-500/10" 38 + : "border-gray-300 dark:border-slate-600 bg-gray-50 dark:bg-slate-800/50" 39 + }`} 40 + > 41 + <button 42 + type="button" 43 + onClick={() => setExpanded(!expanded)} 44 + className="w-full flex items-center justify-between p-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-t-lg" 45 + > 46 + <span> 47 + {label} ({cards.length}) 48 + </span> 49 + {expanded ? ( 50 + <ChevronUp className="w-4 h-4" /> 51 + ) : ( 52 + <ChevronDown className="w-4 h-4" /> 53 + )} 54 + </button> 55 + {expanded && cards.length > 0 && ( 56 + <div className="p-2 pt-0 grid grid-cols-3 gap-1 max-h-36 overflow-y-auto"> 57 + {cards.map((instance) => ( 58 + <GoldfishCard 59 + key={instance.instanceId} 60 + instance={instance} 61 + card={cardLookup?.(instance.cardId)} 62 + onHover={onHover} 63 + onClick={onClick} 64 + size="tiny" 65 + /> 66 + ))} 67 + </div> 68 + )} 69 + {!expanded && cards.length > 0 && ( 70 + <div className="p-2 pt-0"> 71 + <div className="relative h-14 w-10"> 72 + {cards.slice(-3).map((instance, i) => ( 73 + <GoldfishCard 74 + key={instance.instanceId} 75 + instance={instance} 76 + card={cardLookup?.(instance.cardId)} 77 + onHover={onHover} 78 + onClick={onClick} 79 + size="tiny" 80 + positioning="absolute" 81 + style={{ 82 + top: i * 2, 83 + left: i * 2, 84 + zIndex: i, 85 + }} 86 + /> 87 + ))} 88 + </div> 89 + </div> 90 + )} 91 + </div> 92 + ); 93 + }
+164
src/components/deck/goldfish/GoldfishSidebar.tsx
··· 1 + import { Droplet, Minus, Plus, RefreshCw, RotateCcw } from "lucide-react"; 2 + import type { CardInstance, PlayerState } from "@/lib/goldfish/types"; 3 + import type { Card, ScryfallId } from "@/lib/scryfall-types"; 4 + import { GoldfishLibraryCard } from "./GoldfishLibraryCard"; 5 + import { GoldfishPile } from "./GoldfishPile"; 6 + 7 + interface GoldfishSidebarProps { 8 + library: CardInstance[]; 9 + graveyard: CardInstance[]; 10 + exile: CardInstance[]; 11 + player: PlayerState; 12 + cardLookup?: (id: ScryfallId) => Card | undefined; 13 + onHover?: (instanceId: number | null) => void; 14 + onClick?: (instanceId: number) => void; 15 + onDraw: () => void; 16 + onUntapAll: () => void; 17 + onMulligan: () => void; 18 + onReset: () => void; 19 + onAdjustLife: (amount: number) => void; 20 + onAdjustPoison: (amount: number) => void; 21 + } 22 + 23 + export function GoldfishSidebar({ 24 + library, 25 + graveyard, 26 + exile, 27 + player, 28 + cardLookup, 29 + onHover, 30 + onClick, 31 + onDraw, 32 + onUntapAll, 33 + onMulligan, 34 + onReset, 35 + onAdjustLife, 36 + onAdjustPoison, 37 + }: GoldfishSidebarProps) { 38 + return ( 39 + <div className="w-48 flex flex-col gap-3 p-2 bg-gray-100 dark:bg-slate-900 rounded-lg overflow-y-auto overflow-x-hidden"> 40 + {/* Library */} 41 + <div className="rounded-lg border-2 border-dashed border-gray-300 dark:border-slate-600 bg-gray-50 dark:bg-slate-800/50 p-2"> 42 + <div className="flex items-center justify-between mb-2"> 43 + <span className="text-sm font-medium text-gray-700 dark:text-gray-300"> 44 + Library ({library.length}) 45 + </span> 46 + <span className="text-xs text-gray-500 dark:text-gray-400"> 47 + D to draw 48 + </span> 49 + </div> 50 + {library.length > 0 ? ( 51 + <div className="w-16"> 52 + <GoldfishLibraryCard topCard={library[0]} onDraw={onDraw} /> 53 + </div> 54 + ) : ( 55 + <div className="w-16 aspect-[5/7] rounded-[4.75%/3.5%] border-2 border-dashed border-gray-300 dark:border-slate-600" /> 56 + )} 57 + </div> 58 + 59 + {/* Graveyard */} 60 + <GoldfishPile 61 + zone="graveyard" 62 + label="Graveyard" 63 + cards={graveyard} 64 + cardLookup={cardLookup} 65 + onHover={onHover} 66 + onClick={onClick} 67 + /> 68 + 69 + {/* Exile */} 70 + <GoldfishPile 71 + zone="exile" 72 + label="Exile" 73 + cards={exile} 74 + cardLookup={cardLookup} 75 + onHover={onHover} 76 + onClick={onClick} 77 + /> 78 + 79 + {/* Player Stats */} 80 + <div className="space-y-2"> 81 + {/* Life */} 82 + <div className="flex items-center justify-between p-2 rounded bg-gray-50 dark:bg-slate-800"> 83 + <button 84 + type="button" 85 + onClick={() => onAdjustLife(-1)} 86 + className="p-1 rounded hover:bg-gray-200 dark:hover:bg-slate-700" 87 + aria-label="Decrease life" 88 + > 89 + <Minus className="w-4 h-4" /> 90 + </button> 91 + <span className="text-lg font-bold text-gray-700 dark:text-gray-200"> 92 + {player.life} 93 + </span> 94 + <button 95 + type="button" 96 + onClick={() => onAdjustLife(1)} 97 + className="p-1 rounded hover:bg-gray-200 dark:hover:bg-slate-700" 98 + aria-label="Increase life" 99 + > 100 + <Plus className="w-4 h-4" /> 101 + </button> 102 + </div> 103 + 104 + {/* Poison */} 105 + <div className="flex items-center justify-between p-2 rounded bg-gray-50 dark:bg-slate-800"> 106 + <button 107 + type="button" 108 + onClick={() => onAdjustPoison(-1)} 109 + className="p-1 rounded hover:bg-gray-200 dark:hover:bg-slate-700" 110 + aria-label="Decrease poison" 111 + > 112 + <Minus className="w-4 h-4" /> 113 + </button> 114 + <span className="flex items-center gap-1 text-lg font-bold text-green-600 dark:text-green-400"> 115 + <Droplet className="w-4 h-4" /> 116 + {player.poison} 117 + </span> 118 + <button 119 + type="button" 120 + onClick={() => onAdjustPoison(1)} 121 + className="p-1 rounded hover:bg-gray-200 dark:hover:bg-slate-700" 122 + aria-label="Increase poison" 123 + > 124 + <Plus className="w-4 h-4" /> 125 + </button> 126 + </div> 127 + </div> 128 + 129 + {/* Actions */} 130 + <div className="space-y-2"> 131 + <button 132 + type="button" 133 + onClick={onUntapAll} 134 + className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded bg-blue-600 text-white hover:bg-blue-700" 135 + > 136 + <RotateCcw className="w-4 h-4" /> 137 + Untap All (U) 138 + </button> 139 + <button 140 + type="button" 141 + onClick={onMulligan} 142 + className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-slate-600" 143 + > 144 + <RefreshCw className="w-4 h-4" /> 145 + Mulligan 146 + </button> 147 + <button 148 + type="button" 149 + onClick={onReset} 150 + className="w-full px-3 py-2 text-sm font-medium rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-900/50" 151 + > 152 + Reset Game 153 + </button> 154 + </div> 155 + 156 + {/* Keyboard hints */} 157 + <div className="text-xs text-gray-400 dark:text-gray-500 space-y-1"> 158 + <p>T/Space: tap • F: flip</p> 159 + <p>M: morph • G: graveyard</p> 160 + <p>E: exile • H: hand • B: play</p> 161 + </div> 162 + </div> 163 + ); 164 + }
+6
src/components/deck/goldfish/index.ts
··· 1 + export { GoldfishBattlefield } from "./GoldfishBattlefield"; 2 + export { GoldfishBoard } from "./GoldfishBoard"; 3 + export { GoldfishCard } from "./GoldfishCard"; 4 + export { GoldfishHand } from "./GoldfishHand"; 5 + export { GoldfishPile } from "./GoldfishPile"; 6 + export { GoldfishSidebar } from "./GoldfishSidebar";
+9 -6
src/lib/goldfish/__tests__/engine.test.ts
··· 108 108 graveyard: [], 109 109 exile: [], 110 110 hoveredId: null, 111 + nextZIndex: 1, 111 112 player: { life: 20, poison: 0, counters: {} }, 112 113 }; 113 114 ··· 284 285 let state = createInitialState(deck, createTestRng()); 285 286 const cardId = state.hand[0].instanceId; 286 287 287 - state = moveCard(state, cardId, "battlefield", { x: 200, y: 300 }); 288 + state = moveCard(state, cardId, "battlefield", { 289 + position: { x: 200, y: 300 }, 290 + }); 288 291 289 292 expect(state.battlefield[0].position).toEqual({ x: 200, y: 300 }); 290 293 }); ··· 303 306 const deck = mockDeck(10); 304 307 let state = createInitialState(deck, createTestRng()); 305 308 state = moveCard(state, state.hand[0].instanceId, "battlefield", { 306 - x: 200, 307 - y: 300, 309 + position: { x: 200, y: 300 }, 308 310 }); 309 311 const cardId = state.battlefield[0].instanceId; 310 312 ··· 317 319 const deck = mockDeck(10); 318 320 let state = createInitialState(deck, createTestRng()); 319 321 state = moveCard(state, state.hand[0].instanceId, "battlefield", { 320 - x: 100, 321 - y: 100, 322 + position: { x: 100, y: 100 }, 322 323 }); 323 324 const cardId = state.battlefield[0].instanceId; 324 325 325 - state = moveCard(state, cardId, "battlefield", { x: 500, y: 600 }); 326 + state = moveCard(state, cardId, "battlefield", { 327 + position: { x: 500, y: 600 }, 328 + }); 326 329 327 330 expect(state.battlefield[0].position).toEqual({ x: 500, y: 600 }); 328 331 });
+30 -7
src/lib/goldfish/engine.ts
··· 92 92 isTapped: false, 93 93 isFaceDown: false, 94 94 faceIndex: 0, 95 + zIndex: 0, 95 96 counters: {}, 96 97 position: undefined, 97 98 })); ··· 159 160 })); 160 161 } 161 162 163 + export interface MoveCardOptions { 164 + position?: { x: number; y: number }; 165 + faceDown?: boolean; 166 + } 167 + 162 168 export function moveCard( 163 169 state: GameState, 164 170 instanceId: number, 165 171 toZone: Zone, 166 - position?: { x: number; y: number }, 172 + options: MoveCardOptions = {}, 167 173 ): GameState { 174 + const { position, faceDown } = options; 168 175 const found = findCard(state, instanceId); 169 176 if (!found) return state; 170 177 ··· 172 179 173 180 if (fromZone === toZone) { 174 181 if (toZone === "battlefield" && position) { 175 - return updateCardInState(state, instanceId, (c) => ({ 176 - ...c, 177 - position, 178 - })); 182 + // Moving within battlefield - bring to front 183 + return { 184 + ...updateCardInState(state, instanceId, (c) => ({ 185 + ...c, 186 + position, 187 + zIndex: state.nextZIndex, 188 + })), 189 + nextZIndex: state.nextZIndex + 1, 190 + }; 179 191 } 180 192 return state; 181 193 } 182 194 183 195 const movedCard: CardInstance = 184 196 toZone === "battlefield" 185 - ? { ...card, position: position ?? { x: 100, y: 100 } } 186 - : { ...card, position: undefined }; 197 + ? { 198 + ...card, 199 + position: position ?? { x: 100, y: 100 }, 200 + zIndex: state.nextZIndex, 201 + isFaceDown: faceDown ?? card.isFaceDown, 202 + } 203 + : { 204 + ...card, 205 + position: undefined, 206 + isFaceDown: faceDown ?? card.isFaceDown, 207 + }; 187 208 188 209 return { 189 210 ...state, 190 211 [fromZone]: state[fromZone].filter((c) => c.instanceId !== instanceId), 191 212 [toZone]: [...state[toZone], movedCard], 213 + nextZIndex: 214 + toZone === "battlefield" ? state.nextZIndex + 1 : state.nextZIndex, 192 215 }; 193 216 } 194 217
+4
src/lib/goldfish/types.ts
··· 7 7 isFaceDown: boolean; 8 8 faceIndex: number; 9 9 position?: { x: number; y: number }; 10 + zIndex: number; 10 11 counters: Record<string, number>; 11 12 } 12 13 ··· 25 26 graveyard: CardInstance[]; 26 27 exile: CardInstance[]; 27 28 hoveredId: number | null; 29 + nextZIndex: number; 28 30 player: PlayerState; 29 31 } 30 32 ··· 38 40 isTapped: false, 39 41 isFaceDown: false, 40 42 faceIndex: 0, 43 + zIndex: 0, 41 44 counters: {}, 42 45 }; 43 46 } ··· 58 61 graveyard: [], 59 62 exile: [], 60 63 hoveredId: null, 64 + nextZIndex: 1, 61 65 player: createPlayerState(startingLife), 62 66 }; 63 67 }
+3 -3
src/lib/goldfish/useGoldfishGame.ts
··· 18 18 moveCard: ( 19 19 instanceId: number, 20 20 toZone: Zone, 21 - position?: { x: number; y: number }, 21 + options?: { position?: { x: number; y: number }; faceDown?: boolean }, 22 22 ) => void; 23 23 setHoveredCard: (instanceId: number | null) => void; 24 24 addCounter: ( ··· 71 71 cycleFace: (id, maxFaces) => 72 72 setState((s) => engine.cycleFace(s, id, maxFaces)), 73 73 toggleFaceDown: (id) => setState((s) => engine.toggleFaceDown(s, id)), 74 - moveCard: (id, zone, pos) => 75 - setState((s) => engine.moveCard(s, id, zone, pos)), 74 + moveCard: (id, zone, opts) => 75 + setState((s) => engine.moveCard(s, id, zone, opts)), 76 76 setHoveredCard: (id) => setState((s) => engine.setHoveredCard(s, id)), 77 77 addCounter: (id, type, amount) => 78 78 setState((s) => engine.addCounter(s, id, type, amount)),
+21
src/routeTree.gen.ts
··· 19 19 import { Route as ProfileDidIndexRouteImport } from './routes/profile/$did/index' 20 20 import { Route as ProfileDidDeckRkeyRouteImport } from './routes/profile/$did/deck/$rkey' 21 21 import { Route as ProfileDidDeckRkeyIndexRouteImport } from './routes/profile/$did/deck/$rkey/index' 22 + import { Route as ProfileDidDeckRkeyPlayRouteImport } from './routes/profile/$did/deck/$rkey/play' 22 23 import { Route as ProfileDidDeckRkeyBulkEditRouteImport } from './routes/profile/$did/deck/$rkey/bulk-edit' 23 24 24 25 const SigninRoute = SigninRouteImport.update({ ··· 69 70 const ProfileDidDeckRkeyIndexRoute = ProfileDidDeckRkeyIndexRouteImport.update({ 70 71 id: '/', 71 72 path: '/', 73 + getParentRoute: () => ProfileDidDeckRkeyRoute, 74 + } as any) 75 + const ProfileDidDeckRkeyPlayRoute = ProfileDidDeckRkeyPlayRouteImport.update({ 76 + id: '/play', 77 + path: '/play', 72 78 getParentRoute: () => ProfileDidDeckRkeyRoute, 73 79 } as any) 74 80 const ProfileDidDeckRkeyBulkEditRoute = ··· 89 95 '/profile/$did': typeof ProfileDidIndexRoute 90 96 '/profile/$did/deck/$rkey': typeof ProfileDidDeckRkeyRouteWithChildren 91 97 '/profile/$did/deck/$rkey/bulk-edit': typeof ProfileDidDeckRkeyBulkEditRoute 98 + '/profile/$did/deck/$rkey/play': typeof ProfileDidDeckRkeyPlayRoute 92 99 '/profile/$did/deck/$rkey/': typeof ProfileDidDeckRkeyIndexRoute 93 100 } 94 101 export interface FileRoutesByTo { ··· 101 108 '/cards': typeof CardsIndexRoute 102 109 '/profile/$did': typeof ProfileDidIndexRoute 103 110 '/profile/$did/deck/$rkey/bulk-edit': typeof ProfileDidDeckRkeyBulkEditRoute 111 + '/profile/$did/deck/$rkey/play': typeof ProfileDidDeckRkeyPlayRoute 104 112 '/profile/$did/deck/$rkey': typeof ProfileDidDeckRkeyIndexRoute 105 113 } 106 114 export interface FileRoutesById { ··· 115 123 '/profile/$did/': typeof ProfileDidIndexRoute 116 124 '/profile/$did/deck/$rkey': typeof ProfileDidDeckRkeyRouteWithChildren 117 125 '/profile/$did/deck/$rkey/bulk-edit': typeof ProfileDidDeckRkeyBulkEditRoute 126 + '/profile/$did/deck/$rkey/play': typeof ProfileDidDeckRkeyPlayRoute 118 127 '/profile/$did/deck/$rkey/': typeof ProfileDidDeckRkeyIndexRoute 119 128 } 120 129 export interface FileRouteTypes { ··· 130 139 | '/profile/$did' 131 140 | '/profile/$did/deck/$rkey' 132 141 | '/profile/$did/deck/$rkey/bulk-edit' 142 + | '/profile/$did/deck/$rkey/play' 133 143 | '/profile/$did/deck/$rkey/' 134 144 fileRoutesByTo: FileRoutesByTo 135 145 to: ··· 142 152 | '/cards' 143 153 | '/profile/$did' 144 154 | '/profile/$did/deck/$rkey/bulk-edit' 155 + | '/profile/$did/deck/$rkey/play' 145 156 | '/profile/$did/deck/$rkey' 146 157 id: 147 158 | '__root__' ··· 155 166 | '/profile/$did/' 156 167 | '/profile/$did/deck/$rkey' 157 168 | '/profile/$did/deck/$rkey/bulk-edit' 169 + | '/profile/$did/deck/$rkey/play' 158 170 | '/profile/$did/deck/$rkey/' 159 171 fileRoutesById: FileRoutesById 160 172 } ··· 242 254 preLoaderRoute: typeof ProfileDidDeckRkeyIndexRouteImport 243 255 parentRoute: typeof ProfileDidDeckRkeyRoute 244 256 } 257 + '/profile/$did/deck/$rkey/play': { 258 + id: '/profile/$did/deck/$rkey/play' 259 + path: '/play' 260 + fullPath: '/profile/$did/deck/$rkey/play' 261 + preLoaderRoute: typeof ProfileDidDeckRkeyPlayRouteImport 262 + parentRoute: typeof ProfileDidDeckRkeyRoute 263 + } 245 264 '/profile/$did/deck/$rkey/bulk-edit': { 246 265 id: '/profile/$did/deck/$rkey/bulk-edit' 247 266 path: '/bulk-edit' ··· 254 273 255 274 interface ProfileDidDeckRkeyRouteChildren { 256 275 ProfileDidDeckRkeyBulkEditRoute: typeof ProfileDidDeckRkeyBulkEditRoute 276 + ProfileDidDeckRkeyPlayRoute: typeof ProfileDidDeckRkeyPlayRoute 257 277 ProfileDidDeckRkeyIndexRoute: typeof ProfileDidDeckRkeyIndexRoute 258 278 } 259 279 260 280 const ProfileDidDeckRkeyRouteChildren: ProfileDidDeckRkeyRouteChildren = { 261 281 ProfileDidDeckRkeyBulkEditRoute: ProfileDidDeckRkeyBulkEditRoute, 282 + ProfileDidDeckRkeyPlayRoute: ProfileDidDeckRkeyPlayRoute, 262 283 ProfileDidDeckRkeyIndexRoute: ProfileDidDeckRkeyIndexRoute, 263 284 } 264 285
+17 -13
src/routes/profile/$did/deck/$rkey/index.tsx
··· 526 526 </div> 527 527 528 528 {/* Sticky header with search */} 529 - {isOwner && ( 530 - <div className="sticky top-0 z-10 bg-white dark:bg-slate-900 border-b border-gray-200 dark:border-slate-800 shadow-sm"> 531 - <div className="max-w-7xl 2xl:max-w-[96rem] mx-auto px-6 py-3 flex items-center justify-between gap-4"> 532 - <div className="flex items-center gap-2"> 533 - <DeckActionsMenu 534 - deck={deck} 535 - rkey={asRkey(rkey)} 536 - onUpdateDeck={updateDeck} 537 - onCardsChanged={handleCardsChanged} 538 - /> 529 + <div className="sticky top-0 z-10 bg-white dark:bg-slate-900 border-b border-gray-200 dark:border-slate-800 shadow-sm"> 530 + <div className="max-w-7xl 2xl:max-w-[96rem] mx-auto px-6 py-3 flex items-center justify-between gap-4"> 531 + <div className="flex items-center gap-2"> 532 + <DeckActionsMenu 533 + deck={deck} 534 + did={did} 535 + rkey={asRkey(rkey)} 536 + onUpdateDeck={isOwner ? updateDeck : undefined} 537 + onCardsChanged={handleCardsChanged} 538 + readOnly={!isOwner} 539 + /> 540 + {isOwner && ( 539 541 <Link 540 542 to="/profile/$did/deck/$rkey/bulk-edit" 541 543 params={{ did, rkey }} ··· 543 545 > 544 546 Bulk Edit 545 547 </Link> 546 - </div> 548 + )} 549 + </div> 550 + {isOwner && ( 547 551 <div className="w-full max-w-md"> 548 552 <CardSearchAutocomplete 549 553 deck={deck} ··· 552 556 onCardHover={handleCardHover} 553 557 /> 554 558 </div> 555 - </div> 559 + )} 556 560 </div> 557 - )} 561 + </div> 558 562 559 563 {/* Trash drop zone - only show while dragging, hide on mobile */} 560 564 <div className="hidden md:block">
+93
src/routes/profile/$did/deck/$rkey/play.tsx
··· 1 + import type { Did } from "@atcute/lexicons"; 2 + import { useQueries, useSuspenseQuery } from "@tanstack/react-query"; 3 + import { createFileRoute, Link } from "@tanstack/react-router"; 4 + import { ArrowLeft } from "lucide-react"; 5 + import { useCallback } from "react"; 6 + import { GoldfishBoard } from "@/components/deck/goldfish"; 7 + import { asRkey } from "@/lib/atproto-client"; 8 + import { prefetchCards } from "@/lib/card-prefetch"; 9 + import { getDeckQueryOptions } from "@/lib/deck-queries"; 10 + import { getCardsInSection } from "@/lib/deck-types"; 11 + import { combineCardQueries, getCardByIdQueryOptions } from "@/lib/queries"; 12 + import type { ScryfallId } from "@/lib/scryfall-types"; 13 + 14 + export const Route = createFileRoute("/profile/$did/deck/$rkey/play")({ 15 + component: PlaytestPage, 16 + loader: async ({ context, params }) => { 17 + const deck = await context.queryClient.ensureQueryData( 18 + getDeckQueryOptions(params.did as Did, asRkey(params.rkey)), 19 + ); 20 + 21 + const cardIds = deck.cards.map((card) => card.scryfallId); 22 + await prefetchCards(context.queryClient, cardIds); 23 + 24 + return deck; 25 + }, 26 + head: ({ loaderData: deck }) => ({ 27 + meta: [ 28 + { 29 + title: deck 30 + ? `Playtest: ${deck.name} | DeckBelcher` 31 + : "Playtest | DeckBelcher", 32 + }, 33 + ], 34 + }), 35 + }); 36 + 37 + function PlaytestPage() { 38 + const { did, rkey } = Route.useParams(); 39 + const { data: deck } = useSuspenseQuery( 40 + getDeckQueryOptions(did as Did, asRkey(rkey)), 41 + ); 42 + 43 + const playtestCards = [ 44 + ...getCardsInSection(deck, "commander"), 45 + ...getCardsInSection(deck, "mainboard"), 46 + ]; 47 + 48 + const cardMap = useQueries({ 49 + queries: playtestCards.map((card) => 50 + getCardByIdQueryOptions(card.scryfallId), 51 + ), 52 + combine: combineCardQueries, 53 + }); 54 + 55 + const getCard = useCallback((id: ScryfallId) => cardMap?.get(id), [cardMap]); 56 + 57 + const startingLife = deck.format === "commander" ? 40 : 20; 58 + 59 + if (!cardMap) { 60 + return ( 61 + <div className="h-screen flex items-center justify-center bg-white dark:bg-slate-950"> 62 + <span className="text-gray-500 dark:text-gray-400"> 63 + Loading cards... 64 + </span> 65 + </div> 66 + ); 67 + } 68 + 69 + return ( 70 + <div className="h-screen flex flex-col bg-white dark:bg-slate-950"> 71 + <header className="flex items-center gap-4 px-4 py-2 border-b border-gray-200 dark:border-slate-800"> 72 + <Link 73 + to="/profile/$did/deck/$rkey" 74 + params={{ did, rkey }} 75 + className="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white" 76 + > 77 + <ArrowLeft className="w-4 h-4" /> 78 + Back to Editor 79 + </Link> 80 + <h1 className="text-lg font-semibold text-gray-900 dark:text-white"> 81 + {deck.name} 82 + </h1> 83 + </header> 84 + <div className="flex-1 overflow-hidden"> 85 + <GoldfishBoard 86 + deck={playtestCards} 87 + cardLookup={getCard} 88 + startingLife={startingLife} 89 + /> 90 + </div> 91 + </div> 92 + ); 93 + }