Mobile UI pass: responsive tables, deck-view drill-down, dialog fixes

- Library/Card Search/Deck tables stack into dense card-style rows below
  720px instead of scrolling horizontally (floated thumbnail, wrapping
  chips for MV/Owned/color pips).
- Deck view: fix desktop min-widths and a non-wrapping button row that
  forced horizontal scroll on phones; split the deck list and the
  deck-building pane into separate drill-down screens with a back button
  so a long deck list doesn't bury the builder.
- Suggested Cards modal: fix action buttons crushing the card name down to
  one word per line by giving them their own full-width row.
- Add a close (X) button to the shared dialog title bar — mobile has no
  Escape key and wide dialogs leave little backdrop to tap.
- Info tab, bottom-nav loading/safe-area fixes, and Gradle NDK_HOME
  inheritance fix carried over from the Android bring-up work.
This commit is contained in:
2026-08-02 16:10:25 -05:00
parent 94e4f25ec4
commit 663af32562
10 changed files with 646 additions and 99 deletions

View File

@@ -17,7 +17,7 @@ import { LibraryBrowser } from "./views/LibraryBrowser";
import { CardSearch } from "./views/CardSearch";
import { DeckView } from "./views/DeckView";
type Tab = "add" | "library" | "search" | "decks";
type Tab = "add" | "library" | "search" | "decks" | "info";
const svg = (children: ReactNode, size = 20) => (
<svg
@@ -118,6 +118,20 @@ const NAV: { id: Tab; label: string; icon: ReactNode }[] = [
},
];
/** Mobile-only tab (see `.bottom-nav`) holding card-database management —
* on desktop that lives in the topbar instead, so this isn't in `NAV`. */
const INFO_TAB: { id: Tab; label: string; icon: ReactNode } = {
id: "info",
label: "Info",
icon: svg(
<>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</>,
),
};
/** Compact relative time, e.g. "2h ago" — for the "database updated" stamp. */
function formatRelativeTime(epochSeconds: number): string {
const diffSec = Math.round((Date.now() - epochSeconds * 1000) / 1000);
@@ -133,6 +147,59 @@ function formatRelativeTime(epochSeconds: number): string {
return `${Math.round(months / 12)}y ago`;
}
/** "Your library" summary: unique/copy/deck counts, color distribution, and
* the reference-db freshness stamp. Shared by the desktop nav rail's footer
* and the mobile Info tab, which otherwise have no equivalent place for it. */
function LibrarySummary({
ready,
libStats,
status,
}: {
ready: boolean;
libStats: LibraryStats | null;
status: Status | null;
}) {
if (!ready) {
return (
<div className="nav-foot-simple">
<span className="status-dot off" />
No card data
</div>
);
}
return (
<div className="lib-stats">
<div className="lib-stats-head">
<span className="status-dot on" />
<span className="lib-stats-title">Your library</span>
</div>
{!libStats ? (
<div className="lib-stats-loading">Loading…</div>
) : (
<>
<div className="stat-tiles-row">
<StatTile value={libStats.unique_cards.toLocaleString()} label="unique" />
<StatTile value={libStats.total_copies.toLocaleString()} label="copies" />
<StatTile value={libStats.deck_count.toLocaleString()} label="decks" />
</div>
<ColorDistributionBar
colorCounts={libStats.color_counts}
colorlessCount={libStats.colorless_count}
/>
</>
)}
<div className="lib-stats-sub">
<div>{status?.card_count.toLocaleString()} cards in database</div>
{status?.reference_updated_at != null && (
<div title={new Date(status.reference_updated_at * 1000).toLocaleString()}>
Updated {formatRelativeTime(status.reference_updated_at)}
</div>
)}
</div>
</div>
);
}
/** Percent-fill (0..100) of one segment, given overall cumulative percent. */
function segmentFill(stages: IngestStageInfo[], index: number, overallPercent: number): number {
let start = 0;
@@ -158,6 +225,9 @@ export default function App() {
const [progress, setProgress] = useState<IngestProgress | null>(null);
const [busy, setBusy] = useState(false);
const lastProgress = useRef<IngestProgress | null>(null);
// Set by the ingest-progress listener below; reset before each
// updateCardData() call so it can detect a stalled invoke — see there.
const gotProgress = useRef(false);
// Lives here (not in LibraryBrowser) so it's reachable from the command
// palette, which is mounted at the app level and outlives any one tab's
// view across tab switches. Unlike the snackbar, this has no timeout —
@@ -198,12 +268,41 @@ export default function App() {
}
const refreshStatus = useCallback(async () => {
setStatus(await api.getStatus());
// This is the *only* place status gets fetched — nothing else re-polls it.
// The Android IPC bridge has been observed to silently drop an invoke()
// outright: the promise never resolves *or* rejects (confirmed
// separately for update_card_data — same symptom, no backend log line at
// all, so the call never even reached Rust). A try/catch alone doesn't
// help against that, since nothing ever throws; the race against a
// per-attempt timeout below is what actually turns a hang into a
// retryable failure. Without it, this ran once, hung forever, and the
// app was permanently stuck on "no card data yet" every launch — even
// with a fully loaded reference.db on the backend, since nothing else
// ever asks it again.
for (let attempt = 0; ; attempt++) {
try {
const s = await Promise.race([
api.getStatus(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("get_status timed out")), 5000),
),
]);
setStatus(s);
return;
} catch (err) {
if (attempt >= 4) {
console.error("getStatus failed after retries:", err);
return;
}
await new Promise((r) => setTimeout(r, 300 * (attempt + 1)));
}
}
}, []);
useEffect(() => {
refreshStatus();
const un = listen<IngestProgress>("ingest-progress", (e) => {
gotProgress.current = true;
lastProgress.current = e.payload;
setProgress(e.payload);
});
@@ -221,6 +320,14 @@ export default function App() {
}, []);
const ready = status?.reference_ready ?? false;
// On Android the very first IPC call after launch has been observed taking
// several seconds (likely one-time JIT/class-verification warm-up on the
// native bridge, not a hang — it does complete). `!ready` was previously
// used both for "we don't know yet" and "confirmed no card data", so during
// that warm-up the app showed the full "Download card data" prompt — which
// invited exactly what happened: a redundant download click before the
// real status had even loaded. Distinguish the two explicitly.
const statusLoaded = status !== null;
// Keep the sidebar's library summary fresh: fetch once card data is ready,
// then poll (cards/decks can change from any tab without a shared event bus).
@@ -241,8 +348,27 @@ export default function App() {
async function updateCardData() {
setBusy(true);
gotProgress.current = false;
let stallTimer: ReturnType<typeof setTimeout> | undefined;
try {
const s = await api.updateCardData();
// Android's Tauri IPC bridge has been observed to silently drop an
// invoke() during a dev-mode webview reload: the promise never settles,
// no ingest-progress event ever arrives, and the button was stuck
// "Downloading…" forever with no way to retry (the backend never even
// logged that it started). A real ingest always emits its first
// progress event within milliseconds — it happens before any network
// I/O — so treat "no progress after a generous window" as a dropped
// call rather than hang indefinitely.
const s = await Promise.race([
api.updateCardData(),
new Promise<never>((_, reject) => {
stallTimer = setTimeout(() => {
if (!gotProgress.current) {
reject(new Error("Couldn't reach the app. Try again — if this keeps happening, restart the app."));
}
}, 8000);
}),
]);
setStatus(s);
setProgress({
stage: "done",
@@ -270,6 +396,7 @@ export default function App() {
});
}
} finally {
clearTimeout(stallTimer);
setBusy(false);
}
}
@@ -355,42 +482,7 @@ export default function App() {
</div>
<div className="nav-foot">
{!ready ? (
<div className="nav-foot-simple">
<span className="status-dot off" />
No card data
</div>
) : (
<div className="lib-stats">
<div className="lib-stats-head">
<span className="status-dot on" />
<span className="lib-stats-title">Your library</span>
</div>
{!libStats ? (
<div className="lib-stats-loading">Loading…</div>
) : (
<>
<div className="stat-tiles-row">
<StatTile value={libStats.unique_cards.toLocaleString()} label="unique" />
<StatTile value={libStats.total_copies.toLocaleString()} label="copies" />
<StatTile value={libStats.deck_count.toLocaleString()} label="decks" />
</div>
<ColorDistributionBar
colorCounts={libStats.color_counts}
colorlessCount={libStats.colorless_count}
/>
</>
)}
<div className="lib-stats-sub">
<div>{status?.card_count.toLocaleString()} cards in database</div>
{status?.reference_updated_at != null && (
<div title={new Date(status.reference_updated_at * 1000).toLocaleString()}>
Updated {formatRelativeTime(status.reference_updated_at)}
</div>
)}
</div>
</div>
)}
<LibrarySummary ready={ready} libStats={libStats} status={status} />
</div>
</nav>
@@ -414,10 +506,14 @@ export default function App() {
</button>
</>
)}
<button onClick={updateCardData} disabled={busy} className="primary">
{ICON_DOWNLOAD}
{ready ? "Update card data" : "Download card data"}
</button>
{/* On mobile this lives in the bottom nav's Info tab instead —
see below — rather than crowding the topbar. */}
{status && !status.is_mobile && (
<button onClick={updateCardData} disabled={busy} className="primary">
{ICON_DOWNLOAD}
{ready ? "Update card data" : "Download card data"}
</button>
)}
</header>
{progress && (
@@ -458,7 +554,14 @@ export default function App() {
)}
<div className="content">
{!ready ? (
{!statusLoaded ? (
<div className="empty-state">
<div className="empty-mark">
<ShipLogo size={40} gradientId="empty-ship-grad" />
</div>
<p className="hint">Loading…</p>
</div>
) : !ready ? (
<div className="empty-state">
<div className="empty-mark">
<ShipLogo size={40} gradientId="empty-ship-grad" />
@@ -493,11 +596,58 @@ export default function App() {
<LibraryBrowser onOfferUndo={offerUndo} />
) : tab === "search" ? (
<CardSearch onOfferUndo={offerUndo} />
) : (
) : tab === "decks" ? (
<DeckView refreshSignal={deckRefreshSignal} />
) : (
// Mobile-only tab (see INFO_TAB) — the desktop equivalent is
// split between the topbar's Update card data button (hidden
// here on mobile) and the nav rail's footer summary (which
// has nowhere to live once the rail collapses to the bottom
// nav), so both land here together.
<div className="panel info-panel">
<h2 style={{ margin: 0 }}>Card database</h2>
<div className="hint" style={{ wordBreak: "break-all" }}>
{status?.library_path}
</div>
<div className="row" style={{ gap: "var(--space-3)", flexWrap: "wrap" }}>
<button className="primary" onClick={updateCardData} disabled={busy}>
{ICON_DOWNLOAD}
{busy ? "Downloading…" : "Update card data"}
</button>
<button onClick={importReferenceDb} disabled={busy}>
{ICON_OPEN_LIBRARY}
Import card data…
</button>
</div>
<hr className="deck-section-divider" style={{ margin: 0 }} />
<LibrarySummary ready={ready} libStats={libStats} status={status} />
</div>
)}
</div>
</div>
{/* Android-customary bottom tab bar — replaces the rail above on
narrow viewports (see the ≤720px CSS breakpoint), rather than the
rail collapsing to an icon strip in place. Hidden while the
initial status fetch is still pending: there's nothing to
navigate to yet, and showing it makes the loading screen look
further along than it is. Carries an extra "Info" tab beyond
`NAV` — card-database management, which lives in the topbar on
desktop instead (see the button below). */}
{statusLoaded && (
<nav className="bottom-nav">
{[...NAV, INFO_TAB].map((n) => (
<button
key={n.id}
className={`bottom-nav-item ${tab === n.id ? "active" : ""}`}
onClick={() => setTab(n.id)}
>
<span className="nav-icon">{n.icon}</span>
<span>{n.label}</span>
</button>
))}
</nav>
)}
</div>
{undo && snackbarVisible && (
<UndoBar message={undo.message} onUndo={runUndo} onDismiss={dismissUndo} />