👁️
6
fork

Configure Feed

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

use marquee card for opengraph

+106 -61
+24 -53
src/components/DeckPreview.tsx
··· 5 5 import { CardSpread } from "@/components/CardSpread"; 6 6 import { ClientDate } from "@/components/ClientDate"; 7 7 import { asRkey, type Rkey } from "@/lib/atproto-client"; 8 - import { 9 - getDeckNameWords, 10 - isNonCreatureLand, 11 - textMatchesDeckTitle, 12 - } from "@/lib/deck-preview-utils"; 8 + import { getPreviewCardIds } from "@/lib/deck-preview-utils"; 13 9 import type { Deck } from "@/lib/deck-types"; 14 10 import { didDocumentQueryOptions, extractHandle } from "@/lib/did-to-handle"; 15 11 import { formatDisplayName } from "@/lib/format-utils"; ··· 79 75 : ""; 80 76 const dateString = deck.updatedAt ?? deck.createdAt; 81 77 82 - const commanders = useMemo( 83 - () => deck.cards.filter((c) => c.section === "commander"), 84 - [deck.cards], 85 - ); 78 + const hasCommanders = deck.cards.some((c) => c.section === "commander"); 86 79 const mainboardCards = useMemo( 87 80 () => deck.cards.filter((c) => c.section === "mainboard"), 88 81 [deck.cards], 89 82 ); 90 - const hasCommanders = commanders.length > 0; 91 83 92 84 // Load card data for mainboard to filter lands (skip for commander decks) 93 85 const cardQueries = useQueries({ ··· 97 89 })), 98 90 }); 99 91 100 - const deckWords = useMemo(() => getDeckNameWords(deck.name), [deck.name]); 92 + const isLoadingCards = cardQueries.some((q) => q.isLoading); 101 93 102 - const isLoadingCards = cardQueries.some((q) => q.isLoading); 94 + // Build a lookup map from card queries 95 + const cardDataMap = useMemo(() => { 96 + const map = new Map< 97 + string, 98 + { name?: string; type_line?: string; oracle_text?: string } 99 + >(); 100 + mainboardCards.forEach((c, i) => { 101 + const data = cardQueries[i]?.data; 102 + if (data) map.set(c.scryfallId, data); 103 + }); 104 + return map; 105 + }, [mainboardCards, cardQueries]); 103 106 104 107 const previewCardIds = useMemo(() => { 105 - if (hasCommanders) { 106 - return commanders.slice(0, 3).map((c) => c.scryfallId); 107 - } 108 - 109 108 // While loading, show top 3 by quantity as placeholders 110 - if (isLoadingCards) { 111 - return mainboardCards 109 + if (isLoadingCards && !hasCommanders) { 110 + return [...mainboardCards] 112 111 .sort((a, b) => b.quantity - a.quantity) 113 112 .slice(0, 3) 114 113 .map((c) => c.scryfallId); 115 114 } 116 115 117 - // Filter lands, sort by quantity (tiebreak by name matching deck title), take top 3 118 - const withData = mainboardCards 119 - .map((deckCard, i) => ({ 120 - deckCard, 121 - card: cardQueries[i]?.data, 122 - })) 123 - .filter(({ card }) => card && !isNonCreatureLand(card.type_line)); 124 - 125 - return withData 126 - .sort((a, b) => { 127 - const qtyDiff = b.deckCard.quantity - a.deckCard.quantity; 128 - if (qtyDiff !== 0) return qtyDiff; 129 - // Tiebreak 1: prefer cards whose name matches deck title 130 - const aNameMatch = textMatchesDeckTitle(a.card?.name, deckWords); 131 - const bNameMatch = textMatchesDeckTitle(b.card?.name, deckWords); 132 - if (aNameMatch && !bNameMatch) return -1; 133 - if (bNameMatch && !aNameMatch) return 1; 134 - // Tiebreak 2: prefer cards whose type line matches deck title 135 - const aTypeMatch = textMatchesDeckTitle(a.card?.type_line, deckWords); 136 - const bTypeMatch = textMatchesDeckTitle(b.card?.type_line, deckWords); 137 - if (aTypeMatch && !bTypeMatch) return -1; 138 - if (bTypeMatch && !aTypeMatch) return 1; 139 - // Tiebreak 3: prefer cards whose oracle text matches deck title 140 - const aTextMatch = textMatchesDeckTitle(a.card?.oracle_text, deckWords); 141 - const bTextMatch = textMatchesDeckTitle(b.card?.oracle_text, deckWords); 142 - if (aTextMatch && !bTextMatch) return -1; 143 - if (bTextMatch && !aTextMatch) return 1; 144 - return 0; 145 - }) 146 - .slice(0, 3) 147 - .map(({ deckCard }) => deckCard.scryfallId); 116 + return getPreviewCardIds(deck.name, deck.cards, (id) => 117 + cardDataMap.get(id), 118 + ); 148 119 }, [ 120 + deck.name, 121 + deck.cards, 122 + cardDataMap, 123 + isLoadingCards, 149 124 hasCommanders, 150 - commanders, 151 125 mainboardCards, 152 - cardQueries, 153 - deckWords, 154 - isLoadingCards, 155 126 ]); 156 127 157 128 return (
+65
src/lib/deck-preview-utils.ts
··· 1 + import type { DeckCard } from "./deck-types"; 2 + import type { Card } from "./scryfall-types"; 3 + 1 4 /** 2 5 * Irregular plurals relevant to MTG typal deck names. 3 6 * These can't be derived algorithmically. ··· 81 84 const lower = typeLine.toLowerCase(); 82 85 return lower.includes("land") && !lower.includes("creature"); 83 86 } 87 + 88 + type CardPreviewData = Partial< 89 + Pick<Card, "name" | "type_line" | "oracle_text"> 90 + >; 91 + 92 + /** 93 + * Select the best cards to show in a deck preview. 94 + * 95 + * For commander decks: returns commanders (up to 3). 96 + * For other decks: returns mainboard cards sorted by quantity, with tiebreakers 97 + * preferring cards whose name/type/text match the deck title. Filters out lands. 98 + * 99 + * @param deckName - The deck's name (used for title matching) 100 + * @param deckCards - Array of deck cards with scryfallId, quantity, section 101 + * @param getCard - Function to look up card data by ID (returns undefined if not found) 102 + * @param count - Number of cards to return (default 3) 103 + */ 104 + export function getPreviewCardIds( 105 + deckName: string, 106 + deckCards: DeckCard[], 107 + getCard: (id: string) => CardPreviewData | undefined, 108 + count = 3, 109 + ): string[] { 110 + const commanders = deckCards.filter((c) => c.section === "commander"); 111 + if (commanders.length > 0) { 112 + return commanders.slice(0, count).map((c) => c.scryfallId); 113 + } 114 + 115 + const mainboardCards = deckCards.filter((c) => c.section === "mainboard"); 116 + const deckWords = getDeckNameWords(deckName); 117 + 118 + const withData = mainboardCards 119 + .map((deckCard) => ({ 120 + deckCard, 121 + card: getCard(deckCard.scryfallId), 122 + })) 123 + .filter(({ card }) => card && !isNonCreatureLand(card.type_line)); 124 + 125 + return withData 126 + .sort((a, b) => { 127 + const qtyDiff = b.deckCard.quantity - a.deckCard.quantity; 128 + if (qtyDiff !== 0) return qtyDiff; 129 + // Tiebreak 1: prefer cards whose name matches deck title 130 + const aNameMatch = textMatchesDeckTitle(a.card?.name, deckWords); 131 + const bNameMatch = textMatchesDeckTitle(b.card?.name, deckWords); 132 + if (aNameMatch && !bNameMatch) return -1; 133 + if (bNameMatch && !aNameMatch) return 1; 134 + // Tiebreak 2: prefer cards whose type line matches deck title 135 + const aTypeMatch = textMatchesDeckTitle(a.card?.type_line, deckWords); 136 + const bTypeMatch = textMatchesDeckTitle(b.card?.type_line, deckWords); 137 + if (aTypeMatch && !bTypeMatch) return -1; 138 + if (bTypeMatch && !aTypeMatch) return 1; 139 + // Tiebreak 3: prefer cards whose oracle text matches deck title 140 + const aTextMatch = textMatchesDeckTitle(a.card?.oracle_text, deckWords); 141 + const bTextMatch = textMatchesDeckTitle(b.card?.oracle_text, deckWords); 142 + if (aTextMatch && !bTextMatch) return -1; 143 + if (bTextMatch && !aTextMatch) return 1; 144 + return 0; 145 + }) 146 + .slice(0, count) 147 + .map(({ deckCard }) => deckCard.scryfallId); 148 + }
+17 -8
src/routes/profile/$did/deck/$rkey/index.tsx
··· 28 28 import { prefetchCards } from "@/lib/card-prefetch"; 29 29 import { DECK_LIST_NSID } from "@/lib/constellation-client"; 30 30 import { prefetchSocialStats } from "@/lib/constellation-queries"; 31 + import { getPreviewCardIds } from "@/lib/deck-preview-utils"; 31 32 import { getDeckQueryOptions, useUpdateDeckMutation } from "@/lib/deck-queries"; 32 33 import type { Deck, GroupBy, Section, SortBy } from "@/lib/deck-types"; 33 34 import { ··· 74 75 socialPromise, 75 76 ]); 76 77 77 - return deck; 78 + // Compute featured card for OG image using same logic as deck preview 79 + const previewCardIds = getPreviewCardIds(deck.name, deck.cards, (id) => { 80 + const queryKey = ["cards", "byId", id]; 81 + return context.queryClient.getQueryData(queryKey) as 82 + | { name?: string; type_line?: string; oracle_text?: string } 83 + | undefined; 84 + }); 85 + const featuredCardId = previewCardIds[0] ?? deck.cards[0]?.scryfallId; 86 + 87 + return { deck, featuredCardId }; 78 88 }, 79 - head: ({ loaderData: deck }) => { 80 - if (!deck) { 89 + head: ({ loaderData }) => { 90 + if (!loaderData) { 81 91 return { meta: [{ title: "Deck Not Found | DeckBelcher" }] }; 82 92 } 93 + 94 + const { deck, featuredCardId } = loaderData; 83 95 84 96 const format = formatDisplayName(deck.format); 85 97 const title = format ··· 96 108 ? `${primerText.slice(0, 150)}${primerText.length > 150 ? "..." : ""}` 97 109 : `${cardCount} card${cardCount === 1 ? "" : "s"}`; 98 110 99 - // Use first commander's image, or first card if no commanders 100 - const commanders = deck.cards.filter((c) => c.section === "commander"); 101 - const featuredCard = commanders[0] ?? deck.cards[0]; 102 - const cardImageUrl = featuredCard 103 - ? getImageUri(featuredCard.scryfallId, "large") 111 + const cardImageUrl = featuredCardId 112 + ? getImageUri(featuredCardId as ScryfallId, "large") 104 113 : undefined; 105 114 106 115 return {