Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
This commit is contained in:
357
src/App.tsx
357
src/App.tsx
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState, useCallback, ReactNode } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||
import { api, IngestProgress, IngestStageInfo, LibraryStats, Status } from "./api";
|
||||
import { api, IngestProgress, IngestStageInfo, LibraryStats, Status, SyncStatus } from "./api";
|
||||
import {
|
||||
ColorDistributionBar,
|
||||
HoverPreviewProvider,
|
||||
@@ -218,12 +218,25 @@ function segmentFill(stages: IngestStageInfo[], index: number, overallPercent: n
|
||||
// so missing the snackbar isn't the last chance to undo.
|
||||
const UNDO_SNACKBAR_MS = 8000;
|
||||
|
||||
/** How often an open, visible window re-syncs. Long enough not to churn the
|
||||
* synced folder, short enough that a second device sees edits without being
|
||||
* restarted. Launch and foreground/background transitions sync too. */
|
||||
const AUTO_SYNC_MS = 3 * 60 * 1000;
|
||||
|
||||
export default function App() {
|
||||
const [status, setStatus] = useState<Status | null>(null);
|
||||
const [libStats, setLibStats] = useState<LibraryStats | null>(null);
|
||||
const [tab, setTab] = usePersistedState<Tab>("active-tab", "add");
|
||||
const [progress, setProgress] = useState<IngestProgress | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Synced-folder grant for library sync. Only fetched when the Info tab is
|
||||
// open: the grant can be revoked from system settings while the app is
|
||||
// backgrounded, so a value cached at startup would be stale exactly when the
|
||||
// user comes back to check it.
|
||||
const [sync, setSync] = useState<SyncStatus | null>(null);
|
||||
const [syncBusy, setSyncBusy] = useState(false);
|
||||
// Result of the last round-trip self-test, shown until the next action.
|
||||
const [syncTest, setSyncTest] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
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.
|
||||
@@ -328,6 +341,13 @@ export default function App() {
|
||||
// invited exactly what happened: a redundant download click before the
|
||||
// real status had even loaded. Distinguish the two explicitly.
|
||||
const statusLoaded = status !== null;
|
||||
// Mirrors `ready` for the automatic-sync callbacks, which must stay stable
|
||||
// across renders (re-creating them would re-arm the listeners below and fire
|
||||
// a fresh sync every time card data finished loading).
|
||||
const readyRef = useRef(ready);
|
||||
useEffect(() => {
|
||||
readyRef.current = ready;
|
||||
}, [ready]);
|
||||
|
||||
// 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).
|
||||
@@ -405,16 +425,215 @@ export default function App() {
|
||||
await api.cancelUpdateCardData();
|
||||
}
|
||||
|
||||
async function switchLibrary(path: string) {
|
||||
|
||||
/** Refresh the synced-folder grant state. Never throws: "no folder chosen" is
|
||||
* a normal state, reported in the panel rather than as an error. */
|
||||
const refreshSync = useCallback(async () => {
|
||||
setSyncBusy(true);
|
||||
try {
|
||||
setStatus(await api.setLibraryPath(path));
|
||||
setSync(await api.syncStatus());
|
||||
} catch (err) {
|
||||
setProgress({
|
||||
stage: "error",
|
||||
message: `Couldn't open that library: ${String(err)}`,
|
||||
overall_percent: 0,
|
||||
stages: lastProgress.current?.stages ?? [],
|
||||
setSyncTest({ ok: false, message: String(err) });
|
||||
} finally {
|
||||
setSyncBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Once at startup: the automatic sync below needs to know whether a folder is
|
||||
// configured at all.
|
||||
useEffect(() => {
|
||||
if (statusLoaded) refreshSync();
|
||||
}, [statusLoaded, refreshSync]);
|
||||
|
||||
// And whenever the Info tab is opened, which is also when the user gets back
|
||||
// from the folder picker, and when a revoked grant would first matter.
|
||||
useEffect(() => {
|
||||
if (tab === "info") refreshSync();
|
||||
}, [tab, refreshSync]);
|
||||
|
||||
|
||||
/** Choose where the library lives. Android grants a folder through the system
|
||||
* picker (SAF has no path to hand back); desktop picks the JSON file itself. */
|
||||
async function pickLibrary() {
|
||||
setSyncTest(null);
|
||||
try {
|
||||
if (sync?.picker_based) {
|
||||
setSyncBusy(true);
|
||||
setSync(await api.syncPickFolder());
|
||||
return;
|
||||
}
|
||||
const path = await open({
|
||||
title: "Choose your library file",
|
||||
// Start where the library currently lives, so the dialog opens
|
||||
// somewhere useful rather than at an arbitrary default.
|
||||
defaultPath: sync?.label ?? undefined,
|
||||
filters: [
|
||||
{ name: "mtgc library", extensions: ["json"] },
|
||||
{ name: "All files", extensions: ["*"] },
|
||||
],
|
||||
});
|
||||
if (typeof path !== "string") return;
|
||||
setSyncBusy(true);
|
||||
setSync(await api.syncSetDocument(path));
|
||||
} catch (err) {
|
||||
setSyncTest({ ok: false, message: String(err) });
|
||||
} finally {
|
||||
setSyncBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Desktop: start a new library file, e.g. inside a synced folder. */
|
||||
async function newLibrary() {
|
||||
setSyncTest(null);
|
||||
try {
|
||||
const path = await save({
|
||||
title: "Save your library here (put it in a synced folder)",
|
||||
defaultPath: "mtgc-library.json",
|
||||
filters: [{ name: "mtgc library", extensions: ["json"] }],
|
||||
});
|
||||
if (typeof path !== "string") return;
|
||||
setSyncBusy(true);
|
||||
// Pointing at a file that doesn't exist yet moves the current library
|
||||
// there rather than starting empty — see sync_set_document.
|
||||
setSync(await api.syncSetDocument(path));
|
||||
setSyncTest({ ok: true, message: `Your library is now kept at ${path}.` });
|
||||
} catch (err) {
|
||||
setSyncTest({ ok: false, message: String(err) });
|
||||
} finally {
|
||||
setSyncBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function forgetLibrary() {
|
||||
setSyncBusy(true);
|
||||
setSyncTest(null);
|
||||
try {
|
||||
setSync(await api.syncForgetDocument());
|
||||
} catch (err) {
|
||||
setSyncTest({ ok: false, message: String(err) });
|
||||
} finally {
|
||||
setSyncBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert a library.db from an older build into the current format. */
|
||||
async function convertLegacy(path: string) {
|
||||
setSyncBusy(true);
|
||||
setSyncTest(null);
|
||||
try {
|
||||
const r = await api.convertLegacyLibrary(path);
|
||||
const n = r.report.cards_changed + r.report.decks_changed;
|
||||
const status = await api.syncStatus();
|
||||
setSync(status);
|
||||
// Say where it landed — otherwise a successful conversion looks like
|
||||
// nothing happened.
|
||||
setSyncTest({
|
||||
ok: true,
|
||||
message: `Converted your old library (${n} entries). It's now kept at ${status.label ?? "your library file"}.`,
|
||||
});
|
||||
if (readyRef.current) api.getLibraryStats().then(setLibStats).catch(() => {});
|
||||
setDeckRefreshSignal((v) => v + 1);
|
||||
} catch (err) {
|
||||
setSyncTest({ ok: false, message: String(err) });
|
||||
} finally {
|
||||
setSyncBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Merge the library document with local edits and publish the result.
|
||||
*
|
||||
* Shared by the Info tab's button and the automatic triggers below. Returns
|
||||
* the report so the caller can decide whether to say anything; throws only
|
||||
* what the backend threw. */
|
||||
const runSync = useCallback(async () => {
|
||||
const report = await api.syncNow();
|
||||
if (!report) return null; // Another sync had it in hand.
|
||||
const changed = report.changed;
|
||||
if (changed) {
|
||||
// Library and deck views hold fetched copies; a merge changes rows
|
||||
// underneath them, so pull them back into line rather than waiting for
|
||||
// the summary's 5s poll. Needs card data loaded, hence the guard.
|
||||
if (readyRef.current) {
|
||||
api.getLibraryStats().then(setLibStats).catch(() => {});
|
||||
}
|
||||
setDeckRefreshSignal((v) => v + 1);
|
||||
}
|
||||
return { report, changed };
|
||||
}, []);
|
||||
|
||||
// Automatic sync. Runs once the folder is usable, then on every trip to or
|
||||
// from the background: leaving publishes what changed here, returning picks
|
||||
// up what changed elsewhere. Keyed on `granted` alone so merely re-checking
|
||||
// the folder's status doesn't re-arm the listeners and resync.
|
||||
const syncGranted = sync?.granted ?? false;
|
||||
useEffect(() => {
|
||||
if (!syncGranted) return;
|
||||
let cancelled = false;
|
||||
|
||||
// Failures here are deliberately silent: the folder can be temporarily
|
||||
// unavailable (drive unmounted, grant revoked), and nagging on every app
|
||||
// switch would be worse than the Info tab reporting it on demand. Nothing
|
||||
// is lost by a skipped run — the merge base only advances to a document we
|
||||
// actually read, so a lost publish is simply re-detected next time.
|
||||
const run = () => {
|
||||
if (cancelled) return;
|
||||
runSync().catch(() => {});
|
||||
};
|
||||
|
||||
run();
|
||||
document.addEventListener("visibilitychange", run);
|
||||
// Visibility changes alone would strand a desktop session that simply stays
|
||||
// open: edits made after launch would never reach the other devices. Poll
|
||||
// while visible so an always-open window still publishes. Skipped while
|
||||
// hidden — the transition back handles that case.
|
||||
const timer = setInterval(() => {
|
||||
if (!document.hidden) run();
|
||||
}, AUTO_SYNC_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
document.removeEventListener("visibilitychange", run);
|
||||
};
|
||||
}, [syncGranted, runSync]);
|
||||
|
||||
/** The Info tab's explicit "Sync now" — same work, but reports the outcome. */
|
||||
async function syncNow() {
|
||||
setSyncBusy(true);
|
||||
setSyncTest(null);
|
||||
try {
|
||||
const result = await runSync();
|
||||
if (!result) {
|
||||
setSyncTest({ ok: true, message: "A sync was already running." });
|
||||
return;
|
||||
}
|
||||
const { report, changed } = result;
|
||||
const parts: string[] = [];
|
||||
if (changed) {
|
||||
const r = report.report;
|
||||
const bits = [
|
||||
r.cards_changed && `${r.cards_changed} card${r.cards_changed === 1 ? "" : "s"}`,
|
||||
r.decks_changed && `${r.decks_changed} deck${r.decks_changed === 1 ? "" : "s"}`,
|
||||
r.decks_removed && `${r.decks_removed} removed`,
|
||||
].filter(Boolean);
|
||||
parts.push(`Merged ${bits.join(", ")}.`);
|
||||
} else {
|
||||
parts.push(report.published ? "Published your changes." : "Already up to date.");
|
||||
}
|
||||
if (report.absorbed > 0) {
|
||||
parts.push(
|
||||
`Absorbed ${report.absorbed} conflict cop${report.absorbed === 1 ? "y" : "ies"}.`,
|
||||
);
|
||||
}
|
||||
// Conflicts are the one thing worth spelling out — the user may want to
|
||||
// correct a value that lost.
|
||||
if (report.report.conflicts.length > 0) {
|
||||
parts.push(`Kept the other copy's version of: ${report.report.conflicts.join("; ")}.`);
|
||||
}
|
||||
setSyncTest({ ok: true, message: parts.join(" ") });
|
||||
} catch (err) {
|
||||
setSyncTest({ ok: false, message: String(err) });
|
||||
} finally {
|
||||
setSyncBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,23 +658,6 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openLibrary() {
|
||||
const path = await open({
|
||||
title: "Open an existing library.db",
|
||||
filters: [{ name: "SQLite", extensions: ["db", "sqlite"] }],
|
||||
});
|
||||
if (typeof path === "string") await switchLibrary(path);
|
||||
}
|
||||
|
||||
async function newLibrary() {
|
||||
const path = await save({
|
||||
title: "Create a new library.db (put it in a synced folder)",
|
||||
defaultPath: "library.db",
|
||||
filters: [{ name: "SQLite", extensions: ["db", "sqlite"] }],
|
||||
});
|
||||
if (typeof path === "string") await switchLibrary(path);
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverPreviewProvider>
|
||||
<div className="app">
|
||||
@@ -468,7 +670,9 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
<div className="nav-items">
|
||||
{NAV.map((n) => (
|
||||
{/* Info carries library sync, which desktop needs too — it's no
|
||||
longer the mobile-only card-data page it started as. */}
|
||||
{[...NAV, INFO_TAB].map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
className={`nav-item ${tab === n.id ? "active" : ""}`}
|
||||
@@ -491,16 +695,15 @@ export default function App() {
|
||||
<span className="path" title={status?.library_path ?? ""}>
|
||||
{status?.library_path ?? ""}
|
||||
</span>
|
||||
{/* Choosing where library.db lives is desktop-only: on Android the
|
||||
live db must stay in app-private storage (SQLite can't open a
|
||||
content:// URI), so these would only ever fail there. */}
|
||||
{/* Desktop-only: Android grants a folder through the system picker
|
||||
instead of naming a path, so these live in the Info tab there. */}
|
||||
{status && !status.is_mobile && (
|
||||
<>
|
||||
<button onClick={openLibrary} disabled={busy}>
|
||||
<button onClick={pickLibrary} disabled={busy || syncBusy}>
|
||||
{ICON_OPEN_LIBRARY}
|
||||
Open Library
|
||||
</button>
|
||||
<button onClick={newLibrary} disabled={busy}>
|
||||
<button onClick={newLibrary} disabled={busy || syncBusy}>
|
||||
{ICON_NEW_LIBRARY}
|
||||
New Library
|
||||
</button>
|
||||
@@ -599,11 +802,12 @@ export default function App() {
|
||||
) : 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.
|
||||
// The per-install status page: card data, library sync, and the
|
||||
// collection summary. On mobile it's the only home for the
|
||||
// card-data buttons (no topbar room) and the summary (the nav
|
||||
// rail it normally sits in is replaced by the bottom nav);
|
||||
// on desktop those also appear in the topbar and rail, but sync
|
||||
// lives only here.
|
||||
<div className="panel info-panel">
|
||||
<h2 style={{ margin: 0 }}>Card database</h2>
|
||||
<div className="hint" style={{ wordBreak: "break-all" }}>
|
||||
@@ -620,6 +824,85 @@ export default function App() {
|
||||
</button>
|
||||
</div>
|
||||
<hr className="deck-section-divider" style={{ margin: 0 }} />
|
||||
<h2 style={{ margin: 0 }}>Your library</h2>
|
||||
{sync === null ? (
|
||||
<div className="hint">Checking…</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="hint">
|
||||
Your collection is kept in a single file. Put it in a folder you sync and
|
||||
every device stays in step — edits made in two places are combined, not
|
||||
overwritten.
|
||||
</div>
|
||||
{sync.label && (
|
||||
<div className="sync-folder" style={{ wordBreak: "break-word" }}>
|
||||
{sync.label}
|
||||
</div>
|
||||
)}
|
||||
{sync.chosen && !sync.granted && (
|
||||
<div className="sync-bad">
|
||||
✕{" "}
|
||||
{sync.picker_based
|
||||
? "Access to that folder was lost — choose it again."
|
||||
: "That folder isn't there right now — is the drive mounted?"}
|
||||
</div>
|
||||
)}
|
||||
{syncTest && (
|
||||
<div className={syncTest.ok ? "sync-ok" : "sync-bad"}>
|
||||
{syncTest.ok ? "✓ " : "✕ "}
|
||||
{syncTest.message}
|
||||
</div>
|
||||
)}
|
||||
<div className="row" style={{ gap: "var(--space-3)", flexWrap: "wrap" }}>
|
||||
{sync.granted && (
|
||||
<button className="primary" onClick={syncNow} disabled={syncBusy}>
|
||||
{syncBusy ? "Syncing…" : "Sync now"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={sync.granted ? "" : "primary"}
|
||||
onClick={pickLibrary}
|
||||
disabled={syncBusy}
|
||||
>
|
||||
{sync.chosen
|
||||
? sync.picker_based
|
||||
? "Change folder…"
|
||||
: "Change file…"
|
||||
: sync.picker_based
|
||||
? "Choose folder…"
|
||||
: "Choose file…"}
|
||||
</button>
|
||||
{sync.chosen && (
|
||||
<button onClick={forgetLibrary} disabled={syncBusy}>
|
||||
Forget
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Only shown while an old library.db is still waiting; the
|
||||
offer disappears for good once it's been converted. */}
|
||||
{sync.legacy_library && (
|
||||
<>
|
||||
<div className="hint">
|
||||
An older library from a previous version is still on this device. Convert
|
||||
it to bring your collection across.
|
||||
</div>
|
||||
<div className="sync-folder" style={{ wordBreak: "break-word" }}>
|
||||
{sync.legacy_library}
|
||||
</div>
|
||||
<div className="row">
|
||||
<button
|
||||
className="primary"
|
||||
onClick={() => convertLegacy(sync.legacy_library!)}
|
||||
disabled={syncBusy}
|
||||
>
|
||||
Convert old library
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<hr className="deck-section-divider" style={{ margin: 0 }} />
|
||||
<LibrarySummary ready={ready} libStats={libStats} status={status} />
|
||||
</div>
|
||||
)}
|
||||
@@ -655,7 +938,7 @@ export default function App() {
|
||||
<CommandPalette
|
||||
onNavigate={setTab}
|
||||
onUpdateCardData={updateCardData}
|
||||
onOpenLibrary={openLibrary}
|
||||
onOpenLibrary={pickLibrary}
|
||||
onNewLibrary={newLibrary}
|
||||
undo={undo}
|
||||
onRunUndo={runUndo}
|
||||
|
||||
69
src/api.ts
69
src/api.ts
@@ -94,6 +94,28 @@ export interface LibraryFilter {
|
||||
in_deck?: number | null;
|
||||
location?: string | null;
|
||||
oracle_text?: string | null;
|
||||
/** Hide cards that exist *only* as Universes Beyond (Lord of the Rings,
|
||||
* Warhammer 40,000, Marvel, …). Ordinary cards reprinted into a UB set are
|
||||
* kept. Needs card data downloaded after this shipped; inert otherwise. */
|
||||
exclude_universes_beyond?: boolean;
|
||||
/** Hide Arena/MTGO-only cards (Alchemy rebalances) — they can't be owned on
|
||||
* paper. Needs card data downloaded after this shipped; inert otherwise. */
|
||||
exclude_digital_only?: boolean;
|
||||
/** Hide Un-set cards (Unglued, Unhinged, Unstable, Unfinity). */
|
||||
exclude_funny?: boolean;
|
||||
/** Cheapest known printing in USD; cards with no known price count as 0. */
|
||||
price_min?: number | null;
|
||||
price_max?: number | null;
|
||||
/** Format slug ("commander", "modern", …). Legal and restricted match. */
|
||||
legal_in?: string | null;
|
||||
/** Only cards that can head a Commander deck. */
|
||||
can_be_commander?: boolean;
|
||||
/** Printed power/toughness. Cards printed with "*" or "1+*" are excluded,
|
||||
* since those can't be compared numerically. */
|
||||
power_min?: number | null;
|
||||
power_max?: number | null;
|
||||
toughness_min?: number | null;
|
||||
toughness_max?: number | null;
|
||||
}
|
||||
|
||||
export interface Status {
|
||||
@@ -108,6 +130,40 @@ export interface Status {
|
||||
is_mobile: boolean;
|
||||
}
|
||||
|
||||
/** State of the user's library document — the JSON file they keep in whatever
|
||||
* folder they sync. The SQLite database behind it is internal and never shown. */
|
||||
export interface SyncStatus {
|
||||
/** Whether a document has been chosen. */
|
||||
chosen: boolean;
|
||||
/** Whether it's currently reachable. Distinct from `chosen`: an Android grant
|
||||
* can be revoked and a desktop folder can be deleted or unmounted — both need
|
||||
* re-picking rather than first-time setup. */
|
||||
granted: boolean;
|
||||
label: string | null;
|
||||
/** True where the folder is chosen through the system picker (Android) rather
|
||||
* than by path — the one thing the UI branches on. */
|
||||
picker_based: boolean;
|
||||
/** A pre-document library.db from an older build, waiting to be converted. */
|
||||
legacy_library: string | null;
|
||||
}
|
||||
|
||||
/** What one sync did. */
|
||||
export interface SyncReport {
|
||||
/** Whether the merge altered the local library. */
|
||||
changed: boolean;
|
||||
/** Whether the document was rewritten (skipped when we had nothing to add). */
|
||||
published: boolean;
|
||||
/** Conflict copies absorbed and cleaned up. */
|
||||
absorbed: number;
|
||||
report: {
|
||||
cards_changed: number;
|
||||
decks_changed: number;
|
||||
decks_removed: number;
|
||||
/** Fields both sides changed differently, described in user terms. */
|
||||
conflicts: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface LibraryStats {
|
||||
unique_cards: number;
|
||||
total_copies: number;
|
||||
@@ -181,11 +237,22 @@ export function legalStatusFor(legalities: number, formatLabel: string): LegalSt
|
||||
|
||||
export const api = {
|
||||
getStatus: () => invoke<Status>("get_status"),
|
||||
setLibraryPath: (path: string) => invoke<Status>("set_library_path", { path }),
|
||||
updateCardData: () => invoke<Status>("update_card_data"),
|
||||
cancelUpdateCardData: () => invoke<void>("cancel_update_card_data"),
|
||||
/** Adopt an already-built reference.db from `path` instead of downloading it. */
|
||||
importReferenceDb: (path: string) => invoke<Status>("import_reference_db", { path }),
|
||||
syncStatus: () => invoke<SyncStatus>("sync_status"),
|
||||
/** Merge the library document with local edits and publish the result.
|
||||
* Resolves with `null` when a sync was already running and this call stood
|
||||
* down — expected, since sync also fires automatically. */
|
||||
syncNow: () => invoke<SyncReport | null>("sync_now"),
|
||||
/** Android: grant access to a folder through the system picker. */
|
||||
syncPickFolder: () => invoke<SyncStatus>("sync_pick_folder"),
|
||||
/** Desktop: adopt a library document chosen with the file dialog. */
|
||||
syncSetDocument: (path: string) => invoke<SyncStatus>("sync_set_document", { path }),
|
||||
syncForgetDocument: () => invoke<SyncStatus>("sync_forget_document"),
|
||||
/** Convert a pre-document library.db into the current format. */
|
||||
convertLegacyLibrary: (path: string) => invoke<SyncReport>("convert_legacy_library", { path }),
|
||||
|
||||
searchCards: (query: string, limit = 20) =>
|
||||
invoke<CardRef[]>("search_cards", { query, limit }),
|
||||
|
||||
@@ -8,7 +8,16 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { api, CardRef, COLORS, LEGALITY_FORMATS, legalStatusFor, maskToLetters, PreviewCard } from "./api";
|
||||
import {
|
||||
api,
|
||||
CardRef,
|
||||
COLORS,
|
||||
LEGALITY_FORMATS,
|
||||
LibraryFilter,
|
||||
legalStatusFor,
|
||||
maskToLetters,
|
||||
PreviewCard,
|
||||
} from "./api";
|
||||
|
||||
// --- Icons ------------------------------------------------------------
|
||||
|
||||
@@ -1386,3 +1395,296 @@ export function ColorToggles({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Shared card criteria -------------------------------------------------
|
||||
|
||||
/** Ringed planet — a card from another world/property (Universes Beyond). */
|
||||
const ICON_CROSSOVER = icon(
|
||||
<>
|
||||
<circle cx="12" cy="11" r="6" />
|
||||
<ellipse cx="12" cy="11" rx="11" ry="4" transform="rotate(-20 12 11)" />
|
||||
</>,
|
||||
);
|
||||
/** Monitor — exists only on Arena/MTGO. */
|
||||
const ICON_SCREEN = icon(
|
||||
<>
|
||||
<rect x="3" y="4" width="18" height="12" rx="2" />
|
||||
<path d="M9 20h6" />
|
||||
<path d="M12 16v4" />
|
||||
</>,
|
||||
);
|
||||
/** Smiley — the joke sets. */
|
||||
const ICON_SMILEY = icon(
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M8.5 14.5a4.5 4.5 0 0 0 7 0" />
|
||||
<line x1="9" y1="9.5" x2="9.01" y2="9.5" />
|
||||
<line x1="15" y1="9.5" x2="15.01" y2="9.5" />
|
||||
</>,
|
||||
);
|
||||
/** Crown — can head a Commander deck. */
|
||||
const ICON_CROWN = icon(
|
||||
<>
|
||||
<path d="M3 8l3.5 3L12 5l5.5 6L21 8l-1.5 10h-15z" />
|
||||
<line x1="5" y1="21" x2="19" y2="21" />
|
||||
</>,
|
||||
);
|
||||
|
||||
/**
|
||||
* Card-origin and numeric criteria shared by the Library and Card Search
|
||||
* filter panels. Kept here rather than duplicated in both views, since they
|
||||
* must stay in step: a filter that behaves differently between browsing what
|
||||
* you own and searching everything would be a bug, not a feature.
|
||||
*
|
||||
* Numeric fields are strings so the inputs can be empty (meaning "no bound")
|
||||
* rather than coercing a blank to 0.
|
||||
*/
|
||||
export type CardCriteria = {
|
||||
excludeUb: boolean;
|
||||
excludeDigital: boolean;
|
||||
excludeFunny: boolean;
|
||||
priceMin: string;
|
||||
priceMax: string;
|
||||
legalIn: string;
|
||||
canBeCommander: boolean;
|
||||
powerMin: string;
|
||||
powerMax: string;
|
||||
toughMin: string;
|
||||
toughMax: string;
|
||||
};
|
||||
|
||||
export const EMPTY_CRITERIA: CardCriteria = {
|
||||
excludeUb: false,
|
||||
excludeDigital: false,
|
||||
excludeFunny: false,
|
||||
priceMin: "",
|
||||
priceMax: "",
|
||||
legalIn: "",
|
||||
canBeCommander: false,
|
||||
powerMin: "",
|
||||
powerMax: "",
|
||||
toughMin: "",
|
||||
toughMax: "",
|
||||
};
|
||||
|
||||
export function useCardCriteria(key: string): [CardCriteria, (patch: Partial<CardCriteria>) => void] {
|
||||
const [value, setValue] = usePersistedState<CardCriteria>(key, EMPTY_CRITERIA);
|
||||
// Merged over the defaults so a stored object written before a field existed
|
||||
// doesn't leave it undefined.
|
||||
const merged = { ...EMPTY_CRITERIA, ...value };
|
||||
const patch = useCallback(
|
||||
(p: Partial<CardCriteria>) => setValue({ ...EMPTY_CRITERIA, ...value, ...p }),
|
||||
[setValue, value],
|
||||
);
|
||||
return [merged, patch];
|
||||
}
|
||||
|
||||
const num = (s: string): number | null => (s.trim() === "" ? null : Number(s));
|
||||
|
||||
/** Fold criteria into the filter sent to the backend. */
|
||||
export function applyCardCriteria(c: CardCriteria): Partial<LibraryFilter> {
|
||||
return {
|
||||
exclude_universes_beyond: c.excludeUb,
|
||||
exclude_digital_only: c.excludeDigital,
|
||||
exclude_funny: c.excludeFunny,
|
||||
price_min: num(c.priceMin),
|
||||
price_max: num(c.priceMax),
|
||||
legal_in: c.legalIn || null,
|
||||
can_be_commander: c.canBeCommander,
|
||||
power_min: num(c.powerMin),
|
||||
power_max: num(c.powerMax),
|
||||
toughness_min: num(c.toughMin),
|
||||
toughness_max: num(c.toughMax),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether these criteria amount to a search in their own right.
|
||||
*
|
||||
* The three "exclude" toggles deliberately don't count: they refine a result
|
||||
* set rather than select one, so ticking "hide Un-sets" on its own shouldn't
|
||||
* make Card Search pull every card in the database. Choosing a format or a
|
||||
* power range is a real query, and does.
|
||||
*/
|
||||
export function criteriaActive(c: CardCriteria): boolean {
|
||||
return (
|
||||
c.canBeCommander ||
|
||||
c.legalIn !== "" ||
|
||||
[c.priceMin, c.priceMax, c.powerMin, c.powerMax, c.toughMin, c.toughMax].some(
|
||||
(v) => v.trim() !== "",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function RangePair({
|
||||
label,
|
||||
min,
|
||||
max,
|
||||
onMin,
|
||||
onMax,
|
||||
step,
|
||||
prefix,
|
||||
}: {
|
||||
label: string;
|
||||
min: string;
|
||||
max: string;
|
||||
onMin: (v: string) => void;
|
||||
onMax: (v: string) => void;
|
||||
step?: string;
|
||||
prefix?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="field">
|
||||
<label>{label}</label>
|
||||
<div className="row">
|
||||
{prefix && <span className="muted">{prefix}</span>}
|
||||
<input
|
||||
type="number"
|
||||
step={step}
|
||||
style={{ width: 70 }}
|
||||
placeholder="min"
|
||||
value={min}
|
||||
onChange={(e) => onMin(e.target.value)}
|
||||
/>
|
||||
<span className="muted">–</span>
|
||||
{prefix && <span className="muted">{prefix}</span>}
|
||||
<input
|
||||
type="number"
|
||||
step={step}
|
||||
style={{ width: 70 }}
|
||||
placeholder="max"
|
||||
value={max}
|
||||
onChange={(e) => onMax(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardCriteriaFields({
|
||||
value: c,
|
||||
onChange,
|
||||
}: {
|
||||
value: CardCriteria;
|
||||
onChange: (patch: Partial<CardCriteria>) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="field">
|
||||
<label>Legal in</label>
|
||||
<select value={c.legalIn} onChange={(e) => onChange({ legalIn: e.target.value })}>
|
||||
<option value="">Any format</option>
|
||||
{LEGALITY_FORMATS.map((f) => (
|
||||
<option key={f.key} value={f.key}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<RangePair
|
||||
label="Price (USD)"
|
||||
prefix="$"
|
||||
step="0.01"
|
||||
min={c.priceMin}
|
||||
max={c.priceMax}
|
||||
onMin={(v) => onChange({ priceMin: v })}
|
||||
onMax={(v) => onChange({ priceMax: v })}
|
||||
/>
|
||||
<RangePair
|
||||
label="Power"
|
||||
min={c.powerMin}
|
||||
max={c.powerMax}
|
||||
onMin={(v) => onChange({ powerMin: v })}
|
||||
onMax={(v) => onChange({ powerMax: v })}
|
||||
/>
|
||||
<RangePair
|
||||
label="Toughness"
|
||||
min={c.toughMin}
|
||||
max={c.toughMax}
|
||||
onMin={(v) => onChange({ toughMin: v })}
|
||||
onMax={(v) => onChange({ toughMax: v })}
|
||||
/>
|
||||
|
||||
<div className="field">
|
||||
<label>Quick filters</label>
|
||||
<div className="icon-toggles">
|
||||
{/* Exclusions read as "no X" — tinted with the danger colour when
|
||||
active, so removing things looks different from selecting them. */}
|
||||
<IconToggle
|
||||
icon={ICON_CROSSOVER}
|
||||
label="Hide Universes Beyond"
|
||||
hint="Cards that only exist as another property — Lord of the Rings, Warhammer 40,000, Marvel. Normal cards reprinted in those sets stay."
|
||||
exclude
|
||||
on={c.excludeUb}
|
||||
onToggle={() => onChange({ excludeUb: !c.excludeUb })}
|
||||
/>
|
||||
<IconToggle
|
||||
icon={ICON_SCREEN}
|
||||
label="Hide digital-only"
|
||||
hint="Arena and MTGO cards such as Alchemy rebalances, which don't exist on paper."
|
||||
exclude
|
||||
on={c.excludeDigital}
|
||||
onToggle={() => onChange({ excludeDigital: !c.excludeDigital })}
|
||||
/>
|
||||
<IconToggle
|
||||
icon={ICON_SMILEY}
|
||||
label="Hide Un-set cards"
|
||||
hint="Unglued, Unhinged, Unstable and Unfinity joke cards."
|
||||
exclude
|
||||
on={c.excludeFunny}
|
||||
onToggle={() => onChange({ excludeFunny: !c.excludeFunny })}
|
||||
/>
|
||||
<span className="icon-toggles-sep" />
|
||||
<IconToggle
|
||||
icon={ICON_CROWN}
|
||||
label="Only cards that can be a commander"
|
||||
hint="Legendary creatures, plus cards whose rules text says they can be your commander."
|
||||
on={c.canBeCommander}
|
||||
onToggle={() => onChange({ canBeCommander: !c.canBeCommander })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single icon filter toggle, styled like [`ColorToggles`]' pips: dim when
|
||||
* off, lit with a ring when on. `exclude` marks a filter that *removes* cards
|
||||
* rather than selecting them, which is worth distinguishing visually since
|
||||
* both live in the same row.
|
||||
*/
|
||||
function IconToggle({
|
||||
icon,
|
||||
label,
|
||||
hint,
|
||||
on,
|
||||
exclude,
|
||||
onToggle,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
hint: string;
|
||||
on: boolean;
|
||||
exclude?: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`icon-toggle ${exclude ? "exclude" : ""} ${on ? "on" : ""}`}
|
||||
// Title carries the full explanation the old inline hint text did — the
|
||||
// whole point of this row is to stop that prose eating the panel.
|
||||
title={`${label}\n\n${hint}`}
|
||||
aria-label={label}
|
||||
aria-pressed={on}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{icon}
|
||||
{/* A slash over the glyph when an exclusion is active, so "hidden" is
|
||||
legible without reading the tooltip. */}
|
||||
{exclude && on && <span className="icon-toggle-slash" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -784,6 +784,28 @@ input[type="checkbox"] {
|
||||
gap: var(--space-3);
|
||||
max-width: 480px;
|
||||
}
|
||||
/* The currently chosen synced folder. */
|
||||
.sync-folder {
|
||||
font-size: var(--fs-body);
|
||||
font-weight: 600;
|
||||
color: var(--text-strong);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* Outcome of the synced-folder self-test. Not a `.hint`: whether sync works is
|
||||
the point of the section, so it reads at body size rather than as
|
||||
de-emphasized supporting text. */
|
||||
.sync-ok,
|
||||
.sync-bad {
|
||||
font-size: var(--fs-body);
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sync-ok {
|
||||
color: var(--success);
|
||||
}
|
||||
.sync-bad {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Typography helpers
|
||||
@@ -1695,6 +1717,65 @@ tbody tr:hover {
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
/* Compact icon filter toggles, following .color-toggles' visual grammar: dim
|
||||
when off, lit with an accent ring when on. Replaces a stack of labelled
|
||||
checkboxes that ate most of the filter panel's height — the explanations
|
||||
moved into tooltips. */
|
||||
.icon-toggles {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.icon-toggle {
|
||||
position: relative;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-elev);
|
||||
color: var(--text);
|
||||
opacity: 0.4;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.1s, box-shadow 0.1s, color 0.1s;
|
||||
}
|
||||
.icon-toggle:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.icon-toggle.on {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 0 2px var(--accent-hover);
|
||||
}
|
||||
/* Removing cards should not look like selecting them. */
|
||||
.icon-toggle.exclude.on {
|
||||
color: var(--danger);
|
||||
box-shadow: 0 0 0 2px var(--danger);
|
||||
}
|
||||
/* Diagonal bar across an active exclusion, so "hidden" reads without the
|
||||
tooltip. */
|
||||
.icon-toggle-slash {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
to bottom right,
|
||||
transparent calc(50% - 1px),
|
||||
currentColor calc(50% - 1px),
|
||||
currentColor calc(50% + 1px),
|
||||
transparent calc(50% + 1px)
|
||||
);
|
||||
}
|
||||
/* Separates the exclusions from the selecting filter beside them. */
|
||||
.icon-toggles-sep {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
margin: 2px 3px;
|
||||
background: var(--border-soft);
|
||||
}
|
||||
|
||||
.location-input {
|
||||
padding: 4px 8px;
|
||||
font-size: 13px;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter } from "../api";
|
||||
import {
|
||||
CardThumb,
|
||||
@@ -15,8 +15,12 @@ import {
|
||||
SortMode,
|
||||
TagInput,
|
||||
ViewToggle,
|
||||
CardCriteriaFields,
|
||||
applyCardCriteria,
|
||||
criteriaActive,
|
||||
groupCards,
|
||||
sortCards,
|
||||
useCardCriteria,
|
||||
usePersistedState,
|
||||
useViewMode,
|
||||
} from "../components";
|
||||
@@ -50,6 +54,7 @@ export function CardSearch({
|
||||
const [subtypes, setSubtypes] = useState<string[]>([]);
|
||||
const [keywords, setKeywords] = useState<string[]>([]);
|
||||
const [oracleText, setOracleText] = useState("");
|
||||
const [criteria, patchCriteria] = useCardCriteria("cardsearch-criteria");
|
||||
// Collapsed by default (relevant on narrow/mobile screens only — see
|
||||
// .filters-more's CSS, which is always expanded above that breakpoint
|
||||
// regardless of this).
|
||||
@@ -62,6 +67,10 @@ export function CardSearch({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<Set<string>>(new Set());
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const [bulkToMaybeboard, setBulkToMaybeboard] = useState(false);
|
||||
const selectAllRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [view, setView] = useViewMode("cardsearch-view");
|
||||
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("cardsearch-group", "none");
|
||||
@@ -85,7 +94,8 @@ export function CardSearch({
|
||||
types.length > 0 ||
|
||||
subtypes.length > 0 ||
|
||||
keywords.length > 0 ||
|
||||
oracleText.trim() !== "";
|
||||
oracleText.trim() !== "" ||
|
||||
criteriaActive(criteria);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasActiveFilter) {
|
||||
@@ -103,6 +113,7 @@ export function CardSearch({
|
||||
subtypes,
|
||||
keywords,
|
||||
oracle_text: oracleText.trim() || null,
|
||||
...applyCardCriteria(criteria),
|
||||
};
|
||||
const t = setTimeout(() => {
|
||||
api
|
||||
@@ -128,9 +139,82 @@ export function CardSearch({
|
||||
subtypes,
|
||||
keywords,
|
||||
oracleText,
|
||||
criteria,
|
||||
refreshKey,
|
||||
]);
|
||||
|
||||
// Drop selections that fall out of the result set when the filter changes,
|
||||
// so a hidden card can't be silently added to a deck later.
|
||||
useEffect(() => {
|
||||
setSelected((s) => {
|
||||
const visible = new Set(rows.map((r) => r.oracle_id));
|
||||
const next = new Set([...s].filter((id) => visible.has(id)));
|
||||
return next.size === s.size ? s : next;
|
||||
});
|
||||
}, [rows]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectAllRef.current) {
|
||||
selectAllRef.current.indeterminate = selected.size > 0 && selected.size < rows.length;
|
||||
}
|
||||
}, [selected, rows]);
|
||||
|
||||
function toggleOne(oracleId: string) {
|
||||
setSelected((s) => {
|
||||
const next = new Set(s);
|
||||
if (next.has(oracleId)) next.delete(oracleId);
|
||||
else next.add(oracleId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
setSelected((s) => (s.size === rows.length ? new Set() : new Set(rows.map((r) => r.oracle_id))));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add every selected card to the deck chosen in the header, at one copy each.
|
||||
*
|
||||
* Mirrors the Library tab's bulk add, including that it *sets* the quantity
|
||||
* rather than incrementing — so re-running it is idempotent — and offers a
|
||||
* single undo restoring every card's prior deck entry at once.
|
||||
*/
|
||||
async function bulkAddToDeck() {
|
||||
if (deckId === "") return;
|
||||
const deck = decks.find((d) => d.id === deckId);
|
||||
if (!deck) return;
|
||||
setBulkBusy(true);
|
||||
try {
|
||||
const targets = rows.filter((r) => selected.has(r.oracle_id));
|
||||
const before = await api.deckContents(deck.id);
|
||||
const prevByOracle = new Map(before.map((e) => [e.oracle_id, e]));
|
||||
await Promise.all(
|
||||
targets.map((r) => api.setDeckCard(deck.id, r.oracle_id, 1, false, bulkToMaybeboard)),
|
||||
);
|
||||
setRefreshKey((k) => k + 1);
|
||||
onOfferUndo(
|
||||
`Added ${targets.length} card${targets.length === 1 ? "" : "s"} to ${deck.name}.`,
|
||||
async () => {
|
||||
await Promise.all(
|
||||
targets.map((r) => {
|
||||
const prev = prevByOracle.get(r.oracle_id);
|
||||
return api.setDeckCard(
|
||||
deck.id,
|
||||
r.oracle_id,
|
||||
prev?.quantity ?? 0,
|
||||
prev?.is_commander ?? false,
|
||||
prev?.is_maybeboard ?? false,
|
||||
);
|
||||
}),
|
||||
);
|
||||
setRefreshKey((k) => k + 1);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
setBulkBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function adjust(oracleId: string, delta: number) {
|
||||
const qty = await api.addOwned(oracleId, delta);
|
||||
setRows((rs) => rs.map((c) => (c.oracle_id === oracleId ? { ...c, quantity_owned: qty } : c)));
|
||||
@@ -176,6 +260,7 @@ export function CardSearch({
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th>Colors</th>
|
||||
<th>Name</th>
|
||||
@@ -189,6 +274,13 @@ export function CardSearch({
|
||||
<tbody>
|
||||
{cards.map((c) => (
|
||||
<tr key={c.oracle_id}>
|
||||
<td className="cell-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(c.oracle_id)}
|
||||
onChange={() => toggleOne(c.oracle_id)}
|
||||
/>
|
||||
</td>
|
||||
<td className="cell-thumb">
|
||||
<CardThumb card={c} />
|
||||
</td>
|
||||
@@ -235,6 +327,14 @@ export function CardSearch({
|
||||
<CardTile
|
||||
key={c.oracle_id}
|
||||
card={c}
|
||||
selected={selected.has(c.oracle_id)}
|
||||
cornerLeft={
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(c.oracle_id)}
|
||||
onChange={() => toggleOne(c.oracle_id)}
|
||||
/>
|
||||
}
|
||||
corner={
|
||||
<QtyStepper
|
||||
overlay
|
||||
@@ -290,6 +390,61 @@ export function CardSearch({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected.size > 0 && (
|
||||
<div className="bulk-bar">
|
||||
<span className="bulk-bar-count">{selected.size} selected</span>
|
||||
<div className="bulk-bar-actions">
|
||||
{/* Bound to the same deck as the header picker rather than a
|
||||
separate one — two controls that disagreed about the target
|
||||
deck would be worse than the redundancy. It's repeated here
|
||||
because a disabled button whose reason lives elsewhere on the
|
||||
page reads as broken. */}
|
||||
<select
|
||||
value={deckId}
|
||||
onChange={(e) => setDeckId(e.target.value === "" ? "" : Number(e.target.value))}
|
||||
disabled={bulkBusy}
|
||||
>
|
||||
<option value="">Choose a deck…</option>
|
||||
{decks.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="primary"
|
||||
onClick={bulkAddToDeck}
|
||||
disabled={bulkBusy || deckId === ""}
|
||||
title={
|
||||
deckId === ""
|
||||
? "Choose a deck first"
|
||||
: "Set each selected card to 1 copy in the deck"
|
||||
}
|
||||
>
|
||||
Add to deck
|
||||
</button>
|
||||
<label
|
||||
className="row"
|
||||
style={{ gap: 4, cursor: "pointer" }}
|
||||
title="Send to the maybeboard instead of the mainboard"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bulkToMaybeboard}
|
||||
onChange={(e) => setBulkToMaybeboard(e.target.checked)}
|
||||
disabled={bulkBusy}
|
||||
/>
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
Maybeboard
|
||||
</span>
|
||||
</label>
|
||||
<button onClick={() => setSelected(new Set())} disabled={bulkBusy}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="panel workspace">
|
||||
<div className="filters">
|
||||
<div className="field">
|
||||
@@ -355,6 +510,7 @@ export function CardSearch({
|
||||
placeholder="e.g. landfall"
|
||||
/>
|
||||
</div>
|
||||
<CardCriteriaFields value={criteria} onChange={patchCriteria} />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -365,6 +521,17 @@ export function CardSearch({
|
||||
</button>
|
||||
|
||||
<div className="workspace-bar">
|
||||
{rows.length > 0 && (
|
||||
<label className="workspace-bar-check" title="Select all matching cards">
|
||||
<input
|
||||
ref={selectAllRef}
|
||||
type="checkbox"
|
||||
checked={rows.length > 0 && selected.size === rows.length}
|
||||
onChange={toggleSelectAll}
|
||||
/>
|
||||
Select all
|
||||
</label>
|
||||
)}
|
||||
{hasActiveFilter && (
|
||||
<span className="workspace-bar-count">
|
||||
{rows.length} result{rows.length === 1 ? "" : "s"}
|
||||
|
||||
@@ -17,8 +17,11 @@ import {
|
||||
SortMode,
|
||||
TagInput,
|
||||
ViewToggle,
|
||||
CardCriteriaFields,
|
||||
applyCardCriteria,
|
||||
groupCards,
|
||||
sortCards,
|
||||
useCardCriteria,
|
||||
usePersistedState,
|
||||
useViewMode,
|
||||
} from "../components";
|
||||
@@ -54,6 +57,7 @@ export function LibraryBrowser({
|
||||
const [inDeck, setInDeck] = useState<number | "">("");
|
||||
const [location, setLocation] = useState("");
|
||||
const [oracleText, setOracleText] = useState("");
|
||||
const [criteria, patchCriteria] = useCardCriteria("library-criteria");
|
||||
// Collapsed by default (relevant on narrow/mobile screens only — see
|
||||
// .filters-more's CSS, which is always expanded above that breakpoint
|
||||
// regardless of this).
|
||||
@@ -123,6 +127,7 @@ export function LibraryBrowser({
|
||||
in_deck: inDeck === "" ? null : Number(inDeck),
|
||||
location: location.trim() || null,
|
||||
oracle_text: oracleText.trim() || null,
|
||||
...applyCardCriteria(criteria),
|
||||
};
|
||||
const t = setTimeout(() => {
|
||||
api.searchLibrary(filter).then(setRows).catch(() => setRows([]));
|
||||
@@ -140,6 +145,7 @@ export function LibraryBrowser({
|
||||
inDeck,
|
||||
location,
|
||||
oracleText,
|
||||
criteria,
|
||||
refreshKey,
|
||||
]);
|
||||
|
||||
@@ -607,6 +613,7 @@ export function LibraryBrowser({
|
||||
placeholder="e.g. landfall"
|
||||
/>
|
||||
</div>
|
||||
<CardCriteriaFields value={criteria} onChange={patchCriteria} />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user