👁️
5
fork

Configure Feed

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

further work on card wireframes

+443 -386
+398 -267
src/components/CardWireframe.tsx
··· 1 1 /** 2 - * Card wireframe - text-based card display 2 + * Card wireframe - text-based card placeholder 3 3 * 4 - * Renders card data as styled text instead of an image. 5 - * Useful for loading states, dense views, or accessibility. 4 + * Renders card data as styled text matching card layout. 5 + * Useful for loading states before images arrive. 6 + * 7 + * TODO: Could add "art" mode (with art_crop) and "dense" mode (compact text-only) 8 + * if needed in the future. 6 9 */ 7 10 8 11 import { RotateCcw } from "lucide-react"; ··· 14 17 hasBackImage, 15 18 } from "@/lib/card-faces"; 16 19 import type { Card, CardFace, Rarity } from "@/lib/scryfall-types"; 17 - import { getImageUri } from "@/lib/scryfall-utils"; 18 20 import { ManaCost } from "./ManaCost"; 19 21 import { OracleText } from "./OracleText"; 20 22 21 23 interface CardWireframeProps { 22 24 card: Card; 23 - mode?: "dense" | "art" | "placeholder"; 24 - sizing?: "fixed" | "content"; 25 25 className?: string; 26 26 } 27 27 28 - const RARITY_COLORS: Record<Rarity, { border: string; text: string }> = { 28 + const RARITY_COLORS: Record< 29 + Rarity, 30 + { border: string; bg: string; text: string } 31 + > = { 29 32 common: { 30 33 border: "border-gray-400 dark:border-gray-500", 31 - text: "text-gray-600 dark:text-gray-400", 34 + bg: "bg-gray-200 dark:bg-gray-600", 35 + text: "text-gray-900 dark:text-gray-100", 32 36 }, 33 37 uncommon: { 34 38 border: "border-slate-400 dark:border-slate-400", 35 - text: "text-slate-500 dark:text-slate-300", 39 + bg: "bg-gradient-to-r from-slate-300 to-slate-400 dark:from-slate-500 dark:to-slate-400", 40 + text: "text-slate-900 dark:text-slate-100", 36 41 }, 37 42 rare: { 38 43 border: "border-amber-500 dark:border-amber-400", 39 - text: "text-amber-600 dark:text-amber-400", 44 + bg: "bg-gradient-to-r from-amber-300 to-amber-500 dark:from-amber-600 dark:to-amber-400", 45 + text: "text-amber-900 dark:text-amber-100", 40 46 }, 41 47 mythic: { 42 48 border: "border-orange-500 dark:border-orange-400", 43 - text: "text-orange-600 dark:text-orange-400", 49 + bg: "bg-gradient-to-r from-orange-400 to-red-500 dark:from-orange-500 dark:to-red-400", 50 + text: "text-orange-900 dark:text-orange-100", 44 51 }, 45 52 special: { 46 53 border: "border-purple-500 dark:border-purple-400", 47 - text: "text-purple-600 dark:text-purple-400", 54 + bg: "bg-gradient-to-r from-purple-400 to-purple-500 dark:from-purple-500 dark:to-purple-400", 55 + text: "text-purple-900 dark:text-purple-100", 48 56 }, 49 57 bonus: { 50 58 border: "border-fuchsia-500 dark:border-fuchsia-400", 51 - text: "text-fuchsia-600 dark:text-fuchsia-400", 59 + bg: "bg-gradient-to-r from-fuchsia-400 to-fuchsia-500 dark:from-fuchsia-500 dark:to-fuchsia-400", 60 + text: "text-fuchsia-900 dark:text-fuchsia-100", 52 61 }, 53 62 }; 54 63 55 - interface CardFaceWireframeProps { 64 + // Rarity symbols (approximate) 65 + const RARITY_SYMBOL: Record<Rarity, string> = { 66 + common: "●", 67 + uncommon: "◆", 68 + rare: "★", 69 + mythic: "✦", 70 + special: "✧", 71 + bonus: "✦", 72 + }; 73 + 74 + interface FaceContentProps { 56 75 face: CardFace; 57 - rarity?: Rarity; 58 - showFooter?: boolean; 59 - set?: string; 60 - collectorNumber?: string; 61 - artist?: string; 62 - className?: string; 76 + showStats?: boolean; 63 77 } 64 78 65 - function CardFaceWireframe({ 66 - face, 67 - rarity = "common", 68 - showFooter = true, 69 - set, 70 - collectorNumber, 71 - artist, 72 - className, 73 - }: CardFaceWireframeProps) { 74 - const rarityStyle = RARITY_COLORS[rarity] || RARITY_COLORS.common; 79 + function FaceContent({ face, showStats = true }: FaceContentProps) { 75 80 const hasPT = face.power !== undefined && face.toughness !== undefined; 76 81 const hasLoyalty = face.loyalty !== undefined; 77 82 const hasDefense = face.defense !== undefined; 78 - const hasStats = hasPT || hasLoyalty || hasDefense; 83 + const hasStats = showStats && (hasPT || hasLoyalty || hasDefense); 84 + const isPlaneswalker = face.type_line?.includes("Planeswalker"); 79 85 80 86 return ( 81 - <div 82 - className={`flex flex-col bg-gray-50 dark:bg-slate-800 ${className ?? ""}`} 83 - > 84 - {/* Header: Name + Mana Cost */} 85 - <div className="flex items-center justify-between gap-2 px-2 py-1.5 border-b border-gray-200 dark:border-slate-700"> 86 - <span 87 - className={`font-semibold text-sm truncate ${rarityStyle.text}`} 88 - title={face.name} 89 - > 90 - {face.name} 91 - </span> 92 - {face.mana_cost && <ManaCost cost={face.mana_cost} size="small" />} 93 - </div> 94 - 87 + <div className="flex flex-col h-full"> 95 88 {/* Type Line */} 96 89 {face.type_line && ( 97 - <div className="px-2 py-1 text-xs text-gray-700 dark:text-gray-300 border-b border-gray-200 dark:border-slate-700 truncate"> 90 + <div className="px-[4cqw] py-[1.5cqw] text-[5cqw] tracking-tight text-gray-700 dark:text-gray-300 border-b border-gray-300 dark:border-slate-600 truncate bg-gray-100 dark:bg-slate-700"> 98 91 {face.type_line} 99 92 </div> 100 93 )} 101 94 102 95 {/* Oracle Text */} 103 - <div className="flex-1 px-2 py-1.5 text-xs text-gray-800 dark:text-gray-200 leading-relaxed overflow-hidden"> 96 + <div className="flex-1 px-[4cqw] py-[2cqw] text-[4.5cqw] leading-snug tracking-tight text-gray-800 dark:text-gray-200 overflow-hidden"> 104 97 {face.oracle_text ? ( 105 - <OracleText text={face.oracle_text} /> 98 + isPlaneswalker ? ( 99 + <PlaneswalkerAbilities text={face.oracle_text} /> 100 + ) : ( 101 + <OracleText text={face.oracle_text} /> 102 + ) 106 103 ) : ( 107 104 <span className="text-gray-400 dark:text-gray-500 italic"> 108 105 No text ··· 112 109 113 110 {/* Stats (P/T, Loyalty, Defense) */} 114 111 {hasStats && ( 115 - <div className="flex justify-end px-2 py-1 border-t border-gray-200 dark:border-slate-700"> 116 - <span className="text-sm font-bold text-gray-900 dark:text-white bg-gray-200 dark:bg-slate-700 px-1.5 py-0.5 rounded"> 112 + <div className="flex justify-end px-[2cqw] py-[1cqw]"> 113 + <span className="text-[6cqw] font-bold text-gray-900 dark:text-white bg-gray-200 dark:bg-slate-600 px-[2cqw] py-[0.5cqw] rounded"> 117 114 {hasPT && `${face.power}/${face.toughness}`} 118 115 {hasLoyalty && face.loyalty} 119 116 {hasDefense && face.defense} 120 117 </span> 121 118 </div> 122 119 )} 120 + </div> 121 + ); 122 + } 123 123 124 - {/* Footer: Set, Collector Number, Artist */} 125 - {showFooter && (set || collectorNumber || artist) && ( 126 - <div className="flex items-center justify-between gap-2 px-2 py-1 text-[10px] text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-slate-700"> 127 - <span className="truncate"> 128 - {set && collectorNumber 129 - ? `${set.toUpperCase()} #${collectorNumber}` 130 - : set?.toUpperCase()} 124 + function PlaneswalkerAbilities({ text }: { text: string }) { 125 + const lines = text.split("\n"); 126 + 127 + return ( 128 + <div className="flex flex-col gap-[1.5cqw]"> 129 + {lines.map((line) => { 130 + const loyaltyMatch = line.match(/^([+−-]?\d+):\s*(.*)$/); 131 + if (loyaltyMatch) { 132 + const [, cost, ability] = loyaltyMatch; 133 + return ( 134 + <div key={line} className="flex gap-[1.5cqw] items-start"> 135 + <span className="text-[4cqw] font-bold bg-gray-300 dark:bg-slate-600 px-[1.5cqw] rounded shrink-0"> 136 + {cost} 137 + </span> 138 + <span className="text-[4cqw]"> 139 + <OracleText text={ability} /> 140 + </span> 141 + </div> 142 + ); 143 + } 144 + return ( 145 + <span key={line} className="text-[4cqw]"> 146 + <OracleText text={line} /> 131 147 </span> 132 - {artist && ( 133 - <span className="truncate italic" title={artist}> 134 - {artist} 135 - </span> 136 - )} 137 - </div> 138 - )} 148 + ); 149 + })} 150 + </div> 151 + ); 152 + } 153 + 154 + function isAftermathCard(card: Card): boolean { 155 + const faces = getAllFaces(card); 156 + return ( 157 + card.layout === "split" && 158 + faces.length > 1 && 159 + (faces[1].oracle_text?.includes("Aftermath") ?? false) 160 + ); 161 + } 162 + 163 + function CardFooter({ card }: { card: Card }) { 164 + const rarity = card.rarity || "common"; 165 + const rarityStyle = RARITY_COLORS[rarity] || RARITY_COLORS.common; 166 + const collectorNum = card.collector_number; 167 + 168 + return ( 169 + <div className="flex items-center justify-between px-[3cqw] py-[1.5cqw] text-[3.5cqw] tracking-tight text-gray-600 dark:text-gray-400 border-t border-gray-300 dark:border-slate-600 bg-gray-50 dark:bg-slate-800"> 170 + <div className="flex items-center gap-[1.5cqw]"> 171 + <span className={rarityStyle.text}>{RARITY_SYMBOL[rarity]}</span> 172 + <span>{card.set?.toUpperCase()}</span> 173 + {collectorNum && <span>#{collectorNum}</span>} 174 + </div> 175 + {card.artist && <span className="truncate italic">{card.artist}</span>} 139 176 </div> 140 177 ); 141 178 } 142 179 143 - export function CardWireframe({ 144 - card, 145 - mode = "dense", 146 - sizing = "fixed", 147 - className, 148 - }: CardWireframeProps) { 180 + export function CardWireframe({ card, className }: CardWireframeProps) { 149 181 const [isFlipped, setIsFlipped] = useState(false); 150 182 151 183 const faces = getAllFaces(card); ··· 153 185 const flippable = canFlip(card); 154 186 const flipBehavior = getFlipBehavior(card.layout); 155 187 const hasBack = hasBackImage(card.layout); 188 + const isAdventure = card.layout === "adventure"; 189 + const isAftermath = isAftermathCard(card); 190 + const isFlipCard = card.layout === "flip"; 191 + const isSplit = card.layout === "split" && !isAftermath; 156 192 157 193 const rarity = card.rarity || "common"; 158 194 const rarityStyle = RARITY_COLORS[rarity] || RARITY_COLORS.common; 159 195 160 - // For art/placeholder, use artist from card or first face 161 - const artist = card.artist || faces[0]?.artist; 196 + const handleFlip = (e: React.MouseEvent) => { 197 + e.preventDefault(); 198 + e.stopPropagation(); 199 + setIsFlipped(!isFlipped); 200 + }; 162 201 163 - // Dense mode: stack faces vertically 164 - if (mode === "dense") { 165 - const baseClasses = `border-2 ${rarityStyle.border} rounded-lg overflow-hidden ${className ?? ""}`; 202 + // Use container queries for proportional sizing 203 + const baseClasses = `@container aspect-[5/7] border-2 ${rarityStyle.border} rounded-[4.75%/3.5%] overflow-hidden bg-gray-100 dark:bg-slate-900 ${className ?? ""}`; 166 204 167 - if (sizing === "content") { 168 - return ( 169 - <div className={baseClasses}> 170 - {faces.map((face, i) => ( 171 - <CardFaceWireframe 172 - key={face.name} 173 - face={face} 174 - rarity={rarity} 175 - showFooter={i === faces.length - 1} 176 - set={card.set} 177 - collectorNumber={card.collector_number} 178 - artist={artist} 179 - className={ 180 - i > 0 181 - ? "border-t-2 border-dashed border-gray-300 dark:border-slate-600" 182 - : "" 183 - } 184 - /> 185 - ))} 186 - </div> 187 - ); 188 - } 205 + // Flip cards (Kamigawa style): show both faces, one upside down 206 + if (isFlipCard && isMultiFaced) { 207 + const topFace = isFlipped ? faces[1] : faces[0]; 208 + const bottomFace = isFlipped ? faces[0] : faces[1]; 189 209 190 - // Fixed sizing with scroll + shadow indicators 191 210 return ( 192 - <div className={`${baseClasses} relative`}> 211 + <div className="relative group"> 193 212 <div 194 - className="overflow-y-auto max-h-48 scrollbar-thin" 213 + className={`${baseClasses} flex flex-col motion-safe:transition-transform motion-safe:duration-500`} 195 214 style={{ 196 - maskImage: 197 - "linear-gradient(to bottom, transparent, black 8px, black calc(100% - 8px), transparent)", 198 - WebkitMaskImage: 199 - "linear-gradient(to bottom, transparent, black 8px, black calc(100% - 8px), transparent)", 215 + transform: isFlipped ? "rotate(180deg)" : "rotate(0deg)", 200 216 }} 201 217 > 202 - {faces.map((face, i) => ( 203 - <CardFaceWireframe 204 - key={face.name} 205 - face={face} 206 - rarity={rarity} 207 - showFooter={i === faces.length - 1} 208 - set={card.set} 209 - collectorNumber={card.collector_number} 210 - artist={artist} 211 - className={ 212 - i > 0 213 - ? "border-t-2 border-dashed border-gray-300 dark:border-slate-600" 214 - : "" 215 - } 216 - /> 217 - ))} 218 + {/* Top half (current face) */} 219 + <div className="h-1/2 flex flex-col border-b-2 border-gray-300 dark:border-slate-600"> 220 + {/* Title bar */} 221 + <div 222 + className={`flex items-center justify-between gap-[1cqw] px-[3cqw] py-[1cqw] ${rarityStyle.bg}`} 223 + > 224 + <span 225 + className={`font-bold text-[6cqw] tracking-tight truncate ${rarityStyle.text}`} 226 + > 227 + {topFace.name} 228 + </span> 229 + {topFace.mana_cost && ( 230 + <ManaCost 231 + cost={topFace.mana_cost} 232 + className="w-[5cqw] h-[5cqw]" 233 + /> 234 + )} 235 + </div> 236 + <div className="flex-1 overflow-hidden"> 237 + <FaceContent face={topFace} /> 238 + </div> 239 + </div> 240 + 241 + {/* Bottom half (flipped face, shown upside down) */} 242 + <div className="h-1/2 flex flex-col rotate-180"> 243 + <div 244 + className={`flex items-center justify-between gap-[1cqw] px-[3cqw] py-[1cqw] ${rarityStyle.bg}`} 245 + > 246 + <span 247 + className={`font-bold text-[6cqw] tracking-tight truncate ${rarityStyle.text}`} 248 + > 249 + {bottomFace.name} 250 + </span> 251 + {bottomFace.mana_cost && ( 252 + <ManaCost 253 + cost={bottomFace.mana_cost} 254 + className="w-[5cqw] h-[5cqw]" 255 + /> 256 + )} 257 + </div> 258 + <div className="flex-1 overflow-hidden"> 259 + <FaceContent face={bottomFace} showStats={false} /> 260 + </div> 261 + </div> 218 262 </div> 263 + 264 + {flippable && ( 265 + <button 266 + type="button" 267 + onClick={handleFlip} 268 + className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 p-[2cqw] rounded-full bg-black/60 text-white opacity-60 hover:opacity-100 transition-opacity z-10" 269 + aria-label="Flip card" 270 + > 271 + <RotateCcw className="w-[4cqw] h-[4cqw]" /> 272 + </button> 273 + )} 219 274 </div> 220 275 ); 221 276 } 222 277 223 - // Art or Placeholder mode: full card aspect ratio 224 - const showArt = mode === "art"; 225 - const rotateScale = 5 / 7; 278 + // Split cards (fuse style): both halves side by side, rotated 279 + if (isSplit && isMultiFaced) { 280 + const rotateScale = 5 / 7; 226 281 227 - // For single-faced or transform/MDFC cards 228 - const renderFaceContent = (faceIndex: number) => { 229 - const face = faces[faceIndex] || faces[0]; 230 - return ( 231 - <CardFaceWireframe 232 - face={face} 233 - rarity={rarity} 234 - showFooter={true} 235 - set={card.set} 236 - collectorNumber={card.collector_number} 237 - artist={face.artist || artist} 238 - className="flex-1" 239 - /> 240 - ); 241 - }; 242 - 243 - const handleFlip = (e: React.MouseEvent) => { 244 - e.preventDefault(); 245 - e.stopPropagation(); 246 - setIsFlipped(!isFlipped); 247 - }; 248 - 249 - // Button position varies by card type 250 - const buttonPosition = 251 - flipBehavior === "rotate90" 252 - ? "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" 253 - : flipBehavior === "rotate180" 254 - ? "top-[15%] right-[15%]" 255 - : "top-[15%] right-[8%]"; 256 - 257 - const baseClasses = `aspect-[5/7] border-2 ${rarityStyle.border} rounded-[4.75%/3.5%] overflow-hidden bg-gray-100 dark:bg-slate-900 ${className ?? ""}`; 258 - 259 - // For split cards (rotate90) - show both halves side by side, then rotate 260 - if (flipBehavior === "rotate90" && isMultiFaced) { 261 282 return ( 262 283 <div className="relative group"> 263 284 <div 264 - className={`${baseClasses} flex flex-col motion-safe:transition-transform motion-safe:duration-500 motion-safe:ease-in-out`} 285 + className={`${baseClasses} flex flex-row motion-safe:transition-transform motion-safe:duration-500`} 265 286 style={{ 266 287 transformOrigin: "center center", 267 288 transform: isFlipped ··· 269 290 : "rotate(0deg)", 270 291 }} 271 292 > 272 - {/* Art area or placeholder */} 273 - <div className="h-[40%] bg-gray-200 dark:bg-slate-700 flex items-center justify-center"> 274 - {showArt ? ( 275 - <img 276 - src={getImageUri(card.id, "art_crop", "front")} 277 - alt="" 278 - className="w-full h-full object-cover" 279 - loading="lazy" 280 - /> 281 - ) : ( 282 - <span className="text-gray-400 dark:text-gray-500 text-xs"> 283 - {card.type_line || "Split Card"} 293 + {/* Left half */} 294 + <div className="w-1/2 flex flex-col border-r border-gray-300 dark:border-slate-600"> 295 + <div 296 + className={`flex items-center justify-between gap-[1cqw] px-[2cqw] py-[1cqw] ${rarityStyle.bg}`} 297 + > 298 + <span 299 + className={`font-bold text-[5.5cqw] tracking-tight truncate ${rarityStyle.text}`} 300 + > 301 + {faces[0].name} 284 302 </span> 285 - )} 303 + {faces[0].mana_cost && ( 304 + <ManaCost 305 + cost={faces[0].mana_cost} 306 + className="w-[5cqw] h-[5cqw]" 307 + /> 308 + )} 309 + </div> 310 + <div className="flex-1 overflow-hidden"> 311 + <FaceContent face={faces[0]} showStats={false} /> 312 + </div> 286 313 </div> 287 - {/* Show both faces stacked in body */} 288 - <div className="h-[60%] flex flex-col overflow-hidden"> 289 - {faces.map((face, i) => ( 290 - <CardFaceWireframe 291 - key={face.name} 292 - face={face} 293 - rarity={rarity} 294 - showFooter={i === faces.length - 1} 295 - set={card.set} 296 - collectorNumber={card.collector_number} 297 - artist={face.artist || artist} 298 - className={`flex-1 ${i > 0 ? "border-t border-dashed border-gray-300 dark:border-slate-600" : ""}`} 299 - /> 300 - ))} 314 + 315 + {/* Right half */} 316 + <div className="w-1/2 flex flex-col"> 317 + <div 318 + className={`flex items-center justify-between gap-[1cqw] px-[2cqw] py-[1cqw] ${rarityStyle.bg}`} 319 + > 320 + <span 321 + className={`font-bold text-[5.5cqw] tracking-tight truncate ${rarityStyle.text}`} 322 + > 323 + {faces[1].name} 324 + </span> 325 + {faces[1].mana_cost && ( 326 + <ManaCost 327 + cost={faces[1].mana_cost} 328 + className="w-[5cqw] h-[5cqw]" 329 + /> 330 + )} 331 + </div> 332 + <div className="flex-1 overflow-hidden"> 333 + <FaceContent face={faces[1]} showStats={false} /> 334 + </div> 335 + <CardFooter card={card} /> 301 336 </div> 302 337 </div> 338 + 303 339 {flippable && ( 304 340 <button 305 341 type="button" 306 342 onClick={handleFlip} 307 - className={`absolute ${buttonPosition} p-2 rounded-full bg-black/60 text-white opacity-60 hover:opacity-100 transition-opacity z-10`} 343 + className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 p-2 rounded-full bg-black/60 text-white opacity-60 hover:opacity-100 transition-opacity z-10" 308 344 aria-label="Rotate card" 309 345 > 310 346 <RotateCcw className="w-4 h-4" /> ··· 314 350 ); 315 351 } 316 352 317 - // For flip cards (Kamigawa style - rotate180) 318 - if (flipBehavior === "rotate180" && isMultiFaced) { 353 + // Aftermath cards: top half normal, bottom half rotated 90° 354 + if (isAftermath && isMultiFaced) { 319 355 return ( 320 - <div className="relative group"> 321 - <div 322 - className={`${baseClasses} flex flex-col motion-safe:transition-transform motion-safe:duration-500 motion-safe:ease-in-out`} 323 - style={{ 324 - transformOrigin: "center center", 325 - transform: isFlipped ? "rotate(180deg)" : "rotate(0deg)", 326 - }} 327 - > 328 - {/* Art area or placeholder */} 329 - <div className="h-[40%] bg-gray-200 dark:bg-slate-700 flex items-center justify-center"> 330 - {showArt ? ( 331 - <img 332 - src={getImageUri(card.id, "art_crop", "front")} 333 - alt="" 334 - className="w-full h-full object-cover" 335 - loading="lazy" 356 + <div className={`${baseClasses} flex flex-col`}> 357 + {/* Top half - main spell */} 358 + <div className="h-[55%] flex flex-col border-b-2 border-gray-300 dark:border-slate-600"> 359 + <div 360 + className={`flex items-center justify-between gap-[1cqw] px-[3cqw] py-[1cqw] ${rarityStyle.bg}`} 361 + > 362 + <span 363 + className={`font-bold text-[6cqw] tracking-tight truncate ${rarityStyle.text}`} 364 + > 365 + {faces[0].name} 366 + </span> 367 + {faces[0].mana_cost && ( 368 + <ManaCost 369 + cost={faces[0].mana_cost} 370 + className="w-[5cqw] h-[5cqw]" 336 371 /> 337 - ) : ( 338 - <span className="text-gray-400 dark:text-gray-500 text-xs"> 339 - {faces[0].type_line || "Flip Card"} 340 - </span> 341 372 )} 342 373 </div> 343 - {/* Show primary face */} 344 - <div className="h-[60%] flex flex-col overflow-hidden"> 345 - {renderFaceContent(0)} 374 + <div className="flex-1 overflow-hidden"> 375 + <FaceContent face={faces[0]} showStats={false} /> 346 376 </div> 347 377 </div> 348 - {flippable && ( 349 - <button 350 - type="button" 351 - onClick={handleFlip} 352 - className={`absolute ${buttonPosition} p-2 rounded-full bg-black/60 text-white opacity-60 hover:opacity-100 transition-opacity z-10`} 353 - aria-label="Flip card" 378 + 379 + {/* Bottom half - aftermath (rotated) */} 380 + <div className="h-[45%] flex overflow-hidden"> 381 + <div 382 + className="flex flex-col w-full origin-center" 383 + style={{ 384 + transform: "rotate(-90deg) translateX(-50%)", 385 + width: "140%", 386 + }} 354 387 > 355 - <RotateCcw className="w-4 h-4" /> 356 - </button> 357 - )} 388 + <div 389 + className={`flex items-center justify-between gap-[1cqw] px-[2cqw] py-[0.5cqw] ${rarityStyle.bg}`} 390 + > 391 + <span 392 + className={`font-bold text-[4.5cqw] tracking-tight truncate ${rarityStyle.text}`} 393 + > 394 + {faces[1].name} 395 + </span> 396 + {faces[1].mana_cost && ( 397 + <ManaCost 398 + cost={faces[1].mana_cost} 399 + className="w-[5cqw] h-[5cqw]" 400 + /> 401 + )} 402 + </div> 403 + <FaceContent face={faces[1]} showStats={false} /> 404 + </div> 405 + </div> 358 406 </div> 359 407 ); 360 408 } 361 409 362 - // For transform/MDFC (3D flip to back) 410 + // Adventure cards: main creature with adventure box 411 + if (isAdventure && isMultiFaced) { 412 + const mainFace = faces[0]; 413 + const adventureFace = faces[1]; 414 + 415 + return ( 416 + <div className={`${baseClasses} flex flex-col`}> 417 + {/* Title bar */} 418 + <div 419 + className={`flex items-center justify-between gap-[1cqw] px-[3cqw] py-[1cqw] ${rarityStyle.bg}`} 420 + > 421 + <span 422 + className={`font-bold text-[6cqw] tracking-tight truncate ${rarityStyle.text}`} 423 + > 424 + {mainFace.name} 425 + </span> 426 + {mainFace.mana_cost && ( 427 + <ManaCost cost={mainFace.mana_cost} className="w-[5cqw] h-[5cqw]" /> 428 + )} 429 + </div> 430 + 431 + {/* Art placeholder with adventure box */} 432 + <div className="h-[35%] bg-gray-200 dark:bg-slate-700 flex items-center justify-center relative"> 433 + <span className="text-[5cqw] text-gray-400 dark:text-gray-500"> 434 + {mainFace.type_line} 435 + </span> 436 + {/* Adventure spell box */} 437 + <div className="absolute bottom-[2cqw] left-[2cqw] right-[40%] bg-gray-50/95 dark:bg-slate-800/95 rounded border border-gray-300 dark:border-slate-600 p-[1.5cqw]"> 438 + <div className="flex items-center justify-between gap-[1cqw] mb-[0.5cqw]"> 439 + <span className="font-bold text-[4cqw] tracking-tight text-gray-900 dark:text-white truncate"> 440 + {adventureFace.name} 441 + </span> 442 + {adventureFace.mana_cost && ( 443 + <ManaCost 444 + cost={adventureFace.mana_cost} 445 + className="w-[4cqw] h-[4cqw]" 446 + /> 447 + )} 448 + </div> 449 + <div className="text-[3.5cqw] text-gray-600 dark:text-gray-400 truncate"> 450 + {adventureFace.type_line} 451 + </div> 452 + {adventureFace.oracle_text && ( 453 + <div className="text-[3.5cqw] leading-tight tracking-tight text-gray-800 dark:text-gray-200 line-clamp-2"> 454 + <OracleText text={adventureFace.oracle_text} /> 455 + </div> 456 + )} 457 + </div> 458 + </div> 459 + 460 + {/* Main card body */} 461 + <div className="flex-1 flex flex-col overflow-hidden"> 462 + <FaceContent face={mainFace} /> 463 + </div> 464 + 465 + <CardFooter card={card} /> 466 + </div> 467 + ); 468 + } 469 + 470 + // Transform/MDFC: 3D flip between faces 363 471 if (flipBehavior === "transform" && hasBack && isMultiFaced) { 364 472 return ( 365 473 <div className="relative group"> 366 474 <div 367 - className="w-full motion-safe:transition-transform motion-safe:duration-500 motion-safe:ease-in-out" 475 + className="w-full motion-safe:transition-transform motion-safe:duration-500" 368 476 style={{ 369 477 transformStyle: "preserve-3d", 370 478 transform: isFlipped ? "rotateY(180deg)" : "rotateY(0deg)", 371 479 }} 372 480 > 373 - {/* Front face */} 481 + {/* Front */} 374 482 <div 375 483 className={`${baseClasses} flex flex-col`} 376 484 style={{ backfaceVisibility: "hidden" }} 377 485 > 378 - <div className="h-[40%] bg-gray-200 dark:bg-slate-700 flex items-center justify-center overflow-hidden"> 379 - {showArt ? ( 380 - <img 381 - src={getImageUri(card.id, "art_crop", "front")} 382 - alt="" 383 - className="w-full h-full object-cover" 384 - loading="lazy" 486 + <div 487 + className={`flex items-center justify-between gap-[1cqw] px-[3cqw] py-[1cqw] ${rarityStyle.bg}`} 488 + > 489 + <span 490 + className={`font-bold text-[6cqw] tracking-tight truncate ${rarityStyle.text}`} 491 + > 492 + {faces[0].name} 493 + </span> 494 + {faces[0].mana_cost && ( 495 + <ManaCost 496 + cost={faces[0].mana_cost} 497 + className="w-[5cqw] h-[5cqw]" 385 498 /> 386 - ) : ( 387 - <span className="text-gray-400 dark:text-gray-500 text-xs"> 388 - {faces[0].type_line || "Front"} 389 - </span> 390 499 )} 391 500 </div> 392 - <div className="h-[60%] flex flex-col overflow-hidden"> 393 - {renderFaceContent(0)} 501 + <div className="h-[35%] bg-gray-200 dark:bg-slate-700 flex items-center justify-center"> 502 + <span className="text-[5cqw] text-gray-400 dark:text-gray-500"> 503 + {faces[0].type_line} 504 + </span> 394 505 </div> 506 + <div className="flex-1 flex flex-col overflow-hidden"> 507 + <FaceContent face={faces[0]} /> 508 + </div> 509 + <CardFooter card={card} /> 395 510 </div> 396 511 397 - {/* Back face */} 512 + {/* Back */} 398 513 <div 399 514 className={`${baseClasses} flex flex-col absolute inset-0`} 400 515 style={{ ··· 402 517 transform: "rotateY(180deg)", 403 518 }} 404 519 > 405 - <div className="h-[40%] bg-gray-200 dark:bg-slate-700 flex items-center justify-center overflow-hidden"> 406 - {showArt ? ( 407 - <img 408 - src={getImageUri(card.id, "art_crop", "back")} 409 - alt="" 410 - className="w-full h-full object-cover" 411 - loading="lazy" 520 + <div 521 + className={`flex items-center justify-between gap-[1cqw] px-[3cqw] py-[1cqw] ${rarityStyle.bg}`} 522 + > 523 + <span 524 + className={`font-bold text-[6cqw] tracking-tight truncate ${rarityStyle.text}`} 525 + > 526 + {faces[1]?.name} 527 + </span> 528 + {faces[1]?.mana_cost && ( 529 + <ManaCost 530 + cost={faces[1].mana_cost} 531 + className="w-[5cqw] h-[5cqw]" 412 532 /> 413 - ) : ( 414 - <span className="text-gray-400 dark:text-gray-500 text-xs"> 415 - {faces[1]?.type_line || "Back"} 416 - </span> 417 533 )} 418 534 </div> 419 - <div className="h-[60%] flex flex-col overflow-hidden"> 420 - {renderFaceContent(1)} 535 + <div className="h-[35%] bg-gray-200 dark:bg-slate-700 flex items-center justify-center"> 536 + <span className="text-[5cqw] text-gray-400 dark:text-gray-500"> 537 + {faces[1]?.type_line} 538 + </span> 421 539 </div> 540 + <div className="flex-1 flex flex-col overflow-hidden"> 541 + <FaceContent face={faces[1] || faces[0]} /> 542 + </div> 543 + <CardFooter card={card} /> 422 544 </div> 423 545 </div> 546 + 424 547 {flippable && ( 425 548 <button 426 549 type="button" 427 550 onClick={handleFlip} 428 - className={`absolute ${buttonPosition} p-2 rounded-full bg-black/60 text-white opacity-60 hover:opacity-100 transition-opacity z-10`} 551 + className="absolute top-[15%] right-[8%] p-2 rounded-full bg-black/60 text-white opacity-60 hover:opacity-100 transition-opacity z-10" 429 552 aria-label="Transform card" 430 553 > 431 554 <RotateCcw className="w-4 h-4" /> ··· 435 558 ); 436 559 } 437 560 438 - // Default: single-faced card or no flip behavior 561 + // Default: single-faced card 439 562 return ( 440 563 <div className={`${baseClasses} flex flex-col`}> 441 - {/* Art area or placeholder */} 442 - <div className="h-[40%] bg-gray-200 dark:bg-slate-700 flex items-center justify-center overflow-hidden"> 443 - {showArt ? ( 444 - <img 445 - src={getImageUri(card.id, "art_crop", "front")} 446 - alt="" 447 - className="w-full h-full object-cover" 448 - loading="lazy" 449 - /> 450 - ) : ( 451 - <span className="text-gray-400 dark:text-gray-500 text-xs"> 452 - {faces[0].type_line || "Card"} 453 - </span> 564 + {/* Title bar */} 565 + <div 566 + className={`flex items-center justify-between gap-[1cqw] px-[3cqw] py-[1cqw] ${rarityStyle.bg}`} 567 + > 568 + <span 569 + className={`font-bold text-[6cqw] tracking-tight truncate ${rarityStyle.text}`} 570 + > 571 + {faces[0].name} 572 + </span> 573 + {faces[0].mana_cost && ( 574 + <ManaCost cost={faces[0].mana_cost} className="w-[5cqw] h-[5cqw]" /> 454 575 )} 455 576 </div> 577 + 578 + {/* Art placeholder */} 579 + <div className="h-[35%] bg-gray-200 dark:bg-slate-700 flex items-center justify-center"> 580 + <span className="text-[5cqw] text-gray-400 dark:text-gray-500"> 581 + {faces[0].type_line} 582 + </span> 583 + </div> 584 + 456 585 {/* Card body */} 457 - <div className="h-[60%] flex flex-col overflow-hidden"> 458 - {renderFaceContent(0)} 586 + <div className="flex-1 flex flex-col overflow-hidden"> 587 + <FaceContent face={faces[0]} /> 459 588 </div> 589 + 590 + <CardFooter card={card} /> 460 591 </div> 461 592 ); 462 593 }
+3 -1
src/components/ManaCost.tsx
··· 9 9 interface ManaCostProps { 10 10 cost: string; 11 11 size?: "small" | "medium" | "large"; 12 + className?: string; 12 13 } 13 14 14 - export function ManaCost({ cost, size = "medium" }: ManaCostProps) { 15 + export function ManaCost({ cost, size = "medium", className }: ManaCostProps) { 15 16 // Parse mana cost string like "{2}{U}{B}" into symbols 16 17 const symbols = Array.from( 17 18 cost.matchAll(/\{([^}]+)\}/g), ··· 30 31 key={i} 31 32 symbol={symbol} 32 33 size={size} 34 + className={className} 33 35 /> 34 36 ))} 35 37 </div>
+41 -117
src/routes/components/card-wireframe.tsx
··· 12 12 component: CardWireframeDemo, 13 13 }); 14 14 15 - type Mode = "dense" | "art" | "placeholder"; 16 - type Sizing = "fixed" | "content"; 17 - 18 15 const SAMPLE_QUERIES = [ 19 - { label: "Creature", query: "layout:normal t:creature o:flying", limit: 2 }, 20 - { label: "Instant", query: "layout:normal t:instant mv<=2", limit: 2 }, 21 - { label: "Planeswalker", query: "layout:normal t:planeswalker", limit: 2 }, 22 - { label: "Transform", query: "layout:transform", limit: 2 }, 23 - { label: "Modal DFC", query: "layout:modal_dfc", limit: 2 }, 24 - { label: "Split", query: "layout:split", limit: 2 }, 25 - { label: "Flip", query: "layout:flip", limit: 2 }, 26 - { label: "Adventure", query: "layout:adventure", limit: 2 }, 16 + { 17 + label: "Creature", 18 + query: "layout:normal t:creature o:flying -is:digital", 19 + limit: 2, 20 + }, 21 + { 22 + label: "Instant", 23 + query: "layout:normal t:instant mv<=2 -is:digital", 24 + limit: 2, 25 + }, 26 + { 27 + label: "Planeswalker", 28 + query: "layout:normal t:planeswalker -is:digital", 29 + limit: 2, 30 + }, 31 + { label: "Transform", query: "layout:transform -is:digital", limit: 2 }, 32 + { label: "Modal DFC", query: "layout:modal_dfc -is:digital", limit: 2 }, 33 + { label: "Split", query: "layout:split -is:digital", limit: 2 }, 34 + { label: "Flip", query: "layout:flip -is:digital", limit: 2 }, 35 + { label: "Adventure", query: "layout:adventure -is:digital", limit: 2 }, 27 36 ]; 28 37 29 38 function CardWireframeDemo() { 30 - const [mode, setMode] = useState<Mode>("placeholder"); 31 - const [sizing, setSizing] = useState<Sizing>("fixed"); 32 39 const [showImages, setShowImages] = useState(false); 33 40 34 41 const queryResults = useQueries({ ··· 63 70 CardWireframe 64 71 </h1> 65 72 <p className="text-gray-600 dark:text-gray-400"> 66 - Text-based card display with multiple modes. Great for loading 67 - states, dense views, or accessibility. 73 + Text-based card placeholder. Renders card data matching the card's 74 + layout while images load. 68 75 </p> 69 76 </div> 70 77 ··· 72 79 <div className="mb-8 p-4 bg-gray-100 dark:bg-slate-800 rounded-lg flex flex-wrap gap-6"> 73 80 <div> 74 81 <span className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> 75 - Mode 82 + Compare 76 83 </span> 77 - <div className="flex gap-2"> 78 - {(["dense", "placeholder", "art"] as const).map((m) => ( 79 - <button 80 - key={m} 81 - type="button" 82 - onClick={() => setMode(m)} 83 - className={`px-3 py-1.5 text-sm rounded-md transition-colors ${ 84 - mode === m 85 - ? "bg-cyan-500 text-white" 86 - : "bg-white dark:bg-slate-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-slate-600" 87 - }`} 88 - > 89 - {m} 90 - </button> 91 - ))} 92 - </div> 93 - </div> 94 - 95 - <div> 96 - <span className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> 97 - Sizing 98 - </span> 99 - <div className="flex gap-2"> 100 - {(["fixed", "content"] as const).map((s) => ( 101 - <button 102 - key={s} 103 - type="button" 104 - onClick={() => setSizing(s)} 105 - className={`px-3 py-1.5 text-sm rounded-md transition-colors ${ 106 - sizing === s 107 - ? "bg-cyan-500 text-white" 108 - : "bg-white dark:bg-slate-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-slate-600" 109 - }`} 110 - > 111 - {s} 112 - </button> 113 - ))} 114 - </div> 84 + <button 85 + type="button" 86 + onClick={() => setShowImages(!showImages)} 87 + className={`px-3 py-1.5 text-sm rounded-md transition-colors ${ 88 + showImages 89 + ? "bg-cyan-500 text-white" 90 + : "bg-white dark:bg-slate-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-slate-600" 91 + }`} 92 + > 93 + {showImages ? "Showing images" : "Show images"} 94 + </button> 115 95 </div> 116 - 117 - {mode === "placeholder" && ( 118 - <div> 119 - <span className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> 120 - Compare 121 - </span> 122 - <button 123 - type="button" 124 - onClick={() => setShowImages(!showImages)} 125 - className={`px-3 py-1.5 text-sm rounded-md transition-colors ${ 126 - showImages 127 - ? "bg-cyan-500 text-white" 128 - : "bg-white dark:bg-slate-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-slate-600" 129 - }`} 130 - > 131 - {showImages ? "Showing images" : "Show images"} 132 - </button> 133 - </div> 134 - )} 135 96 </div> 136 97 137 98 {/* Loading state */} ··· 154 115 <h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4"> 155 116 {label} 156 117 </h2> 157 - <div 158 - className={`grid gap-4 ${ 159 - mode === "dense" 160 - ? "grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5" 161 - : "grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5" 162 - }`} 163 - > 118 + <div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> 164 119 {cards.map((card) => ( 165 120 <div key={card.id}> 166 - {mode === "placeholder" && showImages ? ( 121 + {showImages ? ( 167 122 <CardImage 168 123 card={card} 169 124 size="normal" 170 125 className="w-full" 171 126 /> 172 127 ) : ( 173 - <CardWireframe 174 - card={card} 175 - mode={mode} 176 - sizing={sizing} 177 - /> 128 + <CardWireframe card={card} /> 178 129 )} 179 130 <p className="mt-1 text-xs text-gray-500 dark:text-gray-400 truncate"> 180 131 {card.name} ··· 218 169 <td className="py-2 pr-4">required</td> 219 170 <td className="py-2">The card data to display</td> 220 171 </tr> 221 - <tr className="border-b border-gray-100 dark:border-slate-800"> 222 - <td className="py-2 pr-4 font-mono text-xs">mode</td> 223 - <td className="py-2 pr-4 font-mono text-xs"> 224 - "dense" | "art" | "placeholder" 225 - </td> 226 - <td className="py-2 font-mono text-xs">"dense"</td> 227 - <td className="py-2"> 228 - Display mode: dense (text only), art (with art crop), or 229 - placeholder (full size, no art) 230 - </td> 231 - </tr> 232 - <tr className="border-b border-gray-100 dark:border-slate-800"> 233 - <td className="py-2 pr-4 font-mono text-xs">sizing</td> 234 - <td className="py-2 pr-4 font-mono text-xs"> 235 - "fixed" | "content" 236 - </td> 237 - <td className="py-2 font-mono text-xs">"fixed"</td> 238 - <td className="py-2"> 239 - Fixed height with scroll, or content-based height 240 - </td> 241 - </tr> 242 172 <tr> 243 173 <td className="py-2 pr-4 font-mono text-xs">className</td> 244 174 <td className="py-2 pr-4 font-mono text-xs">string</td> ··· 258 188 <pre className="bg-gray-100 dark:bg-slate-800 p-4 rounded-lg overflow-x-auto text-sm"> 259 189 <code className="text-gray-800 dark:text-gray-200">{`import { CardWireframe } from "@/components/CardWireframe"; 260 190 261 - // Dense mode (text only, compact) 262 - <CardWireframe card={card} mode="dense" /> 263 - 264 - // Placeholder mode (full card size, no art load) 265 - <CardWireframe card={card} mode="placeholder" /> 266 - 267 - // Art mode (with art crop image) 268 - <CardWireframe card={card} mode="art" /> 191 + // Basic usage - renders card as styled text placeholder 192 + <CardWireframe card={card} /> 269 193 270 - // Content-based sizing (grows with content) 271 - <CardWireframe card={card} mode="dense" sizing="content" />`}</code> 194 + // With custom className 195 + <CardWireframe card={card} className="max-w-xs" />`}</code> 272 196 </pre> 273 197 </section> 274 198 </div>
+1 -1
src/routes/components/index.tsx
··· 9 9 name: "CardWireframe", 10 10 path: "/components/card-wireframe", 11 11 description: 12 - "Text-based card display with dense, art, and placeholder modes. Great for loading states or dense views.", 12 + "Text-based card placeholder. Renders card data matching the card's layout while images load.", 13 13 }, 14 14 ]; 15 15