👁️
5
fork

Configure Feed

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

printing selection

+283 -40
+16
src/components/deck/CardModal.tsx
··· 2 2 import { Minus, Plus, Trash2, X } from "lucide-react"; 3 3 import { useEffect, useId, useState } from "react"; 4 4 import { CardImage } from "@/components/CardImage"; 5 + import { PrintingSelector } from "@/components/deck/PrintingSelector"; 5 6 import { TagAutocomplete } from "@/components/deck/TagAutocomplete"; 6 7 import { ManaCost } from "@/components/ManaCost"; 7 8 import { getPrimaryFace } from "@/lib/card-faces"; 8 9 import type { DeckCard, Section } from "@/lib/deck-types"; 9 10 import { getCardByIdQueryOptions } from "@/lib/queries"; 11 + import type { ScryfallId } from "@/lib/scryfall-types"; 10 12 11 13 interface CardModalProps { 12 14 card: DeckCard; 13 15 isOpen: boolean; 14 16 onClose: () => void; 15 17 onUpdateQuantity: (quantity: number) => void; 18 + onUpdatePrinting: (newScryfallId: ScryfallId) => void; 16 19 onUpdateTags: (tags: string[]) => void; 17 20 onMoveToSection: (section: Section) => void; 18 21 onDelete: () => void; 22 + onCardHover?: (cardId: ScryfallId | null) => void; 19 23 readOnly?: boolean; 20 24 allTags?: string[]; 21 25 } ··· 25 29 isOpen, 26 30 onClose, 27 31 onUpdateQuantity, 32 + onUpdatePrinting, 28 33 onUpdateTags, 29 34 onMoveToSection, 30 35 onDelete, 36 + onCardHover, 31 37 readOnly = false, 32 38 allTags = [], 33 39 }: CardModalProps) { ··· 286 292 </div> 287 293 )} 288 294 </div> 295 + 296 + {/* Printing */} 297 + {!readOnly && ( 298 + <PrintingSelector 299 + oracleId={card.oracleId} 300 + currentScryfallId={card.scryfallId} 301 + onSelect={onUpdatePrinting} 302 + onHover={onCardHover} 303 + /> 304 + )} 289 305 </div> 290 306 291 307 {/* Footer */}
+83
src/components/deck/PrintingSelector.tsx
··· 1 + import type { OracleId, ScryfallId } from "@/lib/scryfall-types"; 2 + import { usePrintings } from "@/lib/usePrintings"; 3 + 4 + interface PrintingSelectorProps { 5 + oracleId: OracleId; 6 + currentScryfallId: ScryfallId; 7 + onSelect: (scryfallId: ScryfallId) => void; 8 + onHover?: (scryfallId: ScryfallId | null) => void; 9 + disabled?: boolean; 10 + } 11 + 12 + export function PrintingSelector({ 13 + oracleId, 14 + currentScryfallId, 15 + onSelect, 16 + onHover, 17 + disabled = false, 18 + }: PrintingSelectorProps) { 19 + const { printings, isLoading } = usePrintings(oracleId); 20 + 21 + if (isLoading) { 22 + return ( 23 + <div> 24 + <div className="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2"> 25 + Printing 26 + </div> 27 + <div 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"> 28 + <div className="flex flex-wrap gap-1.5"> 29 + {Array.from({ length: 4 }).map((_, i) => ( 30 + <div 31 + // biome-ignore lint/suspicious/noArrayIndexKey: static skeleton 32 + key={i} 33 + className="h-7 w-28 bg-gray-300 dark:bg-zinc-700 rounded animate-pulse" 34 + /> 35 + ))} 36 + </div> 37 + </div> 38 + </div> 39 + ); 40 + } 41 + 42 + if (printings.length <= 1) return null; 43 + 44 + return ( 45 + <div> 46 + <div className="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2"> 47 + Printing ({printings.length}) 48 + </div> 49 + {/* biome-ignore lint/a11y/noStaticElementInteractions: hover preview is supplemental, buttons handle interaction */} 50 + <div 51 + onMouseLeave={() => onHover?.(currentScryfallId)} 52 + 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" 53 + > 54 + <div className="flex flex-wrap gap-1.5"> 55 + {printings.map((printing) => { 56 + const isCurrent = printing.id === currentScryfallId; 57 + return ( 58 + <button 59 + key={printing.id} 60 + type="button" 61 + disabled={disabled || isCurrent} 62 + onMouseEnter={() => onHover?.(printing.id)} 63 + onClick={() => onSelect(printing.id)} 64 + className={`px-2.5 py-1 text-sm rounded transition-colors whitespace-nowrap ${ 65 + isCurrent 66 + ? "bg-cyan-400 text-gray-900 font-medium" 67 + : "bg-gray-200 dark:bg-zinc-700 text-gray-900 dark:text-zinc-200 hover:bg-gray-300 dark:hover:bg-zinc-600 disabled:opacity-50 disabled:cursor-not-allowed" 68 + }`} 69 + > 70 + {printing.set_name} 71 + {printing.collector_number && ( 72 + <span className="ml-1 opacity-70"> 73 + #{printing.collector_number} 74 + </span> 75 + )} 76 + </button> 77 + ); 78 + })} 79 + </div> 80 + </div> 81 + </div> 82 + ); 83 + }
+93 -1
src/lib/__tests__/deck-types.test.ts
··· 1 1 import { describe, expect, it } from "vitest"; 2 2 import type { Deck } from "../deck-types"; 3 - import { getCommanderColorIdentity } from "../deck-types"; 3 + import { getCommanderColorIdentity, updateCardPrinting } from "../deck-types"; 4 4 import type { Card } from "../scryfall-types"; 5 5 import { asOracleId, asScryfallId } from "../scryfall-types"; 6 6 ··· 129 129 expect(result).toEqual([]); 130 130 }); 131 131 }); 132 + 133 + describe("updateCardPrinting", () => { 134 + const oldId = asScryfallId("11111111-1111-1111-1111-111111111111"); 135 + const newId = asScryfallId("22222222-2222-2222-2222-222222222222"); 136 + const otherId = asScryfallId("33333333-3333-3333-3333-333333333333"); 137 + const oracleId = asOracleId("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); 138 + 139 + function makeDeck( 140 + cards: Array<{ 141 + scryfallId: ReturnType<typeof asScryfallId>; 142 + section: "mainboard" | "sideboard" | "commander" | "maybeboard"; 143 + quantity?: number; 144 + tags?: string[]; 145 + }>, 146 + ): Deck { 147 + return { 148 + $type: "com.deckbelcher.deck.list", 149 + name: "Test Deck", 150 + cards: cards.map((c) => ({ 151 + scryfallId: c.scryfallId, 152 + oracleId, 153 + quantity: c.quantity ?? 1, 154 + section: c.section, 155 + tags: c.tags ?? [], 156 + })), 157 + createdAt: "2025-01-01T00:00:00Z", 158 + }; 159 + } 160 + 161 + it("updates scryfallId for the matching card", () => { 162 + const deck = makeDeck([{ scryfallId: oldId, section: "mainboard" }]); 163 + const result = updateCardPrinting(deck, oldId, "mainboard", newId); 164 + 165 + expect(result.cards[0].scryfallId).toBe(newId); 166 + }); 167 + 168 + it("preserves quantity, tags, and section", () => { 169 + const deck = makeDeck([ 170 + { 171 + scryfallId: oldId, 172 + section: "mainboard", 173 + quantity: 4, 174 + tags: ["removal"], 175 + }, 176 + ]); 177 + const result = updateCardPrinting(deck, oldId, "mainboard", newId); 178 + 179 + expect(result.cards[0]).toMatchObject({ 180 + scryfallId: newId, 181 + quantity: 4, 182 + section: "mainboard", 183 + tags: ["removal"], 184 + }); 185 + }); 186 + 187 + it("only updates the card in the matching section", () => { 188 + const deck = makeDeck([ 189 + { scryfallId: oldId, section: "mainboard" }, 190 + { scryfallId: oldId, section: "sideboard" }, 191 + ]); 192 + const result = updateCardPrinting(deck, oldId, "sideboard", newId); 193 + 194 + expect(result.cards[0].scryfallId).toBe(oldId); 195 + expect(result.cards[1].scryfallId).toBe(newId); 196 + }); 197 + 198 + it("does not modify other cards", () => { 199 + const deck = makeDeck([ 200 + { scryfallId: oldId, section: "mainboard" }, 201 + { scryfallId: otherId, section: "mainboard" }, 202 + ]); 203 + const result = updateCardPrinting(deck, oldId, "mainboard", newId); 204 + 205 + expect(result.cards[1].scryfallId).toBe(otherId); 206 + }); 207 + 208 + it("updates the updatedAt timestamp", () => { 209 + const deck = makeDeck([{ scryfallId: oldId, section: "mainboard" }]); 210 + const result = updateCardPrinting(deck, oldId, "mainboard", newId); 211 + 212 + expect(result.updatedAt).toBeDefined(); 213 + expect(result.updatedAt).not.toBe(deck.updatedAt); 214 + }); 215 + 216 + it("returns a new deck object (immutable)", () => { 217 + const deck = makeDeck([{ scryfallId: oldId, section: "mainboard" }]); 218 + const result = updateCardPrinting(deck, oldId, "mainboard", newId); 219 + 220 + expect(result).not.toBe(deck); 221 + expect(result.cards).not.toBe(deck.cards); 222 + }); 223 + });
+20
src/lib/deck-types.ts
··· 198 198 } 199 199 200 200 /** 201 + * Update a card's printing (scryfallId) while preserving all other fields 202 + */ 203 + export function updateCardPrinting( 204 + deck: Deck, 205 + scryfallId: ScryfallId, 206 + section: Section, 207 + newScryfallId: ScryfallId, 208 + ): Deck { 209 + return { 210 + ...deck, 211 + cards: deck.cards.map((card) => 212 + card.scryfallId === scryfallId && card.section === section 213 + ? { ...card, scryfallId: newScryfallId } 214 + : card, 215 + ), 216 + updatedAt: new Date().toISOString(), 217 + }; 218 + } 219 + 220 + /** 201 221 * Move a card to a different section 202 222 * If the card exists in the target section, merge quantities and combine tags 203 223 */
+40
src/lib/usePrintings.ts
··· 1 + import { useQueries, useQuery } from "@tanstack/react-query"; 2 + import { 3 + combineCardQueries, 4 + getCardByIdQueryOptions, 5 + getCardPrintingsQueryOptions, 6 + } from "@/lib/queries"; 7 + import type { Card, OracleId, ScryfallId } from "@/lib/scryfall-types"; 8 + 9 + interface UsePrintingsResult { 10 + printings: Card[]; 11 + printingsMap: Map<ScryfallId, Card> | undefined; 12 + isLoading: boolean; 13 + } 14 + 15 + export function usePrintings( 16 + oracleId: OracleId | undefined, 17 + ): UsePrintingsResult { 18 + const { data: printingIds, isLoading: idsLoading } = useQuery({ 19 + ...getCardPrintingsQueryOptions(oracleId ?? ("" as OracleId)), 20 + enabled: !!oracleId, 21 + }); 22 + 23 + const printingsMap = useQueries({ 24 + queries: (printingIds ?? []).map((printingId) => 25 + getCardByIdQueryOptions(printingId), 26 + ), 27 + combine: combineCardQueries, 28 + }); 29 + 30 + const printings = (printingIds ?? []) 31 + .map((pid) => printingsMap?.get(pid)) 32 + .filter((c): c is Card => c !== undefined) 33 + .sort((a, b) => (b.released_at ?? "").localeCompare(a.released_at ?? "")); 34 + 35 + return { 36 + printings, 37 + printingsMap, 38 + isLoading: idsLoading || (!!printingIds && !printingsMap), 39 + }; 40 + }
+9 -39
src/routes/card/$id.tsx
··· 1 - import { useQueries, useQuery } from "@tanstack/react-query"; 1 + import { useQuery } from "@tanstack/react-query"; 2 2 import { createFileRoute, Link } from "@tanstack/react-router"; 3 3 import { useEffect, useRef, useState } from "react"; 4 4 import { CardImage, CardSkeleton } from "@/components/CardImage"; ··· 11 11 import { FORMAT_GROUPS } from "@/lib/format-utils"; 12 12 import { 13 13 getCardByIdQueryOptions, 14 - getCardPrintingsQueryOptions, 15 14 getVolatileDataQueryOptions, 16 15 } from "@/lib/queries"; 17 - import type { 18 - Card, 19 - CardFace, 20 - OracleId, 21 - ScryfallId, 22 - } from "@/lib/scryfall-types"; 16 + import type { CardFace, OracleId, ScryfallId } from "@/lib/scryfall-types"; 23 17 import { 24 18 asOracleId, 25 19 asScryfallId, ··· 28 22 toScryfallUri, 29 23 } from "@/lib/scryfall-types"; 30 24 import { getImageUri } from "@/lib/scryfall-utils"; 25 + import { usePrintings } from "@/lib/usePrintings"; 31 26 32 27 const NOT_FOUND_META = { 33 28 meta: [ ··· 148 143 component: CardDetailPage, 149 144 }); 150 145 151 - function combinePrintingQueries( 152 - results: Array<{ data?: Card | undefined }>, 153 - ): Map<ScryfallId, Card> | undefined { 154 - const map = new Map<ScryfallId, Card>(); 155 - for (const result of results) { 156 - if (result.data) { 157 - map.set(result.data.id, result.data); 158 - } 159 - } 160 - return results.every((r) => r.data) ? map : undefined; 161 - } 162 - 163 146 function CardDetailPage() { 164 147 const { id } = Route.useParams(); 165 148 const [hoveredPrintingId, setHoveredPrintingId] = useState<ScryfallId | null>( ··· 173 156 getCardByIdQueryOptions(isValidId ? id : ("" as ScryfallId)), 174 157 ); 175 158 176 - const { data: printingIds, isLoading: printingIdsLoading } = useQuery({ 177 - ...getCardPrintingsQueryOptions(card?.oracle_id ?? asOracleId("")), 178 - enabled: !!card, 179 - }); 180 - 181 - const printingsMap = useQueries({ 182 - queries: (printingIds ?? []).map((printingId) => 183 - getCardByIdQueryOptions(printingId), 184 - ), 185 - combine: combinePrintingQueries, 186 - }); 159 + const { 160 + printings: allPrintings, 161 + printingsMap, 162 + isLoading: printingsLoading, 163 + } = usePrintings(card?.oracle_id ? asOracleId(card.oracle_id) : undefined); 187 164 188 165 // Use hovered printing's ID for volatile data, fall back to current card 189 166 const displayedId = hoveredPrintingId ?? (isValidId ? id : null); ··· 257 234 const displayCard = hoveredPrintingId 258 235 ? (printingsMap?.get(hoveredPrintingId) ?? card) 259 236 : card; 260 - 261 - // Sort printings by release date (newest first) for display 262 - // oracleIdToPrintings is canonical order, but users expect chronological when browsing 263 - const allPrintings = (printingIds ?? []) 264 - .map((pid) => printingsMap?.get(pid)) 265 - .filter((c): c is Card => c !== undefined) 266 - .sort((a, b) => (b.released_at ?? "").localeCompare(a.released_at ?? "")); 267 237 268 238 return ( 269 239 <div className="min-h-screen bg-white dark:bg-zinc-900"> ··· 411 381 ) : null} 412 382 </div> 413 383 414 - {printingIdsLoading ? ( 384 + {printingsLoading ? ( 415 385 <div> 416 386 <h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-3"> 417 387 Printings
+22
src/routes/profile/$did/deck/$rkey/index.tsx
··· 43 43 moveCardToSection, 44 44 removeCardFromDeck, 45 45 toEmbeddedPrimer, 46 + updateCardPrinting, 46 47 updateCardQuantity, 47 48 updateCardTags, 48 49 } from "@/lib/deck-types"; ··· 264 265 ); 265 266 }; 266 267 268 + const handleUpdatePrinting = (newScryfallId: ScryfallId) => { 269 + if (!modalCard) return; 270 + updateDeck((prev) => 271 + updateCardPrinting( 272 + prev, 273 + modalCard.scryfallId, 274 + modalCard.section as Section, 275 + newScryfallId, 276 + ), 277 + ); 278 + setModalCard((prev) => 279 + prev ? { ...prev, scryfallId: newScryfallId } : null, 280 + ); 281 + setPreviewCard(newScryfallId); 282 + }; 283 + 267 284 const handleUpdateTags = (tags: string[]) => { 268 285 if (!modalCard) return; 269 286 updateDeck((prev) => ··· 500 517 handleCardClick={handleCardClick} 501 518 handleModalClose={handleModalClose} 502 519 handleUpdateQuantity={handleUpdateQuantity} 520 + handleUpdatePrinting={handleUpdatePrinting} 503 521 handleUpdateTags={handleUpdateTags} 504 522 handleMoveToSection={handleMoveToSection} 505 523 handleDeleteCard={handleDeleteCard} ··· 543 561 handleCardClick: (card: DeckCard) => void; 544 562 handleModalClose: () => void; 545 563 handleUpdateQuantity: (quantity: number) => void; 564 + handleUpdatePrinting: (newScryfallId: ScryfallId) => void; 546 565 handleUpdateTags: (tags: string[]) => void; 547 566 handleMoveToSection: (section: Section) => void; 548 567 handleDeleteCard: () => void; ··· 583 602 handleCardClick, 584 603 handleModalClose, 585 604 handleUpdateQuantity, 605 + handleUpdatePrinting, 586 606 handleUpdateTags, 587 607 handleMoveToSection, 588 608 handleDeleteCard, ··· 867 887 isOpen={true} 868 888 onClose={handleModalClose} 869 889 onUpdateQuantity={handleUpdateQuantity} 890 + onUpdatePrinting={handleUpdatePrinting} 870 891 onUpdateTags={handleUpdateTags} 871 892 onMoveToSection={handleMoveToSection} 872 893 onDelete={handleDeleteCard} 894 + onCardHover={handleCardHover} 873 895 readOnly={!isOwner} 874 896 allTags={allTags} 875 897 />