Files
mtgc/src/views/LibraryBrowser.tsx

872 lines
29 KiB
TypeScript
Raw Normal View History

import { listen } from "@tauri-apps/api/event";
import { open, save } from "@tauri-apps/plugin-dialog";
import { useEffect, useRef, useState } from "react";
Printing tracking for owned copies and deck art, and an outdated-card-data notice Owned copies can now be pinned to a printing + finish (nonfoil / foil / etched), activating the long-dormant library_card_copies table as groups of identical copies. library_cards.quantity_owned stays authoritative: groups are a subset of it, and the rest are "unspecified". Tracking more copies than are unspecified grows ownership to fit; lowering ownership trims the most recently added groups first. Migration 0006 adds the group quantity, a unique (card, printing, finish) index, and deck_cards.preferred_scryfall_id. A deck slot can show a chosen printing's art, both faces from that printing, falling back to the default when the id no longer resolves. It survives quantity/board changes and is kept by duplicate_deck. UI: the click preview's info tile gets a Printings section when opened from the Library or a deck (copy groups with steppers, "Add a printing…", and "Shown in this deck" with Change…/Default), backed by a printing picker grid. The picker takes Escape in the capture phase so it closes without closing the preview under it. Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and DeckCardEntry.printing. Cards now merge field by field with groups per key, so a quantity edit and a printing added on another device both survive; merged groups are clamped deterministically. A document is written as version 2 only once it holds printing data, so older builds and servers refuse it instead of dropping the data and syncing the loss. Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck and library export/import. Library export writes a line per group plus one for unspecified copies. reference.db printings gain lang and released_at for picker labels and ordering. When the installed card data lacks any column this build's reference schema has (found by diffing against the schema applied in-memory, so no list to maintain), a notice under the top bar offers Update card data (and Import card data… on phones); "Not now" is remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
import {
api,
Deck,
LibraryCard,
LibraryFacets,
LibraryFilter,
LibraryImportResult,
PreviewContext,
} from "../api";
import {
CardThumb,
CardTile,
DialogFrame,
ColorPips,
ColorToggles,
GroupBySelect,
GroupMode,
InfoDialog,
LocationField,
ProgressDialog,
PromptDialog,
QtyStepper,
SortBySelect,
SortDir,
SortMode,
TagInput,
TileColumnsControl,
ViewToggle,
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.
2026-08-07 22:03:49 -05:00
CardCriteriaFields,
applyCardCriteria,
groupCards,
sortCards,
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.
2026-08-07 22:03:49 -05:00
useCardCriteria,
usePersistedState,
useTileColumns,
useViewMode,
} from "../components";
// How long a card that's been decremented to 0 lingers (dimmed, still
// adjustable) before actually dropping out of the library view — gives the
// user a window to undo an accidental removal by clicking + again.
const REMOVAL_GRACE_MS = 5000;
const NO_FACETS: LibraryFacets = { types: [], subtypes: [], keywords: [] };
/**
* Browse & filter the owned library by name and card criteria.
*
* `onOfferUndo` is threaded in from `App` (rather than owning an `UndoBar`
* here) so the resulting "Undo" is also reachable from the command palette,
* which is mounted at the app level and outlives this view across tab
* switches.
*/
export function LibraryBrowser({
onOfferUndo,
}: {
onOfferUndo: (message: string, run: () => void | Promise<void>) => void;
}) {
const [text, setText] = useState("");
const [commander, setCommander] = useState(0);
const [colorsInclude, setColorsInclude] = useState(0);
Add colorless filters, and make set listings filterable Colorless There was no way to ask for colorless cards — the colour toggles are a 5-bit WUBRG mask where 0 already means "don't filter by colour", leaving nothing to express "no colours at all". That's exactly what you need to find a colorless commander's card pool. Adds a "C" pip to both colour rows, matching how ColorPips already renders colorless cards, backed by two explicit booleans rather than a sixth bit — a filter mask and a card's real colour mask staying the same shape is worth keeping. The two rows mean different things and both are useful: on the commander row C filters colour *identity*, which is the Commander rule; on the includes row it filters the card's own colours. A card printed colorless can still carry a coloured identity through mana symbols in its text, and it's the identity that bars it from a colorless commander's deck. Set listings News > Releases had its own card path and so couldn't be filtered. A new set_code filter (matched against printings.set_code, already ingested) lets the set browser run through search_reference instead, so every criterion works there identically rather than being reimplemented over a live Scryfall response. That alone would have cost the ability to browse an upcoming set, since search_reference only knows downloaded card data — and upcoming sets are half the point of the tab. So the browser picks a source per set: downloaded data where it exists, with the full filter panel; otherwise Scryfall directly, with the panel hidden and a line explaining why. A panel that silently does nothing would be worse than no panel. The source is decided by an *unfiltered* probe. Keying off whether the current criteria match anything would flip a properly indexed set to "live" the moment a filter excluded everything, dropping the panel exactly when it was in use.
2026-08-10 22:34:46 -05:00
// Colourlessness can't ride in the bitmask — 0 there means "no filter".
const [colorlessIdentity, setColorlessIdentity] = useState(false);
const [colorlessColors, setColorlessColors] = useState(false);
const [cmcMin, setCmcMin] = useState("");
const [cmcMax, setCmcMax] = useState("");
const [types, setTypes] = useState<string[]>([]);
const [subtypes, setSubtypes] = useState<string[]>([]);
const [keywords, setKeywords] = useState<string[]>([]);
const [inDeck, setInDeck] = useState<number | "">("");
const [location, setLocation] = useState("");
const [oracleText, setOracleText] = useState("");
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.
2026-08-07 22:03:49 -05:00
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).
const [filtersExpanded, setFiltersExpanded] = useState(false);
const [facets, setFacets] = useState<LibraryFacets>(NO_FACETS);
const [decks, setDecks] = useState<Deck[]>([]);
const [rows, setRows] = useState<LibraryCard[]>([]);
const [view, setView] = useViewMode("library-view");
const tiles = useTileColumns();
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("library-group", "none");
const [sortBy, setSortBy] = usePersistedState<SortMode>("library-sort", "name");
const [sortDir, setSortDir] = usePersistedState<SortDir>("library-sort-dir", "asc");
const [refreshKey, setRefreshKey] = useState(0);
const [pendingRemoval, setPendingRemoval] = useState<Set<string>>(new Set());
const removalTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkDeckId, setBulkDeckId] = useState<number | "">("");
const [bulkToMaybeboard, setBulkToMaybeboard] = useState(false);
const [bulkDelta, setBulkDelta] = useState(1);
const [locationDialogOpen, setLocationDialogOpen] = useState(false);
const [bulkBusy, setBulkBusy] = useState(false);
const [importResult, setImportResult] = useState<LibraryImportResult | null>(null);
const [importError, setImportError] = useState<string | null>(null);
// The "what can I import?" explainer shown before the file picker.
const [importIntroOpen, setImportIntroOpen] = useState(false);
// The file picked to import, while asking where its cards are kept.
const [importPath, setImportPath] = useState<string | null>(null);
// Lines done / total while an import runs; null when idle.
const [importProgress, setImportProgress] = useState<{ done: number; total: number } | null>(null);
// Only show the progress dialog for an import that's taking a while, so a
// quick one doesn't flash it up.
const [showImportProgress, setShowImportProgress] = useState(false);
const selectAllRef = useRef<HTMLInputElement>(null);
useEffect(() => {
api.listDecks().then(setDecks);
}, []);
// Refetch whenever refreshKey bumps too, so newly-owned cards' types
// become suggestible without restarting the app.
useEffect(() => {
api.getLibraryFacets().then(setFacets).catch(() => setFacets(NO_FACETS));
}, [refreshKey]);
// Cancel any in-flight removal timers on unmount so they don't fire against
// an unmounted component.
useEffect(() => () => removalTimers.current.forEach(clearTimeout), []);
// Keep selection in sync with what's actually visible — drop ids that fall
// out of the filtered set (filter changed, or a card's qty hit 0 and its
// grace period expired).
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]);
useEffect(() => {
const filter: LibraryFilter = {
text: text.trim() || null,
commander_identity: commander || null,
colors_include: colorsInclude || null,
Add colorless filters, and make set listings filterable Colorless There was no way to ask for colorless cards — the colour toggles are a 5-bit WUBRG mask where 0 already means "don't filter by colour", leaving nothing to express "no colours at all". That's exactly what you need to find a colorless commander's card pool. Adds a "C" pip to both colour rows, matching how ColorPips already renders colorless cards, backed by two explicit booleans rather than a sixth bit — a filter mask and a card's real colour mask staying the same shape is worth keeping. The two rows mean different things and both are useful: on the commander row C filters colour *identity*, which is the Commander rule; on the includes row it filters the card's own colours. A card printed colorless can still carry a coloured identity through mana symbols in its text, and it's the identity that bars it from a colorless commander's deck. Set listings News > Releases had its own card path and so couldn't be filtered. A new set_code filter (matched against printings.set_code, already ingested) lets the set browser run through search_reference instead, so every criterion works there identically rather than being reimplemented over a live Scryfall response. That alone would have cost the ability to browse an upcoming set, since search_reference only knows downloaded card data — and upcoming sets are half the point of the tab. So the browser picks a source per set: downloaded data where it exists, with the full filter panel; otherwise Scryfall directly, with the panel hidden and a line explaining why. A panel that silently does nothing would be worse than no panel. The source is decided by an *unfiltered* probe. Keying off whether the current criteria match anything would flip a properly indexed set to "live" the moment a filter excluded everything, dropping the panel exactly when it was in use.
2026-08-10 22:34:46 -05:00
colorless_identity: colorlessIdentity,
colorless_colors: colorlessColors,
cmc_min: cmcMin === "" ? null : Number(cmcMin),
cmc_max: cmcMax === "" ? null : Number(cmcMax),
types,
subtypes,
keywords,
in_deck: inDeck === "" ? null : Number(inDeck),
location: location.trim() || null,
oracle_text: oracleText.trim() || null,
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.
2026-08-07 22:03:49 -05:00
...applyCardCriteria(criteria),
};
const t = setTimeout(() => {
api.searchLibrary(filter).then(setRows).catch(() => setRows([]));
}, 120);
return () => clearTimeout(t);
}, [
text,
commander,
colorsInclude,
Add colorless filters, and make set listings filterable Colorless There was no way to ask for colorless cards — the colour toggles are a 5-bit WUBRG mask where 0 already means "don't filter by colour", leaving nothing to express "no colours at all". That's exactly what you need to find a colorless commander's card pool. Adds a "C" pip to both colour rows, matching how ColorPips already renders colorless cards, backed by two explicit booleans rather than a sixth bit — a filter mask and a card's real colour mask staying the same shape is worth keeping. The two rows mean different things and both are useful: on the commander row C filters colour *identity*, which is the Commander rule; on the includes row it filters the card's own colours. A card printed colorless can still carry a coloured identity through mana symbols in its text, and it's the identity that bars it from a colorless commander's deck. Set listings News > Releases had its own card path and so couldn't be filtered. A new set_code filter (matched against printings.set_code, already ingested) lets the set browser run through search_reference instead, so every criterion works there identically rather than being reimplemented over a live Scryfall response. That alone would have cost the ability to browse an upcoming set, since search_reference only knows downloaded card data — and upcoming sets are half the point of the tab. So the browser picks a source per set: downloaded data where it exists, with the full filter panel; otherwise Scryfall directly, with the panel hidden and a line explaining why. A panel that silently does nothing would be worse than no panel. The source is decided by an *unfiltered* probe. Keying off whether the current criteria match anything would flip a properly indexed set to "live" the moment a filter excluded everything, dropping the panel exactly when it was in use.
2026-08-10 22:34:46 -05:00
colorlessIdentity,
colorlessColors,
cmcMin,
cmcMax,
types,
subtypes,
keywords,
inDeck,
location,
oracleText,
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.
2026-08-07 22:03:49 -05:00
criteria,
refreshKey,
]);
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)));
// Re-adding during the grace period below cancels the pending removal.
const timer = removalTimers.current.get(oracleId);
if (timer) {
clearTimeout(timer);
removalTimers.current.delete(oracleId);
setPendingRemoval((p) => {
const next = new Set(p);
next.delete(oracleId);
return next;
});
}
if (qty <= 0) {
// search_library only returns owned (>0) cards — rather than dropping
// the row immediately, dim it and give the user a window to undo a
// mistaken decrement (via +) before it actually disappears.
setPendingRemoval((p) => new Set(p).add(oracleId));
removalTimers.current.set(
oracleId,
setTimeout(() => {
setRows((rs) => rs.filter((c) => c.oracle_id !== oracleId));
setPendingRemoval((p) => {
const next = new Set(p);
next.delete(oracleId);
return next;
});
removalTimers.current.delete(oracleId);
}, REMOVAL_GRACE_MS),
);
}
}
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))));
}
/**
* Set the in-deck quantity of every selected card to either 1 or the full
* owned count (e.g. select a handful of basic lands, "Add All" to put
* every copy you own into the deck at once) — overwrites the quantity for
* cards already in the deck rather than skipping them, so it doubles as a
* bulk "fix the land count" tool. `bulkToMaybeboard` sends them to the
* deck's maybeboard instead of the mainboard.
*
* Since this overwrites rather than skips, it can clobber an existing
* in-deck quantity/board — so it snapshots each target's prior deck_cards
* row (or absence of one) first and offers an "Undo" that restores exactly
* that prior state (deleting the row again for cards that weren't in the
* deck before).
*/
async function bulkAddToDeck(mode: "one" | "all") {
if (bulkDeckId === "") return;
const deck = decks.find((d) => d.id === bulkDeckId);
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,
mode === "all" ? r.quantity_owned : 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 bulkSetLocation(value: string) {
setBulkBusy(true);
try {
await Promise.all([...selected].map((id) => api.setLocation(id, value)));
setLocationDialogOpen(false);
setRefreshKey((k) => k + 1);
} finally {
setBulkBusy(false);
}
}
async function bulkAdjust(delta: number) {
setBulkBusy(true);
try {
await Promise.all([...selected].map((id) => adjust(id, delta)));
} finally {
setBulkBusy(false);
}
}
async function exportSelected() {
const targets = rows.filter((r) => selected.has(r.oracle_id)).map((r) => r.oracle_id);
if (targets.length === 0) return;
const path = await save({
title: "Export library cards",
defaultPath: "library-export.txt",
filters: [{ name: "Decklist", extensions: ["txt"] }],
});
if (typeof path === "string") await api.exportLibraryCards(targets, path);
}
async function pickImportFile() {
setImportIntroOpen(false);
const path = await open({
title: "Import library cards",
filters: [{ name: "Card list (decklist or ManaBox export)", extensions: ["txt", "csv"] }],
});
if (typeof path === "string") setImportPath(path);
}
async function runImport(path: string, location: string) {
setImportPath(null);
setImportProgress({ done: 0, total: 0 });
const reveal = setTimeout(() => setShowImportProgress(true), 300);
const unlisten = await listen<{ done: number; total: number }>("library-import-progress", (e) =>
setImportProgress(e.payload),
);
try {
setImportResult(await api.importLibraryCards(path, location));
} catch (err) {
setImportError(String(err));
} finally {
unlisten();
clearTimeout(reveal);
setShowImportProgress(false);
setImportProgress(null);
setRefreshKey((k) => k + 1);
}
}
const deckOrLocation = (c: LibraryCard) =>
c.decks.length === 0 ? (
<LocationField
oracleId={c.oracle_id}
location={c.location}
onSaved={() => setRefreshKey((k) => k + 1)}
/>
) : (
c.decks.map((d) => (
<span key={d} className="deck-chip">
{d}
</span>
))
);
Spare/rarity filters, owned-only suggestions, and a Game night tab Library filters: - "Only spare copies" quick filter (Library): cards with more copies owned than the decks' main boards use, the opposite of the ⚠ over-assignment warning. Rows show "N spare" while it's on. - Rarity toggles (C/U/R/M) in the shared criteria panel, so Library, Card Search and the set browser all get them. Any printing counts, since the library tracks cards rather than printings; within a set it's that set's printing. Arena-only printings don't count for a card that also exists on paper, so a digital re-rarity can't make a paper common read as rare. Suggestions: - "From my collection" toggle (remembered) narrows candidates to owned cards before scoring and paging, so "Suggest more" keeps finding them. - Each suggestion reports copies other decks already use, shown as "N spare" / "all in other decks". suggest_cards now takes a SuggestOptions struct instead of a bare `unusual` flag. Game tab: a life counter for 2-6 players with tap/hold ±1/±10, a running change bubble, poison and per-opponent commander damage (which also comes off life), out-of-the-game markers, dice and a coin, and a random first player. A seat's deck puts its commander's art behind the life total. On phones the board fills the screen with the far row turned to face across the table, and asks to keep the screen awake. The game is per-device scratch state in localStorage, not synced. Plain filter chips now show an accent background when active; before, "Unusual picks" only changed its text colour.
2026-10-14 22:09:39 -05:00
// Under the spares filter, how many copies are free — only worth saying for
// a card some deck uses, since otherwise it's every copy shown.
const spareNote = (c: LibraryCard) =>
criteria.sparesOnly &&
c.quantity_assigned > 0 && (
<span className="muted" title="Copies no deck is using">
{c.quantity_owned - c.quantity_assigned} spare
</span>
);
Printing tracking for owned copies and deck art, and an outdated-card-data notice Owned copies can now be pinned to a printing + finish (nonfoil / foil / etched), activating the long-dormant library_card_copies table as groups of identical copies. library_cards.quantity_owned stays authoritative: groups are a subset of it, and the rest are "unspecified". Tracking more copies than are unspecified grows ownership to fit; lowering ownership trims the most recently added groups first. Migration 0006 adds the group quantity, a unique (card, printing, finish) index, and deck_cards.preferred_scryfall_id. A deck slot can show a chosen printing's art, both faces from that printing, falling back to the default when the id no longer resolves. It survives quantity/board changes and is kept by duplicate_deck. UI: the click preview's info tile gets a Printings section when opened from the Library or a deck (copy groups with steppers, "Add a printing…", and "Shown in this deck" with Change…/Default), backed by a printing picker grid. The picker takes Escape in the capture phase so it closes without closing the preview under it. Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and DeckCardEntry.printing. Cards now merge field by field with groups per key, so a quantity edit and a printing added on another device both survive; merged groups are clamped deterministically. A document is written as version 2 only once it holds printing data, so older builds and servers refuse it instead of dropping the data and syncing the loss. Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck and library export/import. Library export writes a line per group plus one for unspecified copies. reference.db printings gain lang and released_at for picker labels and ordering. When the installed card data lacks any column this build's reference schema has (found by diffing against the schema applied in-memory, so no list to maintain), a notice under the top bar offers Update card data (and Import card data… on phones); "Not now" is remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
// Lets the click preview track which printings the copies are.
const preview: PreviewContext = { onChanged: () => setRefreshKey((k) => k + 1) };
function libTable(cards: LibraryCard[]) {
return (
<table>
<thead>
<tr>
<th></th>
<th></th>
<th>Colors</th>
<th>Name</th>
<th>Type</th>
<th>MV</th>
<th>Owned</th>
<th>Decks / Location</th>
</tr>
</thead>
<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">
Printing tracking for owned copies and deck art, and an outdated-card-data notice Owned copies can now be pinned to a printing + finish (nonfoil / foil / etched), activating the long-dormant library_card_copies table as groups of identical copies. library_cards.quantity_owned stays authoritative: groups are a subset of it, and the rest are "unspecified". Tracking more copies than are unspecified grows ownership to fit; lowering ownership trims the most recently added groups first. Migration 0006 adds the group quantity, a unique (card, printing, finish) index, and deck_cards.preferred_scryfall_id. A deck slot can show a chosen printing's art, both faces from that printing, falling back to the default when the id no longer resolves. It survives quantity/board changes and is kept by duplicate_deck. UI: the click preview's info tile gets a Printings section when opened from the Library or a deck (copy groups with steppers, "Add a printing…", and "Shown in this deck" with Change…/Default), backed by a printing picker grid. The picker takes Escape in the capture phase so it closes without closing the preview under it. Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and DeckCardEntry.printing. Cards now merge field by field with groups per key, so a quantity edit and a printing added on another device both survive; merged groups are clamped deterministically. A document is written as version 2 only once it holds printing data, so older builds and servers refuse it instead of dropping the data and syncing the loss. Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck and library export/import. Library export writes a line per group plus one for unspecified copies. reference.db printings gain lang and released_at for picker labels and ordering. When the installed card data lacks any column this build's reference schema has (found by diffing against the schema applied in-memory, so no list to maintain), a notice under the top bar offers Update card data (and Import card data… on phones); "Not now" is remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
<CardThumb card={c} preview={preview} />
</td>
<td>
<ColorPips mask={c.color_identity} />
</td>
<td className="card-name">{c.name}</td>
<td className="card-type">{c.type_line}</td>
<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}
onDecrement={() => adjust(c.oracle_id, -1)}
onIncrement={() => adjust(c.oracle_id, 1)}
warn={c.quantity_assigned > c.quantity_owned}
pending={pendingRemoval.has(c.oracle_id)}
/>
{c.quantity_assigned > c.quantity_owned && (
<span title="Assigned to more decks than you own">
⚠ {c.quantity_assigned} assigned
</span>
)}
Spare/rarity filters, owned-only suggestions, and a Game night tab Library filters: - "Only spare copies" quick filter (Library): cards with more copies owned than the decks' main boards use, the opposite of the ⚠ over-assignment warning. Rows show "N spare" while it's on. - Rarity toggles (C/U/R/M) in the shared criteria panel, so Library, Card Search and the set browser all get them. Any printing counts, since the library tracks cards rather than printings; within a set it's that set's printing. Arena-only printings don't count for a card that also exists on paper, so a digital re-rarity can't make a paper common read as rare. Suggestions: - "From my collection" toggle (remembered) narrows candidates to owned cards before scoring and paging, so "Suggest more" keeps finding them. - Each suggestion reports copies other decks already use, shown as "N spare" / "all in other decks". suggest_cards now takes a SuggestOptions struct instead of a bare `unusual` flag. Game tab: a life counter for 2-6 players with tap/hold ±1/±10, a running change bubble, poison and per-opponent commander damage (which also comes off life), out-of-the-game markers, dice and a coin, and a random first player. A seat's deck puts its commander's art behind the life total. On phones the board fills the screen with the far row turned to face across the table, and asks to keep the screen awake. The game is per-device scratch state in localStorage, not synced. Plain filter chips now show an accent background when active; before, "Unusual picks" only changed its text colour.
2026-10-14 22:09:39 -05:00
{spareNote(c)}
</span>
</td>
<td data-label="Decks / Location">{deckOrLocation(c)}</td>
</tr>
))}
</tbody>
</table>
);
}
function libTiles(cards: LibraryCard[]) {
return (
<div {...tiles.gridProps}>
{cards.map((c) => (
<CardTile
key={c.oracle_id}
card={c}
Printing tracking for owned copies and deck art, and an outdated-card-data notice Owned copies can now be pinned to a printing + finish (nonfoil / foil / etched), activating the long-dormant library_card_copies table as groups of identical copies. library_cards.quantity_owned stays authoritative: groups are a subset of it, and the rest are "unspecified". Tracking more copies than are unspecified grows ownership to fit; lowering ownership trims the most recently added groups first. Migration 0006 adds the group quantity, a unique (card, printing, finish) index, and deck_cards.preferred_scryfall_id. A deck slot can show a chosen printing's art, both faces from that printing, falling back to the default when the id no longer resolves. It survives quantity/board changes and is kept by duplicate_deck. UI: the click preview's info tile gets a Printings section when opened from the Library or a deck (copy groups with steppers, "Add a printing…", and "Shown in this deck" with Change…/Default), backed by a printing picker grid. The picker takes Escape in the capture phase so it closes without closing the preview under it. Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and DeckCardEntry.printing. Cards now merge field by field with groups per key, so a quantity edit and a printing added on another device both survive; merged groups are clamped deterministically. A document is written as version 2 only once it holds printing data, so older builds and servers refuse it instead of dropping the data and syncing the loss. Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck and library export/import. Library export writes a line per group plus one for unspecified copies. reference.db printings gain lang and released_at for picker labels and ordering. When the installed card data lacks any column this build's reference schema has (found by diffing against the schema applied in-memory, so no list to maintain), a notice under the top bar offers Update card data (and Import card data… on phones); "Not now" is remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
preview={preview}
selected={selected.has(c.oracle_id)}
cornerLeft={
<span className="tile-select">
<input
type="checkbox"
checked={selected.has(c.oracle_id)}
onChange={() => toggleOne(c.oracle_id)}
/>
</span>
}
corner={
<QtyStepper
overlay
value={c.quantity_owned}
onDecrement={() => adjust(c.oracle_id, -1)}
onIncrement={() => adjust(c.oracle_id, 1)}
warn={c.quantity_assigned > c.quantity_owned}
pending={pendingRemoval.has(c.oracle_id)}
/>
}
Spare/rarity filters, owned-only suggestions, and a Game night tab Library filters: - "Only spare copies" quick filter (Library): cards with more copies owned than the decks' main boards use, the opposite of the ⚠ over-assignment warning. Rows show "N spare" while it's on. - Rarity toggles (C/U/R/M) in the shared criteria panel, so Library, Card Search and the set browser all get them. Any printing counts, since the library tracks cards rather than printings; within a set it's that set's printing. Arena-only printings don't count for a card that also exists on paper, so a digital re-rarity can't make a paper common read as rare. Suggestions: - "From my collection" toggle (remembered) narrows candidates to owned cards before scoring and paging, so "Suggest more" keeps finding them. - Each suggestion reports copies other decks already use, shown as "N spare" / "all in other decks". suggest_cards now takes a SuggestOptions struct instead of a bare `unusual` flag. Game tab: a life counter for 2-6 players with tap/hold ±1/±10, a running change bubble, poison and per-opponent commander damage (which also comes off life), out-of-the-game markers, dice and a coin, and a random first player. A seat's deck puts its commander's art behind the life total. On phones the board fills the screen with the far row turned to face across the table, and asks to keep the screen awake. The game is per-device scratch state in localStorage, not synced. Plain filter chips now show an accent background when active; before, "Unusual picks" only changed its text colour.
2026-10-14 22:09:39 -05:00
footer={
<>
{deckOrLocation(c)}
{spareNote(c)}
</>
}
/>
))}
</div>
);
}
const groups = groupCards(sortCards(rows, sortBy, sortDir), groupBy);
return (
<div>
<div className="page-header">
<h2 className="page-header-title">
Library <span className="badge">{rows.length}</span>
</h2>
<div className="page-header-actions">
<button
onClick={() => setImportIntroOpen(true)}
disabled={importProgress !== null}
>
Import cards…
</button>
</div>
</div>
{selected.size > 0 && (
<div className="bulk-bar">
<span className="bulk-bar-count">{selected.size} selected</span>
<div className="bulk-bar-actions">
<span className="row" style={{ gap: 6 }}>
<select
value={bulkDeckId}
onChange={(e) => setBulkDeckId(e.target.value === "" ? "" : Number(e.target.value))}
disabled={bulkBusy}
>
<option value="">Add to deck…</option>
{decks.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
<button
onClick={() => bulkAddToDeck("one")}
disabled={bulkBusy || bulkDeckId === ""}
title="Set each selected card to 1 copy in the deck"
>
Add 1
</button>
<button
onClick={() => bulkAddToDeck("all")}
disabled={bulkBusy || bulkDeckId === ""}
title="Set each selected card to every copy you own"
>
Add All
</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>
</span>
<button onClick={() => setLocationDialogOpen(true)} disabled={bulkBusy}>
Set location…
</button>
<span className="row" style={{ gap: 4 }}>
<button
onClick={() => bulkAdjust(-bulkDelta)}
disabled={bulkBusy}
title="Remove copies from each selected card"
>
−
</button>
<input
className="bulk-bar-delta"
type="number"
min={1}
value={bulkDelta}
onChange={(e) => setBulkDelta(Math.max(1, Number(e.target.value) || 1))}
/>
<button
onClick={() => bulkAdjust(bulkDelta)}
disabled={bulkBusy}
title="Add copies to each selected card"
>
+
</button>
</span>
<button
onClick={exportSelected}
disabled={bulkBusy}
title="Export the selected cards to a decklist file"
>
Export selected
</button>
</div>
<span className="spacer" />
<button onClick={() => setSelected(new Set())} disabled={bulkBusy}>
Clear
</button>
</div>
)}
{locationDialogOpen && (
<PromptDialog
title="Set location"
label={`Location for ${selected.size} card${selected.size === 1 ? "" : "s"}`}
defaultValue=""
confirmLabel="Apply"
onConfirm={bulkSetLocation}
onCancel={() => setLocationDialogOpen(false)}
/>
)}
{importIntroOpen && (
<DialogFrame title="Import cards" onCancel={() => setImportIntroOpen(false)} wide>
<p className="dialog-message">
Adds the cards in a file to your library. Nothing you already own is replaced or
removed. You can import:
</p>
<ul className="dialog-list">
<li>
<strong>A card list</strong> (.txt), one card per line, like <code>4 Lightning Bolt</code>.
A line can name a printing, like <code>1 Sol Ring (2X2) 315 *F*</code>, where{" "}
<code>*F*</code> is foil and <code>*E*</code> etched. Library export, Moxfield and
Arena all write this format.
</li>
<li>
<strong>A ManaBox export</strong> of a collection or binder, either the .csv or the
.txt. The CSV records each copy's exact printing and finish, so it's the better
choice. ManaBox's condition, language and price fields aren't kept.
</li>
</ul>
<p className="dialog-message">
After you choose a file, you can file its cards under a location, like the binder they
came from. Any cards that can't be matched are listed afterwards.
</p>
<div className="dialog-actions">
<button onClick={() => setImportIntroOpen(false)}>Cancel</button>
<button className="primary" onClick={pickImportFile} autoFocus>
Choose file…
</button>
</div>
</DialogFrame>
)}
{importPath && (
<ImportLocationDialog
path={importPath}
onImport={(location) => runImport(importPath, location)}
onCancel={() => setImportPath(null)}
/>
)}
{importProgress && showImportProgress && (
<ProgressDialog
title="Importing cards…"
done={importProgress.done}
total={importProgress.total}
unit="lines"
/>
)}
{importError && (
<InfoDialog
title="Couldn't import that file"
message={importError}
onClose={() => setImportError(null)}
/>
)}
{importResult && (
<InfoDialog
title="Cards imported"
message={
importResult.unresolved.length > 0
? `Added ${importResult.added.length} card${importResult.added.length === 1 ? "" : "s"}. ${importResult.unresolved.length} name${importResult.unresolved.length === 1 ? "" : "s"} couldn't be matched:`
: `Added ${importResult.added.length} card${importResult.added.length === 1 ? "" : "s"} to your library.`
}
items={importResult.unresolved}
onClose={() => setImportResult(null)}
/>
)}
<div className="panel workspace">
<div className="filters">
<div className="field">
<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>
Add colorless filters, and make set listings filterable Colorless There was no way to ask for colorless cards — the colour toggles are a 5-bit WUBRG mask where 0 already means "don't filter by colour", leaving nothing to express "no colours at all". That's exactly what you need to find a colorless commander's card pool. Adds a "C" pip to both colour rows, matching how ColorPips already renders colorless cards, backed by two explicit booleans rather than a sixth bit — a filter mask and a card's real colour mask staying the same shape is worth keeping. The two rows mean different things and both are useful: on the commander row C filters colour *identity*, which is the Commander rule; on the includes row it filters the card's own colours. A card printed colorless can still carry a coloured identity through mana symbols in its text, and it's the identity that bars it from a colorless commander's deck. Set listings News > Releases had its own card path and so couldn't be filtered. A new set_code filter (matched against printings.set_code, already ingested) lets the set browser run through search_reference instead, so every criterion works there identically rather than being reimplemented over a live Scryfall response. That alone would have cost the ability to browse an upcoming set, since search_reference only knows downloaded card data — and upcoming sets are half the point of the tab. So the browser picks a source per set: downloaded data where it exists, with the full filter panel; otherwise Scryfall directly, with the panel hidden and a line explaining why. A panel that silently does nothing would be worse than no panel. The source is decided by an *unfiltered* probe. Keying off whether the current criteria match anything would flip a properly indexed set to "live" the moment a filter excluded everything, dropping the panel exactly when it was in use.
2026-08-10 22:34:46 -05:00
<ColorToggles
value={commander}
onChange={setCommander}
colorless={colorlessIdentity}
onColorlessChange={setColorlessIdentity}
colorlessTitle="Colorless commander — only cards with no color identity"
/>
</div>
<div className="field">
<label>Includes colors</label>
Add colorless filters, and make set listings filterable Colorless There was no way to ask for colorless cards — the colour toggles are a 5-bit WUBRG mask where 0 already means "don't filter by colour", leaving nothing to express "no colours at all". That's exactly what you need to find a colorless commander's card pool. Adds a "C" pip to both colour rows, matching how ColorPips already renders colorless cards, backed by two explicit booleans rather than a sixth bit — a filter mask and a card's real colour mask staying the same shape is worth keeping. The two rows mean different things and both are useful: on the commander row C filters colour *identity*, which is the Commander rule; on the includes row it filters the card's own colours. A card printed colorless can still carry a coloured identity through mana symbols in its text, and it's the identity that bars it from a colorless commander's deck. Set listings News > Releases had its own card path and so couldn't be filtered. A new set_code filter (matched against printings.set_code, already ingested) lets the set browser run through search_reference instead, so every criterion works there identically rather than being reimplemented over a live Scryfall response. That alone would have cost the ability to browse an upcoming set, since search_reference only knows downloaded card data — and upcoming sets are half the point of the tab. So the browser picks a source per set: downloaded data where it exists, with the full filter panel; otherwise Scryfall directly, with the panel hidden and a line explaining why. A panel that silently does nothing would be worse than no panel. The source is decided by an *unfiltered* probe. Keying off whether the current criteria match anything would flip a properly indexed set to "live" the moment a filter excluded everything, dropping the panel exactly when it was in use.
2026-08-10 22:34:46 -05:00
<ColorToggles
value={colorsInclude}
onChange={setColorsInclude}
colorless={colorlessColors}
onColorlessChange={setColorlessColors}
colorlessTitle="Colorless cards only"
/>
</div>
<div className="field">
<label>Mana value</label>
<div className="row">
<input
type="number"
style={{ width: 70 }}
placeholder="min"
value={cmcMin}
onChange={(e) => setCmcMin(e.target.value)}
/>
<span className="muted">–</span>
<input
type="number"
style={{ width: 70 }}
placeholder="max"
value={cmcMax}
onChange={(e) => setCmcMax(e.target.value)}
/>
</div>
</div>
<div className="field">
<label>Type(s)</label>
<TagInput values={types} onChange={setTypes} suggestions={facets.types} placeholder="Creature…" />
</div>
<div className="field">
<label>Subtype(s)</label>
<TagInput
values={subtypes}
onChange={setSubtypes}
suggestions={facets.subtypes}
placeholder="Elf…"
/>
</div>
<div className="field">
<label>Keyword(s)</label>
<TagInput
values={keywords}
onChange={setKeywords}
suggestions={facets.keywords}
placeholder="Flying…"
/>
</div>
<div className="field">
<label>In deck</label>
<select value={inDeck} onChange={(e) => setInDeck(e.target.value === "" ? "" : Number(e.target.value))}>
<option value="">Any</option>
{decks.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
</div>
<div className="field">
<label>Location</label>
<input
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="e.g. Box 1"
/>
</div>
<div className="field">
<label>Oracle text</label>
<input
value={oracleText}
onChange={(e) => setOracleText(e.target.value)}
placeholder="e.g. landfall"
/>
</div>
Spare/rarity filters, owned-only suggestions, and a Game night tab Library filters: - "Only spare copies" quick filter (Library): cards with more copies owned than the decks' main boards use, the opposite of the ⚠ over-assignment warning. Rows show "N spare" while it's on. - Rarity toggles (C/U/R/M) in the shared criteria panel, so Library, Card Search and the set browser all get them. Any printing counts, since the library tracks cards rather than printings; within a set it's that set's printing. Arena-only printings don't count for a card that also exists on paper, so a digital re-rarity can't make a paper common read as rare. Suggestions: - "From my collection" toggle (remembered) narrows candidates to owned cards before scoring and paging, so "Suggest more" keeps finding them. - Each suggestion reports copies other decks already use, shown as "N spare" / "all in other decks". suggest_cards now takes a SuggestOptions struct instead of a bare `unusual` flag. Game tab: a life counter for 2-6 players with tap/hold ±1/±10, a running change bubble, poison and per-opponent commander damage (which also comes off life), out-of-the-game markers, dice and a coin, and a random first player. A seat's deck puts its commander's art behind the life total. On phones the board fills the screen with the far row turned to face across the table, and asks to keep the screen awake. The game is per-device scratch state in localStorage, not synced. Plain filter chips now show an accent background when active; before, "Unusual picks" only changed its text colour.
2026-10-14 22:09:39 -05:00
<CardCriteriaFields value={criteria} onChange={patchCriteria} hideNotOwned showSpares />
</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">
<input
ref={selectAllRef}
type="checkbox"
checked={rows.length > 0 && selected.size === rows.length}
onChange={toggleSelectAll}
disabled={rows.length === 0}
/>
Select all
</label>
<span className="workspace-bar-count">
{rows.length} card{rows.length === 1 ? "" : "s"}
</span>
<span className="spacer" />
<GroupBySelect value={groupBy} onChange={setGroupBy} />
<SortBySelect
value={sortBy}
dir={sortDir}
onChangeValue={setSortBy}
onToggleDir={() => setSortDir(sortDir === "asc" ? "desc" : "asc")}
/>
<ViewToggle mode={view} onChange={setView} />
{view === "tiles" && <TileColumnsControl tiles={tiles} />}
</div>
</div>
{rows.length === 0 ? (
<p className="hint">
No matching cards owned. Add some in the Add Cards tab, or loosen the filters.
</p>
) : (
groups.map((g) => (
<div key={g.label || "all"} style={{ marginBottom: g.label ? 20 : 0 }}>
{g.label && (
<h3 className="group-header">
{g.label} <span className="badge">{g.cards.length}</span>
</h3>
)}
{view === "list" ? libTable(g.cards) : libTiles(g.cards)}
</div>
))
)}
</div>
);
}
/** A picked file's name without its folder or extension — a ManaBox export
* is named after its binder ("Green Bulk.csv"). Copes with Android content
* URIs, whose last segment is a percent-encoded "primary:Download/x.csv". */
function fileStem(path: string): string {
let name = path;
try {
name = decodeURIComponent(path);
} catch {
// Not percent-encoded after all; use it as it is.
}
name = name.split(/[/\\:]/).pop() ?? "";
return name.replace(/\.[^.]*$/, "").trim();
}
/** Asks where an import's cards are kept, pre-filled with the file's name.
* Blank imports them without touching any card's location. */
function ImportLocationDialog({
path,
onImport,
onCancel,
}: {
path: string;
onImport: (location: string) => void;
onCancel: () => void;
}) {
const [value, setValue] = useState(() => fileStem(path));
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => inputRef.current?.select(), []);
const submit = () => onImport(value.trim());
return (
<DialogFrame title="Where are these cards?" onCancel={onCancel}>
<label className="dialog-field">
<span className="muted">Location</span>
<input
ref={inputRef}
autoFocus
value={value}
placeholder="e.g. Box 1"
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submit()}
/>
</label>
<p className="dialog-message">
Every imported card is filed under this location, replacing any it already had. Leave it
blank to keep cards' current locations.
</p>
<div className="dialog-actions">
<button onClick={onCancel}>Cancel</button>
<button className="primary" onClick={submit}>
Import
</button>
</div>
</DialogFrame>
);
}