import { useCallback, useEffect, useState } from "react"; import { openUrl } from "@tauri-apps/plugin-opener"; import { api, LibraryCard, LibraryFilter, News, SetCard, SetInfo } from "../api"; import { CardCriteriaFields, CardTile, ColorToggles, QtyStepper, TileColumnsControl, applyCardCriteria, useCardCriteria, useTileColumns, } from "../components"; import { useBackButton } from "../backButton"; /** 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<(LibraryCard | SetCard)[] | null>(null); // Where a set's cards come from. "local" reads downloaded card data, which is // what makes the criteria filters possible; "live" queries Scryfall directly // for a set that isn't in reference.db yet — an upcoming or newly-released // one — which can't be filtered, so the panel is hidden rather than shown // doing nothing. Null while we work out which. const [source, setSource] = useState<"local" | "live" | null>(null); const [cardsError, setCardsError] = useState(null); const tiles = useTileColumns(); // The same criteria the Library and Card Search panels use — the set browser // runs through search_reference rather than its own path, so every filter // works here identically instead of being reimplemented. const [setText, setSetText] = useState(""); const [setColors, setSetColors] = useState(0); const [setColorless, setSetColorless] = useState(false); const [setCriteria, patchSetCriteria] = useCardCriteria("news-set-criteria"); 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]); // Android back from a set's card list returns to the news, like the // "Back to news" button. useBackButton(() => setOpenSet(null), !!openSet); function openSetBrowser(set: SetInfo) { setOpenSet(set); setCards(null); setCardsError(null); setSource(null); } // Decide the source once per set, with an *unfiltered* probe: asking whether // the current criteria match anything would flip a perfectly indexed set to // "live" the moment a filter excluded everything. useEffect(() => { if (!openSet) return; let cancelled = false; setSource(null); api .searchReference({ set_code: openSet.code }, 1) .then((r) => !cancelled && setSource(r.length > 0 ? "local" : "live")) .catch(() => !cancelled && setSource("live")); return () => { cancelled = true; }; }, [openSet]); // Not in the downloaded card data: fetch the set straight from Scryfall so an // upcoming set is still browsable, just without filters. useEffect(() => { if (!openSet || source !== "live") return; let cancelled = false; api .getSetCards(openSet.code) .then((r) => { if (cancelled) return; setCards(r); setCardsError(null); }) .catch((e) => { if (cancelled) return; setCards([]); setCardsError(String(e)); }); return () => { cancelled = true; }; }, [openSet, source]); // Refetch whenever the set or any criterion changes, debounced like the other // filter panels so typing doesn't fire a query per keystroke. useEffect(() => { if (!openSet || source !== "local") return; const filter: LibraryFilter = { set_code: openSet.code, text: setText.trim() || null, colors_include: setColors || null, colorless_colors: setColorless, ...applyCardCriteria(setCriteria), }; let cancelled = false; const t = setTimeout(() => { api .searchReference(filter, 500) .then((r) => { if (cancelled) return; setCards(r); setCardsError(null); }) .catch((e) => { if (cancelled) return; setCards([]); setCardsError(String(e)); }); }, 120); return () => { cancelled = true; clearTimeout(t); }; }, [openSet, source, setText, setColors, setColorless, setCriteria]); 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()}
{/* Filters run against downloaded card data, so they only exist in "local" mode. Showing a panel that silently does nothing would be worse than not showing one. */} {source === "local" && (
setSetText(e.target.value)} placeholder="e.g. bolt" />
)} {source === "live" && (

This set isn't in your downloaded card data yet, so it's being read straight from Scryfall — filters aren't available here. Run{" "} Update card data once it's out to filter it like the rest.

)} {cardsError ? (

{cardsError}

) : !cards || source === null ? (

Loading cards…

) : cards.length === 0 ? (

{source === "local" ? "No cards match these filters." : "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 (
  • openSetBrowser(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.

    )}
    )}
    ); }