diff --git a/src/components/deck/CardModal.tsx b/src/components/deck/CardModal.tsx index 2038336..3cf46ad 100644 --- a/src/components/deck/CardModal.tsx +++ b/src/components/deck/CardModal.tsx @@ -2,20 +2,24 @@ import { useQuery } from "@tanstack/react-query"; import { Minus, Plus, Trash2, X } from "lucide-react"; import { useEffect, useId, useState } from "react"; import { CardImage } from "@/components/CardImage"; +import { PrintingSelector } from "@/components/deck/PrintingSelector"; import { TagAutocomplete } from "@/components/deck/TagAutocomplete"; import { ManaCost } from "@/components/ManaCost"; import { getPrimaryFace } from "@/lib/card-faces"; import type { DeckCard, Section } from "@/lib/deck-types"; import { getCardByIdQueryOptions } from "@/lib/queries"; +import type { ScryfallId } from "@/lib/scryfall-types"; interface CardModalProps { card: DeckCard; isOpen: boolean; onClose: () => void; onUpdateQuantity: (quantity: number) => void; + onUpdatePrinting: (newScryfallId: ScryfallId) => void; onUpdateTags: (tags: string[]) => void; onMoveToSection: (section: Section) => void; onDelete: () => void; + onCardHover?: (cardId: ScryfallId | null) => void; readOnly?: boolean; allTags?: string[]; } @@ -25,9 +29,11 @@ export function CardModal({ isOpen, onClose, onUpdateQuantity, + onUpdatePrinting, onUpdateTags, onMoveToSection, onDelete, + onCardHover, readOnly = false, allTags = [], }: CardModalProps) { @@ -286,6 +292,16 @@ export function CardModal({ )} + + {/* Printing */} + {!readOnly && ( + + )} {/* Footer */} diff --git a/src/components/deck/PrintingSelector.tsx b/src/components/deck/PrintingSelector.tsx new file mode 100644 index 0000000..19e3780 --- /dev/null +++ b/src/components/deck/PrintingSelector.tsx @@ -0,0 +1,83 @@ +import type { OracleId, ScryfallId } from "@/lib/scryfall-types"; +import { usePrintings } from "@/lib/usePrintings"; + +interface PrintingSelectorProps { + oracleId: OracleId; + currentScryfallId: ScryfallId; + onSelect: (scryfallId: ScryfallId) => void; + onHover?: (scryfallId: ScryfallId | null) => void; + disabled?: boolean; +} + +export function PrintingSelector({ + oracleId, + currentScryfallId, + onSelect, + onHover, + disabled = false, +}: PrintingSelectorProps) { + const { printings, isLoading } = usePrintings(oracleId); + + if (isLoading) { + return ( +
+
+ Printing +
+
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+
+
+ ); + } + + if (printings.length <= 1) return null; + + return ( +
+
+ Printing ({printings.length}) +
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: hover preview is supplemental, buttons handle interaction */} +
onHover?.(currentScryfallId)} + className="max-h-40 overflow-y-auto border border-gray-300 dark:border-zinc-600 rounded-lg p-2 bg-gray-50 dark:bg-zinc-800/50" + > +
+ {printings.map((printing) => { + const isCurrent = printing.id === currentScryfallId; + return ( + + ); + })} +
+
+
+ ); +} diff --git a/src/lib/__tests__/deck-types.test.ts b/src/lib/__tests__/deck-types.test.ts index 0a99325..fa52a4a 100644 --- a/src/lib/__tests__/deck-types.test.ts +++ b/src/lib/__tests__/deck-types.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { Deck } from "../deck-types"; -import { getCommanderColorIdentity } from "../deck-types"; +import { getCommanderColorIdentity, updateCardPrinting } from "../deck-types"; import type { Card } from "../scryfall-types"; import { asOracleId, asScryfallId } from "../scryfall-types"; @@ -129,3 +129,95 @@ describe("getCommanderColorIdentity", () => { expect(result).toEqual([]); }); }); + +describe("updateCardPrinting", () => { + const oldId = asScryfallId("11111111-1111-1111-1111-111111111111"); + const newId = asScryfallId("22222222-2222-2222-2222-222222222222"); + const otherId = asScryfallId("33333333-3333-3333-3333-333333333333"); + const oracleId = asOracleId("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + + function makeDeck( + cards: Array<{ + scryfallId: ReturnType; + section: "mainboard" | "sideboard" | "commander" | "maybeboard"; + quantity?: number; + tags?: string[]; + }>, + ): Deck { + return { + $type: "com.deckbelcher.deck.list", + name: "Test Deck", + cards: cards.map((c) => ({ + scryfallId: c.scryfallId, + oracleId, + quantity: c.quantity ?? 1, + section: c.section, + tags: c.tags ?? [], + })), + createdAt: "2025-01-01T00:00:00Z", + }; + } + + it("updates scryfallId for the matching card", () => { + const deck = makeDeck([{ scryfallId: oldId, section: "mainboard" }]); + const result = updateCardPrinting(deck, oldId, "mainboard", newId); + + expect(result.cards[0].scryfallId).toBe(newId); + }); + + it("preserves quantity, tags, and section", () => { + const deck = makeDeck([ + { + scryfallId: oldId, + section: "mainboard", + quantity: 4, + tags: ["removal"], + }, + ]); + const result = updateCardPrinting(deck, oldId, "mainboard", newId); + + expect(result.cards[0]).toMatchObject({ + scryfallId: newId, + quantity: 4, + section: "mainboard", + tags: ["removal"], + }); + }); + + it("only updates the card in the matching section", () => { + const deck = makeDeck([ + { scryfallId: oldId, section: "mainboard" }, + { scryfallId: oldId, section: "sideboard" }, + ]); + const result = updateCardPrinting(deck, oldId, "sideboard", newId); + + expect(result.cards[0].scryfallId).toBe(oldId); + expect(result.cards[1].scryfallId).toBe(newId); + }); + + it("does not modify other cards", () => { + const deck = makeDeck([ + { scryfallId: oldId, section: "mainboard" }, + { scryfallId: otherId, section: "mainboard" }, + ]); + const result = updateCardPrinting(deck, oldId, "mainboard", newId); + + expect(result.cards[1].scryfallId).toBe(otherId); + }); + + it("updates the updatedAt timestamp", () => { + const deck = makeDeck([{ scryfallId: oldId, section: "mainboard" }]); + const result = updateCardPrinting(deck, oldId, "mainboard", newId); + + expect(result.updatedAt).toBeDefined(); + expect(result.updatedAt).not.toBe(deck.updatedAt); + }); + + it("returns a new deck object (immutable)", () => { + const deck = makeDeck([{ scryfallId: oldId, section: "mainboard" }]); + const result = updateCardPrinting(deck, oldId, "mainboard", newId); + + expect(result).not.toBe(deck); + expect(result.cards).not.toBe(deck.cards); + }); +}); diff --git a/src/lib/deck-types.ts b/src/lib/deck-types.ts index 2e7f2de..4892257 100644 --- a/src/lib/deck-types.ts +++ b/src/lib/deck-types.ts @@ -197,6 +197,26 @@ export function updateCardTags( }; } +/** + * Update a card's printing (scryfallId) while preserving all other fields + */ +export function updateCardPrinting( + deck: Deck, + scryfallId: ScryfallId, + section: Section, + newScryfallId: ScryfallId, +): Deck { + return { + ...deck, + cards: deck.cards.map((card) => + card.scryfallId === scryfallId && card.section === section + ? { ...card, scryfallId: newScryfallId } + : card, + ), + updatedAt: new Date().toISOString(), + }; +} + /** * Move a card to a different section * If the card exists in the target section, merge quantities and combine tags diff --git a/src/lib/usePrintings.ts b/src/lib/usePrintings.ts new file mode 100644 index 0000000..360627f --- /dev/null +++ b/src/lib/usePrintings.ts @@ -0,0 +1,40 @@ +import { useQueries, useQuery } from "@tanstack/react-query"; +import { + combineCardQueries, + getCardByIdQueryOptions, + getCardPrintingsQueryOptions, +} from "@/lib/queries"; +import type { Card, OracleId, ScryfallId } from "@/lib/scryfall-types"; + +interface UsePrintingsResult { + printings: Card[]; + printingsMap: Map | undefined; + isLoading: boolean; +} + +export function usePrintings( + oracleId: OracleId | undefined, +): UsePrintingsResult { + const { data: printingIds, isLoading: idsLoading } = useQuery({ + ...getCardPrintingsQueryOptions(oracleId ?? ("" as OracleId)), + enabled: !!oracleId, + }); + + const printingsMap = useQueries({ + queries: (printingIds ?? []).map((printingId) => + getCardByIdQueryOptions(printingId), + ), + combine: combineCardQueries, + }); + + const printings = (printingIds ?? []) + .map((pid) => printingsMap?.get(pid)) + .filter((c): c is Card => c !== undefined) + .sort((a, b) => (b.released_at ?? "").localeCompare(a.released_at ?? "")); + + return { + printings, + printingsMap, + isLoading: idsLoading || (!!printingIds && !printingsMap), + }; +} diff --git a/src/routes/card/$id.tsx b/src/routes/card/$id.tsx index cf24143..5cd6a8d 100644 --- a/src/routes/card/$id.tsx +++ b/src/routes/card/$id.tsx @@ -1,4 +1,4 @@ -import { useQueries, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { createFileRoute, Link } from "@tanstack/react-router"; import { useEffect, useRef, useState } from "react"; import { CardImage, CardSkeleton } from "@/components/CardImage"; @@ -11,15 +11,9 @@ import { prefetchSocialStats } from "@/lib/constellation-queries"; import { FORMAT_GROUPS } from "@/lib/format-utils"; import { getCardByIdQueryOptions, - getCardPrintingsQueryOptions, getVolatileDataQueryOptions, } from "@/lib/queries"; -import type { - Card, - CardFace, - OracleId, - ScryfallId, -} from "@/lib/scryfall-types"; +import type { CardFace, OracleId, ScryfallId } from "@/lib/scryfall-types"; import { asOracleId, asScryfallId, @@ -28,6 +22,7 @@ import { toScryfallUri, } from "@/lib/scryfall-types"; import { getImageUri } from "@/lib/scryfall-utils"; +import { usePrintings } from "@/lib/usePrintings"; const NOT_FOUND_META = { meta: [ @@ -148,18 +143,6 @@ export const Route = createFileRoute("/card/$id")({ component: CardDetailPage, }); -function combinePrintingQueries( - results: Array<{ data?: Card | undefined }>, -): Map | undefined { - const map = new Map(); - for (const result of results) { - if (result.data) { - map.set(result.data.id, result.data); - } - } - return results.every((r) => r.data) ? map : undefined; -} - function CardDetailPage() { const { id } = Route.useParams(); const [hoveredPrintingId, setHoveredPrintingId] = useState( @@ -173,17 +156,11 @@ function CardDetailPage() { getCardByIdQueryOptions(isValidId ? id : ("" as ScryfallId)), ); - const { data: printingIds, isLoading: printingIdsLoading } = useQuery({ - ...getCardPrintingsQueryOptions(card?.oracle_id ?? asOracleId("")), - enabled: !!card, - }); - - const printingsMap = useQueries({ - queries: (printingIds ?? []).map((printingId) => - getCardByIdQueryOptions(printingId), - ), - combine: combinePrintingQueries, - }); + const { + printings: allPrintings, + printingsMap, + isLoading: printingsLoading, + } = usePrintings(card?.oracle_id ? asOracleId(card.oracle_id) : undefined); // Use hovered printing's ID for volatile data, fall back to current card const displayedId = hoveredPrintingId ?? (isValidId ? id : null); @@ -258,13 +235,6 @@ function CardDetailPage() { ? (printingsMap?.get(hoveredPrintingId) ?? card) : card; - // Sort printings by release date (newest first) for display - // oracleIdToPrintings is canonical order, but users expect chronological when browsing - const allPrintings = (printingIds ?? []) - .map((pid) => printingsMap?.get(pid)) - .filter((c): c is Card => c !== undefined) - .sort((a, b) => (b.released_at ?? "").localeCompare(a.released_at ?? "")); - return (
@@ -411,7 +381,7 @@ function CardDetailPage() { ) : null}
- {printingIdsLoading ? ( + {printingsLoading ? (

Printings diff --git a/src/routes/profile/$did/deck/$rkey/index.tsx b/src/routes/profile/$did/deck/$rkey/index.tsx index 04587e9..1e3b687 100644 --- a/src/routes/profile/$did/deck/$rkey/index.tsx +++ b/src/routes/profile/$did/deck/$rkey/index.tsx @@ -43,6 +43,7 @@ import { moveCardToSection, removeCardFromDeck, toEmbeddedPrimer, + updateCardPrinting, updateCardQuantity, updateCardTags, } from "@/lib/deck-types"; @@ -264,6 +265,22 @@ function DeckEditorPage() { ); }; + const handleUpdatePrinting = (newScryfallId: ScryfallId) => { + if (!modalCard) return; + updateDeck((prev) => + updateCardPrinting( + prev, + modalCard.scryfallId, + modalCard.section as Section, + newScryfallId, + ), + ); + setModalCard((prev) => + prev ? { ...prev, scryfallId: newScryfallId } : null, + ); + setPreviewCard(newScryfallId); + }; + const handleUpdateTags = (tags: string[]) => { if (!modalCard) return; updateDeck((prev) => @@ -500,6 +517,7 @@ function DeckEditorPage() { handleCardClick={handleCardClick} handleModalClose={handleModalClose} handleUpdateQuantity={handleUpdateQuantity} + handleUpdatePrinting={handleUpdatePrinting} handleUpdateTags={handleUpdateTags} handleMoveToSection={handleMoveToSection} handleDeleteCard={handleDeleteCard} @@ -543,6 +561,7 @@ interface DeckEditorInnerProps { handleCardClick: (card: DeckCard) => void; handleModalClose: () => void; handleUpdateQuantity: (quantity: number) => void; + handleUpdatePrinting: (newScryfallId: ScryfallId) => void; handleUpdateTags: (tags: string[]) => void; handleMoveToSection: (section: Section) => void; handleDeleteCard: () => void; @@ -583,6 +602,7 @@ function DeckEditorInner({ handleCardClick, handleModalClose, handleUpdateQuantity, + handleUpdatePrinting, handleUpdateTags, handleMoveToSection, handleDeleteCard, @@ -867,9 +887,11 @@ function DeckEditorInner({ isOpen={true} onClose={handleModalClose} onUpdateQuantity={handleUpdateQuantity} + onUpdatePrinting={handleUpdatePrinting} onUpdateTags={handleUpdateTags} onMoveToSection={handleMoveToSection} onDelete={handleDeleteCard} + onCardHover={handleCardHover} readOnly={!isOwner} allTags={allTags} />