👁️
5
fork

Configure Feed

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

sort and sort2!

+205 -125
+30 -3
src/components/ClientDate.tsx
··· 3 3 interface ClientDateProps { 4 4 dateString: string; 5 5 className?: string; 6 + format?: "date" | "relative"; 6 7 } 7 8 8 - export function ClientDate({ dateString, className }: ClientDateProps) { 9 + function formatRelativeTime(date: Date): string { 10 + const now = new Date(); 11 + const diffMs = now.getTime() - date.getTime(); 12 + const diffSecs = Math.round(diffMs / 1000); 13 + const diffMins = Math.round(diffMs / 60000); 14 + const diffHours = Math.round(diffMs / 3600000); 15 + const diffDays = Math.round(diffMs / 86400000); 16 + 17 + const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); 18 + 19 + if (Math.abs(diffSecs) < 60) return rtf.format(-diffSecs, "second"); 20 + if (Math.abs(diffMins) < 60) return rtf.format(-diffMins, "minute"); 21 + if (Math.abs(diffHours) < 24) return rtf.format(-diffHours, "hour"); 22 + if (Math.abs(diffDays) < 30) return rtf.format(-diffDays, "day"); 23 + return date.toLocaleDateString(); 24 + } 25 + 26 + export function ClientDate({ 27 + dateString, 28 + className, 29 + format = "date", 30 + }: ClientDateProps) { 9 31 const [formatted, setFormatted] = useState<string | null>(null); 10 32 11 33 useEffect(() => { 12 - setFormatted(new Date(dateString).toLocaleDateString()); 13 - }, [dateString]); 34 + const date = new Date(dateString); 35 + setFormatted( 36 + format === "relative" 37 + ? formatRelativeTime(date) 38 + : date.toLocaleDateString(), 39 + ); 40 + }, [dateString, format]); 14 41 15 42 if (formatted === null) { 16 43 return (
+2 -2
src/components/Header.tsx
··· 42 42 <div className="flex-1 flex justify-center px-2 sm:px-4"> 43 43 <Link 44 44 to="/cards" 45 - search={{ q: "", sort: undefined }} 45 + search={{ q: "", sort: undefined, sort2: undefined }} 46 46 className="flex items-center gap-2 px-3 sm:px-4 py-2 bg-cyan-600 hover:bg-cyan-700 rounded-lg transition-colors text-white text-sm font-medium" 47 47 > 48 48 <Search size={18} /> ··· 129 129 130 130 <Link 131 131 to="/cards" 132 - search={{ q: "", sort: undefined }} 132 + search={{ q: "", sort: undefined, sort2: undefined }} 133 133 onClick={() => setIsOpen(false)} 134 134 className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors mb-2" 135 135 activeProps={{
+1 -1
src/lib/card-data-provider.ts
··· 84 84 paginatedUnifiedSearch?( 85 85 query: string, 86 86 restrictions: SearchRestrictions | undefined, 87 - sort: SortOption, 87 + sort: SortOption[], 88 88 offset: number, 89 89 limit: number, 90 90 ): Promise<PaginatedSearchResult>;
+1 -1
src/lib/cards-client-provider.ts
··· 79 79 async paginatedUnifiedSearch( 80 80 query: string, 81 81 restrictions: SearchRestrictions | undefined, 82 - sort: SortOption, 82 + sort: SortOption[], 83 83 offset: number, 84 84 limit: number, 85 85 ): Promise<PaginatedSearchResult> {
+1 -1
src/lib/queries.ts
··· 208 208 query: string, 209 209 offset: number, 210 210 restrictions?: SearchRestrictions, 211 - sort: SortOption = { field: "name", direction: "auto" }, 211 + sort: SortOption[] = [{ field: "name", direction: "auto" }], 212 212 ) => 213 213 queryOptions({ 214 214 queryKey: [
+105 -62
src/routes/cards/index.tsx
··· 5 5 AlertCircle, 6 6 ArrowUpDown, 7 7 ChevronDown, 8 + ListFilter, 8 9 Loader2, 9 10 Search, 10 11 } from "lucide-react"; 11 12 import { useEffect, useMemo, useRef, useState } from "react"; 12 13 import { CardSkeleton, CardThumbnail } from "@/components/CardImage"; 14 + import { ClientDate } from "@/components/ClientDate"; 13 15 import { OracleText } from "@/components/OracleText"; 14 16 import { 15 17 getCardsMetadataQueryOptions, ··· 26 28 return { 27 29 q: (search.q as string) || "", 28 30 sort: (search.sort as string) || undefined, 31 + sort2: (search.sort2 as string) || undefined, 29 32 }; 30 33 }, 31 34 head: () => ({ ··· 38 41 39 42 if (!metadata) { 40 43 return ( 41 - <p className="text-gray-400"> 42 - <Loader2 className="inline w-4 h-4 animate-spin" /> 43 - </p> 44 + <span className="text-gray-400 text-sm"> 45 + <Loader2 className="inline w-3 h-3 animate-spin" /> 46 + </span> 44 47 ); 45 48 } 46 49 47 50 return ( 48 - <p className="text-gray-400"> 49 - {metadata.cardCount.toLocaleString()} cards • Version: {metadata.version}{" "} 50 - • Data from{" "} 51 + <span 52 + className="text-gray-400 text-sm" 53 + title={`Version: ${metadata.version}`} 54 + > 55 + {metadata.cardCount.toLocaleString()} cards • updated{" "} 56 + <ClientDate dateString={metadata.version} format="relative" /> from{" "} 51 57 <a 52 58 href="https://scryfall.com" 53 59 target="_blank" ··· 56 62 > 57 63 Scryfall 58 64 </a> 59 - </p> 65 + </span> 60 66 ); 61 67 } 62 68 ··· 166 172 }, 167 173 ]; 168 174 169 - const DEFAULT_SORT = SORT_OPTIONS[0]; 175 + const DEFAULT_PRIMARY = SORT_OPTIONS[0]; 176 + 177 + function buildSortArray( 178 + primary: (typeof SORT_OPTIONS)[number], 179 + secondary: (typeof SORT_OPTIONS)[number] | null, 180 + ): SortOption[] { 181 + if (!secondary) return [primary.sort]; 182 + return [primary.sort, secondary.sort]; 183 + } 170 184 171 185 function CardsPage() { 172 186 const navigate = Route.useNavigate(); ··· 191 205 const listRef = useRef<HTMLDivElement>(null); 192 206 const columns = useColumns(); 193 207 const hasRestoredScroll = useRef(false); 194 - const sortOption = 195 - SORT_OPTIONS.find((o) => o.value === search.sort) ?? DEFAULT_SORT; 208 + const primarySort = 209 + SORT_OPTIONS.find((o) => o.value === search.sort) ?? DEFAULT_PRIMARY; 210 + const secondarySort = search.sort2 211 + ? (SORT_OPTIONS.find((o) => o.value === search.sort2) ?? null) 212 + : null; 213 + const sortArray = buildSortArray(primarySort, secondarySort); 196 214 197 215 // First page query to get totalCount and metadata 198 216 const firstPageQuery = useQuery( 199 - searchPageQueryOptions(debouncedSearchQuery, 0, undefined, sortOption.sort), 217 + searchPageQueryOptions(debouncedSearchQuery, 0, undefined, sortArray), 200 218 ); 201 219 const totalCount = firstPageQuery.data?.totalCount ?? 0; 202 220 const hasError = firstPageQuery.data?.error != null; ··· 284 302 debouncedSearchQuery, 285 303 offset, 286 304 undefined, 287 - sortOption.sort, 305 + sortArray, 288 306 ), 289 307 ), 290 308 }); ··· 324 342 return ( 325 343 <div className="min-h-screen bg-white dark:bg-slate-900"> 326 344 <div className="max-w-7xl w-full mx-auto px-6 pt-8 pb-4"> 327 - <div className="mb-8"> 328 - <h1 className="text-4xl font-bold text-gray-900 dark:text-white mb-2"> 329 - Card Browser 330 - </h1> 331 - <MetadataDisplay /> 332 - </div> 345 + <div className="mb-4 flex flex-col gap-2"> 346 + <div className="flex items-center justify-between gap-2"> 347 + <h1 className="text-3xl font-bold text-gray-900 dark:text-white"> 348 + Card Browser 349 + </h1> 350 + <div className="flex gap-2"> 351 + <div className="relative"> 352 + <select 353 + value={primarySort.value} 354 + onChange={(e) => 355 + navigate({ 356 + search: (prev) => ({ ...prev, sort: e.target.value }), 357 + replace: true, 358 + }) 359 + } 360 + className="appearance-none h-9 w-9 sm:w-auto pl-2 sm:pl-3 pr-2 sm:pr-8 py-1.5 bg-gray-100 dark:bg-slate-800 border border-gray-300 dark:border-slate-700 rounded-lg text-transparent sm:text-gray-900 dark:sm:text-white text-sm focus:outline-none focus:border-cyan-500 transition-colors cursor-pointer" 361 + > 362 + {SORT_OPTIONS.map((opt) => ( 363 + <option key={opt.value} value={opt.value}> 364 + {opt.label} 365 + </option> 366 + ))} 367 + </select> 368 + <ArrowUpDown className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none sm:hidden" /> 369 + <ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400 pointer-events-none hidden sm:block" /> 370 + </div> 371 + <div className="relative"> 372 + <select 373 + value={secondarySort?.value ?? ""} 374 + onChange={(e) => 375 + navigate({ 376 + search: (prev) => ({ 377 + ...prev, 378 + sort2: e.target.value || undefined, 379 + }), 380 + replace: true, 381 + }) 382 + } 383 + className="appearance-none h-9 w-9 sm:w-auto pl-2 sm:pl-3 pr-2 sm:pr-8 py-1.5 bg-gray-100 dark:bg-slate-800 border border-gray-300 dark:border-slate-700 rounded-lg text-transparent sm:text-gray-900 dark:sm:text-white text-sm focus:outline-none focus:border-cyan-500 transition-colors cursor-pointer" 384 + > 385 + <option value="">then...</option> 386 + {SORT_OPTIONS.filter( 387 + (opt) => opt.sort.field !== primarySort.sort.field, 388 + ).map((opt) => ( 389 + <option key={opt.value} value={opt.value}> 390 + {opt.label} 391 + </option> 392 + ))} 393 + </select> 394 + <ListFilter className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none sm:hidden" /> 395 + <ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400 pointer-events-none hidden sm:block" /> 396 + </div> 397 + </div> 398 + </div> 333 399 334 - <div className="mb-4"> 335 - <div className="flex gap-2"> 336 - <div className="relative flex-1 min-w-0"> 337 - <Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" /> 338 - <input 339 - ref={searchInputRef} 340 - type="text" 341 - placeholder="Search by name or try t:creature cmc<=3" 342 - defaultValue={search.q} 343 - onChange={(e) => { 344 - lastSyncedQuery.current = e.target.value; 345 - navigate({ 346 - search: (prev) => ({ ...prev, q: e.target.value }), 347 - replace: true, 348 - }); 349 - }} 350 - className={`w-full pl-12 pr-4 py-3 bg-gray-100 dark:bg-slate-800 border rounded-lg text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none transition-colors ${ 351 - hasError 352 - ? "border-red-500 focus:border-red-500" 353 - : "border-gray-300 dark:border-slate-700 focus:border-cyan-500" 354 - }`} 355 - /> 356 - </div> 357 - <div className="relative flex-shrink-0"> 358 - <select 359 - value={sortOption.value} 360 - onChange={(e) => 361 - navigate({ 362 - search: (prev) => ({ ...prev, sort: e.target.value }), 363 - replace: true, 364 - }) 365 - } 366 - className="appearance-none h-full w-11 sm:w-auto pl-3 sm:pl-4 pr-3 sm:pr-10 py-3 bg-gray-100 dark:bg-slate-800 border border-gray-300 dark:border-slate-700 rounded-lg text-transparent sm:text-gray-900 dark:sm:text-white focus:outline-none focus:border-cyan-500 transition-colors cursor-pointer" 367 - > 368 - {SORT_OPTIONS.map((opt) => ( 369 - <option key={opt.value} value={opt.value}> 370 - {opt.label} 371 - </option> 372 - ))} 373 - </select> 374 - <ArrowUpDown className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 pointer-events-none sm:hidden" /> 375 - <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none hidden sm:block" /> 376 - </div> 400 + <div className="relative"> 401 + <Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" /> 402 + <input 403 + ref={searchInputRef} 404 + type="text" 405 + placeholder="Search by name or try t:creature cmc<=3" 406 + defaultValue={search.q} 407 + onChange={(e) => { 408 + lastSyncedQuery.current = e.target.value; 409 + navigate({ 410 + search: (prev) => ({ ...prev, q: e.target.value }), 411 + replace: true, 412 + }); 413 + }} 414 + className={`w-full pl-12 pr-4 py-3 bg-gray-100 dark:bg-slate-800 border rounded-lg text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none transition-colors ${ 415 + hasError 416 + ? "border-red-500 focus:border-red-500" 417 + : "border-gray-300 dark:border-slate-700 focus:border-cyan-500" 418 + }`} 419 + /> 377 420 </div> 378 421 379 422 {hasError && firstPage?.error && ( ··· 407 450 408 451 {!search.q && ( 409 452 <p className="text-sm text-gray-400 mt-2"> 410 - Enter a search query to find cards 453 + Enter a search query to find cards • <MetadataDisplay /> 411 454 </p> 412 455 )} 413 456 </div>
+1 -1
src/routes/index.tsx
··· 85 85 86 86 <Link 87 87 to="/cards" 88 - search={{ q: "", sort: undefined }} 88 + search={{ q: "", sort: undefined, sort2: undefined }} 89 89 className="group flex flex-col items-center p-6 bg-white dark:bg-slate-800/50 border border-gray-200 dark:border-slate-700 rounded-xl hover:border-cyan-500 dark:hover:border-cyan-500 motion-safe:hover:shadow-lg transition-colors motion-safe:transition-shadow" 90 90 > 91 91 <div className="w-12 h-12 bg-gray-100 dark:bg-slate-700 rounded-full flex items-center justify-center mb-4 group-hover:bg-gray-200 dark:group-hover:bg-slate-600 transition-colors">
+14 -17
src/workers/__tests__/syntax-search.test.ts
··· 1 1 import { beforeAll, describe, expect, it } from "vitest"; 2 2 import { mockFetchFromPublicDir } from "../../lib/__tests__/test-helpers"; 3 + import type { SortOption } from "../../lib/search-types"; 3 4 import { __CardsWorkerForTestingOnly as CardsWorker } from "../cards.worker"; 4 5 5 6 describe("CardsWorker syntaxSearch", () => { ··· 246 247 }); 247 248 248 249 it("sorts by mana value ascending", () => { 249 - const result = worker.syntaxSearch("s:lea t:creature", 20, { 250 - field: "mv", 251 - direction: "asc", 252 - }); 250 + const result = worker.syntaxSearch("s:lea t:creature", 20, [ 251 + { field: "mv", direction: "asc" }, 252 + ]); 253 253 expect(result.ok).toBe(true); 254 254 if (result.ok) { 255 255 const cmcs = result.cards.map((c) => c.cmc ?? 0); ··· 258 258 }); 259 259 260 260 it("sorts by mana value descending", () => { 261 - const result = worker.syntaxSearch("s:lea t:creature", 20, { 262 - field: "mv", 263 - direction: "desc", 264 - }); 261 + const result = worker.syntaxSearch("s:lea t:creature", 20, [ 262 + { field: "mv", direction: "desc" }, 263 + ]); 265 264 expect(result.ok).toBe(true); 266 265 if (result.ok) { 267 266 const cmcs = result.cards.map((c) => c.cmc ?? 0); ··· 270 269 }); 271 270 272 271 it("uses name as tiebreaker when sorting by mv", () => { 273 - const result = worker.syntaxSearch("s:lea cmc=1", 100, { 274 - field: "mv", 275 - direction: "asc", 276 - }); 272 + const result = worker.syntaxSearch("s:lea cmc=1", 100, [ 273 + { field: "mv", direction: "asc" }, 274 + ]); 277 275 expect(result.ok).toBe(true); 278 276 if (result.ok) { 279 277 // All cards have mv=1, so should be sorted by name ··· 284 282 }); 285 283 286 284 it("sorts by rarity descending (mythic first)", () => { 287 - const result = worker.syntaxSearch("s:dom rarity>=rare", 50, { 288 - field: "rarity", 289 - direction: "desc", 290 - }); 285 + const result = worker.syntaxSearch("s:dom rarity>=rare", 50, [ 286 + { field: "rarity", direction: "desc" }, 287 + ]); 291 288 expect(result.ok).toBe(true); 292 289 if (result.ok && result.cards.length > 0) { 293 290 // Mythics should come before rares ··· 361 358 }); 362 359 363 360 describe("paginatedUnifiedSearch", () => { 364 - const sort = { field: "name", direction: "auto" } as const; 361 + const sort: SortOption[] = [{ field: "name", direction: "auto" }]; 365 362 366 363 it("returns totalCount and requested page", async () => { 367 364 const result = await worker.paginatedUnifiedSearch(
+50 -37
src/workers/cards.worker.ts
··· 98 98 return name.startsWith("A-") ? name.slice(2) : name; 99 99 } 100 100 101 - function sortCards(cards: Card[], sort: SortOption): void { 101 + type CardComparator = (a: Card, b: Card) => number; 102 + 103 + function buildComparator(sort: SortOption): CardComparator { 102 104 const dir = resolveDirection(sort.field, sort.direction); 103 105 const mult = dir === "desc" ? -1 : 1; 104 106 105 - cards.sort((a, b) => { 106 - let cmp = 0; 107 - const nameA = getSortableName(a.name); 108 - const nameB = getSortableName(b.name); 109 - switch (sort.field) { 110 - case "name": 111 - cmp = nameA.localeCompare(nameB); 112 - break; 113 - case "mv": 114 - cmp = (a.cmc ?? 0) - (b.cmc ?? 0); 115 - break; 116 - case "released": 117 - cmp = (a.released_at ?? "").localeCompare(b.released_at ?? ""); 118 - break; 119 - case "rarity": 120 - cmp = 121 - (RARITY_ORDER[a.rarity ?? ""] ?? 99) - 122 - (RARITY_ORDER[b.rarity ?? ""] ?? 99); 123 - break; 124 - case "color": 125 - cmp = 126 - colorIdentityRank(a.color_identity) - 127 - colorIdentityRank(b.color_identity); 128 - break; 107 + switch (sort.field) { 108 + case "name": 109 + return (a, b) => 110 + mult * getSortableName(a.name).localeCompare(getSortableName(b.name)); 111 + case "mv": 112 + return (a, b) => mult * ((a.cmc ?? 0) - (b.cmc ?? 0)); 113 + case "released": 114 + return (a, b) => 115 + mult * (a.released_at ?? "").localeCompare(b.released_at ?? ""); 116 + case "rarity": 117 + return (a, b) => 118 + mult * 119 + ((RARITY_ORDER[a.rarity ?? ""] ?? 99) - 120 + (RARITY_ORDER[b.rarity ?? ""] ?? 99)); 121 + case "color": 122 + return (a, b) => 123 + mult * 124 + (colorIdentityRank(a.color_identity) - 125 + colorIdentityRank(b.color_identity)); 126 + } 127 + } 128 + 129 + function buildChainedComparator(sorts: SortOption[]): CardComparator { 130 + const comparators = sorts.map(buildComparator); 131 + const nameTiebreaker: CardComparator = (a, b) => 132 + getSortableName(a.name).localeCompare(getSortableName(b.name)); 133 + 134 + return (a, b) => { 135 + for (const cmp of comparators) { 136 + const result = cmp(a, b); 137 + if (result !== 0) return result; 129 138 } 130 - cmp *= mult; 131 - return cmp !== 0 ? cmp : nameA.localeCompare(nameB); 132 - }); 139 + return nameTiebreaker(a, b); 140 + }; 141 + } 142 + 143 + function sortCards(cards: Card[], sorts: SortOption[]): void { 144 + if (sorts.length === 0) return; 145 + cards.sort(buildChainedComparator(sorts)); 133 146 } 134 147 135 148 const VOLATILE_RECORD_SIZE = 44; // 16 (UUID) + 4 (rank) + 6*4 (prices) ··· 188 201 syntaxSearch( 189 202 query: string, 190 203 maxResults?: number, 191 - sort?: SortOption, 204 + sort?: SortOption[], 192 205 ): 193 206 | { ok: true; cards: Card[] } 194 207 | { ok: false; error: { message: string; start: number; end: number } }; ··· 207 220 query: string, 208 221 restrictions?: SearchRestrictions, 209 222 maxResults?: number, 210 - sort?: SortOption, 223 + sort?: SortOption[], 211 224 ): UnifiedSearchResult; 212 225 213 226 /** ··· 217 230 paginatedUnifiedSearch( 218 231 query: string, 219 232 restrictions: SearchRestrictions | undefined, 220 - sort: SortOption, 233 + sort: SortOption[], 221 234 offset: number, 222 235 limit: number, 223 236 ): Promise<PaginatedSearchResult>; ··· 434 447 syntaxSearch( 435 448 query: string, 436 449 maxResults = 100, 437 - sort: SortOption = { field: "name", direction: "auto" }, 450 + sort: SortOption[] = [{ field: "name", direction: "auto" }], 438 451 ): 439 452 | { ok: true; cards: Card[] } 440 453 | { ok: false; error: { message: string; start: number; end: number } } { ··· 476 489 query: string, 477 490 restrictions?: SearchRestrictions, 478 491 maxResults = 50, 479 - sort: SortOption = { field: "name", direction: "auto" }, 492 + sort: SortOption[] = [{ field: "name", direction: "auto" }], 480 493 ): UnifiedSearchResult { 481 494 if (!this.data || !this.searchIndex) { 482 495 throw new Error("Worker not initialized - call initialize() first"); ··· 524 537 async paginatedUnifiedSearch( 525 538 query: string, 526 539 restrictions: SearchRestrictions | undefined, 527 - sort: SortOption, 540 + sort: SortOption[], 528 541 offset: number, 529 542 limit: number, 530 543 ): Promise<PaginatedSearchResult> { ··· 563 576 private executeFullUnifiedSearch( 564 577 query: string, 565 578 restrictions: SearchRestrictions | undefined, 566 - sort: SortOption, 579 + sort: SortOption[], 567 580 ): CachedSearchResult { 568 581 if (!this.data || !this.searchIndex) { 569 582 return { mode: "fuzzy", cards: [], description: null, error: null }; ··· 614 627 private runFullParsedQuery( 615 628 ast: SearchNode, 616 629 match: CardPredicate, 617 - sort: SortOption, 630 + sort: SortOption[], 618 631 restrictions?: SearchRestrictions, 619 632 ): Card[] { 620 633 if (!this.data) return []; ··· 647 660 ast: SearchNode, 648 661 match: CardPredicate, 649 662 maxResults: number, 650 - sort: SortOption, 663 + sort: SortOption[], 651 664 restrictions?: SearchRestrictions, 652 665 ): Card[] { 653 666 if (!this.data) return [];