/** * archidekt.ts * --------------------------------------------------------------------------- * Server-side helper for pulling Greg's public MTG decks from Archidekt into * the TCGs page. The site is SSR (output: 'server'), so this runs on the * server — no CORS or API key required (Archidekt's decks list endpoint is * public JSON). * * Endpoint: GET https://archidekt.com/api/decks/v3/?ownerUsername= * -> { count, results: [{ id, name, featured, colors, size, deckFormat, * updatedAt, private, unlisted, theorycrafted, ... }] } * * Decks are cached to a filesystem file for `CACHE_TTL_MS` (default 1 hour) so * repeat page loads are instant and we're gentle on Archidekt. The cache is * stale-while-revalidate: if a refresh fails, we serve the stale data rather * than erroring. Every failure path returns an empty list — the page renders a * fallback link and never breaks. */ /** Greg's Archidekt username. Change here to point at a different account. */ export const ARCHIDEKT_USERNAME = 'Patiun'; /** How long the cached deck list is considered fresh. Default: 1 hour. */ export const CACHE_TTL_MS = 60 * 60 * 1000; const API_BASE = 'https://archidekt.com/api/decks/v3'; const USER_AGENT = 'GregKilmerProfileSite/1.0 (+https://gregkilmer.com)'; /** Known Archidekt deckFormat ids → labels. Add as needed. */ const FORMAT_LABELS: Record = { 1: 'Casual', 2: 'Modern', 3: 'Commander', 5: 'Standard', 7: 'Legacy', 10: 'Pauper', 14: 'Historic', 16: 'Pioneer', 37: 'Oathbreaker', 45: 'Brawl', }; export interface ArchidektDeck { id: number; name: string; /** Featured card art URL (may be empty). */ featured: string; /** Mana color counts present in the deck. */ colors: { W: number; U: number; B: number; R: number; G: number }; /** Total card count. */ size: number; /** Archidekt format id. */ formatId: number; /** Human-readable format label (e.g. "Commander"). */ formatLabel: string; /** ISO timestamp of last update. */ updatedAt: string; /** Direct link to the deck on Archidekt. */ href: string; theorycrafted: boolean; } export const formatLabel = (id: number): string => FORMAT_LABELS[id] ?? 'Custom'; interface RawDeck { id: number; name: string; featured?: string; customFeatured?: string; colors?: { W?: number; U?: number; B?: number; R?: number; G?: number }; size?: number; deckFormat?: number; updatedAt?: string; private?: boolean; unlisted?: boolean; theorycrafted?: boolean; } const fileURL = (username: string): string => // node_modules/.cache is gitignored and survives across dev reloads. new URL( `../node_modules/.cache/archidekt-${username}.json`, import.meta.url, ).href; /** * Reads the cached decks for `username` if still fresh, else null. * Best-effort: any read/parse error returns null (treated as a miss). */ async function readCache(username: string): Promise { try { const { readFile } = await import('node:fs/promises'); const raw = await readFile(fileURL(username), 'utf8'); const data = JSON.parse(raw) as { fetchedAt: number; decks: ArchidektDeck[] }; if (!data?.fetchedAt || !Array.isArray(data.decks)) return null; if (Date.now() - data.fetchedAt > CACHE_TTL_MS) return null; return data.decks; } catch { return null; } } /** Writes the cache. Failures are non-fatal (best-effort). */ async function writeCache(username: string, decks: ArchidektDeck[]): Promise { try { const { mkdir, writeFile } = await import('node:fs/promises'); const { dirname } = await import('node:path'); const { fileURLToPath } = await import('node:url'); const path = fileURLToPath(fileURL(username)); await mkdir(dirname(path), { recursive: true }); await writeFile(path, JSON.stringify({ fetchedAt: Date.now(), decks }, null, 2)); } catch { /* cache write is best-effort */ } } /** Reads stale cache regardless of TTL (used as a fallback on fetch failure). */ async function readStale(username: string): Promise { try { const { readFile } = await import('node:fs/promises'); const raw = await readFile(fileURL(username), 'utf8'); const data = JSON.parse(raw) as { fetchedAt: number; decks: ArchidektDeck[] }; return Array.isArray(data?.decks) ? data.decks : null; } catch { return null; } } /** * Fetches Greg's public Archidekt decks, using a ~1h filesystem cache. * Never throws — on any failure returns an empty list (page shows a fallback). */ export async function getDecks(username = ARCHIDEKT_USERNAME): Promise { // 1. Fresh cache hit. const cached = await readCache(username); if (cached) return cached; // 2. Live fetch. const url = `${API_BASE}/?ownerUsername=${encodeURIComponent(username)}`; try { const res = await fetch(url, { headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' }, // Don't let a slow Archidekt hang the page render. signal: AbortSignal.timeout(8000), }); if (!res.ok) throw new Error(`Archidekt responded ${res.status}`); const data = (await res.json()) as { results?: RawDeck[] }; const decks: ArchidektDeck[] = (data.results ?? []) .filter((d) => !d.private && !d.unlisted) .map((d) => ({ id: d.id, name: d.name, featured: d.customFeatured || d.featured || '', colors: { W: d.colors?.W ?? 0, U: d.colors?.U ?? 0, B: d.colors?.B ?? 0, R: d.colors?.R ?? 0, G: d.colors?.G ?? 0, }, size: d.size ?? 0, formatId: d.deckFormat ?? 0, formatLabel: formatLabel(d.deckFormat ?? 0), updatedAt: d.updatedAt ?? '', href: `https://archidekt.com/decks/${d.id}`, theorycrafted: Boolean(d.theorycrafted), })); await writeCache(username, decks); return decks; } catch { // 3. Stale-while-revalidate fallback, else empty. return (await readStale(username)) ?? []; } } /** The Archidekt search-URL for the full decks list (used in fallback links). */ export const archidektSearchUrl = (username = ARCHIDEKT_USERNAME): string => `https://archidekt.com/search/decks?ownerUsername=${encodeURIComponent(username)}`;