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:
15
src/App.tsx
15
src/App.tsx
@@ -14,9 +14,10 @@ import {
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
import { CardEntry } from "./views/CardEntry";
|
||||
import { LibraryBrowser } from "./views/LibraryBrowser";
|
||||
import { CardSearch } from "./views/CardSearch";
|
||||
import { DeckView } from "./views/DeckView";
|
||||
|
||||
type Tab = "add" | "library" | "decks";
|
||||
type Tab = "add" | "library" | "search" | "decks";
|
||||
|
||||
const svg = (children: ReactNode, size = 20) => (
|
||||
<svg
|
||||
@@ -94,6 +95,16 @@ const NAV: { id: Tab; label: string; icon: ReactNode }[] = [
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "search",
|
||||
label: "Card Search",
|
||||
icon: svg(
|
||||
<>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "decks",
|
||||
label: "Decks",
|
||||
@@ -439,6 +450,8 @@ export default function App() {
|
||||
<CardEntry />
|
||||
) : tab === "library" ? (
|
||||
<LibraryBrowser onOfferUndo={offerUndo} />
|
||||
) : tab === "search" ? (
|
||||
<CardSearch onOfferUndo={offerUndo} />
|
||||
) : (
|
||||
<DeckView refreshSignal={deckRefreshSignal} />
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { api, CardRef, Deck } from "./api";
|
||||
import { DialogFrame, useClickPreview } from "./components";
|
||||
|
||||
type Tab = "add" | "library" | "decks";
|
||||
type Tab = "add" | "library" | "search" | "decks";
|
||||
|
||||
interface PaletteItem {
|
||||
key: string;
|
||||
@@ -93,6 +93,7 @@ export function CommandPalette({
|
||||
...(undo ? [{ key: "undo", label: `Undo: ${undo.message}`, run: onRunUndo }] : []),
|
||||
{ key: "nav-add", label: "Go to Add Cards", run: () => onNavigate("add") },
|
||||
{ key: "nav-library", label: "Go to Library", run: () => onNavigate("library") },
|
||||
{ key: "nav-search", label: "Go to Card Search", run: () => onNavigate("search") },
|
||||
{ key: "nav-decks", label: "Go to Decks", run: () => onNavigate("decks") },
|
||||
{ key: "update-card-data", label: "Update card data", run: onUpdateCardData },
|
||||
{ key: "open-library", label: "Switch library file…", run: onOpenLibrary },
|
||||
|
||||
@@ -10,6 +10,10 @@ export interface CardRef {
|
||||
colors: number; // WUBRG bitmask
|
||||
color_identity: number; // WUBRG bitmask
|
||||
type_line: string;
|
||||
// Printed/displayed sideways (Battles, Planes, Phenomena). The stored
|
||||
// image itself is portrait-shaped with the content rotated, so viewers
|
||||
// must rotate it 90° to display right-side up.
|
||||
horizontal: boolean;
|
||||
image_uri: string; // Scryfall "normal" image URL, or "" if unavailable
|
||||
back_image_uri: string; // back face for transform/MDFC/reversible cards, else ""
|
||||
produced_mana: number; // WUBRG bitmask of mana this card can tap for
|
||||
@@ -177,8 +181,11 @@ export const api = {
|
||||
|
||||
searchLibrary: (filter: LibraryFilter) =>
|
||||
invoke<LibraryCard[]>("search_library", { filter }),
|
||||
searchReference: (filter: LibraryFilter, limit: number) =>
|
||||
invoke<LibraryCard[]>("search_reference", { filter, limit }),
|
||||
getLibraryStats: () => invoke<LibraryStats>("get_library_stats"),
|
||||
getLibraryFacets: () => invoke<LibraryFacets>("get_library_facets"),
|
||||
getReferenceFacets: () => invoke<LibraryFacets>("get_reference_facets"),
|
||||
|
||||
listDecks: () => invoke<Deck[]>("list_decks"),
|
||||
createDeck: (name: string, commanderOracleId: string | null) =>
|
||||
|
||||
@@ -756,20 +756,62 @@ const GAP = 24;
|
||||
const PAD = 12;
|
||||
|
||||
function FloatingCard({ card, x, y }: { card: CardRef; x: number; y: number }) {
|
||||
// Battles/Planes/Phenomena store their image portrait-shaped with the
|
||||
// content itself rotated, so the box footprint swaps (landscape) and the
|
||||
// image inside gets rotated 90° back into place — see `CardRef.horizontal`.
|
||||
const boxW = card.horizontal ? PREVIEW_H : PREVIEW_W;
|
||||
const boxH = card.horizontal ? PREVIEW_W : PREVIEW_H;
|
||||
// Prefer the right of the cursor; flip left if it would overflow.
|
||||
let left = x + GAP;
|
||||
if (left + PREVIEW_W + PAD > window.innerWidth) left = x - PREVIEW_W - GAP;
|
||||
const top = Math.max(PAD, Math.min(y - PREVIEW_H / 2, window.innerHeight - PREVIEW_H - PAD));
|
||||
if (left + boxW + PAD > window.innerWidth) left = x - boxW - GAP;
|
||||
const top = Math.max(PAD, Math.min(y - boxH / 2, window.innerHeight - boxH - PAD));
|
||||
return (
|
||||
<img
|
||||
className="hover-card"
|
||||
src={card.image_uri}
|
||||
alt={card.name}
|
||||
style={{ left, top, width: PREVIEW_W }}
|
||||
/>
|
||||
<div className="hover-card" style={{ left, top, width: boxW, height: boxH }}>
|
||||
<img
|
||||
className="hover-card-img"
|
||||
src={card.image_uri}
|
||||
alt={card.name}
|
||||
style={
|
||||
card.horizontal
|
||||
? {
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
width: PREVIEW_W,
|
||||
height: PREVIEW_H,
|
||||
transform: "translate(-50%, -50%) rotate(90deg)",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Landscape (post-rotation) aspect ratio of a Scryfall "normal" card image —
|
||||
// the raw file is portrait (488x680); rotated 90° it reads as 680x488.
|
||||
const ROTATED_ASPECT = 680 / 488;
|
||||
|
||||
/** The largest landscape box (matching `.preview-modal-img`'s own
|
||||
* `max-width: min(720px, 90vw)` / `max-height: 90vh` bounds) for a rotated
|
||||
* horizontal-card preview. Recomputed on resize so it stays live like the
|
||||
* CSS-only sizing used for normal (portrait) cards. */
|
||||
function useRotatedPreviewBox() {
|
||||
const compute = () => {
|
||||
const boundW = Math.min(720, window.innerWidth * 0.9);
|
||||
const boundH = window.innerHeight * 0.9;
|
||||
const w = Math.min(boundH, boundW * ROTATED_ASPECT);
|
||||
return { w, h: w / ROTATED_ASPECT };
|
||||
};
|
||||
const [box, setBox] = useState(compute);
|
||||
useEffect(() => {
|
||||
const onResize = () => setBox(compute());
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, []);
|
||||
return box;
|
||||
}
|
||||
|
||||
function ClickPreviewModal({
|
||||
state,
|
||||
onClose,
|
||||
@@ -780,6 +822,7 @@ function ClickPreviewModal({
|
||||
onFlip: () => void;
|
||||
}) {
|
||||
const { card, showBack } = state;
|
||||
const rotatedBox = useRotatedPreviewBox();
|
||||
const hasBack = !!card.back_image_uri;
|
||||
// Both faces are always in the DOM when the card has one; a wide-viewport
|
||||
// media query shows them side by side (no flip needed), while a narrow one
|
||||
@@ -791,11 +834,28 @@ function ClickPreviewModal({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="preview-modal-faces">
|
||||
<img
|
||||
className={`preview-modal-img ${showBack ? "" : "active"}`}
|
||||
src={card.image_uri}
|
||||
alt={card.name}
|
||||
/>
|
||||
{/* Only the front face can be horizontal (Battles) — a transformed
|
||||
Battle's back face is always a normal, correctly-oriented
|
||||
creature card, per `CardRef.horizontal`'s doc comment. */}
|
||||
{card.horizontal ? (
|
||||
<div
|
||||
className={`preview-modal-img preview-modal-img-rotated ${showBack ? "" : "active"}`}
|
||||
style={{ width: rotatedBox.w, height: rotatedBox.h }}
|
||||
>
|
||||
<img
|
||||
className="preview-modal-rotate-img"
|
||||
src={card.image_uri}
|
||||
alt={card.name}
|
||||
style={{ width: rotatedBox.h, height: rotatedBox.w }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
className={`preview-modal-img ${showBack ? "" : "active"}`}
|
||||
src={card.image_uri}
|
||||
alt={card.name}
|
||||
/>
|
||||
)}
|
||||
{hasBack && (
|
||||
<img
|
||||
className={`preview-modal-img ${showBack ? "active" : ""}`}
|
||||
@@ -848,6 +908,13 @@ function primaryType(typeLine: string): string {
|
||||
return words[words.length - 1] || "Other";
|
||||
}
|
||||
|
||||
/** Where a primary type falls in [`TYPE_PRIORITY`] — used both to order
|
||||
* type groups and to sort cards by type. */
|
||||
function typeRank(primary: string): number {
|
||||
const i = TYPE_PRIORITY.indexOf(primary);
|
||||
return i === -1 ? TYPE_PRIORITY.length : i;
|
||||
}
|
||||
|
||||
/** e.g. "Creature — Chimera" — prefixed with the primary type so cards group
|
||||
* by (type, subtype) rather than colliding on a subtype shared across types. */
|
||||
function subtypeLabel(typeLine: string): string {
|
||||
@@ -887,11 +954,7 @@ export function groupCards<T extends CardRef>(
|
||||
|
||||
const groups = [...map.entries()].map(([label, cs]) => ({ label, cards: cs }));
|
||||
if (mode === "type") {
|
||||
const rank = (l: string) => {
|
||||
const i = TYPE_PRIORITY.indexOf(l);
|
||||
return i === -1 ? TYPE_PRIORITY.length : i;
|
||||
};
|
||||
groups.sort((a, b) => rank(a.label) - rank(b.label) || a.label.localeCompare(b.label));
|
||||
groups.sort((a, b) => typeRank(a.label) - typeRank(b.label) || a.label.localeCompare(b.label));
|
||||
} else if (mode === "cmc") {
|
||||
groups.sort((a, b) => a.cards[0].mana_value - b.cards[0].mana_value);
|
||||
} else {
|
||||
@@ -922,6 +985,86 @@ export function GroupBySelect({
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sorting ----------------------------------------------------------
|
||||
|
||||
export type SortMode = "name" | "mv" | "color" | "type" | "qty" | "price";
|
||||
|
||||
export const SORT_OPTIONS: { value: SortMode; label: string }[] = [
|
||||
{ value: "name", label: "Name" },
|
||||
{ value: "mv", label: "Mana value" },
|
||||
{ value: "color", label: "Color identity" },
|
||||
{ value: "type", label: "Card type" },
|
||||
{ value: "qty", label: "Quantity owned" },
|
||||
{ value: "price", label: "Price" },
|
||||
];
|
||||
|
||||
export type SortDir = "asc" | "desc";
|
||||
|
||||
/** Sort cards by the chosen attribute; ties always break by name so the
|
||||
* order stays stable and predictable regardless of `mode`. Grouping (see
|
||||
* [`groupCards`]) determines section order independently — sort this array
|
||||
* first, then group it, and each group keeps this order internally. */
|
||||
export function sortCards<T extends CardRef & { quantity_owned: number }>(
|
||||
cards: T[],
|
||||
mode: SortMode,
|
||||
dir: SortDir,
|
||||
): T[] {
|
||||
const sorted = [...cards].sort((a, b) => {
|
||||
const cmp =
|
||||
mode === "name"
|
||||
? a.name.localeCompare(b.name)
|
||||
: mode === "mv"
|
||||
? a.mana_value - b.mana_value || a.name.localeCompare(b.name)
|
||||
: mode === "color"
|
||||
? colorLabel(a.color_identity).localeCompare(colorLabel(b.color_identity)) ||
|
||||
a.name.localeCompare(b.name)
|
||||
: mode === "type"
|
||||
? typeRank(primaryType(a.type_line)) - typeRank(primaryType(b.type_line)) ||
|
||||
a.name.localeCompare(b.name)
|
||||
: mode === "qty"
|
||||
? a.quantity_owned - b.quantity_owned || a.name.localeCompare(b.name)
|
||||
: a.price_usd - b.price_usd || a.name.localeCompare(b.name);
|
||||
return dir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/** Dropdown + ascending/descending toggle for choosing a [`SortMode`]. */
|
||||
export function SortBySelect({
|
||||
value,
|
||||
dir,
|
||||
onChangeValue,
|
||||
onToggleDir,
|
||||
}: {
|
||||
value: SortMode;
|
||||
dir: SortDir;
|
||||
onChangeValue: (m: SortMode) => void;
|
||||
onToggleDir: () => void;
|
||||
}) {
|
||||
return (
|
||||
<span className="row" style={{ gap: 4 }}>
|
||||
<label className="group-by">
|
||||
<span className="muted">Sort by</span>
|
||||
<select value={value} onChange={(e) => onChangeValue(e.target.value as SortMode)}>
|
||||
{SORT_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="sort-dir-btn"
|
||||
onClick={onToggleDir}
|
||||
title={dir === "asc" ? "Ascending — click for descending" : "Descending — click for ascending"}
|
||||
>
|
||||
{dir === "asc" ? "↑" : "↓"}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Stats widgets (sidebar "Your library" panel, per-deck analysis) ------
|
||||
|
||||
/** A single numeric stat in a small panel tile, e.g. "42 / unique". */
|
||||
|
||||
@@ -1481,10 +1481,17 @@ tbody tr:hover {
|
||||
behind the dialog it's in. */
|
||||
z-index: 450;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-lg);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
pointer-events: none;
|
||||
}
|
||||
.hover-card-img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.preview-modal-backdrop {
|
||||
position: fixed;
|
||||
@@ -1527,6 +1534,29 @@ tbody tr:hover {
|
||||
.preview-modal-img.active {
|
||||
display: block;
|
||||
}
|
||||
/* Horizontal cards (Battles/Planes/Phenomena, see `CardRef.horizontal`): the
|
||||
stored image is portrait-shaped with the content itself rotated. The
|
||||
wrapper's width/height (landscape) and the inner img's width/height
|
||||
(the same box transposed, pre-rotation) are computed in JS —
|
||||
`useRotatedPreviewBox` — and passed in as inline styles; this just
|
||||
positions/rotates the img to fill the wrapper exactly. */
|
||||
.preview-modal-img-rotated {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
/* Override `.preview-modal-img`'s portrait-oriented max-width/max-height —
|
||||
they'd otherwise clamp this wrapper's JS-computed (correctly landscape)
|
||||
inline width/height down, while the un-clamped rotated img inside stays
|
||||
at its full computed size and overflows, getting cut off by the
|
||||
`overflow: hidden` above instead of shrinking to fit. */
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
}
|
||||
.preview-modal-rotate-img {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) rotate(90deg);
|
||||
}
|
||||
/* Wide enough to show a double-faced card's front and back side by side —
|
||||
no flip needed. */
|
||||
@media (min-width: 860px) {
|
||||
@@ -1538,6 +1568,14 @@ tbody tr:hover {
|
||||
display: block;
|
||||
max-width: min(430px, 43vw);
|
||||
}
|
||||
/* A horizontal front face (e.g. a transformed Battle) shown side-by-side
|
||||
with its back face: keep using the JS-computed inline width/height
|
||||
(see `.preview-modal-img-rotated` above) rather than the narrower DFC
|
||||
column width, which would clamp the wrapper and crop the rotated image
|
||||
the same way the base max-width/max-height would. */
|
||||
.preview-modal-dfc .preview-modal-img-rotated {
|
||||
max-width: none;
|
||||
}
|
||||
.preview-modal-dfc .preview-modal-flip {
|
||||
display: none;
|
||||
}
|
||||
@@ -1707,6 +1745,16 @@ tbody tr:hover {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.sort-dir-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
.view-toggle {
|
||||
display: inline-flex;
|
||||
background: var(--bg-input);
|
||||
|
||||
391
src/views/CardSearch.tsx
Normal file
391
src/views/CardSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user