👁️
5
fork

Configure Feed

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

generic tweaks for list import

+127 -41
+127 -41
src/routes/deck/import.tsx
··· 11 11 import { useCardHover } from "@/components/HoverCardPreview"; 12 12 import { ManaCost } from "@/components/ManaCost"; 13 13 import { getCardDataProvider } from "@/lib/card-data-provider"; 14 - import { getPrimaryFace } from "@/lib/card-faces"; 14 + import { getAllFaces, getPrimaryFace } from "@/lib/card-faces"; 15 15 import { 16 16 DECK_FORMATS, 17 17 type DeckFormat, ··· 22 22 import { type ResolvedCard, resolveCards } from "@/lib/deck-import"; 23 23 import { useCreateDeckMutation } from "@/lib/deck-queries"; 24 24 import type { Section } from "@/lib/deck-types"; 25 - import { FORMAT_GROUPS } from "@/lib/format-utils"; 25 + import { getPreset } from "@/lib/deck-validation/presets"; 26 + import { FORMAT_GROUPS, getFormatInfo } from "@/lib/format-utils"; 26 27 import type { Card } from "@/lib/scryfall-types"; 27 28 import { useDebounce } from "@/lib/useDebounce"; 28 29 ··· 68 69 "bg-pink-100 text-pink-800 dark:bg-pink-900/50 dark:text-pink-300", 69 70 ]; 70 71 71 - function hashString(str: string): number { 72 - let hash = 0; 73 - for (let i = 0; i < str.length; i++) { 74 - hash = (hash << 5) - hash + str.charCodeAt(i); 75 - hash |= 0; 72 + function buildTagColorMap(lines: ImportLine[]): Map<string, string> { 73 + const map = new Map<string, string>(); 74 + let colorIndex = 0; 75 + for (const line of lines) { 76 + if (line.line.type === "resolved") { 77 + for (const tag of line.line.tags) { 78 + if (!map.has(tag)) { 79 + map.set(tag, TAG_COLORS[colorIndex % TAG_COLORS.length]); 80 + colorIndex++; 81 + } 82 + } 83 + } 76 84 } 77 - return Math.abs(hash); 85 + return map; 78 86 } 79 87 80 - function getTagColor(tag: string): string { 81 - return TAG_COLORS[hashString(tag) % TAG_COLORS.length]; 88 + /** 89 + * Check if a parsed name matches the card (including individual face names). 90 + * Returns true if the name is a valid way to refer to the card. 91 + */ 92 + function nameMatchesCard(parsedName: string, card: Card): boolean { 93 + const lower = parsedName.toLowerCase(); 94 + 95 + // Exact match on full name 96 + if (lower === card.name.toLowerCase()) return true; 97 + 98 + // Match any face name (for DFCs like "Delver of Secrets // Insectile Aberration") 99 + for (const face of getAllFaces(card)) { 100 + if (lower === face.name.toLowerCase()) return true; 101 + } 102 + 103 + return false; 82 104 } 83 105 84 106 const SECTION_CHIPS: Record< ··· 166 188 167 189 (async () => { 168 190 const provider = await getCardDataProvider(); 169 - const restrictions = gameFormat ? { format: gameFormat } : undefined; 191 + // Use the legality field (e.g., oathbreaker uses legacy legality) 192 + const legalityField = gameFormat 193 + ? getPreset(gameFormat)?.config.legalityField 194 + : undefined; 195 + const restrictions = legalityField 196 + ? { format: legalityField } 197 + : undefined; 170 198 const result = await resolveCards( 171 199 allParsed, 172 200 async (name) => ··· 230 258 231 259 const resolved = resolvedMap.get(trimmed); 232 260 if (resolved) { 233 - const isImperfect = 234 - parsed.name.toLowerCase() !== resolved.cardData.name.toLowerCase(); 261 + const isImperfect = !nameMatchesCard(parsed.name, resolved.cardData); 235 262 return { 236 263 key, 237 264 line: { ··· 250 277 }, [text, parsedDeck, resolvedMap, errorMap]); 251 278 252 279 // Stats 253 - const totalCards = useMemo(() => { 254 - let count = 0; 280 + const { totalCards, warningCount } = useMemo(() => { 281 + let total = 0; 282 + let warnings = 0; 255 283 for (const line of previewLines) { 256 284 if (line.line.type === "resolved") { 257 - count += line.line.quantity; 285 + total += line.line.quantity; 286 + if (line.line.isImperfect) warnings++; 258 287 } 259 288 } 260 - return count; 289 + return { totalCards: total, warningCount: warnings }; 261 290 }, [previewLines]); 262 291 263 292 const errorCount = errorMap.size; 264 293 const hasErrors = errorCount > 0; 294 + const hasWarnings = warningCount > 0; 295 + 296 + // Format suggestion hint 297 + const formatHint = useMemo(() => { 298 + const formatInfo = getFormatInfo(gameFormat); 299 + const hasCommander = parsedDeck.commander.length > 0; 300 + const isCommanderFormat = formatInfo.commanderType !== null; 301 + 302 + // Deck size from parsed deck (mainboard + commander) 303 + const deckSize = 304 + parsedDeck.mainboard.reduce((sum, c) => sum + c.quantity, 0) + 305 + parsedDeck.commander.reduce((sum, c) => sum + c.quantity, 0); 306 + 307 + // Has commander section but not a commander format 308 + if (hasCommander && !isCommanderFormat) { 309 + return "Deck has a commander — try a Commander format?"; 310 + } 311 + 312 + // Size mismatch heuristics 313 + const expectedSize = 314 + formatInfo.deckSize === "variable" ? null : formatInfo.deckSize; 315 + if (expectedSize && deckSize > 0) { 316 + // ~100 cards but format expects 60 317 + if (deckSize >= 90 && expectedSize === 60) { 318 + return "Deck has ~100 cards — try Commander or Gladiator?"; 319 + } 320 + // ~60 cards but format expects 100 321 + if (deckSize >= 50 && deckSize <= 70 && expectedSize === 100) { 322 + return "Deck has ~60 cards — try a 60-card format?"; 323 + } 324 + } 325 + 326 + // Cards not resolving 327 + if (hasErrors) { 328 + return "Some cards not found — try changing the format?"; 329 + } 330 + 331 + return null; 332 + }, [gameFormat, parsedDeck, hasErrors]); 265 333 266 334 const handleCreate = useCallback(() => { 267 335 if (!deckName.trim()) return; ··· 305 373 Import Deck 306 374 </h1> 307 375 <p className="text-gray-600 dark:text-zinc-300 mt-1"> 308 - Paste a deck list from any major site. Format is auto-detected. 376 + Paste a deck list from any major site. List syntax is auto-detected. 309 377 </p> 310 378 </div> 311 379 ··· 354 422 </div> 355 423 </div> 356 424 357 - {/* Format detection badge */} 425 + {/* Syntax detection badge */} 358 426 <div className="flex items-center gap-2 mb-4"> 359 427 <span className="text-sm text-gray-600 dark:text-zinc-300"> 360 - Detected: 428 + Syntax: 361 429 </span> 362 430 <FormatBadge 363 431 detected={detectedFormat} ··· 368 436 <span className="flex items-center gap-1 text-sm text-gray-500 dark:text-zinc-400"> 369 437 <Loader2 className="w-4 h-4 animate-spin" /> 370 438 Resolving... 439 + </span> 440 + )} 441 + {formatHint && !isResolving && ( 442 + <span className="ml-auto text-sm text-amber-600 dark:text-amber-400"> 443 + {formatHint} 371 444 </span> 372 445 )} 373 446 </div> ··· 398 471 {/* Stats */} 399 472 <div className="mt-2 flex items-center gap-4 text-sm text-gray-500 dark:text-zinc-300"> 400 473 <span>{totalCards} cards</span> 474 + {hasWarnings && ( 475 + <span className="text-amber-600 dark:text-amber-400"> 476 + {warningCount} {warningCount === 1 ? "warning" : "warnings"} 477 + </span> 478 + )} 401 479 {hasErrors && ( 402 480 <span className="text-red-600 dark:text-red-400"> 403 481 {errorCount} {errorCount === 1 ? "error" : "errors"} ··· 471 549 } 472 550 473 551 function ImportPreview({ lines }: ImportPreviewProps) { 552 + const tagColors = useMemo(() => buildTagColorMap(lines), [lines]); 553 + 474 554 return ( 475 555 <div className="flex-1 p-4 border-l border-gray-200 dark:border-zinc-600 overflow-hidden"> 476 556 {lines.map((line) => ( 477 - <PreviewRow key={line.key} line={line.line} /> 557 + <PreviewRow key={line.key} line={line.line} tagColors={tagColors} /> 478 558 ))} 479 559 </div> 480 560 ); ··· 483 563 const ROW_CLASS = 484 564 "font-mono text-sm leading-[1.5] whitespace-nowrap [font-variant-ligatures:none] flex items-center gap-2"; 485 565 486 - function PreviewRow({ line }: { line: ImportLineType }) { 566 + function PreviewRow({ 567 + line, 568 + tagColors, 569 + }: { 570 + line: ImportLineType; 571 + tagColors: Map<string, string>; 572 + }) { 487 573 switch (line.type) { 488 574 case "empty": 489 575 return <div className={ROW_CLASS}>&nbsp;</div>; ··· 516 602 ); 517 603 518 604 case "resolved": 519 - return <ResolvedRow line={line} />; 605 + return <ResolvedRow line={line} tagColors={tagColors} />; 520 606 } 521 607 } 522 608 523 609 function ResolvedRow({ 524 610 line, 611 + tagColors, 525 612 }: { 526 613 line: Extract<ImportLineType, { type: "resolved" }>; 614 + tagColors: Map<string, string>; 527 615 }) { 528 616 const hoverProps = useCardHover(line.card.id); 529 617 const primaryFace = getPrimaryFace(line.card); ··· 549 637 <span className="text-gray-900 dark:text-white truncate min-w-0"> 550 638 {primaryFace?.name ?? "Unknown"} 551 639 </span> 552 - <div className="flex-shrink-0 flex items-center"> 640 + {sectionChip && ( 641 + <span 642 + className={`flex-shrink-0 px-1.5 py-0.5 text-xs font-medium rounded ${sectionChip.className}`} 643 + > 644 + {sectionChip.label} 645 + </span> 646 + )} 647 + {line.tags.map((tag) => ( 648 + <span 649 + key={tag} 650 + className={`flex-shrink-0 px-1.5 py-0.5 text-xs font-medium rounded ${tagColors.get(tag) ?? TAG_COLORS[0]}`} 651 + > 652 + #{tag} 653 + </span> 654 + ))} 655 + <div className="flex-shrink-0 flex items-center ml-auto"> 553 656 {primaryFace?.mana_cost && ( 554 657 <ManaCost cost={primaryFace.mana_cost} size="small" /> 555 658 )} 556 - </div> 557 - <div className="flex-shrink-0 flex items-center gap-1 overflow-hidden ml-auto"> 558 - {sectionChip && ( 559 - <span 560 - className={`px-1.5 py-0.5 text-xs font-medium rounded ${sectionChip.className}`} 561 - > 562 - {sectionChip.label} 563 - </span> 564 - )} 565 - {line.tags.map((tag) => ( 566 - <span 567 - key={tag} 568 - className={`px-1.5 py-0.5 text-xs font-medium rounded ${getTagColor(tag)}`} 569 - > 570 - #{tag} 571 - </span> 572 - ))} 573 659 </div> 574 660 </div> 575 661 );