import { useCallback, useEffect, useState } from "react"; import { openUrl } from "@tauri-apps/plugin-opener"; import { api, News, SetCard, SetInfo } from "../api"; import { CardTile, QtyStepper } from "../components"; /** Sets whose release date is still ahead of us, newest-announced first. */ function splitByRelease(sets: SetInfo[]): { upcoming: SetInfo[]; released: SetInfo[] } { const today = new Date().toISOString().slice(0, 10); const upcoming = sets.filter((s) => s.released_at !== null && s.released_at > today).reverse(); const released = sets.filter((s) => s.released_at === null || s.released_at <= today); return { upcoming, released }; } function formatDate(iso: string | null): string { if (!iso) return "Date TBA"; const d = new Date(`${iso}T00:00:00`); return Number.isNaN(d.getTime()) ? iso : d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); } /** Date split for the timeline gutter, so every row's name starts at the same x. */ function dateParts(iso: string | null): { top: string; bottom: string } { if (!iso) return { top: "TBA", bottom: "" }; const d = new Date(`${iso}T00:00:00`); if (Number.isNaN(d.getTime())) return { top: iso, bottom: "" }; return { top: d.toLocaleDateString(undefined, { month: "short", day: "numeric" }), bottom: String(d.getFullYear()), }; } function formatAge(fetchedAt: number): string { if (!fetchedAt) return "not loaded yet"; const mins = Math.floor((Date.now() / 1000 - fetchedAt) / 60); if (mins < 2) return "just now"; if (mins < 60) return `${mins} minutes ago`; const hours = Math.floor(mins / 60); if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`; const days = Math.floor(hours / 24); return `${days} day${days === 1 ? "" : "s"} ago`; } /** How many sets to show before "Show more" — a full list is 500+ rows. */ const RELEASED_PAGE = 12; /** * What's happening in Magic outside your own collection: set releases you can * add cards from, and links to Wizards' latest articles. * * The two halves are deliberately independent. Releases come from Scryfall's * documented API; articles are scraped from a page with no supported feed, so * that section is allowed to fail on its own without taking the page with it. */ export function NewsView() { const [news, setNews] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [showAll, setShowAll] = useState(false); const [openSet, setOpenSet] = useState(null); const [cards, setCards] = useState(null); const [cardsError, setCardsError] = useState(null); const load = useCallback(async (refresh: boolean) => { setBusy(true); setError(null); try { setNews(await api.getNews(refresh)); } catch (err) { setError(String(err)); } finally { setBusy(false); } }, []); useEffect(() => { load(false); }, [load]); async function chooseSet(set: SetInfo) { setOpenSet(set); setCards(null); setCardsError(null); try { setCards(await api.getSetCards(set.code)); } catch (err) { setCardsError(String(err)); } } async function adjust(oracleId: string, delta: number) { const qty = await api.addOwned(oracleId, delta); setCards((cs) => cs ? cs.map((c) => (c.oracle_id === oracleId ? { ...c, quantity_owned: qty } : c)) : cs, ); } // --- a set's cards ------------------------------------------------------ if (openSet) { return (

{openSet.name} {cards && {cards.length}}

{formatDate(openSet.released_at)} · {openSet.set_type.replace(/_/g, " ")} · set code{" "} {openSet.code.toUpperCase()}
{cardsError ? (

{cardsError}

) : !cards ? (

Loading cards…

) : cards.length === 0 ? (

No cards listed for this set yet.

) : (
{cards.map((c) => ( adjust(c.oracle_id, -1)} onIncrement={() => adjust(c.oracle_id, 1)} /> } /> ))}
)}
); } // --- the feed ----------------------------------------------------------- const { upcoming, released } = splitByRelease(news?.sets ?? []); const shown = showAll ? released : released.slice(0, RELEASED_PAGE); const setRow = (s: SetInfo, upcoming = false) => { const { top, bottom } = dateParts(s.released_at); return (
  • chooseSet(s)} > {/* Fixed-width date gutter: every set name then starts at the same x, which is what turns this from a list into a timeline. */} {top} {bottom} {s.icon_uri ? ( ) : ( )} {s.name} {s.set_type.replace(/_/g, " ")} · {s.card_count.toLocaleString()} cards
  • ); }; return (

    News

    {error &&

    {error}

    } {news && (
    Updated {formatAge(news.fetched_at)} {news.fetched_at === 0 && " — check your connection"}
    )} {!news && !error &&

    Loading…

    } {news && (

    Releases

    {news.sets.length === 0 ? (

    Couldn't load set releases.

    ) : ( <> {upcoming.length > 0 && ( <>
    Upcoming
      {upcoming.map((s) => setRow(s, true))}
    )}
    Released
      {shown.map((s) => setRow(s))}
    {released.length > shown.length && (
    )} )}

    Latest from Wizards

    {/* This half has no supported feed, so it's expected to break eventually. Say so plainly and leave releases alone. */} {news.articles.length === 0 ? (

    {news.articles_ok ? "No articles found." : "Couldn't load the latest news right now."}

    ) : ( <> {!news.articles_ok && (

    Showing the last articles we could load.

    )}
      {news.articles.map((a) => (
    • openUrl(a.url)} title={a.url}> {/* Fixed-width so titles line up; the categories are a small closed set, not free text. */} {a.category.replace(/-/g, " ")} {a.title}
    • ))}

    Articles open on magic.wizards.com.

    )}
    )}
    ); }