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:
240
src/App.tsx
240
src/App.tsx
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user