Files
mtgc/src/views/NewsView.tsx
Garrett Mills 6072d44ebe Tiles view: +/- control for cards per row
Adjusts the tile grid's column count as an offset from the existing
width-based auto-fill default, shared across Library, Decks, Card search,
and set card lists. Clicking the count resets to the default.
2026-10-16 21:14:43 -05:00

398 lines
14 KiB
TypeScript

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<News | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showAll, setShowAll] = useState(false);
const [openSet, setOpenSet] = useState<SetInfo | null>(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<string | null>(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 (
<div>
<div className="page-header">
<h2 className="page-header-title">
{openSet.name}
{cards && <span className="badge">{cards.length}</span>}
</h2>
<div className="page-header-actions">
<TileColumnsControl tiles={tiles} />
<button onClick={() => setOpenSet(null)}>← Back to news</button>
</div>
</div>
<div className="hint">
{formatDate(openSet.released_at)} · {openSet.set_type.replace(/_/g, " ")} · set code{" "}
{openSet.code.toUpperCase()}
</div>
{/* 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" && (
<div className="panel workspace">
<div className="filters">
<div className="field">
<label>Name contains</label>
<input
value={setText}
onChange={(e) => setSetText(e.target.value)}
placeholder="e.g. bolt"
/>
</div>
<div className="field">
<label>Includes colors</label>
<ColorToggles
value={setColors}
onChange={setSetColors}
colorless={setColorless}
onColorlessChange={setSetColorless}
colorlessTitle="Colorless cards only"
/>
</div>
<CardCriteriaFields value={setCriteria} onChange={patchSetCriteria} />
</div>
</div>
)}
{source === "live" && (
<p className="hint">
This set isn't in your downloaded card data yet, so it's being read
straight from Scryfall — filters aren't available here. Run{" "}
<strong>Update card data</strong> once it's out to filter it like the
rest.
</p>
)}
{cardsError ? (
<p className="hint">{cardsError}</p>
) : !cards || source === null ? (
<p className="hint">Loading cards…</p>
) : cards.length === 0 ? (
<p className="hint">
{source === "local"
? "No cards match these filters."
: "No cards listed for this set yet."}
</p>
) : (
<div {...tiles.gridProps} style={{ ...tiles.gridProps.style, marginTop: "var(--space-4)" }}>
{cards.map((c) => (
<CardTile
key={c.oracle_id}
card={c}
corner={
<QtyStepper
overlay
value={c.quantity_owned}
onDecrement={() => adjust(c.oracle_id, -1)}
onIncrement={() => adjust(c.oracle_id, 1)}
/>
}
/>
))}
</div>
)}
</div>
);
}
// --- 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 (
<li
key={s.code}
className={`set-row ${upcoming ? "upcoming" : ""}`}
onClick={() => 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. */}
<span className="set-date">
<span className="set-date-day">{top}</span>
<span className="set-date-year">{bottom}</span>
</span>
{s.icon_uri ? (
<img className="set-icon" src={s.icon_uri} alt="" loading="lazy" />
) : (
<span className="set-icon" />
)}
<span className="set-row-main">
<span className="set-name">{s.name}</span>
<span className="set-row-meta">
{s.set_type.replace(/_/g, " ")} · {s.card_count.toLocaleString()} cards
</span>
</span>
</li>
);
};
return (
<div>
<div className="page-header">
<h2 className="page-header-title">News</h2>
<div className="page-header-actions">
<button onClick={() => load(true)} disabled={busy}>
{busy ? "Refreshing…" : "Refresh"}
</button>
</div>
</div>
{error && <p className="hint">{error}</p>}
{news && (
<div className="hint">
Updated {formatAge(news.fetched_at)}
{news.fetched_at === 0 && " — check your connection"}
</div>
)}
{!news && !error && <p className="hint">Loading…</p>}
{news && (
<div className="news-columns">
<section className="news-col">
<h3 className="group-header">Releases</h3>
{news.sets.length === 0 ? (
<p className="hint">Couldn't load set releases.</p>
) : (
<>
{upcoming.length > 0 && (
<>
<div className="section-label">Upcoming</div>
<ul className="result-list set-list">{upcoming.map((s) => setRow(s, true))}</ul>
</>
)}
<div className="section-label" style={{ marginTop: "var(--space-4)" }}>
Released
</div>
<ul className="result-list set-list">{shown.map((s) => setRow(s))}</ul>
{released.length > shown.length && (
<div className="row" style={{ marginTop: "var(--space-3)" }}>
<button onClick={() => setShowAll(true)}>
Show all {released.length} sets
</button>
</div>
)}
</>
)}
</section>
<section className="news-col">
<h3 className="group-header">Latest from Wizards</h3>
{/* This half has no supported feed, so it's expected to break
eventually. Say so plainly and leave releases alone. */}
{news.articles.length === 0 ? (
<p className="hint">
{news.articles_ok
? "No articles found."
: "Couldn't load the latest news right now."}
</p>
) : (
<>
{!news.articles_ok && (
<p className="hint">Showing the last articles we could load.</p>
)}
<ul className="result-list article-list">
{news.articles.map((a) => (
<li key={a.url} onClick={() => openUrl(a.url)} title={a.url}>
{/* Fixed-width so titles line up; the categories are a
small closed set, not free text. */}
<span className="article-cat">{a.category.replace(/-/g, " ")}</span>
<span className="article-title">{a.title}</span>
</li>
))}
</ul>
<p className="hint">Articles open on magic.wizards.com.</p>
</>
)}
</section>
</div>
)}
</div>
);
}