aa8541298e
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.
243 lines
8.5 KiB
TypeScript
243 lines
8.5 KiB
TypeScript
import { MASTODON_SERVERS } from '@/lib/mastodonServers';
|
|
|
|
/** Extract a YouTube video ID from a URL, or null if not a YouTube link. */
|
|
export function extractYouTubeId(url: string): string | null {
|
|
try {
|
|
const u = new URL(url);
|
|
if ((u.hostname === 'www.youtube.com' || u.hostname === 'youtube.com' || u.hostname === 'm.youtube.com') && u.pathname === '/watch') {
|
|
return u.searchParams.get('v');
|
|
}
|
|
if ((u.hostname === 'www.youtube.com' || u.hostname === 'youtube.com') && u.pathname.startsWith('/embed/')) {
|
|
return u.pathname.split('/')[2] || null;
|
|
}
|
|
if ((u.hostname === 'www.youtube.com' || u.hostname === 'youtube.com') && u.pathname.startsWith('/shorts/')) {
|
|
return u.pathname.split('/')[2] || null;
|
|
}
|
|
if (u.hostname === 'youtu.be') {
|
|
return u.pathname.slice(1) || null;
|
|
}
|
|
} catch {
|
|
// not a valid URL
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Extract a tweet/post ID from a Twitter or X URL, or null if not a tweet link. */
|
|
export function extractTweetId(url: string): string | null {
|
|
try {
|
|
const u = new URL(url);
|
|
const host = u.hostname.replace(/^www\./, '').replace(/^mobile\./, '');
|
|
if (host !== 'twitter.com' && host !== 'x.com') return null;
|
|
const match = u.pathname.match(/^\/[^/]+\/status\/(\d+)/);
|
|
return match ? match[1] : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Bluesky post info extracted from a bsky.app URL. */
|
|
export interface BlueskyPostInfo {
|
|
/** Handle or DID of the author. */
|
|
author: string;
|
|
/** Record key of the post. */
|
|
rkey: string;
|
|
}
|
|
|
|
/** Extract Bluesky post info from a bsky.app URL, or null if not a Bluesky post link. */
|
|
export function extractBlueskyPost(url: string): BlueskyPostInfo | null {
|
|
try {
|
|
const u = new URL(url);
|
|
const host = u.hostname.replace(/^www\./, '');
|
|
if (host !== 'bsky.app') return null;
|
|
// Match /profile/{handle-or-did}/post/{rkey}
|
|
const match = u.pathname.match(/^\/profile\/([^/]+)\/post\/([a-z0-9]+)$/i);
|
|
return match ? { author: match[1], rkey: match[2] } : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Extract a Mastodon post URL if the domain is a known Mastodon instance. */
|
|
export function extractMastodonPost(url: string): string | null {
|
|
try {
|
|
const u = new URL(url);
|
|
const host = u.hostname.replace(/^www\./, '');
|
|
if (!MASTODON_SERVERS.has(host)) return null;
|
|
// Match /@{user}/{id} or /@{user}@{domain}/{id} (remote posts)
|
|
if (/^\/@[^/]+\/\d+$/.test(u.pathname)) {
|
|
return url;
|
|
}
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Spotify embed info extracted from an open.spotify.com URL. */
|
|
export interface SpotifyEmbedInfo {
|
|
/** Content type: track, album, playlist, episode, show. */
|
|
type: string;
|
|
/** Spotify content ID. */
|
|
id: string;
|
|
}
|
|
|
|
/** Extract Spotify embed info from an open.spotify.com URL. */
|
|
export function extractSpotifyEmbed(url: string): SpotifyEmbedInfo | null {
|
|
try {
|
|
const u = new URL(url);
|
|
const host = u.hostname.replace(/^www\./, '');
|
|
if (host !== 'open.spotify.com') return null;
|
|
// Match /{type}/{id} where type is track, album, playlist, episode, or show
|
|
const match = u.pathname.match(/^\/(track|album|playlist|episode|show)\/([a-zA-Z0-9]+)/);
|
|
return match ? { type: match[1], id: match[2] } : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Extract a Reddit post URL, or null if not a Reddit post link. */
|
|
export function extractRedditPost(url: string): string | null {
|
|
try {
|
|
const u = new URL(url);
|
|
const host = u.hostname.replace(/^www\./, '').replace(/^old\./, '').replace(/^new\./, '');
|
|
if (host !== 'reddit.com') return null;
|
|
// Match /r/{subreddit}/comments/{id}/...
|
|
if (/^\/r\/[^/]+\/comments\/[a-z0-9]+/i.test(u.pathname)) {
|
|
return url;
|
|
}
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Extract an Internet Archive item identifier from an archive.org URL, or null if not a match. */
|
|
export function extractArchiveOrgId(url: string): string | null {
|
|
try {
|
|
const u = new URL(url);
|
|
const host = u.hostname.replace(/^www\./, '');
|
|
if (host !== 'archive.org') return null;
|
|
// Match /details/{identifier} or /embed/{identifier}
|
|
const match = u.pathname.match(/^\/(details|embed)\/([^/?#]+)/);
|
|
return match ? match[2] : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract a Wikidata entity ID (Q…, P…, or L…) from a Wikidata URL, or null if not one.
|
|
*
|
|
* Supports the "globally unique" concept URI form described at
|
|
* https://www.wikidata.org/wiki/Wikidata:Identifiers:
|
|
* - https://www.wikidata.org/entity/Q42
|
|
* - http://www.wikidata.org/entity/Q42
|
|
* …as well as the browser-facing page URL:
|
|
* - https://www.wikidata.org/wiki/Q42
|
|
*/
|
|
export function extractWikidataId(url: string): string | null {
|
|
try {
|
|
const u = new URL(url);
|
|
const host = u.hostname.replace(/^www\./, '');
|
|
if (host !== 'wikidata.org') return null;
|
|
// Match /entity/{id} or /wiki/{id} where id is Q|P|L followed by digits.
|
|
const match = u.pathname.match(/^\/(?:entity|wiki)\/([QPL]\d+)$/);
|
|
return match ? match[1] : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract a Wikipedia article title from a Wikipedia URL, or null if not a Wikipedia article link.
|
|
* Supports en.wikipedia.org/wiki/{title} and other language subdomains.
|
|
*/
|
|
export function extractWikipediaTitle(url: string): string | null {
|
|
try {
|
|
const u = new URL(url);
|
|
const host = u.hostname.replace(/^www\./, '');
|
|
// Match {lang}.wikipedia.org
|
|
if (!host.endsWith('.wikipedia.org')) return null;
|
|
// Match /wiki/{title} — ignore special pages, talk pages, etc.
|
|
const match = u.pathname.match(/^\/wiki\/([^:][^#]*)$/);
|
|
if (!match) return null;
|
|
return decodeURIComponent(match[1].replace(/_/g, ' '));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lookup keys that can identify a Magic: The Gathering card printing on Scryfall.
|
|
* Returned by {@link extractGathererCard}.
|
|
*/
|
|
export type GathererCard =
|
|
| { kind: 'multiverse'; multiverseId: string }
|
|
| { kind: 'set'; set: string; number: string; lang: string };
|
|
|
|
/**
|
|
* Extract a Magic card reference from a gatherer.wizards.com URL.
|
|
*
|
|
* Supports two URL shapes Wizards has used over the years:
|
|
*
|
|
* 1. Modern pretty URLs:
|
|
* `https://gatherer.wizards.com/{SET}/{LANG}/{COLLECTOR_NUMBER}/{slug}`
|
|
* e.g. `/BNG/en-us/156/xenagos-god-of-revels`
|
|
*
|
|
* 2. Legacy ASPX query-string URLs:
|
|
* `https://gatherer.wizards.com/Pages/Card/Details.aspx?multiverseid=12345`
|
|
*
|
|
* Returns `null` for any Gatherer URL that can't be mapped to a Scryfall
|
|
* lookup (e.g. name-only searches, card list pages, printings pages).
|
|
*/
|
|
export function extractGathererCard(url: string): GathererCard | null {
|
|
try {
|
|
const u = new URL(url);
|
|
const host = u.hostname.replace(/^www\./, '');
|
|
if (host !== 'gatherer.wizards.com') return null;
|
|
|
|
// Legacy query-string form.
|
|
if (/\/Pages\/Card\/[^/]+\.aspx$/i.test(u.pathname)) {
|
|
const multiverseId = u.searchParams.get('multiverseid');
|
|
if (multiverseId && /^\d+$/.test(multiverseId)) {
|
|
return { kind: 'multiverse', multiverseId };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Modern /{SET}/{LANG}/{NUM}/{slug} form. The set code is 2-6 alphanum
|
|
// chars (e.g. BNG, MH2, 30A), lang is the bcp-47-ish locale tag used in
|
|
// Gatherer URLs (e.g. en-us, ja-jp), and the collector number can
|
|
// contain letters/digits/stars (e.g. 156, 42a, 1★).
|
|
const match = u.pathname.match(/^\/([A-Za-z0-9]{2,6})\/([a-z]{2}(?:-[a-z]{2})?)\/([^/]+)(?:\/[^/]*)?$/);
|
|
if (match) {
|
|
const [, set, lang, number] = match;
|
|
return { kind: 'set', set, number: decodeURIComponent(number), lang };
|
|
}
|
|
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Returns true if the URL should be rendered as a rich embed rather than a plain link. */
|
|
export function isEmbeddableUrl(url: string): boolean {
|
|
return !!extractYouTubeId(url) || !!extractTweetId(url) || !!extractBlueskyPost(url)
|
|
|| !!extractMastodonPost(url) || !!extractSpotifyEmbed(url) || !!extractRedditPost(url)
|
|
|| !!extractArchiveOrgId(url) || !!extractGathererCard(url);
|
|
}
|
|
|
|
/** Get a short label for the embed type. */
|
|
export function embedLabel(url: string): string | null {
|
|
if (extractYouTubeId(url)) return 'YouTube';
|
|
if (extractTweetId(url)) return 'Twitter';
|
|
if (extractBlueskyPost(url)) return 'Bluesky';
|
|
if (extractMastodonPost(url)) return 'Mastodon';
|
|
if (extractSpotifyEmbed(url)) return 'Spotify';
|
|
if (extractRedditPost(url)) return 'Reddit';
|
|
if (extractArchiveOrgId(url)) return 'Internet Archive';
|
|
if (extractGathererCard(url)) return 'Gatherer';
|
|
return null;
|
|
}
|