diff --git a/src/components/CardImage.tsx b/src/components/CardImage.tsx index 9620f2a..9831b5a 100644 --- a/src/components/CardImage.tsx +++ b/src/components/CardImage.tsx @@ -14,7 +14,7 @@ import type { } from "../lib/scryfall-types"; import { type CardFaceType, getImageUri } from "../lib/scryfall-utils"; -const PLACEHOLDER_STRIPES = `repeating-linear-gradient( +export const PLACEHOLDER_STRIPES = `repeating-linear-gradient( -45deg, transparent, transparent 8px, diff --git a/src/components/deck/DeckActionsMenu.tsx b/src/components/deck/DeckActionsMenu.tsx index 7c973e5..49c5e0e 100644 --- a/src/components/deck/DeckActionsMenu.tsx +++ b/src/components/deck/DeckActionsMenu.tsx @@ -1,5 +1,6 @@ import { useQueryClient } from "@tanstack/react-query"; -import { MoreVertical, Trash2 } from "lucide-react"; +import { Link } from "@tanstack/react-router"; +import { MoreVertical, Play, Trash2 } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { DeleteDeckDialog } from "@/components/deck/DeleteDeckDialog"; @@ -17,16 +18,20 @@ import type { ScryfallId } from "@/lib/scryfall-types"; interface DeckActionsMenuProps { deck: Deck; + did: string; rkey: Rkey; - onUpdateDeck: (updater: (prev: Deck) => Deck) => Promise; + onUpdateDeck?: (updater: (prev: Deck) => Deck) => Promise; onCardsChanged?: (changedIds: Set) => void; + readOnly?: boolean; } export function DeckActionsMenu({ deck, + did, rkey, onUpdateDeck, onCardsChanged, + readOnly = false, }: DeckActionsMenuProps) { const queryClient = useQueryClient(); const [isOpen, setIsOpen] = useState(false); @@ -49,6 +54,7 @@ export function DeckActionsMenu({ }, [isOpen]); const handleSetAllToCheapest = async () => { + if (!onUpdateDeck) return; setIsOpen(false); const toastId = toast.loading("Finding cheapest printings..."); @@ -71,6 +77,7 @@ export function DeckActionsMenu({ }; const handleSetAllToBest = async () => { + if (!onUpdateDeck) return; setIsOpen(false); const toastId = toast.loading("Finding best printings..."); @@ -106,32 +113,46 @@ export function DeckActionsMenu({ {isOpen && (
- - -
- + + Playtest + + {!readOnly && ( + <> +
+ + +
+ + + )}
)} diff --git a/src/components/deck/GoldfishDragDropProvider.tsx b/src/components/deck/GoldfishDragDropProvider.tsx new file mode 100644 index 0000000..e2590a1 --- /dev/null +++ b/src/components/deck/GoldfishDragDropProvider.tsx @@ -0,0 +1,140 @@ +import { + DndContext, + type DragEndEvent, + type DragMoveEvent, + type DragOverEvent, + DragOverlay, + type DragStartEvent, + KeyboardSensor, + PointerSensor, + TouchSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { type ReactNode, useId, useRef, useState } from "react"; +import { PLACEHOLDER_STRIPES } from "@/components/CardImage"; +import type { CardInstance } from "@/lib/goldfish/types"; +import { getImageUri } from "@/lib/scryfall-utils"; + +export interface DragPosition { + translated: { left: number; top: number } | null; +} + +interface GoldfishDragDropProviderProps { + children: ReactNode; + onDragStart?: (event: DragStartEvent) => void; + onDragOver?: (event: DragOverEvent) => void; + onDragEnd: (event: DragEndEvent, lastPosition: DragPosition | null) => void; +} + +export function GoldfishDragDropProvider({ + children, + onDragStart, + onDragOver, + onDragEnd, +}: GoldfishDragDropProviderProps) { + const dndContextId = useId(); + const [activeCard, setActiveCard] = useState(null); + // Track position during drag since rect.current.translated is null in onDragEnd + // See: https://github.com/clauderic/dnd-kit/discussions/236 + const lastPositionRef = useRef(null); + + const pointerSensor = useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }); + + const touchSensor = useSensor(TouchSensor, { + activationConstraint: { + delay: 200, + tolerance: 5, + }, + }); + + const keyboardSensor = useSensor(KeyboardSensor); + + const sensors = useSensors(pointerSensor, touchSensor, keyboardSensor); + + const handleDragStart = (event: DragStartEvent) => { + const data = event.active.data.current as + | { instance: CardInstance } + | undefined; + if (data?.instance) { + setActiveCard(data.instance); + } + lastPositionRef.current = null; + onDragStart?.(event); + }; + + const handleDragMove = (event: DragMoveEvent) => { + const rect = event.active.rect.current.translated; + if (rect) { + lastPositionRef.current = { + translated: { left: rect.left, top: rect.top }, + }; + } + }; + + const handleDragEnd = (event: DragEndEvent) => { + const lastPosition = lastPositionRef.current; + setActiveCard(null); + lastPositionRef.current = null; + onDragEnd(event, lastPosition); + }; + + return ( + + {children} + + {activeCard && } + + + ); +} + +function DragPreview({ instance }: { instance: CardInstance }) { + const isFlipped = instance.faceIndex > 0; + const imageSrc = instance.isFaceDown + ? null + : getImageUri(instance.cardId, "normal", isFlipped ? "back" : "front"); + + const counterEntries = Object.entries(instance.counters); + + return ( +
+ {imageSrc ? ( + Dragging card + ) : ( +
+ )} + {counterEntries.length > 0 && ( +
+ {counterEntries.map(([type, count]) => ( + + {type === "+1/+1" ? `+${count}/+${count}` : `${count}`} + + ))} +
+ )} +
+ ); +} diff --git a/src/components/deck/goldfish/GoldfishBattlefield.tsx b/src/components/deck/goldfish/GoldfishBattlefield.tsx new file mode 100644 index 0000000..678f04b --- /dev/null +++ b/src/components/deck/goldfish/GoldfishBattlefield.tsx @@ -0,0 +1,56 @@ +import { useDroppable } from "@dnd-kit/core"; +import { forwardRef } from "react"; +import type { CardInstance } from "@/lib/goldfish/types"; +import type { Card, ScryfallId } from "@/lib/scryfall-types"; +import { GoldfishCard } from "./GoldfishCard"; + +interface GoldfishBattlefieldProps { + cards: CardInstance[]; + cardLookup?: (id: ScryfallId) => Card | undefined; + onHover?: (instanceId: number | null) => void; + onClick?: (instanceId: number) => void; +} + +export const GoldfishBattlefield = forwardRef< + HTMLDivElement, + GoldfishBattlefieldProps +>(function GoldfishBattlefield({ cards, cardLookup, onHover, onClick }, ref) { + const { setNodeRef, isOver } = useDroppable({ + id: "zone-battlefield", + data: { zone: "battlefield" }, + }); + + return ( +
+
+ {cards.length === 0 && ( +
+ Battlefield +
+ )} + {cards.map((instance) => ( + + ))} +
+
+ ); +}); diff --git a/src/components/deck/goldfish/GoldfishBoard.tsx b/src/components/deck/goldfish/GoldfishBoard.tsx new file mode 100644 index 0000000..8f4e2ab --- /dev/null +++ b/src/components/deck/goldfish/GoldfishBoard.tsx @@ -0,0 +1,162 @@ +import type { DragEndEvent } from "@dnd-kit/core"; +import { useCallback, useRef } from "react"; +import { CardImage } from "@/components/CardImage"; +import type { DeckCard } from "@/lib/deck-types"; +import { useGoldfishGame } from "@/lib/goldfish"; +import type { CardInstance, Zone } from "@/lib/goldfish/types"; +import type { Card, ScryfallId } from "@/lib/scryfall-types"; +import { + type DragPosition, + GoldfishDragDropProvider, +} from "../GoldfishDragDropProvider"; +import { GoldfishBattlefield } from "./GoldfishBattlefield"; +import { GoldfishHand } from "./GoldfishHand"; +import { GoldfishSidebar } from "./GoldfishSidebar"; + +interface GoldfishBoardProps { + deck: DeckCard[]; + cardLookup: (id: ScryfallId) => Card | undefined; + startingLife?: number; +} + +export function GoldfishBoard({ + deck, + cardLookup, + startingLife = 20, +}: GoldfishBoardProps) { + const { state, actions, SeedEmbed } = useGoldfishGame(deck, { + startingLife, + cardLookup, + }); + + const battlefieldRef = useRef(null); + + const handleDragEnd = useCallback( + (event: DragEndEvent, lastPosition: DragPosition | null) => { + const { active, over, delta } = event; + + if (!over) return; + + const cardData = active.data.current as + | { instance: CardInstance; fromLibrary?: boolean } + | undefined; + if (!cardData?.instance) return; + + const instance = cardData.instance; + const instanceId = instance.instanceId; + const fromLibrary = cardData.fromLibrary ?? false; + const targetZone = (over.data.current as { zone: Zone } | undefined) + ?.zone; + + if (!targetZone) return; + + if (targetZone === "battlefield") { + const battlefieldRect = battlefieldRef.current?.getBoundingClientRect(); + + if (battlefieldRect) { + let x: number; + let y: number; + + if (instance.position) { + // Card is already on battlefield - just add delta + x = instance.position.x + delta.x; + y = instance.position.y + delta.y; + } else if (lastPosition?.translated) { + // Card coming from another zone - use tracked translated position + // (rect.current.translated is null in onDragEnd, so we track it in onDragMove) + x = lastPosition.translated.left - battlefieldRect.left; + y = lastPosition.translated.top - battlefieldRect.top; + } else { + // Fallback: center of battlefield + x = battlefieldRect.width / 2; + y = battlefieldRect.height / 2; + } + + actions.moveCard(instanceId, targetZone, { + position: { x, y }, + faceDown: fromLibrary ? true : undefined, + }); + } else { + actions.moveCard(instanceId, targetZone, { + faceDown: fromLibrary ? true : undefined, + }); + } + } else { + actions.moveCard(instanceId, targetZone, { + faceDown: fromLibrary ? true : undefined, + }); + } + }, + [actions], + ); + + const hoveredCard = state.hoveredId + ? [ + ...state.hand, + ...state.battlefield, + ...state.graveyard, + ...state.exile, + ].find((c) => c.instanceId === state.hoveredId) + : null; + + const hoveredCardData = hoveredCard ? cardLookup(hoveredCard.cardId) : null; + + return ( + + +
+ {/* Left: Card Preview */} +
+ {hoveredCardData ? ( + 0 : false + } + /> + ) : ( +
+ Hover a card +
+ )} +
+ + {/* Center: Battlefield + Hand */} +
+ + actions.moveCard(id, "battlefield")} + /> +
+ + {/* Right: Sidebar */} + +
+
+ ); +} diff --git a/src/components/deck/goldfish/GoldfishCard.tsx b/src/components/deck/goldfish/GoldfishCard.tsx new file mode 100644 index 0000000..1a5ccfc --- /dev/null +++ b/src/components/deck/goldfish/GoldfishCard.tsx @@ -0,0 +1,84 @@ +import { useDraggable } from "@dnd-kit/core"; +import { PLACEHOLDER_STRIPES } from "@/components/CardImage"; +import type { CardInstance } from "@/lib/goldfish/types"; +import type { Card } from "@/lib/scryfall-types"; +import { getImageUri } from "@/lib/scryfall-utils"; + +interface GoldfishCardProps { + instance: CardInstance; + card?: Card; + onHover?: (instanceId: number | null) => void; + onClick?: (instanceId: number) => void; + size?: "tiny" | "small" | "normal"; + positioning?: "relative" | "absolute"; + className?: string; + style?: React.CSSProperties; +} + +export function GoldfishCard({ + instance, + card, + onHover, + onClick, + size = "normal", + positioning = "relative", + className = "", + style, +}: GoldfishCardProps) { + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: `card-${instance.instanceId}`, + data: { instance }, + }); + + const isFlipped = instance.faceIndex > 0; + const imageSize = size === "normal" ? "normal" : "small"; + const imageSrc = instance.isFaceDown + ? null + : getImageUri(instance.cardId, imageSize, isFlipped ? "back" : "front"); + + const counterEntries = Object.entries(instance.counters); + const sizeClass = + size === "tiny" ? "h-14" : size === "small" ? "h-24" : "h-40"; + + return ( + + ); +} diff --git a/src/components/deck/goldfish/GoldfishHand.tsx b/src/components/deck/goldfish/GoldfishHand.tsx new file mode 100644 index 0000000..f0c13e1 --- /dev/null +++ b/src/components/deck/goldfish/GoldfishHand.tsx @@ -0,0 +1,51 @@ +import { useDroppable } from "@dnd-kit/core"; +import type { CardInstance } from "@/lib/goldfish/types"; +import type { Card, ScryfallId } from "@/lib/scryfall-types"; +import { GoldfishCard } from "./GoldfishCard"; + +interface GoldfishHandProps { + cards: CardInstance[]; + cardLookup?: (id: ScryfallId) => Card | undefined; + onHover?: (instanceId: number | null) => void; + onClick?: (instanceId: number) => void; +} + +export function GoldfishHand({ + cards, + cardLookup, + onHover, + onClick, +}: GoldfishHandProps) { + const { setNodeRef, isOver } = useDroppable({ + id: "zone-hand", + data: { zone: "hand" }, + }); + + return ( +
+ {cards.length === 0 ? ( +
+ Hand is empty +
+ ) : ( + cards.map((instance) => ( + + )) + )} +
+ ); +} diff --git a/src/components/deck/goldfish/GoldfishLibraryCard.tsx b/src/components/deck/goldfish/GoldfishLibraryCard.tsx new file mode 100644 index 0000000..ad12eee --- /dev/null +++ b/src/components/deck/goldfish/GoldfishLibraryCard.tsx @@ -0,0 +1,31 @@ +import { useDraggable } from "@dnd-kit/core"; +import type { CardInstance } from "@/lib/goldfish/types"; + +interface GoldfishLibraryCardProps { + topCard: CardInstance; + onDraw: () => void; +} + +export function GoldfishLibraryCard({ + topCard, + onDraw, +}: GoldfishLibraryCardProps) { + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: `library-top-${topCard.instanceId}`, + data: { + instance: { ...topCard, isFaceDown: true }, + fromLibrary: true, + }, + }); + + return ( + + {expanded && cards.length > 0 && ( +
+ {cards.map((instance) => ( + + ))} +
+ )} + {!expanded && cards.length > 0 && ( +
+
+ {cards.slice(-3).map((instance, i) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/src/components/deck/goldfish/GoldfishSidebar.tsx b/src/components/deck/goldfish/GoldfishSidebar.tsx new file mode 100644 index 0000000..017fa5d --- /dev/null +++ b/src/components/deck/goldfish/GoldfishSidebar.tsx @@ -0,0 +1,164 @@ +import { Droplet, Minus, Plus, RefreshCw, RotateCcw } from "lucide-react"; +import type { CardInstance, PlayerState } from "@/lib/goldfish/types"; +import type { Card, ScryfallId } from "@/lib/scryfall-types"; +import { GoldfishLibraryCard } from "./GoldfishLibraryCard"; +import { GoldfishPile } from "./GoldfishPile"; + +interface GoldfishSidebarProps { + library: CardInstance[]; + graveyard: CardInstance[]; + exile: CardInstance[]; + player: PlayerState; + cardLookup?: (id: ScryfallId) => Card | undefined; + onHover?: (instanceId: number | null) => void; + onClick?: (instanceId: number) => void; + onDraw: () => void; + onUntapAll: () => void; + onMulligan: () => void; + onReset: () => void; + onAdjustLife: (amount: number) => void; + onAdjustPoison: (amount: number) => void; +} + +export function GoldfishSidebar({ + library, + graveyard, + exile, + player, + cardLookup, + onHover, + onClick, + onDraw, + onUntapAll, + onMulligan, + onReset, + onAdjustLife, + onAdjustPoison, +}: GoldfishSidebarProps) { + return ( +
+ {/* Library */} +
+
+ + Library ({library.length}) + + + D to draw + +
+ {library.length > 0 ? ( +
+ +
+ ) : ( +
+ )} +
+ + {/* Graveyard */} + + + {/* Exile */} + + + {/* Player Stats */} +
+ {/* Life */} +
+ + + {player.life} + + +
+ + {/* Poison */} +
+ + + + {player.poison} + + +
+
+ + {/* Actions */} +
+ + + +
+ + {/* Keyboard hints */} +
+

T/Space: tap • F: flip

+

M: morph • G: graveyard

+

E: exile • H: hand • B: play

+
+
+ ); +} diff --git a/src/components/deck/goldfish/index.ts b/src/components/deck/goldfish/index.ts new file mode 100644 index 0000000..5252bfd --- /dev/null +++ b/src/components/deck/goldfish/index.ts @@ -0,0 +1,6 @@ +export { GoldfishBattlefield } from "./GoldfishBattlefield"; +export { GoldfishBoard } from "./GoldfishBoard"; +export { GoldfishCard } from "./GoldfishCard"; +export { GoldfishHand } from "./GoldfishHand"; +export { GoldfishPile } from "./GoldfishPile"; +export { GoldfishSidebar } from "./GoldfishSidebar"; diff --git a/src/lib/goldfish/__tests__/engine.test.ts b/src/lib/goldfish/__tests__/engine.test.ts index bc6d45d..aa35741 100644 --- a/src/lib/goldfish/__tests__/engine.test.ts +++ b/src/lib/goldfish/__tests__/engine.test.ts @@ -108,6 +108,7 @@ describe("draw", () => { graveyard: [], exile: [], hoveredId: null, + nextZIndex: 1, player: { life: 20, poison: 0, counters: {} }, }; @@ -284,7 +285,9 @@ describe("moveCard", () => { let state = createInitialState(deck, createTestRng()); const cardId = state.hand[0].instanceId; - state = moveCard(state, cardId, "battlefield", { x: 200, y: 300 }); + state = moveCard(state, cardId, "battlefield", { + position: { x: 200, y: 300 }, + }); expect(state.battlefield[0].position).toEqual({ x: 200, y: 300 }); }); @@ -303,8 +306,7 @@ describe("moveCard", () => { const deck = mockDeck(10); let state = createInitialState(deck, createTestRng()); state = moveCard(state, state.hand[0].instanceId, "battlefield", { - x: 200, - y: 300, + position: { x: 200, y: 300 }, }); const cardId = state.battlefield[0].instanceId; @@ -317,12 +319,13 @@ describe("moveCard", () => { const deck = mockDeck(10); let state = createInitialState(deck, createTestRng()); state = moveCard(state, state.hand[0].instanceId, "battlefield", { - x: 100, - y: 100, + position: { x: 100, y: 100 }, }); const cardId = state.battlefield[0].instanceId; - state = moveCard(state, cardId, "battlefield", { x: 500, y: 600 }); + state = moveCard(state, cardId, "battlefield", { + position: { x: 500, y: 600 }, + }); expect(state.battlefield[0].position).toEqual({ x: 500, y: 600 }); }); diff --git a/src/lib/goldfish/engine.ts b/src/lib/goldfish/engine.ts index b1b7ff5..b796e9d 100644 --- a/src/lib/goldfish/engine.ts +++ b/src/lib/goldfish/engine.ts @@ -92,6 +92,7 @@ export function mulligan( isTapped: false, isFaceDown: false, faceIndex: 0, + zIndex: 0, counters: {}, position: undefined, })); @@ -159,12 +160,18 @@ export function toggleFaceDown( })); } +export interface MoveCardOptions { + position?: { x: number; y: number }; + faceDown?: boolean; +} + export function moveCard( state: GameState, instanceId: number, toZone: Zone, - position?: { x: number; y: number }, + options: MoveCardOptions = {}, ): GameState { + const { position, faceDown } = options; const found = findCard(state, instanceId); if (!found) return state; @@ -172,23 +179,39 @@ export function moveCard( if (fromZone === toZone) { if (toZone === "battlefield" && position) { - return updateCardInState(state, instanceId, (c) => ({ - ...c, - position, - })); + // Moving within battlefield - bring to front + return { + ...updateCardInState(state, instanceId, (c) => ({ + ...c, + position, + zIndex: state.nextZIndex, + })), + nextZIndex: state.nextZIndex + 1, + }; } return state; } const movedCard: CardInstance = toZone === "battlefield" - ? { ...card, position: position ?? { x: 100, y: 100 } } - : { ...card, position: undefined }; + ? { + ...card, + position: position ?? { x: 100, y: 100 }, + zIndex: state.nextZIndex, + isFaceDown: faceDown ?? card.isFaceDown, + } + : { + ...card, + position: undefined, + isFaceDown: faceDown ?? card.isFaceDown, + }; return { ...state, [fromZone]: state[fromZone].filter((c) => c.instanceId !== instanceId), [toZone]: [...state[toZone], movedCard], + nextZIndex: + toZone === "battlefield" ? state.nextZIndex + 1 : state.nextZIndex, }; } diff --git a/src/lib/goldfish/types.ts b/src/lib/goldfish/types.ts index e909f93..700224f 100644 --- a/src/lib/goldfish/types.ts +++ b/src/lib/goldfish/types.ts @@ -7,6 +7,7 @@ export interface CardInstance { isFaceDown: boolean; faceIndex: number; position?: { x: number; y: number }; + zIndex: number; counters: Record; } @@ -25,6 +26,7 @@ export interface GameState { graveyard: CardInstance[]; exile: CardInstance[]; hoveredId: number | null; + nextZIndex: number; player: PlayerState; } @@ -38,6 +40,7 @@ export function createCardInstance( isTapped: false, isFaceDown: false, faceIndex: 0, + zIndex: 0, counters: {}, }; } @@ -58,6 +61,7 @@ export function createEmptyGameState(startingLife = 20): GameState { graveyard: [], exile: [], hoveredId: null, + nextZIndex: 1, player: createPlayerState(startingLife), }; } diff --git a/src/lib/goldfish/useGoldfishGame.ts b/src/lib/goldfish/useGoldfishGame.ts index 0869476..de9aa9f 100644 --- a/src/lib/goldfish/useGoldfishGame.ts +++ b/src/lib/goldfish/useGoldfishGame.ts @@ -18,7 +18,7 @@ export interface GoldfishActions { moveCard: ( instanceId: number, toZone: Zone, - position?: { x: number; y: number }, + options?: { position?: { x: number; y: number }; faceDown?: boolean }, ) => void; setHoveredCard: (instanceId: number | null) => void; addCounter: ( @@ -71,8 +71,8 @@ export function useGoldfishGame( cycleFace: (id, maxFaces) => setState((s) => engine.cycleFace(s, id, maxFaces)), toggleFaceDown: (id) => setState((s) => engine.toggleFaceDown(s, id)), - moveCard: (id, zone, pos) => - setState((s) => engine.moveCard(s, id, zone, pos)), + moveCard: (id, zone, opts) => + setState((s) => engine.moveCard(s, id, zone, opts)), setHoveredCard: (id) => setState((s) => engine.setHoveredCard(s, id)), addCounter: (id, type, amount) => setState((s) => engine.addCounter(s, id, type, amount)), diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 62ef142..0761ff7 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -19,6 +19,7 @@ import { Route as CardIdRouteImport } from './routes/card/$id' import { Route as ProfileDidIndexRouteImport } from './routes/profile/$did/index' import { Route as ProfileDidDeckRkeyRouteImport } from './routes/profile/$did/deck/$rkey' import { Route as ProfileDidDeckRkeyIndexRouteImport } from './routes/profile/$did/deck/$rkey/index' +import { Route as ProfileDidDeckRkeyPlayRouteImport } from './routes/profile/$did/deck/$rkey/play' import { Route as ProfileDidDeckRkeyBulkEditRouteImport } from './routes/profile/$did/deck/$rkey/bulk-edit' const SigninRoute = SigninRouteImport.update({ @@ -71,6 +72,11 @@ const ProfileDidDeckRkeyIndexRoute = ProfileDidDeckRkeyIndexRouteImport.update({ path: '/', getParentRoute: () => ProfileDidDeckRkeyRoute, } as any) +const ProfileDidDeckRkeyPlayRoute = ProfileDidDeckRkeyPlayRouteImport.update({ + id: '/play', + path: '/play', + getParentRoute: () => ProfileDidDeckRkeyRoute, +} as any) const ProfileDidDeckRkeyBulkEditRoute = ProfileDidDeckRkeyBulkEditRouteImport.update({ id: '/bulk-edit', @@ -89,6 +95,7 @@ export interface FileRoutesByFullPath { '/profile/$did': typeof ProfileDidIndexRoute '/profile/$did/deck/$rkey': typeof ProfileDidDeckRkeyRouteWithChildren '/profile/$did/deck/$rkey/bulk-edit': typeof ProfileDidDeckRkeyBulkEditRoute + '/profile/$did/deck/$rkey/play': typeof ProfileDidDeckRkeyPlayRoute '/profile/$did/deck/$rkey/': typeof ProfileDidDeckRkeyIndexRoute } export interface FileRoutesByTo { @@ -101,6 +108,7 @@ export interface FileRoutesByTo { '/cards': typeof CardsIndexRoute '/profile/$did': typeof ProfileDidIndexRoute '/profile/$did/deck/$rkey/bulk-edit': typeof ProfileDidDeckRkeyBulkEditRoute + '/profile/$did/deck/$rkey/play': typeof ProfileDidDeckRkeyPlayRoute '/profile/$did/deck/$rkey': typeof ProfileDidDeckRkeyIndexRoute } export interface FileRoutesById { @@ -115,6 +123,7 @@ export interface FileRoutesById { '/profile/$did/': typeof ProfileDidIndexRoute '/profile/$did/deck/$rkey': typeof ProfileDidDeckRkeyRouteWithChildren '/profile/$did/deck/$rkey/bulk-edit': typeof ProfileDidDeckRkeyBulkEditRoute + '/profile/$did/deck/$rkey/play': typeof ProfileDidDeckRkeyPlayRoute '/profile/$did/deck/$rkey/': typeof ProfileDidDeckRkeyIndexRoute } export interface FileRouteTypes { @@ -130,6 +139,7 @@ export interface FileRouteTypes { | '/profile/$did' | '/profile/$did/deck/$rkey' | '/profile/$did/deck/$rkey/bulk-edit' + | '/profile/$did/deck/$rkey/play' | '/profile/$did/deck/$rkey/' fileRoutesByTo: FileRoutesByTo to: @@ -142,6 +152,7 @@ export interface FileRouteTypes { | '/cards' | '/profile/$did' | '/profile/$did/deck/$rkey/bulk-edit' + | '/profile/$did/deck/$rkey/play' | '/profile/$did/deck/$rkey' id: | '__root__' @@ -155,6 +166,7 @@ export interface FileRouteTypes { | '/profile/$did/' | '/profile/$did/deck/$rkey' | '/profile/$did/deck/$rkey/bulk-edit' + | '/profile/$did/deck/$rkey/play' | '/profile/$did/deck/$rkey/' fileRoutesById: FileRoutesById } @@ -242,6 +254,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProfileDidDeckRkeyIndexRouteImport parentRoute: typeof ProfileDidDeckRkeyRoute } + '/profile/$did/deck/$rkey/play': { + id: '/profile/$did/deck/$rkey/play' + path: '/play' + fullPath: '/profile/$did/deck/$rkey/play' + preLoaderRoute: typeof ProfileDidDeckRkeyPlayRouteImport + parentRoute: typeof ProfileDidDeckRkeyRoute + } '/profile/$did/deck/$rkey/bulk-edit': { id: '/profile/$did/deck/$rkey/bulk-edit' path: '/bulk-edit' @@ -254,11 +273,13 @@ declare module '@tanstack/react-router' { interface ProfileDidDeckRkeyRouteChildren { ProfileDidDeckRkeyBulkEditRoute: typeof ProfileDidDeckRkeyBulkEditRoute + ProfileDidDeckRkeyPlayRoute: typeof ProfileDidDeckRkeyPlayRoute ProfileDidDeckRkeyIndexRoute: typeof ProfileDidDeckRkeyIndexRoute } const ProfileDidDeckRkeyRouteChildren: ProfileDidDeckRkeyRouteChildren = { ProfileDidDeckRkeyBulkEditRoute: ProfileDidDeckRkeyBulkEditRoute, + ProfileDidDeckRkeyPlayRoute: ProfileDidDeckRkeyPlayRoute, ProfileDidDeckRkeyIndexRoute: ProfileDidDeckRkeyIndexRoute, } diff --git a/src/routes/profile/$did/deck/$rkey/index.tsx b/src/routes/profile/$did/deck/$rkey/index.tsx index 9cddd80..7ed8aeb 100644 --- a/src/routes/profile/$did/deck/$rkey/index.tsx +++ b/src/routes/profile/$did/deck/$rkey/index.tsx @@ -526,16 +526,18 @@ function DeckEditorInner({
{/* Sticky header with search */} - {isOwner && ( -
-
-
- +
+
+
+ + {isOwner && ( Bulk Edit -
+ )} +
+ {isOwner && (
-
+ )}
- )} +
{/* Trash drop zone - only show while dragging, hide on mobile */}
diff --git a/src/routes/profile/$did/deck/$rkey/play.tsx b/src/routes/profile/$did/deck/$rkey/play.tsx new file mode 100644 index 0000000..44b0a8e --- /dev/null +++ b/src/routes/profile/$did/deck/$rkey/play.tsx @@ -0,0 +1,93 @@ +import type { Did } from "@atcute/lexicons"; +import { useQueries, useSuspenseQuery } from "@tanstack/react-query"; +import { createFileRoute, Link } from "@tanstack/react-router"; +import { ArrowLeft } from "lucide-react"; +import { useCallback } from "react"; +import { GoldfishBoard } from "@/components/deck/goldfish"; +import { asRkey } from "@/lib/atproto-client"; +import { prefetchCards } from "@/lib/card-prefetch"; +import { getDeckQueryOptions } from "@/lib/deck-queries"; +import { getCardsInSection } from "@/lib/deck-types"; +import { combineCardQueries, getCardByIdQueryOptions } from "@/lib/queries"; +import type { ScryfallId } from "@/lib/scryfall-types"; + +export const Route = createFileRoute("/profile/$did/deck/$rkey/play")({ + component: PlaytestPage, + loader: async ({ context, params }) => { + const deck = await context.queryClient.ensureQueryData( + getDeckQueryOptions(params.did as Did, asRkey(params.rkey)), + ); + + const cardIds = deck.cards.map((card) => card.scryfallId); + await prefetchCards(context.queryClient, cardIds); + + return deck; + }, + head: ({ loaderData: deck }) => ({ + meta: [ + { + title: deck + ? `Playtest: ${deck.name} | DeckBelcher` + : "Playtest | DeckBelcher", + }, + ], + }), +}); + +function PlaytestPage() { + const { did, rkey } = Route.useParams(); + const { data: deck } = useSuspenseQuery( + getDeckQueryOptions(did as Did, asRkey(rkey)), + ); + + const playtestCards = [ + ...getCardsInSection(deck, "commander"), + ...getCardsInSection(deck, "mainboard"), + ]; + + const cardMap = useQueries({ + queries: playtestCards.map((card) => + getCardByIdQueryOptions(card.scryfallId), + ), + combine: combineCardQueries, + }); + + const getCard = useCallback((id: ScryfallId) => cardMap?.get(id), [cardMap]); + + const startingLife = deck.format === "commander" ? 40 : 20; + + if (!cardMap) { + return ( +
+ + Loading cards... + +
+ ); + } + + return ( +
+
+ + + Back to Editor + +

+ {deck.name} +

+
+
+ +
+
+ ); +}