Files
eranos/src/lib/scryfall.ts
T
Alex Gleason aa8541298e Render Gatherer card URLs as Magic cards via Scryfall
gatherer.wizards.com URLs (e.g. /BNG/en-us/156/xenagos-god-of-revels or
the legacy ?multiverseid=...) are now resolved through the Scryfall API
and rendered as actual Magic: The Gathering cards throughout the app:

- /i/<gatherer-url>: GathererCardHeader shows the card art at 280px max
  width with properly rounded corners, a mouse-driven 3D tilt + specular
  glare matching the badge showcase, a click-to-open lightbox, and a
  face toggle for DFC/MDFC/split cards.
- Page <title> on /i/<gatherer-url> uses the real card name.
- 'Commenting on …' breadcrumbs under kind 1111 comments show the card
  name with the CardsIcon and a hover-card preview.
- Parent context on PostDetailPage (e.g. /nevent1… for a comment rooted
  on a Gatherer URL) shows a compact preview row matching the ISBN and
  country patterns: small card art, 'Magic Card' label, card name, set.

Scryfall integration is centralised in src/lib/scryfall.ts (image URLs
and typed JSON fetching) and src/hooks/useScryfallCard.ts. MagicDeckContent
has been refactored to use the shared image helper. All four call sites
share a single TanStack Query cache keyed on the lookup, so one card
triggers one network request.
2026-04-25 20:46:52 -05:00

161 lines
5.3 KiB
TypeScript

/**
* Utilities for talking to the Scryfall API.
*
* Scryfall is the de-facto Magic: The Gathering card database. It has open
* CORS and rate-limits to ~10 requests/second. See https://scryfall.com/docs/api
* for the full API surface.
*
* This module is used by two features:
* - MagicDeckContent (kind 37381 decklists) — image-only lookups via the
* `format=image` redirect endpoint, no JSON parsing.
* - GathererCardHeader (/i/ page for gatherer.wizards.com URLs) — full
* card metadata lookup for a rich display.
*/
/** A single face of a card (for double-faced, modal DFC, split cards, etc.). */
export interface ScryfallCardFace {
name: string;
mana_cost?: string;
type_line?: string;
oracle_text?: string;
power?: string;
toughness?: string;
loyalty?: string;
flavor_text?: string;
image_uris?: ScryfallImageUris;
colors?: string[];
}
/** Image URIs for various display sizes. */
export interface ScryfallImageUris {
small: string;
normal: string;
large: string;
png: string;
art_crop: string;
border_crop: string;
}
/**
* A subset of the Scryfall card object. Only fields actually used in the UI
* are typed — see https://scryfall.com/docs/api/cards for the full schema.
*/
export interface ScryfallCard {
id: string;
name: string;
lang: string;
released_at: string;
layout: string;
mana_cost?: string;
cmc?: number;
type_line?: string;
oracle_text?: string;
power?: string;
toughness?: string;
loyalty?: string;
flavor_text?: string;
colors?: string[];
color_identity?: string[];
set: string;
set_name: string;
collector_number: string;
rarity: string;
artist?: string;
scryfall_uri: string;
/** Present for single-faced cards. */
image_uris?: ScryfallImageUris;
/** Present for cards with multiple faces (DFC, MDFC, split, adventure, etc.). */
card_faces?: ScryfallCardFace[];
}
/** Version of image to request from the `format=image` Scryfall endpoint. */
export type ScryfallImageVersion = 'small' | 'normal' | 'large' | 'png' | 'art_crop' | 'border_crop';
/** Reference to a card by its Scryfall-native identifiers. */
export interface CardRef {
/** Set code, e.g. "neo". Case-insensitive. */
setId?: string;
/** Collector number, e.g. "42". */
artId?: string;
/** Exact card name, used when setId/artId is unavailable. */
name?: string;
}
/**
* Build a Scryfall image URL for a card. Scryfall's `format=image` endpoint
* serves a redirect to the image file, so the returned URL can be used
* directly as the `src` of an `<img>` tag.
*
* Prefers `set + collector_number` for exact printing, falls back to
* `named?exact=` when only a name is known.
*/
export function scryfallImageUrl(
card: CardRef,
version: ScryfallImageVersion = 'normal',
): string {
if (card.setId && card.artId) {
return `https://api.scryfall.com/cards/${encodeURIComponent(card.setId.toLowerCase())}/${encodeURIComponent(card.artId)}?format=image&version=${version}`;
}
return `https://api.scryfall.com/cards/named?exact=${encodeURIComponent(card.name ?? '')}&format=image&version=${version}`;
}
/**
* Build a Scryfall image URL from a Gatherer multiverse ID.
* See https://scryfall.com/docs/api/cards/multiverse
*/
export function scryfallMultiverseImageUrl(
multiverseId: string,
version: ScryfallImageVersion = 'normal',
): string {
return `https://api.scryfall.com/cards/multiverse/${encodeURIComponent(multiverseId)}?format=image&version=${version}`;
}
/** A Scryfall lookup key. Exactly one form is used per request. */
export type ScryfallLookup =
| { kind: 'multiverse'; multiverseId: string }
| { kind: 'set'; set: string; number: string; lang?: string };
/** Build the JSON API URL for a given lookup. */
function scryfallCardApiUrl(lookup: ScryfallLookup): string {
switch (lookup.kind) {
case 'multiverse':
return `https://api.scryfall.com/cards/multiverse/${encodeURIComponent(lookup.multiverseId)}`;
case 'set': {
const base = `https://api.scryfall.com/cards/${encodeURIComponent(lookup.set.toLowerCase())}/${encodeURIComponent(lookup.number)}`;
// Only append /lang when it's non-English; Scryfall's default is English
// and some collector-number routes 404 when /en is explicitly appended.
if (lookup.lang && lookup.lang.toLowerCase() !== 'en' && lookup.lang.toLowerCase() !== 'en-us') {
return `${base}/${encodeURIComponent(lookup.lang.toLowerCase())}`;
}
return base;
}
}
}
/**
* Fetch a full Scryfall card JSON object for the given lookup.
* Returns null on 404 or network failure. Throws on abort.
*/
export async function fetchScryfallCard(
lookup: ScryfallLookup,
signal?: AbortSignal,
): Promise<ScryfallCard | null> {
const url = scryfallCardApiUrl(lookup);
const response = await fetch(url, {
signal,
headers: { Accept: 'application/json' },
});
if (!response.ok) return null;
const data = (await response.json()) as ScryfallCard;
if (!data || data.id == null) return null;
return data;
}
/**
* Best-effort image URL for a Scryfall card, handling double-faced layouts
* by falling back to the front face.
*/
export function cardPrimaryImage(card: ScryfallCard, version: keyof ScryfallImageUris = 'large'): string | undefined {
return card.image_uris?.[version] ?? card.card_faces?.[0]?.image_uris?.[version];
}