diff --git a/index.html b/index.html index df10597..da7b7ff 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,11 @@ - + + mtgc diff --git a/src-tauri/gen/android/buildSrc/src/main/java/com/garrettmills/mtgc/kotlin/BuildTask.kt b/src-tauri/gen/android/buildSrc/src/main/java/com/garrettmills/mtgc/kotlin/BuildTask.kt index a1b43c0..2c3c5ba 100644 --- a/src-tauri/gen/android/buildSrc/src/main/java/com/garrettmills/mtgc/kotlin/BuildTask.kt +++ b/src-tauri/gen/android/buildSrc/src/main/java/com/garrettmills/mtgc/kotlin/BuildTask.kt @@ -1,4 +1,5 @@ import java.io.File +import java.util.Properties import org.apache.tools.ant.taskdefs.condition.Os import org.gradle.api.DefaultTask import org.gradle.api.GradleException @@ -50,9 +51,23 @@ open class BuildTask : DefaultTask() { val release = release ?: throw GradleException("release cannot be null") val args = listOf("tauri", "android", "android-studio-script"); + // `project.exec` inherits whatever environment the Gradle daemon has — + // which is whatever launched Android Studio, not necessarily a shell + // where ANDROID_HOME/NDK_HOME were exported. Without NDK_HOME, cargo's + // build script for `ring` (and rusqlite's bundled SQLite) can't find + // the NDK's clang and rustBuild*Debug/Release fails outright. Read + // local.properties instead — the same file AGP itself reads sdk.dir + // from — so this works regardless of how Studio was started. + val localProps = Properties().apply { + val f = project.rootProject.file("local.properties") + if (f.exists()) f.inputStream().use { load(it) } + } + project.exec { workingDir(File(project.projectDir, rootDirRel)) executable(executable) + localProps.getProperty("sdk.dir")?.let { environment("ANDROID_HOME", it) } + localProps.getProperty("ndk.dir")?.let { environment("NDK_HOME", it) } args(args) if (project.logger.isEnabled(LogLevel.DEBUG)) { args("-vv") diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b676780..273db46 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -53,7 +53,17 @@ fn status_of(s: &AppState) -> Status { #[tauri::command] pub fn get_status(db: Db) -> Res { - Ok(status_of(&db.lock().unwrap())) + let status = status_of(&db.lock().unwrap()); + // Only ever called once per launch (from the frontend's one status-fetch + // effect, retried on failure/timeout) — cheap to log unconditionally, and + // it's the one way to tell "this invoke never reached Rust" (nothing + // logged at all) apart from "it reached Rust and returned not-ready". + crate::mlog!( + "get_status: reference_ready={} card_count={}", + status.reference_ready, + status.card_count + ); + Ok(status) } #[tauri::command] diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 7ed6853..3f378a5 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -58,8 +58,20 @@ impl AppState { index: None, ingest_cancel: None, }; - // Best-effort open; failures just leave the catalog unloaded. - let _ = s.reopen(); + crate::mlog!( + "AppState::new: library={} reference={} (exists={})", + s.library_path.display(), + s.reference_path.display(), + s.reference_path.exists() + ); + // Best-effort open; failures just leave the catalog unloaded (the UI + // falls back to "no card data yet" and offers Download/Import again). + // That fallback previously made a real open failure indistinguishable + // from a first run — log it so it shows up in `adb logcat -s + // RustStdoutStderr` instead of silently reappearing every launch. + if let Err(e) = s.reopen() { + crate::mlog!("AppState::new: reopen failed: {e}"); + } s } diff --git a/src/App.tsx b/src/App.tsx index f322126..19bad46 100644 --- a/src/App.tsx +++ b/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) => ( + + + + , + ), +}; + /** 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 ( +
+ + No card data +
+ ); + } + return ( +
+
+ + Your library +
+ {!libStats ? ( +
Loading…
+ ) : ( + <> +
+ + + +
+ + + )} +
+
{status?.card_count.toLocaleString()} cards in database
+ {status?.reference_updated_at != null && ( +
+ Updated {formatRelativeTime(status.reference_updated_at)} +
+ )} +
+
+ ); +} + /** 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(null); const [busy, setBusy] = useState(false); const lastProgress = useRef(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((_, 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("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 | 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((_, 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() {
- {!ready ? ( -
- - No card data -
- ) : ( -
-
- - Your library -
- {!libStats ? ( -
Loading…
- ) : ( - <> -
- - - -
- - - )} -
-
{status?.card_count.toLocaleString()} cards in database
- {status?.reference_updated_at != null && ( -
- Updated {formatRelativeTime(status.reference_updated_at)} -
- )} -
-
- )} +
@@ -414,10 +506,14 @@ export default function App() { )} - + {/* On mobile this lives in the bottom nav's Info tab instead — + see below — rather than crowding the topbar. */} + {status && !status.is_mobile && ( + + )} {progress && ( @@ -458,7 +554,14 @@ export default function App() { )}
- {!ready ? ( + {!statusLoaded ? ( +
+
+ +
+

Loading…

+
+ ) : !ready ? (
@@ -493,11 +596,58 @@ export default function App() { ) : tab === "search" ? ( - ) : ( + ) : tab === "decks" ? ( + ) : ( + // 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. +
+

Card database

+
+ {status?.library_path} +
+
+ + +
+
+ +
)}
+ + {/* 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 && ( + + )}
{undo && snackbarVisible && ( diff --git a/src/components.tsx b/src/components.tsx index 0f032cd..7c6e8bc 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -138,7 +138,9 @@ export const Icons = { /** * A centered modal dialog (backdrop + card) shared by [`ConfirmDialog`], * [`PromptDialog`], and other dialogs built outside this module (e.g. the - * command palette). Dismisses on Escape or backdrop click. + * command palette). Dismisses on Escape, backdrop click, or the title-bar + * close button — the explicit button matters most on mobile, where there's + * no Escape key and a `wide` dialog can leave little to no backdrop to tap. */ export function DialogFrame({ title, @@ -161,7 +163,10 @@ export function DialogFrame({ return (
e.stopPropagation()}> -

{title}

+
+

{title}

+ +
{children}
@@ -1256,11 +1261,19 @@ export function usePersistedState(key: string, initial: T): [T, (v: T) => voi export type ViewMode = "list" | "tiles"; -/** View-mode state persisted to localStorage so it survives tab switches. */ +/** View-mode state persisted to localStorage so it survives tab switches. + * + * With nothing persisted yet, the default itself is width-aware: List's wide + * multi-column table forces horizontal scrolling on a phone, so narrow + * screens start on Tiles (a reflowing grid, no horizontal scroll) instead. + * This only picks the *default* — once a mode is explicitly chosen it's + * remembered and used everywhere, same as before. */ export function useViewMode(key: string): [ViewMode, (m: ViewMode) => void] { - const [mode, setMode] = useState( - () => (localStorage.getItem(key) as ViewMode) || "list", - ); + const [mode, setMode] = useState(() => { + const saved = localStorage.getItem(key) as ViewMode | null; + if (saved) return saved; + return typeof window !== "undefined" && window.innerWidth <= 720 ? "tiles" : "list"; + }); const set = useCallback( (m: ViewMode) => { localStorage.setItem(key, m); diff --git a/src/styles.css b/src/styles.css index e72b88f..de4864e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -84,6 +84,9 @@ /* Interactive control height — bumped on coarse pointers (see bottom). */ --control-h: 34px; + /* Height of .bottom-nav's own content — excludes the safe-area inset added + on top of it, so consumers computing clearance must add that separately. */ + --bottom-nav-h: 58px; --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); --shadow-md: 0 6px 20px rgba(0, 0, 0, 0.4); @@ -399,6 +402,57 @@ input[type="checkbox"] { display: inline-flex; color: inherit; } + +/* Android-style bottom tab bar — replaces .nav-rail on narrow viewports (see + the ≤720px block below), rather than the rail collapsing to an icon strip. + Hidden by default; enabled only in that breakpoint. Fixed to the viewport + (not `.app`) since `.app` uses `overflow: hidden` and z-indexes elsewhere + (dialogs 400+, the click-preview modal 450) are the reason this sits at + 200 — above ordinary content, below anything that should cover the whole + screen. */ +.bottom-nav { + display: none; + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 200; + height: calc(var(--bottom-nav-h) + env(safe-area-inset-bottom)); + padding-bottom: env(safe-area-inset-bottom); + background: var(--bg-rail); + border-top: 1px solid var(--border-soft); +} +.bottom-nav-item { + position: relative; + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + height: 100%; + padding: 0 2px; + border: none; + border-radius: 0; + background: transparent; + color: var(--muted); + font-size: 10px; + font-weight: 600; +} +.bottom-nav-item.active { + color: var(--accent); +} +.bottom-nav-item.active::before { + content: ""; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + width: 28px; + height: 3px; + border-radius: 0 0 3px 3px; + background: var(--accent); +} .nav-foot { margin-top: auto; padding: 14px 10px 10px; @@ -574,12 +628,19 @@ input[type="checkbox"] { background: var(--bg-content); } .topbar { - height: 52px; + /* min-height (not height): on Android the window draws edge-to-edge behind + the status bar, so the safe-area inset has to ADD to the bar's height + rather than eat into it — a fixed height would squash the buttons under + the status bar instead of below it. Inert (0) anywhere without a system + bar to avoid, e.g. desktop. */ + min-height: 52px; + padding-top: env(safe-area-inset-top); flex-shrink: 0; display: flex; align-items: center; gap: 10px; - padding: 0 18px; + padding-left: 18px; + padding-right: 18px; background: var(--bg-topbar); border-bottom: 1px solid var(--border-soft); } @@ -715,6 +776,15 @@ input[type="checkbox"] { margin: 4px 0 0; } +/* Mobile's Info tab — card-database management, which lives in the topbar + instead on desktop (see INFO_TAB in App.tsx). */ +.info-panel { + display: flex; + flex-direction: column; + gap: var(--space-3); + max-width: 480px; +} + /* ============================================================ Typography helpers ============================================================ */ @@ -1056,8 +1126,15 @@ li:focus-within .row-actions { max-height: 86vh; overflow-y: auto; } +.dialog-title-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} .dialog-title { - margin: 0 0 10px; + margin: 0; font-size: 16px; font-weight: 600; text-transform: none; @@ -1363,6 +1440,107 @@ tbody tr { tbody tr:hover { background: rgba(255, 255, 255, 0.022); } + +/* On a phone, the List view's 8-column table has no room to lay out + side-by-side and forced horizontal scrolling of the whole page (not just + the table) — see the .content padding/overflow rules elsewhere, nothing + scoped the scroll to the table itself. Below this breakpoint each row + becomes its own stacked card instead: every cell drops to its own line, + headers (meaningless once stacked) are hidden, and cells that need a label + outside their normal column context get one from `data-label` (set in the + view components — see LibraryBrowser/CardSearch/DeckView's table JSX). */ +@media (max-width: 720px) { + table, + tbody, + tr { + display: block; + width: 100%; + } + thead { + display: none; + } + table { + border-collapse: separate; + border-spacing: 0; + } + tbody tr { + position: relative; + overflow: hidden; /* contains .cell-thumb's float below */ + background: var(--bg-panel); + border: 1px solid var(--border-soft); + border-radius: var(--radius-lg); + padding: var(--space-3); + padding-right: calc(var(--space-3) + 28px); /* clears .cell-check */ + margin-bottom: var(--space-3); + } + tbody tr:hover { + background: var(--bg-panel); + } + td { + padding: 0; + border-bottom: none; + } + /* Selection checkbox: pinned to the row-card's corner instead of taking a + line of its own. */ + .cell-check { + position: absolute; + top: var(--space-3); + right: var(--space-3); + width: auto; + } + /* Thumbnail floats so the name/type text wraps beside it — that's what + buys back the vertical space the old one-cell-per-line layout wasted. */ + .cell-thumb { + float: left; + width: 56px; + margin-right: var(--space-3); + } + .cell-thumb .card-thumb, + .cell-thumb .thumb-empty { + display: block; + width: 56px; + height: auto; + } + td.card-name { + display: block; + font-size: var(--fs-body); + font-weight: 700; + color: var(--text-strong); + } + td.card-type { + display: block; + color: var(--muted); + font-size: var(--fs-caption); + } + /* Color pips and any other small cell (MV, Owned, In deck, Decks / + Location, ...) sit inline as wrapping chips instead of one per line — + as long as they fit within the floated thumbnail's height they tuck in + beside it, otherwise they wrap below it like normal text. */ + td:has(.pips), + td[data-label] { + display: inline-flex; + align-items: center; + gap: 4px; + margin: var(--space-2) var(--space-3) 0 0; + font-size: var(--fs-caption); + } + td[data-label]::before { + content: attr(data-label) ":"; + font-size: var(--fs-micro); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--faint); + } + /* Buttons (Add to deck, maybeboard toggle, ...) always get their own full + row below everything else. */ + td:has(button) { + display: block; + clear: both; + margin-top: var(--space-2); + } +} + .over-assigned { color: var(--warn); font-weight: 600; @@ -1459,6 +1637,28 @@ tbody tr:hover { grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: var(--space-4); } +/* Wraps every field after "Name contains" so it can be hidden as a group on + narrow screens — a 9-10 field grid is a wall to scroll past on a phone + before any results appear. `display: contents` on the wrapper (rather than + e.g. a nested grid) keeps every field a direct child of `.filters` at every + width, so it doesn't disturb the shared grid's column sizing/auto-flow; + only the max-width:720px rule below actually hides anything. */ +.filters-more { + display: contents; +} +/* Irrelevant above the breakpoint (every field is already visible there). */ +.filters-toggle { + display: none; +} +@media (max-width: 720px) { + .filters-toggle { + display: inline-flex; + margin-top: var(--space-3); + } + .filters-more:not(.expanded) { + display: none; + } +} /* The results toolbar strip at the bottom of the workspace panel: selection + count on the left, group/sort/view on the right, divided from the filters. */ .workspace-bar { @@ -1639,6 +1839,13 @@ tbody tr:hover { justify-content: center; overflow-y: auto; padding: 24px 12px; + /* The close button pokes up above the (flex-centered) modal content, so on + a tall/near-fullscreen card image it can land right under the Android + status bar — same edge-to-edge issue as .topbar, same fix: pad the safe + area instead of a fixed inset. Degrades to the base 24px on anything + without a system bar to avoid. */ + padding-top: calc(24px + env(safe-area-inset-top)); + padding-bottom: calc(24px + env(safe-area-inset-bottom)); background: rgba(10, 10, 12, 0.72); backdrop-filter: blur(2px); cursor: zoom-out; @@ -2036,6 +2243,10 @@ tbody tr:hover { width: 240px; flex-shrink: 0; } +.deck-detail-pane { + flex: 1; + min-width: 0; +} /* Surface from `.panel`; layout only here. */ .deck-meta { @@ -2205,45 +2416,128 @@ tbody tr:hover { } } +/* Back button into the deck list — only meaningful once the list and the + deck-building pane become separate screens below, so it's hidden outside + that breakpoint (and on the tablet stacked layout, where both panes are + still visible together). */ +.deck-back-btn { + display: none; +} + /* Compact: tighten page gutters and collapse the nav rail to an icon strip - (same items — labels, wordmark, and the library summary hide; hover/title - still names each tab). No separate mobile navigation is introduced. */ + the left rail's job moves to .bottom-nav below instead, the customary + placement on Android). */ @media (max-width: 720px) { .content { padding: var(--space-4); + /* Clear the fixed bottom nav, or its last row of content is hidden + behind it — the safe-area inset is already baked into the nav's own + height, so it isn't added again here. */ + padding-bottom: calc(var(--bottom-nav-h) + env(safe-area-inset-bottom) + var(--space-4)); } .nav-rail { - width: 60px; - padding: var(--space-3) var(--space-2) var(--space-2); - } - .brand { - justify-content: center; - padding: var(--space-1) 0 var(--space-4); - } - .brand-text { display: none; } - .nav-item { - justify-content: center; - padding: var(--space-2); - } - .nav-label { - display: none; - } - .nav-item.active::before { - left: -8px; - } - .nav-foot { - display: none; + .bottom-nav { + display: flex; } .topbar { - padding: 0 var(--space-3); + padding-left: var(--space-3); + padding-right: var(--space-3); + /* padding-top (the safe-area inset) is untouched here — this must not + reintroduce the shorthand `padding: 0 ...` that was zeroing it out. */ } /* The library path is the first thing to go when horizontal space is tight — the library actions stay reachable. */ .path { display: none; } + /* Deck view's header (name + Suggest/Export/Sample buttons) and several + panels below assume desktop width via `min-width` (280/220px) and a + non-wrapping `.row` — those don't fit a phone and forced the whole page + to scroll horizontally. Let everything wrap and drop the min-widths + instead. */ + .row { + flex-wrap: wrap; + } + .commander-box, + .deck-stats-numbers, + .deck-stats-curve { + min-width: 0; + } + /* `.deck-meta` puts the Format selector and the commander search box side + by side as flex-wrap row items — with `.commander-box`'s min-width now + 0, it no longer wraps onto its own line, it just gets squeezed into + whatever sliver is left next to "Format", crushing the search results. + Stack them instead. */ + .deck-meta { + flex-direction: column; + align-items: stretch; + } + /* Deck list and deck-building pane become separate screens (only one + visible at a time) instead of stacking above one another — with many + decks, scrolling past the whole list to reach the builder doesn't + scale. `.deck-layout`'s show-list/show-detail class is toggled by + DeckView in response to picking a deck / the back button. */ + .deck-back-btn { + display: inline-flex; + } + .deck-layout.show-list .deck-detail-pane { + display: none; + } + .deck-layout.show-detail .deck-sidebar { + display: none; + } + /* The Suggested Cards dialog: `.suggestion-actions`' two nowrap buttons + are flex-shrink:0, so they claimed their full fixed width no matter how + little room was left, crushing `.suggestion-main` down to almost + nothing — the name text then wrapped one word (sometimes one syllable) + per line instead of actually gaining any space. `flex-basis: 100%` on + `.suggestion-actions` forces it onto its own full-width line below the + thumbnail + name/reasons instead of competing with them for the same + line. */ + .dialog-wide { + width: 96vw; + padding: 14px; + } + .suggestion-row { + flex-wrap: wrap; + } + .suggestion-head { + flex-wrap: wrap; + row-gap: 2px; + } + .suggestion-actions { + flex-basis: 100%; + flex-direction: row; + margin-top: 6px; + } + .suggestion-actions button { + flex: 1; + } + /* Clear the bottom nav the same way .content does, so the undo snackbar + doesn't land underneath it. */ + .undo-bar { + bottom: calc(var(--bottom-nav-h) + env(safe-area-inset-bottom) + var(--space-3)); + } + /* The ingest bar's segmented layout assumes room for 4 side-by-side + stage labels — on a phone-width screen the tiny-weighted stages + ("Index", "Indexing") can't fit theirs and it collides with its + neighbor's. Drop the per-segment labels (the header message above + already names the current stage in more detail) and let that header + message wrap instead of ellipsis-truncating, so status text like a + download's running byte count stays readable. */ + .ingest-bar { + padding: var(--space-2) var(--space-3) var(--space-3); + } + .ingest-bar-message { + white-space: normal; + overflow: visible; + text-overflow: clip; + } + .ingest-segment-label { + display: none; + } } /* Touch input (future mobile / touchscreen laptops): grow tap targets to a diff --git a/src/views/CardSearch.tsx b/src/views/CardSearch.tsx index 7d2c3e6..58a8ef5 100644 --- a/src/views/CardSearch.tsx +++ b/src/views/CardSearch.tsx @@ -50,6 +50,10 @@ export function CardSearch({ const [subtypes, setSubtypes] = useState([]); const [keywords, setKeywords] = useState([]); const [oracleText, setOracleText] = useState(""); + // Collapsed by default (relevant on narrow/mobile screens only — see + // .filters-more's CSS, which is always expanded above that breakpoint + // regardless of this). + const [filtersExpanded, setFiltersExpanded] = useState(false); const [facets, setFacets] = useState(NO_FACETS); const [decks, setDecks] = useState([]); @@ -185,7 +189,7 @@ export function CardSearch({ {cards.map((c) => ( - + @@ -193,15 +197,15 @@ export function CardSearch({ {c.name} {c.type_line} - {c.mana_value} - + {c.mana_value} + adjust(c.oracle_id, -1)} onIncrement={() => adjust(c.oracle_id, 1)} /> - + {c.decks.map((d) => ( {d} @@ -292,6 +296,7 @@ export function CardSearch({ setText(e.target.value)} placeholder="e.g. bolt" /> +
@@ -351,6 +356,13 @@ export function CardSearch({ />
+ +
{hasActiveFilter && ( diff --git a/src/views/DeckView.tsx b/src/views/DeckView.tsx index 4221354..3b1e26b 100644 --- a/src/views/DeckView.tsx +++ b/src/views/DeckView.tsx @@ -346,6 +346,11 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) { const [filterQuery, setFilterQuery] = useState(""); const [dialog, setDialog] = useState(null); const [dialogError, setDialogError] = useState(null); + // On narrow screens the deck list and the deck-building interface are + // separate "screens" (only one visible at a time) rather than stacked — + // this only matters below the .deck-layout stacking breakpoint; CSS keeps + // both panes visible side by side above it regardless of this flag. + const [mobileShowList, setMobileShowList] = useState(true); const [libQuery, setLibQuery] = useState(""); const [libResults, setLibResults] = useState([]); @@ -467,6 +472,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) { setNewName(""); await loadDecks(); setSelected(d.id); + setMobileShowList(false); } async function exportDeck(d: Deck) { @@ -497,6 +503,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) { setDialogError(null); await loadDecks(); setSelected(result.deck.id); + setMobileShowList(false); } catch (err) { setDialogError(friendlyDeckError(err)); } @@ -638,7 +645,10 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) { async function confirmDelete() { if (dialog?.kind !== "delete") return; await api.deleteDeck(dialog.deck.id); - if (selected === dialog.deck.id) setSelected(null); + if (selected === dialog.deck.id) { + setSelected(null); + setMobileShowList(true); + } setDialog(null); await loadDecks(); } @@ -765,14 +775,14 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) { {entries.map((e) => ( - + toggleCard(e.oracle_id)} /> - + @@ -783,7 +793,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) { {e.name} {e.type_line} - + setQty(e.oracle_id, e.quantity - 1)} @@ -792,7 +802,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) { pending={pendingRemoval.has(e.oracle_id)} /> - {e.quantity_owned} + {e.quantity_owned} setMaybeboard(e, !e.is_maybeboard)} /> @@ -818,7 +828,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) { } return ( -
+

Decks

@@ -840,7 +850,10 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
  • setSelected(d.id)} + onClick={() => { + setSelected(d.id); + setMobileShowList(false); + }} >
    {d.name} @@ -887,13 +900,22 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
    -
    +
    {selected === null || !deck ? (

    Select or create a deck.

    ) : ( <>
    -

    {deck.name}

    +
    + + setMobileShowList(true)} + /> + +

    {deck.name}

    +
    +
    @@ -600,6 +608,13 @@ export function LibraryBrowser({ />
    +
    +