👁️
5
fork

Configure Feed

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

show cards

+562 -19
+1
CLAUDE.md
··· 92 92 93 93 - **This is a TypeScript project** - ALL code (including scripts) must use TypeScript with proper types 94 94 - **Use `nix-shell -p <package>` for missing commands** - If a command isn't in PATH, use nix-shell to get it temporarily 95 + - **Prefer functional style over exceptions** - Avoid throwing errors for control flow. Use type predicates, Option/Result patterns, and early returns instead. Throwing is like GOTO—it breaks local reasoning and makes code harder to follow 95 96 - `src/routeTree.gen.ts` is auto-generated - never edit manually 96 97 - `typelex/externals.tsp` is auto-generated from lexicons folder - add external lexicon JSON to trigger regeneration 97 98 - Demo files (prefixed with `demo`) are safe to delete
+8 -19
scripts/download-scryfall.ts
··· 15 15 import { writeFile, mkdir, readFile, stat } from "node:fs/promises"; 16 16 import { join, dirname } from "node:path"; 17 17 import { fileURLToPath } from "node:url"; 18 + import type { Card, CardDataOutput } from "../src/lib/scryfall-types.js"; 19 + import { asScryfallId, asOracleId } from "../src/lib/scryfall-types.js"; 18 20 19 21 const __filename = fileURLToPath(import.meta.url); 20 22 const __dirname = dirname(__filename); ··· 83 85 [key: string]: unknown; 84 86 } 85 87 86 - interface FilteredCard { 87 - id: string; 88 - oracle_id: string; 89 - name: string; 90 - [key: string]: unknown; 91 - } 92 88 93 89 interface BulkDataItem { 94 90 type: string; ··· 124 120 data: Migration[]; 125 121 } 126 122 127 - interface CardDataOutput { 128 - version: string; 129 - cardCount: number; 130 - cards: Record<string, FilteredCard>; 131 - oracleIdToPrintings: Record<string, string[]>; 132 - } 133 - 134 123 type MigrationMap = Record<string, string>; 135 124 136 125 async function fetchJSON<T>(url: string): Promise<T> { ··· 155 144 console.log(`Saved to: ${outputPath}`); 156 145 } 157 146 158 - function filterCard(card: ScryfallCard): FilteredCard { 159 - const filtered: FilteredCard = { 160 - id: card.id, 161 - oracle_id: card.oracle_id, 147 + function filterCard(card: ScryfallCard): Card { 148 + const filtered: Card = { 149 + id: asScryfallId(card.id), 150 + oracle_id: asOracleId(card.oracle_id), 162 151 name: card.name, 163 152 }; 164 153 ··· 203 192 console.log("Building indexes..."); 204 193 const cardById = Object.fromEntries(cards.map((card) => [card.id, card])); 205 194 206 - const oracleIdToPrintings = cards.reduce( 195 + const oracleIdToPrintings = cards.reduce<CardDataOutput['oracleIdToPrintings']>( 207 196 (acc, card) => { 208 197 if (!acc[card.oracle_id]) { 209 198 acc[card.oracle_id] = []; ··· 211 200 acc[card.oracle_id].push(card.id); 212 201 return acc; 213 202 }, 214 - {} as Record<string, string[]>, 203 + {}, 215 204 ); 216 205 217 206 const output: CardDataOutput = {
+187
src/lib/scryfall-types.ts
··· 1 + /** 2 + * Shared types for Scryfall card data 3 + * 4 + * Note: These types represent a filtered subset of Scryfall's full card model, 5 + * containing only the fields we've chosen to keep for the application. 6 + */ 7 + 8 + // Branded types for different ID kinds 9 + export type ScryfallId = string & { readonly __brand: "ScryfallId" }; 10 + export type OracleId = string & { readonly __brand: "OracleId" }; 11 + 12 + // UUID format: 8-4-4-4-12 hex digits 13 + const UUID_REGEX = 14 + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; 15 + 16 + export function isScryfallId(id: string): id is ScryfallId { 17 + return UUID_REGEX.test(id); 18 + } 19 + 20 + export function isOracleId(id: string): id is OracleId { 21 + return UUID_REGEX.test(id); 22 + } 23 + 24 + export function asScryfallId(id: string): ScryfallId { 25 + return id as ScryfallId; 26 + } 27 + 28 + export function asOracleId(id: string): OracleId { 29 + return id as OracleId; 30 + } 31 + 32 + export type Rarity = 33 + | "common" 34 + | "uncommon" 35 + | "rare" 36 + | "mythic" 37 + | "special" 38 + | "bonus" 39 + | string; 40 + 41 + export type BorderColor = 42 + | "black" 43 + | "white" 44 + | "borderless" 45 + | "silver" 46 + | "gold" 47 + | string; 48 + 49 + export type Frame = "1993" | "1997" | "2003" | "2015" | "future" | string; 50 + 51 + export type FrameEffect = 52 + | "boosterfun" 53 + | "colorshifted" 54 + | "companion" 55 + | "compasslanddfc" 56 + | "convertdfc" 57 + | "devoid" 58 + | "draft" 59 + | "enchantment" 60 + | "etched" 61 + | "extendedart" 62 + | "fandfc" 63 + | "fullart" 64 + | "inverted" 65 + | "legendary" 66 + | "lesson" 67 + | "miracle" 68 + | "mooneldrazidfc" 69 + | "originpwdfc" 70 + | "shatteredglass" 71 + | "showcase" 72 + | "snow" 73 + | "spree" 74 + | "sunmoondfc" 75 + | "tombstone" 76 + | "upsidedowndfc" 77 + | "wanted" 78 + | "waxingandwaningmoondfc" 79 + | string; 80 + 81 + export type Layout = 82 + | "normal" 83 + | "split" 84 + | "flip" 85 + | "transform" 86 + | "modal_dfc" 87 + | "meld" 88 + | "leveler" 89 + | "class" 90 + | "saga" 91 + | "adventure" 92 + | "mutate" 93 + | "prototype" 94 + | "battle" 95 + | "planar" 96 + | "scheme" 97 + | "vanguard" 98 + | "token" 99 + | "double_faced_token" 100 + | "emblem" 101 + | "augment" 102 + | "host" 103 + | "art_series" 104 + | "reversible_card" 105 + | string; 106 + 107 + export type ImageStatus = 108 + | "missing" 109 + | "placeholder" 110 + | "lowres" 111 + | "highres_scan" 112 + | string; 113 + 114 + export type Finish = "foil" | "nonfoil" | "etched" | string; 115 + 116 + export type Game = "paper" | "arena" | "mtgo" | string; 117 + 118 + export type Legality = "legal" | "not_legal" | "restricted" | "banned" | string; 119 + 120 + export type ImageSize = 121 + | "small" 122 + | "normal" 123 + | "large" 124 + | "png" 125 + | "art_crop" 126 + | "border_crop"; 127 + 128 + export interface Card { 129 + // Core identity 130 + id: ScryfallId; 131 + oracle_id: OracleId; 132 + name: string; 133 + type_line?: string; 134 + mana_cost?: string; 135 + cmc?: number; 136 + oracle_text?: string; 137 + colors?: string[]; 138 + color_identity?: string[]; 139 + keywords?: string[]; 140 + power?: string; 141 + toughness?: string; 142 + loyalty?: string; 143 + defense?: string; 144 + 145 + // Legalities & formats 146 + legalities?: Record<string, Legality>; 147 + games?: Game[]; 148 + reserved?: boolean; 149 + 150 + // Search & filtering 151 + set?: string; 152 + set_name?: string; 153 + collector_number?: string; 154 + rarity?: Rarity; 155 + released_at?: string; 156 + prices?: Record<string, string | null>; 157 + artist?: string; 158 + 159 + // Printing selection (image_uris omitted - can reconstruct from ID) 160 + card_faces?: unknown[]; 161 + border_color?: BorderColor; 162 + frame?: Frame; 163 + frame_effects?: FrameEffect[]; 164 + finishes?: Finish[]; 165 + promo?: boolean; 166 + promo_types?: string[]; 167 + full_art?: boolean; 168 + digital?: boolean; 169 + highres_image?: boolean; 170 + image_status?: ImageStatus; 171 + layout?: Layout; 172 + 173 + // Nice-to-have 174 + edhrec_rank?: number; 175 + reprint?: boolean; 176 + lang?: string; 177 + content_warning?: boolean; 178 + 179 + [key: string]: unknown; 180 + } 181 + 182 + export interface CardDataOutput { 183 + version: string; 184 + cardCount: number; 185 + cards: Record<ScryfallId, Card>; 186 + oracleIdToPrintings: Record<OracleId, ScryfallId[]>; 187 + }
+16
src/lib/scryfall-utils.ts
··· 1 + import type { ImageSize, ScryfallId } from "./scryfall-types"; 2 + 3 + /** 4 + * Reconstruct Scryfall image URI from card ID 5 + * 6 + * Pattern: https://cards.scryfall.io/{size}/front/{id[0]}/{id[1]}/{id}.jpg 7 + * 8 + * This works for 100% of sampled cards (96.5% of all cards have images). 9 + * See .claude/SCRYFALL.md for details. 10 + */ 11 + export function getImageUri( 12 + scryfallId: ScryfallId, 13 + size: ImageSize = "normal", 14 + ): string { 15 + return `https://cards.scryfall.io/${size}/front/${scryfallId[0]}/${scryfallId[1]}/${scryfallId}.jpg`; 16 + }
+42
src/routeTree.gen.ts
··· 10 10 11 11 import { Route as rootRouteImport } from './routes/__root' 12 12 import { Route as IndexRouteImport } from './routes/index' 13 + import { Route as CardsIndexRouteImport } from './routes/cards/index' 13 14 import { Route as DemoTanstackQueryRouteImport } from './routes/demo/tanstack-query' 15 + import { Route as CardsIdRouteImport } from './routes/cards/$id' 14 16 import { Route as DemoStartServerFuncsRouteImport } from './routes/demo/start.server-funcs' 15 17 import { Route as DemoStartApiRequestRouteImport } from './routes/demo/start.api-request' 16 18 import { Route as DemoApiTqTodosRouteImport } from './routes/demo/api.tq-todos' ··· 25 27 path: '/', 26 28 getParentRoute: () => rootRouteImport, 27 29 } as any) 30 + const CardsIndexRoute = CardsIndexRouteImport.update({ 31 + id: '/cards/', 32 + path: '/cards/', 33 + getParentRoute: () => rootRouteImport, 34 + } as any) 28 35 const DemoTanstackQueryRoute = DemoTanstackQueryRouteImport.update({ 29 36 id: '/demo/tanstack-query', 30 37 path: '/demo/tanstack-query', 31 38 getParentRoute: () => rootRouteImport, 32 39 } as any) 40 + const CardsIdRoute = CardsIdRouteImport.update({ 41 + id: '/cards/$id', 42 + path: '/cards/$id', 43 + getParentRoute: () => rootRouteImport, 44 + } as any) 33 45 const DemoStartServerFuncsRoute = DemoStartServerFuncsRouteImport.update({ 34 46 id: '/demo/start/server-funcs', 35 47 path: '/demo/start/server-funcs', ··· 73 85 74 86 export interface FileRoutesByFullPath { 75 87 '/': typeof IndexRoute 88 + '/cards/$id': typeof CardsIdRoute 76 89 '/demo/tanstack-query': typeof DemoTanstackQueryRoute 90 + '/cards': typeof CardsIndexRoute 77 91 '/demo/api/names': typeof DemoApiNamesRoute 78 92 '/demo/api/tq-todos': typeof DemoApiTqTodosRoute 79 93 '/demo/start/api-request': typeof DemoStartApiRequestRoute ··· 85 99 } 86 100 export interface FileRoutesByTo { 87 101 '/': typeof IndexRoute 102 + '/cards/$id': typeof CardsIdRoute 88 103 '/demo/tanstack-query': typeof DemoTanstackQueryRoute 104 + '/cards': typeof CardsIndexRoute 89 105 '/demo/api/names': typeof DemoApiNamesRoute 90 106 '/demo/api/tq-todos': typeof DemoApiTqTodosRoute 91 107 '/demo/start/api-request': typeof DemoStartApiRequestRoute ··· 98 114 export interface FileRoutesById { 99 115 __root__: typeof rootRouteImport 100 116 '/': typeof IndexRoute 117 + '/cards/$id': typeof CardsIdRoute 101 118 '/demo/tanstack-query': typeof DemoTanstackQueryRoute 119 + '/cards/': typeof CardsIndexRoute 102 120 '/demo/api/names': typeof DemoApiNamesRoute 103 121 '/demo/api/tq-todos': typeof DemoApiTqTodosRoute 104 122 '/demo/start/api-request': typeof DemoStartApiRequestRoute ··· 112 130 fileRoutesByFullPath: FileRoutesByFullPath 113 131 fullPaths: 114 132 | '/' 133 + | '/cards/$id' 115 134 | '/demo/tanstack-query' 135 + | '/cards' 116 136 | '/demo/api/names' 117 137 | '/demo/api/tq-todos' 118 138 | '/demo/start/api-request' ··· 124 144 fileRoutesByTo: FileRoutesByTo 125 145 to: 126 146 | '/' 147 + | '/cards/$id' 127 148 | '/demo/tanstack-query' 149 + | '/cards' 128 150 | '/demo/api/names' 129 151 | '/demo/api/tq-todos' 130 152 | '/demo/start/api-request' ··· 136 158 id: 137 159 | '__root__' 138 160 | '/' 161 + | '/cards/$id' 139 162 | '/demo/tanstack-query' 163 + | '/cards/' 140 164 | '/demo/api/names' 141 165 | '/demo/api/tq-todos' 142 166 | '/demo/start/api-request' ··· 149 173 } 150 174 export interface RootRouteChildren { 151 175 IndexRoute: typeof IndexRoute 176 + CardsIdRoute: typeof CardsIdRoute 152 177 DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute 178 + CardsIndexRoute: typeof CardsIndexRoute 153 179 DemoApiNamesRoute: typeof DemoApiNamesRoute 154 180 DemoApiTqTodosRoute: typeof DemoApiTqTodosRoute 155 181 DemoStartApiRequestRoute: typeof DemoStartApiRequestRoute ··· 169 195 preLoaderRoute: typeof IndexRouteImport 170 196 parentRoute: typeof rootRouteImport 171 197 } 198 + '/cards/': { 199 + id: '/cards/' 200 + path: '/cards' 201 + fullPath: '/cards' 202 + preLoaderRoute: typeof CardsIndexRouteImport 203 + parentRoute: typeof rootRouteImport 204 + } 172 205 '/demo/tanstack-query': { 173 206 id: '/demo/tanstack-query' 174 207 path: '/demo/tanstack-query' 175 208 fullPath: '/demo/tanstack-query' 176 209 preLoaderRoute: typeof DemoTanstackQueryRouteImport 210 + parentRoute: typeof rootRouteImport 211 + } 212 + '/cards/$id': { 213 + id: '/cards/$id' 214 + path: '/cards/$id' 215 + fullPath: '/cards/$id' 216 + preLoaderRoute: typeof CardsIdRouteImport 177 217 parentRoute: typeof rootRouteImport 178 218 } 179 219 '/demo/start/server-funcs': { ··· 237 277 238 278 const rootRouteChildren: RootRouteChildren = { 239 279 IndexRoute: IndexRoute, 280 + CardsIdRoute: CardsIdRoute, 240 281 DemoTanstackQueryRoute: DemoTanstackQueryRoute, 282 + CardsIndexRoute: CardsIndexRoute, 241 283 DemoApiNamesRoute: DemoApiNamesRoute, 242 284 DemoApiTqTodosRoute: DemoApiTqTodosRoute, 243 285 DemoStartApiRequestRoute: DemoStartApiRequestRoute,
+192
src/routes/cards/$id.tsx
··· 1 + import { useQuery } from "@tanstack/react-query"; 2 + import { createFileRoute } from "@tanstack/react-router"; 3 + import { ArrowLeft } from "lucide-react"; 4 + import type { Card, CardDataOutput } from "../../lib/scryfall-types"; 5 + import { isScryfallId } from "../../lib/scryfall-types"; 6 + import { getImageUri } from "../../lib/scryfall-utils"; 7 + 8 + export const Route = createFileRoute("/cards/$id")({ 9 + component: CardDetailPage, 10 + }); 11 + 12 + function CardDetailPage() { 13 + const { id } = Route.useParams(); 14 + 15 + const { 16 + data: cardsData, 17 + isLoading, 18 + error, 19 + } = useQuery<CardDataOutput>({ 20 + queryKey: ["cards"], 21 + queryFn: async () => { 22 + const response = await fetch("/data/cards.json"); 23 + if (!response.ok) { 24 + throw new Error("Failed to load card data"); 25 + } 26 + return response.json(); 27 + }, 28 + staleTime: Number.POSITIVE_INFINITY, 29 + }); 30 + 31 + if (isLoading) { 32 + return ( 33 + <div className="min-h-screen bg-slate-900 flex items-center justify-center"> 34 + <p className="text-white text-lg">Loading card...</p> 35 + </div> 36 + ); 37 + } 38 + 39 + if (error || !cardsData) { 40 + return ( 41 + <div className="min-h-screen bg-slate-900 flex items-center justify-center"> 42 + <p className="text-red-400 text-lg">Failed to load card data</p> 43 + </div> 44 + ); 45 + } 46 + 47 + if (!isScryfallId(id)) { 48 + return ( 49 + <div className="min-h-screen bg-slate-900 flex items-center justify-center"> 50 + <p className="text-red-400 text-lg">Invalid card ID format</p> 51 + </div> 52 + ); 53 + } 54 + 55 + const card: Card | undefined = cardsData.cards[id]; 56 + 57 + if (!card) { 58 + return ( 59 + <div className="min-h-screen bg-slate-900 flex items-center justify-center"> 60 + <p className="text-red-400 text-lg">Card not found</p> 61 + </div> 62 + ); 63 + } 64 + 65 + const otherPrintings = cardsData.oracleIdToPrintings[card.oracle_id]?.filter( 66 + (printId) => printId !== id, 67 + ); 68 + 69 + return ( 70 + <div className="min-h-screen bg-slate-900"> 71 + <div className="max-w-7xl mx-auto px-6 py-8"> 72 + <a 73 + href="/cards" 74 + className="inline-flex items-center gap-2 text-cyan-400 hover:text-cyan-300 mb-6 transition-colors" 75 + > 76 + <ArrowLeft className="w-4 h-4" /> 77 + Back to card browser 78 + </a> 79 + 80 + <div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> 81 + <div className="flex justify-center lg:justify-end"> 82 + <img 83 + src={getImageUri(card.id, "large")} 84 + alt={card.name} 85 + className="rounded-xl shadow-2xl max-w-full h-auto" 86 + /> 87 + </div> 88 + 89 + <div className="space-y-6"> 90 + <div> 91 + <h1 className="text-4xl font-bold text-white mb-2"> 92 + {card.name} 93 + </h1> 94 + {card.mana_cost && ( 95 + <p className="text-xl text-gray-300 font-mono"> 96 + {card.mana_cost} 97 + </p> 98 + )} 99 + {card.type_line && ( 100 + <p className="text-lg text-gray-400 mt-2">{card.type_line}</p> 101 + )} 102 + </div> 103 + 104 + {card.oracle_text && ( 105 + <div className="bg-slate-800 rounded-lg p-4 border border-slate-700"> 106 + <p className="text-gray-200 whitespace-pre-line"> 107 + {card.oracle_text} 108 + </p> 109 + </div> 110 + )} 111 + 112 + {(card.power || card.toughness || card.loyalty) && ( 113 + <div className="flex gap-4 text-gray-300"> 114 + {card.power && card.toughness && ( 115 + <div> 116 + <span className="text-gray-400">P/T:</span>{" "} 117 + <span className="font-semibold"> 118 + {card.power}/{card.toughness} 119 + </span> 120 + </div> 121 + )} 122 + {card.loyalty && ( 123 + <div> 124 + <span className="text-gray-400">Loyalty:</span>{" "} 125 + <span className="font-semibold">{card.loyalty}</span> 126 + </div> 127 + )} 128 + </div> 129 + )} 130 + 131 + <div className="grid grid-cols-2 gap-4 text-sm"> 132 + {card.set_name && ( 133 + <div> 134 + <p className="text-gray-400">Set</p> 135 + <p className="text-white"> 136 + {card.set_name} ({card.set?.toUpperCase()}) 137 + </p> 138 + </div> 139 + )} 140 + {card.rarity && ( 141 + <div> 142 + <p className="text-gray-400">Rarity</p> 143 + <p className="text-white capitalize">{card.rarity}</p> 144 + </div> 145 + )} 146 + {card.artist && ( 147 + <div> 148 + <p className="text-gray-400">Artist</p> 149 + <p className="text-white">{card.artist}</p> 150 + </div> 151 + )} 152 + {card.collector_number && ( 153 + <div> 154 + <p className="text-gray-400">Collector Number</p> 155 + <p className="text-white">{card.collector_number}</p> 156 + </div> 157 + )} 158 + </div> 159 + 160 + {otherPrintings && otherPrintings.length > 0 && ( 161 + <div> 162 + <h2 className="text-xl font-semibold text-white mb-3"> 163 + Other Printings ({otherPrintings.length}) 164 + </h2> 165 + <div className="grid grid-cols-4 md:grid-cols-6 gap-2"> 166 + {otherPrintings.slice(0, 12).map((printId) => { 167 + const printing = cardsData.cards[printId]; 168 + return ( 169 + <a 170 + key={printId} 171 + href={`/cards/${printId}`} 172 + className="aspect-[5/7] rounded overflow-hidden hover:ring-2 hover:ring-cyan-500 transition-all" 173 + title={printing.set_name} 174 + > 175 + <img 176 + src={getImageUri(printId, "small")} 177 + alt={printing.name} 178 + className="w-full h-full object-cover" 179 + loading="lazy" 180 + /> 181 + </a> 182 + ); 183 + })} 184 + </div> 185 + </div> 186 + )} 187 + </div> 188 + </div> 189 + </div> 190 + </div> 191 + ); 192 + }
+116
src/routes/cards/index.tsx
··· 1 + import { useQuery } from "@tanstack/react-query"; 2 + import { createFileRoute } from "@tanstack/react-router"; 3 + import { Search } from "lucide-react"; 4 + import { useState } from "react"; 5 + import type { CardDataOutput } from "../../lib/scryfall-types"; 6 + import { getImageUri } from "../../lib/scryfall-utils"; 7 + 8 + export const Route = createFileRoute("/cards/")({ 9 + component: CardsPage, 10 + }); 11 + 12 + function CardsPage() { 13 + const [searchQuery, setSearchQuery] = useState(""); 14 + 15 + const { data, isLoading, error } = useQuery<CardDataOutput>({ 16 + queryKey: ["cards"], 17 + queryFn: async () => { 18 + const response = await fetch("/data/cards.json"); 19 + if (!response.ok) { 20 + throw new Error("Failed to load card data"); 21 + } 22 + return response.json(); 23 + }, 24 + staleTime: Number.POSITIVE_INFINITY, // Static data, never refetch 25 + }); 26 + 27 + if (isLoading) { 28 + return ( 29 + <div className="min-h-screen bg-slate-900 flex items-center justify-center"> 30 + <p className="text-white text-lg">Loading cards...</p> 31 + </div> 32 + ); 33 + } 34 + 35 + if (error || !data) { 36 + return ( 37 + <div className="min-h-screen bg-slate-900 flex items-center justify-center"> 38 + <p className="text-red-400 text-lg">Failed to load card data</p> 39 + </div> 40 + ); 41 + } 42 + 43 + const cards = Object.values(data.cards); 44 + const filteredCards = searchQuery 45 + ? cards 46 + .filter((card) => 47 + card.name.toLowerCase().includes(searchQuery.toLowerCase()), 48 + ) 49 + .slice(0, 100) 50 + : cards.slice(0, 100); 51 + 52 + return ( 53 + <div className="min-h-screen bg-slate-900"> 54 + <div className="max-w-7xl mx-auto px-6 py-8"> 55 + <div className="mb-8"> 56 + <h1 className="text-4xl font-bold text-white mb-2">Card Browser</h1> 57 + <p className="text-gray-400"> 58 + {data.cardCount.toLocaleString()} cards • Version: {data.version} 59 + </p> 60 + </div> 61 + 62 + <div className="mb-6"> 63 + <div className="relative"> 64 + <Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" /> 65 + <input 66 + type="text" 67 + placeholder="Search cards..." 68 + value={searchQuery} 69 + onChange={(e) => setSearchQuery(e.target.value)} 70 + className="w-full pl-12 pr-4 py-3 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-cyan-500 transition-colors" 71 + /> 72 + </div> 73 + {searchQuery && ( 74 + <p className="text-sm text-gray-400 mt-2"> 75 + Showing first 100 results for "{searchQuery}" 76 + </p> 77 + )} 78 + {!searchQuery && ( 79 + <p className="text-sm text-gray-400 mt-2"> 80 + Showing first 100 cards (use search to find specific cards) 81 + </p> 82 + )} 83 + </div> 84 + 85 + <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> 86 + {filteredCards.map((card) => ( 87 + <a 88 + key={card.id} 89 + href={`/cards/${card.id}`} 90 + className="group relative aspect-[5/7] rounded-lg overflow-hidden bg-slate-800 hover:ring-2 hover:ring-cyan-500 transition-all" 91 + > 92 + <img 93 + src={getImageUri(card.id, "small")} 94 + alt={card.name} 95 + className="w-full h-full object-cover" 96 + loading="lazy" 97 + /> 98 + <div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/0 to-black/0 opacity-0 group-hover:opacity-100 transition-opacity"> 99 + <div className="absolute bottom-0 left-0 right-0 p-3"> 100 + <p className="text-white font-semibold text-sm line-clamp-2"> 101 + {card.name} 102 + </p> 103 + {card.set_name && ( 104 + <p className="text-gray-300 text-xs mt-1"> 105 + {card.set_name} 106 + </p> 107 + )} 108 + </div> 109 + </div> 110 + </a> 111 + ))} 112 + </div> 113 + </div> 114 + </div> 115 + ); 116 + }