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:
@@ -2,7 +2,11 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- viewport-fit=cover: required for env(safe-area-inset-*) to report real
|
||||
values rather than 0. The Android window already draws edge-to-edge
|
||||
behind the status/nav bars (that's why content was overlapping them);
|
||||
this is what lets CSS react to it instead of ignoring it. -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>mtgc</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -53,7 +53,17 @@ fn status_of(s: &AppState) -> Status {
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_status(db: Db) -> Res<Status> {
|
||||
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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
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} />
|
||||
|
||||
@@ -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 (
|
||||
<div className="dialog-backdrop" onClick={onCancel}>
|
||||
<div className={`dialog ${wide ? "dialog-wide" : ""}`} onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="dialog-title">{title}</h3>
|
||||
<div className="dialog-title-row">
|
||||
<h3 className="dialog-title">{title}</h3>
|
||||
<IconButton icon={Icons.close} label="Close" onClick={onCancel} />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1256,11 +1261,19 @@ export function usePersistedState<T>(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<ViewMode>(
|
||||
() => (localStorage.getItem(key) as ViewMode) || "list",
|
||||
);
|
||||
const [mode, setMode] = useState<ViewMode>(() => {
|
||||
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);
|
||||
|
||||
346
src/styles.css
346
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
|
||||
|
||||
@@ -50,6 +50,10 @@ export function CardSearch({
|
||||
const [subtypes, setSubtypes] = useState<string[]>([]);
|
||||
const [keywords, setKeywords] = useState<string[]>([]);
|
||||
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<LibraryFacets>(NO_FACETS);
|
||||
|
||||
const [decks, setDecks] = useState<Deck[]>([]);
|
||||
@@ -185,7 +189,7 @@ export function CardSearch({
|
||||
<tbody>
|
||||
{cards.map((c) => (
|
||||
<tr key={c.oracle_id}>
|
||||
<td>
|
||||
<td className="cell-thumb">
|
||||
<CardThumb card={c} />
|
||||
</td>
|
||||
<td>
|
||||
@@ -193,15 +197,15 @@ export function CardSearch({
|
||||
</td>
|
||||
<td className="card-name">{c.name}</td>
|
||||
<td className="card-type">{c.type_line}</td>
|
||||
<td>{c.mana_value}</td>
|
||||
<td>
|
||||
<td data-label="MV">{c.mana_value}</td>
|
||||
<td data-label="Owned">
|
||||
<QtyStepper
|
||||
value={c.quantity_owned}
|
||||
onDecrement={() => adjust(c.oracle_id, -1)}
|
||||
onIncrement={() => adjust(c.oracle_id, 1)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="In decks">
|
||||
{c.decks.map((d) => (
|
||||
<span key={d} className="deck-chip">
|
||||
{d}
|
||||
@@ -292,6 +296,7 @@ export function CardSearch({
|
||||
<label>Name contains</label>
|
||||
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="e.g. bolt" />
|
||||
</div>
|
||||
<div className={`filters-more ${filtersExpanded ? "expanded" : ""}`}>
|
||||
<div className="field">
|
||||
<label>Playable in commander (color identity ⊆)</label>
|
||||
<ColorToggles value={commander} onChange={setCommander} />
|
||||
@@ -351,6 +356,13 @@ export function CardSearch({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="filters-toggle"
|
||||
onClick={() => setFiltersExpanded((v) => !v)}
|
||||
>
|
||||
{filtersExpanded ? "▴ Fewer filters" : "▾ More filters"}
|
||||
</button>
|
||||
|
||||
<div className="workspace-bar">
|
||||
{hasActiveFilter && (
|
||||
|
||||
@@ -346,6 +346,11 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
|
||||
const [filterQuery, setFilterQuery] = useState("");
|
||||
const [dialog, setDialog] = useState<DeckDialog>(null);
|
||||
const [dialogError, setDialogError] = useState<string | null>(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<CardRef[]>([]);
|
||||
@@ -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 }) {
|
||||
<tbody>
|
||||
{entries.map((e) => (
|
||||
<tr key={e.oracle_id}>
|
||||
<td>
|
||||
<td className="cell-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCards.has(e.oracle_id)}
|
||||
onChange={() => toggleCard(e.oracle_id)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<td className="cell-thumb">
|
||||
<CardThumb card={e} />
|
||||
</td>
|
||||
<td>
|
||||
@@ -783,7 +793,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
|
||||
{e.name}
|
||||
</td>
|
||||
<td className="card-type">{e.type_line}</td>
|
||||
<td>
|
||||
<td data-label="In deck">
|
||||
<QtyStepper
|
||||
value={e.quantity}
|
||||
onDecrement={() => setQty(e.oracle_id, e.quantity - 1)}
|
||||
@@ -792,7 +802,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
|
||||
pending={pendingRemoval.has(e.oracle_id)}
|
||||
/>
|
||||
</td>
|
||||
<td>{e.quantity_owned}</td>
|
||||
<td data-label="Owned">{e.quantity_owned}</td>
|
||||
<td>
|
||||
<IconButton {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
|
||||
</td>
|
||||
@@ -818,7 +828,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="deck-layout">
|
||||
<div className={`deck-layout ${mobileShowList ? "show-list" : "show-detail"}`}>
|
||||
<div className="deck-sidebar">
|
||||
<div className="row" style={{ justifyContent: "space-between" }}>
|
||||
<h2>Decks</h2>
|
||||
@@ -840,7 +850,10 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
|
||||
<li
|
||||
key={d.id}
|
||||
style={d.id === selected ? { background: "var(--accent-subtle)" } : undefined}
|
||||
onClick={() => setSelected(d.id)}
|
||||
onClick={() => {
|
||||
setSelected(d.id);
|
||||
setMobileShowList(false);
|
||||
}}
|
||||
>
|
||||
<div className="deck-list-row">
|
||||
<span className="card-name">{d.name}</span>
|
||||
@@ -887,13 +900,22 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="deck-detail-pane">
|
||||
{selected === null || !deck ? (
|
||||
<p className="hint">Select or create a deck.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="row" style={{ justifyContent: "space-between" }}>
|
||||
<h2>{deck.name}</h2>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<span className="deck-back-btn">
|
||||
<IconButton
|
||||
icon={Icons.arrowLeftCircle}
|
||||
label="Back to decks"
|
||||
onClick={() => setMobileShowList(true)}
|
||||
/>
|
||||
</span>
|
||||
<h2>{deck.name}</h2>
|
||||
</div>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<button
|
||||
onClick={() => setSuggestOpen(true)}
|
||||
|
||||
@@ -54,6 +54,10 @@ export function LibraryBrowser({
|
||||
const [inDeck, setInDeck] = useState<number | "">("");
|
||||
const [location, setLocation] = 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<LibraryFacets>(NO_FACETS);
|
||||
|
||||
const [decks, setDecks] = useState<Deck[]>([]);
|
||||
@@ -321,14 +325,14 @@ export function LibraryBrowser({
|
||||
<tbody>
|
||||
{cards.map((c) => (
|
||||
<tr key={c.oracle_id}>
|
||||
<td>
|
||||
<td className="cell-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(c.oracle_id)}
|
||||
onChange={() => toggleOne(c.oracle_id)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<td className="cell-thumb">
|
||||
<CardThumb card={c} />
|
||||
</td>
|
||||
<td>
|
||||
@@ -336,8 +340,11 @@ export function LibraryBrowser({
|
||||
</td>
|
||||
<td className="card-name">{c.name}</td>
|
||||
<td className="card-type">{c.type_line}</td>
|
||||
<td>{c.mana_value}</td>
|
||||
<td className={c.quantity_assigned > c.quantity_owned ? "over-assigned" : ""}>
|
||||
<td data-label="MV">{c.mana_value}</td>
|
||||
<td
|
||||
data-label="Owned"
|
||||
className={c.quantity_assigned > c.quantity_owned ? "over-assigned" : ""}
|
||||
>
|
||||
<span className="row" style={{ gap: 8 }}>
|
||||
<QtyStepper
|
||||
value={c.quantity_owned}
|
||||
@@ -353,7 +360,7 @@ export function LibraryBrowser({
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td>{deckOrLocation(c)}</td>
|
||||
<td data-label="Decks / Location">{deckOrLocation(c)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -522,6 +529,7 @@ export function LibraryBrowser({
|
||||
<label>Name contains</label>
|
||||
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="e.g. bolt" />
|
||||
</div>
|
||||
<div className={`filters-more ${filtersExpanded ? "expanded" : ""}`}>
|
||||
<div className="field">
|
||||
<label>Playable in commander (color identity ⊆)</label>
|
||||
<ColorToggles value={commander} onChange={setCommander} />
|
||||
@@ -600,6 +608,13 @@ export function LibraryBrowser({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="filters-toggle"
|
||||
onClick={() => setFiltersExpanded((v) => !v)}
|
||||
>
|
||||
{filtersExpanded ? "▴ Fewer filters" : "▾ More filters"}
|
||||
</button>
|
||||
|
||||
<div className="workspace-bar">
|
||||
<label className="workspace-bar-check" title="Select all filtered cards">
|
||||
|
||||
Reference in New Issue
Block a user