Add a News tab: set releases and links to Wizards' articles
The app knew nothing about the game outside your own collection — no way to see that a set drops next week, or that a Secret Lair was announced. Two sources with very different reliability, kept apart so one failing can't take the page down. Releases come from Scryfall's documented /sets endpoint. Clicking a set fetches its cards live rather than reading reference.db, so a set released or spoiled since the last card-data download still works; ownership is joined locally by oracle_id, and the same add-to-library stepper works there as anywhere else. Articles have no supported source at all: Wizards publishes no RSS or Atom feed and no public API, and their page is a Nuxt app whose CSS classes are per-build hashes. So the app links out rather than reproducing anyone's writing, which keeps the scraped surface to titles and hrefs. parse_articles keys on nothing cosmetic — anchors pointing at /en/news/<category>/<slug>, taking the anchor's own text as the title. A restyle can't break that; only a change to their URL scheme could, and the fixture tests fail loudly when it does rather than the page quietly going empty. Verified against a live capture: 15 articles, correct titles and categories. Two things the fixtures caught. Where an article is linked several times per card, the first link is a "Learn More" button, so the longest text wins rather than the first. And a *successful* fetch yielding zero articles is treated as a failure, since that is the signature of their markup changing. get_set_cards returns a full CardRef rather than the handful of fields the grid needs, so the tab renders the same CardTile as the rest of the app and gets click-to-preview instead of a lookalike that does nothing when clicked. Both feeds cache to news-cache.json for six hours and fall back to whatever was last seen, so opening the tab offline shows stale content with its age rather than an error. Also promotes the HTTP agent out of ingest.rs into a shared http module, so timeouts and User-Agent can't drift between the ingester and anything else that reaches the network. Adds tauri-plugin-opener for opening articles externally.
This commit is contained in:
19
src/App.tsx
19
src/App.tsx
@@ -16,8 +16,9 @@ import { CardEntry } from "./views/CardEntry";
|
||||
import { LibraryBrowser } from "./views/LibraryBrowser";
|
||||
import { CardSearch } from "./views/CardSearch";
|
||||
import { DeckView } from "./views/DeckView";
|
||||
import { NewsView } from "./views/NewsView";
|
||||
|
||||
type Tab = "add" | "library" | "search" | "decks" | "info";
|
||||
type Tab = "add" | "library" | "search" | "decks" | "news" | "info";
|
||||
|
||||
const svg = (children: ReactNode, size = 20) => (
|
||||
<svg
|
||||
@@ -116,6 +117,18 @@ const NAV: { id: Tab; label: string; icon: ReactNode }[] = [
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "news",
|
||||
label: "News",
|
||||
icon: svg(
|
||||
<>
|
||||
<path d="M4 5h11a1 1 0 0 1 1 1v13H5a1 1 0 0 1-1-1z" />
|
||||
<path d="M16 9h3a1 1 0 0 1 1 1v8a1 1 0 0 1-2 0V9" />
|
||||
<line x1="7" y1="9" x2="13" y2="9" />
|
||||
<line x1="7" y1="13" x2="13" y2="13" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
/** Mobile-only tab (see `.bottom-nav`) holding card-database management —
|
||||
@@ -833,6 +846,10 @@ export default function App() {
|
||||
<CardSearch key={status?.library_path} onOfferUndo={offerUndo} />
|
||||
) : tab === "decks" ? (
|
||||
<DeckView key={status?.library_path} refreshSignal={deckRefreshSignal} />
|
||||
) : tab === "news" ? (
|
||||
// Library-independent, so unlike the others it isn't keyed on the
|
||||
// document — switching libraries doesn't change the news.
|
||||
<NewsView />
|
||||
) : (
|
||||
// The per-install status page: card data, library sync, and the
|
||||
// collection summary. On mobile it's the only home for the
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { api, CardRef, Deck } from "./api";
|
||||
import { DialogFrame, useClickPreview } from "./components";
|
||||
|
||||
type Tab = "add" | "library" | "search" | "decks";
|
||||
// Kept in step with App.tsx's Tab union by hand — it isn't exported.
|
||||
type Tab = "add" | "library" | "search" | "decks" | "news";
|
||||
|
||||
interface PaletteItem {
|
||||
key: string;
|
||||
@@ -95,6 +96,7 @@ export function CommandPalette({
|
||||
{ key: "nav-library", label: "Go to Library", run: () => onNavigate("library") },
|
||||
{ key: "nav-search", label: "Go to Card Search", run: () => onNavigate("search") },
|
||||
{ key: "nav-decks", label: "Go to Decks", run: () => onNavigate("decks") },
|
||||
{ key: "nav-news", label: "Go to News", run: () => onNavigate("news") },
|
||||
{ key: "update-card-data", label: "Update card data", run: onUpdateCardData },
|
||||
{ key: "open-library", label: "Switch library file…", run: onOpenLibrary },
|
||||
{ key: "new-library", label: "Create new library file…", run: onNewLibrary },
|
||||
|
||||
42
src/api.ts
42
src/api.ts
@@ -164,6 +164,43 @@ export interface SyncReport {
|
||||
};
|
||||
}
|
||||
|
||||
/** A published or upcoming Magic set, from Scryfall. */
|
||||
export interface SetInfo {
|
||||
code: string;
|
||||
name: string;
|
||||
set_type: string;
|
||||
/** ISO date; null for sets with no announced date, which sort last. */
|
||||
released_at: string | null;
|
||||
card_count: number;
|
||||
icon_uri: string;
|
||||
scryfall_uri: string;
|
||||
}
|
||||
|
||||
/** A news article — title and link only; the app links out rather than
|
||||
* reproducing anyone's writing. */
|
||||
export interface Article {
|
||||
category: string;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface News {
|
||||
sets: SetInfo[];
|
||||
articles: Article[];
|
||||
/** Unix seconds of the last successful fetch; 0 if never. */
|
||||
fetched_at: number;
|
||||
/** False when the article source couldn't be reached or its markup changed.
|
||||
* Only that section is affected — releases stay usable. */
|
||||
articles_ok: boolean;
|
||||
}
|
||||
|
||||
/** A card in a set. Extends CardRef so it renders with the same CardTile —
|
||||
* and therefore the same click-to-preview — as every other card in the app. */
|
||||
export interface SetCard extends CardRef {
|
||||
collector_number: string;
|
||||
quantity_owned: number;
|
||||
}
|
||||
|
||||
export interface LibraryStats {
|
||||
unique_cards: number;
|
||||
total_copies: number;
|
||||
@@ -254,6 +291,11 @@ export const api = {
|
||||
/** Convert a pre-document library.db into the current format. */
|
||||
convertLegacyLibrary: (path: string) => invoke<SyncReport>("convert_legacy_library", { path }),
|
||||
|
||||
/** Set releases and article links. `refresh` forces a fetch past the cache. */
|
||||
getNews: (refresh = false) => invoke<News>("get_news", { refresh }),
|
||||
/** Every card in a set, annotated with how many you own. */
|
||||
getSetCards: (code: string) => invoke<SetCard[]>("get_set_cards", { code }),
|
||||
|
||||
searchCards: (query: string, limit = 20) =>
|
||||
invoke<CardRef[]>("search_cards", { query, limit }),
|
||||
|
||||
|
||||
121
src/styles.css
121
src/styles.css
@@ -2636,3 +2636,124 @@ tbody tr:hover {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
News
|
||||
============================================================ */
|
||||
|
||||
/* Releases and articles side by side on desktop; stacked once there isn't
|
||||
room, which the ≤900px rule below handles. */
|
||||
.news-columns {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-5);
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.news-columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.news-col {
|
||||
min-width: 0;
|
||||
}
|
||||
.news-col .group-header {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Both feeds are dense, uniform rows rather than the default result-list's
|
||||
roomier search-result spacing. */
|
||||
.set-list li,
|
||||
.article-list li {
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
/* --- releases ------------------------------------------------------------
|
||||
A fixed-width date gutter is what makes this scan as a timeline: the dates
|
||||
form one column and every set name starts at the same x, regardless of how
|
||||
long the date renders in the user's locale. */
|
||||
.set-date {
|
||||
flex: 0 0 52px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
line-height: 1.15;
|
||||
}
|
||||
.set-date-day {
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: 700;
|
||||
color: var(--text-strong);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.set-date-year {
|
||||
font-size: var(--fs-micro);
|
||||
color: var(--faint);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* Not-yet-released sets get the accent, so "what's coming" is findable at a
|
||||
glance without reading any dates. */
|
||||
.set-row.upcoming .set-date-day {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Scryfall's set symbols are SVGs with dark ink baked in, so they disappear
|
||||
against a dark panel — inverted rather than shipping our own icon set. */
|
||||
.set-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
filter: invert(1);
|
||||
opacity: 0.62;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.set-row:hover .set-icon {
|
||||
opacity: 0.95;
|
||||
}
|
||||
.set-row-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
.set-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-strong);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.set-row-meta {
|
||||
font-size: var(--fs-micro);
|
||||
color: var(--muted);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
/* --- articles ------------------------------------------------------------ */
|
||||
|
||||
/* Fixed width so every title starts at the same x. Variable-width chips left
|
||||
the titles ragged, which read as misalignment rather than as structure.
|
||||
The categories are a small closed set ("announcements" is the longest),
|
||||
so a fixed slot is safe. */
|
||||
.article-cat {
|
||||
flex: 0 0 88px;
|
||||
font-size: var(--fs-micro);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--faint);
|
||||
text-align: right;
|
||||
line-height: 1.3;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.article-list li:hover .article-cat {
|
||||
color: var(--accent-quiet);
|
||||
}
|
||||
.article-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-strong);
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
260
src/views/NewsView.tsx
Normal file
260
src/views/NewsView.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
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<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<SetCard[] | null>(null);
|
||||
const [cardsError, setCardsError] = useState<string | null>(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 (
|
||||
<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">
|
||||
<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>
|
||||
{cardsError ? (
|
||||
<p className="hint">{cardsError}</p>
|
||||
) : !cards ? (
|
||||
<p className="hint">Loading cards…</p>
|
||||
) : cards.length === 0 ? (
|
||||
<p className="hint">No cards listed for this set yet.</p>
|
||||
) : (
|
||||
<div className="tile-grid" style={{ marginTop: "var(--space-4)" }}>
|
||||
{cards.map((c) => (
|
||||
<CardTile
|
||||
key={c.oracle_id + c.collector_number}
|
||||
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={() => 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. */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user