261 lines
9.2 KiB
TypeScript
261 lines
9.2 KiB
TypeScript
|
|
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>
|
||
|
|
);
|
||
|
|
}
|