Add a Card Search tab and fix horizontal-card (Battle/Plane) preview orientation

Card Search browses the whole downloaded card universe (not just owned
cards), reusing the Library tab's filter/group/sort/list/tiles UI; results
show ownership and offer quick "own" and "add to deck" actions.

Battles, Planes, and Phenomena store their Scryfall image portrait-shaped
with the content itself rotated, so hover/click previews now detect this
(a new `horizontal` flag threaded through ingest and every card query, with
graceful fallback for a reference.db built before the column existed) and
rotate the image back into place. Single-faced and non-DFC cases are
verified fixed; a double-faced Battle's front face still needs another pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 20:55:17 -05:00
parent 3c625a9830
commit 2b361c4a85
19 changed files with 1076 additions and 102 deletions

391
src/views/CardSearch.tsx Normal file
View File

@@ -0,0 +1,391 @@
import { useEffect, useState } from "react";
import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter } from "../api";
import {
CardThumb,
CardTile,
ColorPips,
ColorToggles,
GroupBySelect,
GroupMode,
IconButton,
Icons,
QtyStepper,
SortBySelect,
SortDir,
SortMode,
TagInput,
ViewToggle,
groupCards,
sortCards,
usePersistedState,
useViewMode,
} from "../components";
// Server-side cap on results — this browses the whole ~30k-card reference
// db, not just what's owned, so an unbounded/loose filter needs a limit.
const CAP = 300;
const NO_FACETS: LibraryFacets = { types: [], subtypes: [], keywords: [] };
/**
* Browse the whole downloaded card universe (every card in reference.db,
* owned or not) — the Library tab's sibling, reusing its filter/group/sort/
* list/tiles machinery. Results are annotated with ownership: an "Owned ×N"
* stepper and any decks the card is already in.
*
* `onOfferUndo` is threaded in from `App`, same as `LibraryBrowser`, so the
* "Add to deck" undo is also reachable from the command palette.
*/
export function CardSearch({
onOfferUndo,
}: {
onOfferUndo: (message: string, run: () => void | Promise<void>) => void;
}) {
const [text, setText] = useState("");
const [commander, setCommander] = useState(0);
const [colorsInclude, setColorsInclude] = useState(0);
const [cmcMin, setCmcMin] = useState("");
const [cmcMax, setCmcMax] = useState("");
const [types, setTypes] = useState<string[]>([]);
const [subtypes, setSubtypes] = useState<string[]>([]);
const [keywords, setKeywords] = useState<string[]>([]);
const [oracleText, setOracleText] = useState("");
const [facets, setFacets] = useState<LibraryFacets>(NO_FACETS);
const [decks, setDecks] = useState<Deck[]>([]);
const [deckId, setDeckId] = useState<number | "">("");
const [rows, setRows] = useState<LibraryCard[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<Set<string>>(new Set());
const [refreshKey, setRefreshKey] = useState(0);
const [view, setView] = useViewMode("cardsearch-view");
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("cardsearch-group", "none");
const [sortBy, setSortBy] = usePersistedState<SortMode>("cardsearch-sort", "name");
const [sortDir, setSortDir] = usePersistedState<SortDir>("cardsearch-sort-dir", "asc");
useEffect(() => {
api.listDecks().then(setDecks);
}, []);
useEffect(() => {
api.getReferenceFacets().then(setFacets).catch(() => setFacets(NO_FACETS));
}, []);
const hasActiveFilter =
text.trim() !== "" ||
commander !== 0 ||
colorsInclude !== 0 ||
cmcMin !== "" ||
cmcMax !== "" ||
types.length > 0 ||
subtypes.length > 0 ||
keywords.length > 0 ||
oracleText.trim() !== "";
useEffect(() => {
if (!hasActiveFilter) {
setRows([]);
setError(null);
return;
}
const filter: LibraryFilter = {
text: text.trim() || null,
commander_identity: commander || null,
colors_include: colorsInclude || null,
cmc_min: cmcMin === "" ? null : Number(cmcMin),
cmc_max: cmcMax === "" ? null : Number(cmcMax),
types,
subtypes,
keywords,
oracle_text: oracleText.trim() || null,
};
const t = setTimeout(() => {
api
.searchReference(filter, CAP)
.then((r) => {
setRows(r);
setError(null);
})
.catch((e) => {
setRows([]);
setError(String(e));
});
}, 120);
return () => clearTimeout(t);
}, [
hasActiveFilter,
text,
commander,
colorsInclude,
cmcMin,
cmcMax,
types,
subtypes,
keywords,
oracleText,
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)));
}
/**
* Add one copy of `c` to the deck picked in the toolbar. Snapshots the
* card's prior deck_cards row (or absence of one) first and offers an
* "Undo" that restores exactly that prior state — same pattern as
* `LibraryBrowser.bulkAddToDeck`.
*/
async function addToDeck(c: LibraryCard) {
if (deckId === "") return;
const deck = decks.find((d) => d.id === deckId);
if (!deck) return;
setBusy((b) => new Set(b).add(c.oracle_id));
try {
const before = await api.deckContents(deck.id);
const prev = before.find((e) => e.oracle_id === c.oracle_id);
await api.setDeckCard(deck.id, c.oracle_id, 1, false, false);
setRefreshKey((k) => k + 1);
onOfferUndo(`Added ${c.name} to ${deck.name}.`, async () => {
await api.setDeckCard(
deck.id,
c.oracle_id,
prev?.quantity ?? 0,
prev?.is_commander ?? false,
prev?.is_maybeboard ?? false,
);
setRefreshKey((k) => k + 1);
});
} finally {
setBusy((b) => {
const next = new Set(b);
next.delete(c.oracle_id);
return next;
});
}
}
function refTable(cards: LibraryCard[]) {
return (
<table>
<thead>
<tr>
<th></th>
<th>Colors</th>
<th>Name</th>
<th>Type</th>
<th>MV</th>
<th>Owned</th>
<th>In decks</th>
<th></th>
</tr>
</thead>
<tbody>
{cards.map((c) => (
<tr key={c.oracle_id}>
<td>
<CardThumb card={c} />
</td>
<td>
<ColorPips mask={c.color_identity} />
</td>
<td className="card-name">{c.name}</td>
<td className="card-type">{c.type_line}</td>
<td>{c.mana_value}</td>
<td>
<QtyStepper
value={c.quantity_owned}
onDecrement={() => adjust(c.oracle_id, -1)}
onIncrement={() => adjust(c.oracle_id, 1)}
/>
</td>
<td>
{c.decks.map((d) => (
<span key={d} className="deck-chip">
{d}
</span>
))}
</td>
<td>
<button
onClick={() => addToDeck(c)}
disabled={deckId === "" || busy.has(c.oracle_id)}
title={deckId === "" ? "Choose a deck above first" : "Add 1 copy to the selected deck"}
>
Add to deck
</button>
</td>
</tr>
))}
</tbody>
</table>
);
}
function refTiles(cards: LibraryCard[]) {
return (
<div className="tile-grid">
{cards.map((c) => (
<CardTile
key={c.oracle_id}
card={c}
corner={
<QtyStepper
overlay
value={c.quantity_owned}
onDecrement={() => adjust(c.oracle_id, -1)}
onIncrement={() => adjust(c.oracle_id, 1)}
/>
}
cornerBottomLeft={
<IconButton
icon={Icons.cards}
label="Add to deck"
onClick={() => addToDeck(c)}
disabled={deckId === "" || busy.has(c.oracle_id)}
overlay
/>
}
footer={
c.decks.length > 0
? c.decks.map((d) => (
<span key={d} className="deck-chip">
{d}
</span>
))
: undefined
}
/>
))}
</div>
);
}
const groups = groupCards(sortCards(rows, sortBy, sortDir), groupBy);
return (
<div>
<div className="row" style={{ justifyContent: "space-between", marginBottom: 12 }}>
<h2 style={{ margin: 0 }}>
Card Search {hasActiveFilter && <span className="badge">{rows.length}</span>}
</h2>
<div className="row" style={{ gap: 16 }}>
<select
value={deckId}
onChange={(e) => setDeckId(e.target.value === "" ? "" : Number(e.target.value))}
>
<option value="">Add to deck…</option>
{decks.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
<GroupBySelect value={groupBy} onChange={setGroupBy} />
<SortBySelect
value={sortBy}
dir={sortDir}
onChangeValue={setSortBy}
onToggleDir={() => setSortDir(sortDir === "asc" ? "desc" : "asc")}
/>
<ViewToggle mode={view} onChange={setView} />
</div>
</div>
<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="field">
<label>Playable in commander (color identity ⊆)</label>
<ColorToggles value={commander} onChange={setCommander} />
</div>
<div className="field">
<label>Includes colors</label>
<ColorToggles value={colorsInclude} onChange={setColorsInclude} />
</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>Oracle text</label>
<input
value={oracleText}
onChange={(e) => setOracleText(e.target.value)}
placeholder="e.g. landfall"
/>
</div>
</div>
{!hasActiveFilter ? (
<p className="hint">
Search the full card database — type a name or choose filters below to get started.
</p>
) : error ? (
<p className="hint">{error}</p>
) : rows.length === 0 ? (
<p className="hint">No cards match your filters.</p>
) : (
<>
{rows.length === CAP && (
<p className="hint">
Showing the first {CAP} matches — refine your search to narrow it down.
</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" ? refTable(g.cards) : refTiles(g.cards)}
</div>
))}
</>
)}
</div>
);
}

View File

@@ -12,9 +12,13 @@ import {
LocationField,
PromptDialog,
QtyStepper,
SortBySelect,
SortDir,
SortMode,
TagInput,
ViewToggle,
groupCards,
sortCards,
usePersistedState,
useViewMode,
} from "../components";
@@ -56,6 +60,8 @@ export function LibraryBrowser({
const [rows, setRows] = useState<LibraryCard[]>([]);
const [view, setView] = useViewMode("library-view");
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());
@@ -389,7 +395,7 @@ export function LibraryBrowser({
);
}
const groups = groupCards(rows, groupBy);
const groups = groupCards(sortCards(rows, sortBy, sortDir), groupBy);
return (
<div>
@@ -414,6 +420,12 @@ export function LibraryBrowser({
<div className="row" style={{ gap: 16 }}>
<button onClick={pickImportFile}>Import cards…</button>
<GroupBySelect value={groupBy} onChange={setGroupBy} />
<SortBySelect
value={sortBy}
dir={sortDir}
onChangeValue={setSortBy}
onToggleDir={() => setSortDir(sortDir === "asc" ? "desc" : "asc")}
/>
<ViewToggle mode={view} onChange={setView} />
</div>
</div>