👁️
5
fork

Configure Feed

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

use new spread for decks too

+194 -127
+95
src/components/CardSpread.tsx
··· 1 + import { Bookmark } from "lucide-react"; 2 + import { CardImage } from "@/components/CardImage"; 3 + import type { ScryfallId } from "@/lib/scryfall-types"; 4 + 5 + export interface CardSpreadProps { 6 + cardIds: string[]; 7 + /** Fallback icon when no cards (default: Bookmark) */ 8 + emptyIcon?: React.ReactNode; 9 + } 10 + 11 + export function CardSpread({ cardIds, emptyIcon }: CardSpreadProps) { 12 + const cards = cardIds.slice(-3); 13 + 14 + if (cards.length === 0) { 15 + return ( 16 + <div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg shrink-0"> 17 + {emptyIcon ?? ( 18 + <Bookmark className="w-5 h-5 text-blue-600 dark:text-blue-400" /> 19 + )} 20 + </div> 21 + ); 22 + } 23 + 24 + const layouts: Record< 25 + number, 26 + { 27 + rotations: number[]; 28 + xPercents: number[]; 29 + hoverRotations: number[]; 30 + hoverZ: number; 31 + } 32 + > = { 33 + 1: { 34 + rotations: [0], 35 + xPercents: [20], 36 + hoverRotations: [90], 37 + hoverZ: 8, 38 + }, 39 + 2: { 40 + rotations: [-8, 8], 41 + xPercents: [12, 28], 42 + hoverRotations: [-11, 11], 43 + hoverZ: 8, 44 + }, 45 + 3: { 46 + rotations: [-12, 0, 12], 47 + xPercents: [10, 22, 34], 48 + hoverRotations: [-14, 0, 14], 49 + hoverZ: 8, 50 + }, 51 + }; 52 + 53 + const layout = layouts[cards.length] ?? layouts[3]; 54 + 55 + return ( 56 + <div 57 + className="relative shrink-0 w-24 h-[90px]" 58 + style={{ perspective: "150px" }} 59 + > 60 + {cards.map((id, i) => ( 61 + <div 62 + key={id} 63 + className={`absolute w-3/5 shadow-md motion-safe:transition-all motion-safe:ease-out motion-safe:group-hover:shadow-xl ${cards.length === 1 ? "origin-center motion-safe:duration-[350ms]" : "origin-bottom motion-safe:duration-200"}`} 64 + style={ 65 + { 66 + left: `${layout.xPercents[i]}%`, 67 + bottom: "5%", 68 + transform: `rotate(${layout.rotations[i]}deg)`, 69 + zIndex: i, 70 + "--base-rotate": `${layout.rotations[i]}deg`, 71 + "--hover-rotate": `${layout.hoverRotations[i]}deg`, 72 + "--hover-z": `${layout.hoverZ}px`, 73 + } as React.CSSProperties 74 + } 75 + > 76 + <CardImage 77 + card={{ id: id as ScryfallId, name: "" }} 78 + size="small" 79 + className="rounded" 80 + /> 81 + </div> 82 + ))} 83 + <style>{` 84 + .group:hover [style*="--hover-rotate"] { 85 + transform: rotate(var(--hover-rotate)) translateZ(var(--hover-z)) !important; 86 + } 87 + @media (prefers-reduced-motion: reduce) { 88 + .group:hover [style*="--hover-rotate"] { 89 + transform: rotate(var(--base-rotate)) !important; 90 + } 91 + } 92 + `}</style> 93 + </div> 94 + ); 95 + }
+98 -40
src/components/DeckPreview.tsx
··· 1 1 import type { Did } from "@atcute/lexicons"; 2 - import { useQuery } from "@tanstack/react-query"; 2 + import { useQueries, useQuery } from "@tanstack/react-query"; 3 3 import { Link } from "@tanstack/react-router"; 4 - import { CardImage } from "@/components/CardImage"; 4 + import { useMemo } from "react"; 5 + import { CardSpread } from "@/components/CardSpread"; 5 6 import { ClientDate } from "@/components/ClientDate"; 6 7 import { asRkey, type Rkey } from "@/lib/atproto-client"; 7 - import type { Deck, DeckCard } from "@/lib/deck-types"; 8 + import type { Deck } from "@/lib/deck-types"; 8 9 import { didDocumentQueryOptions, extractHandle } from "@/lib/did-to-handle"; 9 10 import { formatDisplayName } from "@/lib/format-utils"; 11 + import { getCardByIdQueryOptions } from "@/lib/queries"; 10 12 import type { ScryfallId } from "@/lib/scryfall-types"; 11 13 12 14 export interface DeckPreviewProps { ··· 54 56 return parts.join(" · "); 55 57 } 56 58 57 - function getThumbnailId(cards: DeckCard[]): ScryfallId | null { 58 - const commander = cards.find((c) => c.section === "commander"); 59 - if (commander) return commander.scryfallId; 59 + function isNonCreatureLand(typeLine: string | undefined): boolean { 60 + if (!typeLine) return false; 61 + const lower = typeLine.toLowerCase(); 62 + return lower.includes("land") && !lower.includes("creature"); 63 + } 60 64 61 - const mainboard = cards.find((c) => c.section === "mainboard"); 62 - if (mainboard) return mainboard.scryfallId; 65 + function getDeckNameWords(name: string): string[] { 66 + // Extract meaningful words (3+ chars, lowercased) 67 + return name 68 + .toLowerCase() 69 + .split(/\s+/) 70 + .filter((w) => w.length >= 3); 71 + } 63 72 64 - return cards[0]?.scryfallId ?? null; 73 + function cardNameMatchesDeckTitle( 74 + cardName: string | undefined, 75 + deckWords: string[], 76 + ): boolean { 77 + if (!cardName || deckWords.length === 0) return false; 78 + const lower = cardName.toLowerCase(); 79 + return deckWords.some((word) => lower.includes(word)); 65 80 } 66 81 67 82 export function DeckPreview({ ··· 81 96 ? formatSectionCounts(getSectionCounts(deck.cards)) 82 97 : ""; 83 98 const dateString = deck.updatedAt ?? deck.createdAt; 84 - const thumbnailId = getThumbnailId(deck.cards); 99 + 100 + const commanders = useMemo( 101 + () => deck.cards.filter((c) => c.section === "commander"), 102 + [deck.cards], 103 + ); 104 + const mainboardCards = useMemo( 105 + () => deck.cards.filter((c) => c.section === "mainboard"), 106 + [deck.cards], 107 + ); 108 + const hasCommanders = commanders.length > 0; 109 + 110 + // Load card data for mainboard to filter lands (skip for commander decks) 111 + const cardQueries = useQueries({ 112 + queries: mainboardCards.map((c) => ({ 113 + ...getCardByIdQueryOptions(c.scryfallId as ScryfallId), 114 + enabled: !hasCommanders, 115 + })), 116 + }); 117 + 118 + const deckWords = useMemo(() => getDeckNameWords(deck.name), [deck.name]); 119 + 120 + const previewCardIds = useMemo(() => { 121 + if (hasCommanders) { 122 + return commanders.slice(0, 3).map((c) => c.scryfallId); 123 + } 124 + 125 + // Filter lands, sort by quantity (tiebreak by name matching deck title), take top 3 126 + const withData = mainboardCards 127 + .map((deckCard, i) => ({ 128 + deckCard, 129 + card: cardQueries[i]?.data, 130 + })) 131 + .filter(({ card }) => card && !isNonCreatureLand(card.type_line)); 132 + 133 + return withData 134 + .sort((a, b) => { 135 + const qtyDiff = b.deckCard.quantity - a.deckCard.quantity; 136 + if (qtyDiff !== 0) return qtyDiff; 137 + // Tiebreak: prefer cards whose name matches deck title (sort to end = on top) 138 + const aMatches = cardNameMatchesDeckTitle(a.card?.name, deckWords); 139 + const bMatches = cardNameMatchesDeckTitle(b.card?.name, deckWords); 140 + if (aMatches && !bMatches) return 1; 141 + if (bMatches && !aMatches) return -1; 142 + return 0; 143 + }) 144 + .slice(0, 3) 145 + .map(({ deckCard }) => deckCard.scryfallId); 146 + }, [hasCommanders, commanders, mainboardCards, cardQueries, deckWords]); 85 147 86 148 return ( 87 149 <Link 88 150 to="/profile/$did/deck/$rkey" 89 151 params={{ did, rkey: asRkey(rkey) }} 90 - className="grid grid-cols-[auto_1fr] grid-rows-[auto_auto_auto_auto_auto] gap-x-4 p-4 bg-gray-50 dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:border-cyan-500 dark:hover:border-cyan-500 motion-safe:hover:shadow-lg transition-colors motion-safe:transition-shadow" 152 + className="group flex items-start gap-4 p-4 bg-gray-50 dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:border-cyan-500 dark:hover:border-cyan-500 motion-safe:hover:shadow-lg transition-colors motion-safe:transition-shadow" 91 153 > 92 - {thumbnailId && ( 93 - <CardImage 94 - card={{ id: thumbnailId, name: deck.name }} 95 - size="small" 96 - className="row-span-5 h-0 min-h-full aspect-[5/7]" 97 - /> 98 - )} 154 + <CardSpread cardIds={previewCardIds} /> 155 + 156 + <div className="flex-1 min-w-0"> 157 + {showHandle && 158 + (handle ? ( 159 + <p className="text-sm text-gray-600 dark:text-gray-400 truncate"> 160 + @{handle} 161 + </p> 162 + ) : ( 163 + <div className="h-5 w-24 bg-gray-200 dark:bg-slate-700 rounded animate-pulse" /> 164 + ))} 99 165 100 - {showHandle && 101 - (handle ? ( 166 + <h2 className="text-lg font-bold text-gray-900 dark:text-white truncate font-display"> 167 + {deck.name} 168 + </h2> 169 + {deck.format && ( 102 170 <p className="text-sm text-gray-600 dark:text-gray-400 truncate"> 103 - @{handle} 171 + {formatDisplayName(deck.format)} 104 172 </p> 105 - ) : ( 106 - <div className="h-5 w-24 bg-gray-200 dark:bg-slate-700 rounded animate-pulse" /> 107 - ))} 108 - 109 - <h2 className="text-lg font-bold text-gray-900 dark:text-white truncate font-display"> 110 - {deck.name} 111 - </h2> 112 - {deck.format && ( 113 - <p className="text-sm text-gray-600 dark:text-gray-400 truncate"> 114 - {formatDisplayName(deck.format)} 115 - </p> 116 - )} 117 - {sectionString && ( 118 - <p className="text-sm text-gray-600 dark:text-gray-400 truncate"> 119 - {sectionString} 173 + )} 174 + {sectionString && ( 175 + <p className="text-sm text-gray-600 dark:text-gray-400 truncate"> 176 + {sectionString} 177 + </p> 178 + )} 179 + <p className="text-sm text-gray-500 dark:text-gray-500"> 180 + Updated <ClientDate dateString={dateString} /> 120 181 </p> 121 - )} 122 - <p className="text-sm text-gray-500 dark:text-gray-500"> 123 - Updated <ClientDate dateString={dateString} /> 124 - </p> 182 + </div> 125 183 </Link> 126 184 ); 127 185 }
+1 -87
src/components/ListPreview.tsx
··· 1 1 import type { Did } from "@atcute/lexicons"; 2 2 import { useQuery } from "@tanstack/react-query"; 3 3 import { Link } from "@tanstack/react-router"; 4 - import { Bookmark } from "lucide-react"; 5 - import { CardImage } from "@/components/CardImage"; 4 + import { CardSpread } from "@/components/CardSpread"; 6 5 import { ClientDate } from "@/components/ClientDate"; 7 6 import { asRkey, type Rkey } from "@/lib/atproto-client"; 8 7 import { ··· 11 10 isDeckItem, 12 11 } from "@/lib/collection-list-types"; 13 12 import { didDocumentQueryOptions, extractHandle } from "@/lib/did-to-handle"; 14 - import type { ScryfallId } from "@/lib/scryfall-types"; 15 13 16 14 export interface ListPreviewProps { 17 15 did: Did; ··· 37 35 38 36 function getCardIds(list: CollectionList): string[] { 39 37 return list.items.filter(isCardItem).map((item) => item.scryfallId); 40 - } 41 - 42 - function CardSpread({ cardIds }: { cardIds: string[] }) { 43 - const cards = cardIds.slice(-3); 44 - 45 - if (cards.length === 0) { 46 - return ( 47 - <div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg shrink-0"> 48 - <Bookmark className="w-5 h-5 text-blue-600 dark:text-blue-400" /> 49 - </div> 50 - ); 51 - } 52 - 53 - const layouts: Record< 54 - number, 55 - { 56 - rotations: number[]; 57 - xPercents: number[]; 58 - hoverRotations: number[]; 59 - hoverZ: number; 60 - } 61 - > = { 62 - 1: { 63 - rotations: [0], 64 - xPercents: [20], 65 - hoverRotations: [90], 66 - hoverZ: 8, 67 - }, 68 - 2: { 69 - rotations: [-8, 8], 70 - xPercents: [12, 28], 71 - hoverRotations: [-11, 11], 72 - hoverZ: 8, 73 - }, 74 - 3: { 75 - rotations: [-12, 0, 12], 76 - xPercents: [10, 22, 34], 77 - hoverRotations: [-14, 0, 14], 78 - hoverZ: 8, 79 - }, 80 - }; 81 - 82 - const layout = layouts[cards.length] ?? layouts[3]; 83 - 84 - return ( 85 - <div 86 - className="relative shrink-0 w-24 h-[90px]" 87 - style={{ perspective: "150px" }} 88 - > 89 - {cards.map((id, i) => ( 90 - <div 91 - key={id} 92 - className={`absolute w-3/5 shadow-md motion-safe:transition-all motion-safe:ease-out motion-safe:group-hover:shadow-xl ${cards.length === 1 ? "origin-center motion-safe:duration-[350ms]" : "origin-bottom motion-safe:duration-200"}`} 93 - style={ 94 - { 95 - left: `${layout.xPercents[i]}%`, 96 - bottom: "5%", 97 - transform: `rotate(${layout.rotations[i]}deg)`, 98 - zIndex: i, 99 - "--base-rotate": `${layout.rotations[i]}deg`, 100 - "--hover-rotate": `${layout.hoverRotations[i]}deg`, 101 - "--hover-z": `${layout.hoverZ}px`, 102 - } as React.CSSProperties 103 - } 104 - > 105 - <CardImage 106 - card={{ id: id as ScryfallId, name: "" }} 107 - size="small" 108 - className="rounded" 109 - /> 110 - </div> 111 - ))} 112 - <style>{` 113 - .group:hover [style*="--hover-rotate"] { 114 - transform: rotate(var(--hover-rotate)) translateZ(var(--hover-z)) !important; 115 - } 116 - @media (prefers-reduced-motion: reduce) { 117 - .group:hover [style*="--hover-rotate"] { 118 - transform: rotate(var(--base-rotate)) !important; 119 - } 120 - } 121 - `}</style> 122 - </div> 123 - ); 124 38 } 125 39 126 40 export function ListPreview({