Add Cards: send new cards straight to a deck

A deck picker beside the location field adds each card to the library and
then to the chosen deck. The copy fills a slot the deck is short on
(unowned or claimed elsewhere for idea decks, not yet pulled for
building/built decks), otherwise the deck gets one more copy. A
maybeboarded card moves to the main board, and building/built decks count
the copy as pulled instead of filing it under a location. Retired decks
aren't offered.

Closes #16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 20:45:33 -05:00
parent d9f92c74fd
commit b3f659005d

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { api, CardRef, libraryKey } from "../api";
import { api, CardRef, Deck, DeckEntry, DECK_STATUSES, isPhysical, libraryKey } from "../api";
import { CardSearchBox, QtyStepper } from "../components";
/** Rapid card entry: fuzzy search-as-you-type, ↑/↓ + Enter (or click) adds one copy. */
@@ -7,17 +7,42 @@ export function CardEntry() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<CardRef[]>([]);
const [recent, setRecent] = useState<
{ oracleId: string; name: string; qty: number; location: string }[]
{ oracleId: string; name: string; qty: number; location: string; deck: string }[]
>([]);
// Persisted so a "box" location sticks while entering a stack of cards.
// Each library has its own boxes.
const [location, setLocation] = useState(() => localStorage.getItem(libraryKey("add-location")) || "");
// Likewise a deck being filled from a pile of new cards. Retired decks
// aren't offered: they're kept for reference, not added to.
const [decks, setDecks] = useState<Deck[]>([]);
const [deckId, setDeckId] = useState<number | "">(() => {
const saved = Number(localStorage.getItem(libraryKey("add-deck")));
return saved > 0 ? saved : "";
});
const searchInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
localStorage.setItem(libraryKey("add-location"), location);
}, [location]);
useEffect(() => {
localStorage.setItem(libraryKey("add-deck"), String(deckId));
}, [deckId]);
useEffect(() => {
api.listDecks().then((all) => {
const open = all.filter((d) => d.status !== "retired");
setDecks(open);
// The remembered deck may since have been deleted or retired.
setDeckId((id) => (open.some((d) => d.id === id) ? id : ""));
});
}, []);
const deck = decks.find((d) => d.id === deckId) ?? null;
// A building/built deck holds its cards, so new copies go straight into it
// rather than into a box.
const intoDeck = deck !== null && isPhysical(deck.status);
// Typing anywhere on this tab — without having clicked into another field
// first — jumps focus to the search box and keeps the keystroke that
// triggered it, instead of it going nowhere.
@@ -62,16 +87,40 @@ export function CardEntry() {
}, [query]);
async function add(card: CardRef) {
// Read the deck's slot before the new copy changes what it has spare.
const entry = deck ? (await api.deckContents(deck.id)).find((e) => e.oracle_id === card.oracle_id) : undefined;
const qty = await api.addOwned(card.oracle_id, 1);
const loc = location.trim();
const loc = intoDeck ? "" : location.trim();
if (loc) await api.setLocation(card.oracle_id, loc);
if (deck) await sendToDeck(deck, card.oracle_id, entry);
setRecent((r) =>
[{ oracleId: card.oracle_id, name: card.name, qty, location: loc }, ...r].slice(0, 12),
[{ oracleId: card.oracle_id, name: card.name, qty, location: loc, deck: deck?.name ?? "" }, ...r].slice(0, 12),
);
setQuery("");
setResults([]);
}
/** Put a just-acquired copy in `deck`. It fills a slot the deck is short
* on when there is one, otherwise the deck gets one more copy. Short means
* not yet pulled for a building/built deck, which also counts the copy as
* pulled since it went straight in instead of into a box; for an idea deck
* it means not owned or claimed by other decks. A maybeboarded card moves
* to the main board. */
async function sendToDeck(deck: Deck, oracleId: string, entry: DeckEntry | undefined) {
const physical = isPhysical(deck.status);
let quantity = 1;
let pulled = 0;
if (entry && entry.is_maybeboard) {
quantity = entry.quantity;
} else if (entry) {
const short = physical ? entry.quantity_pulled < entry.quantity : entry.quantity_spare < entry.quantity;
quantity = short ? entry.quantity : entry.quantity + 1;
pulled = entry.quantity_pulled;
}
await api.setDeckCard(deck.id, oracleId, quantity, entry?.is_commander ?? false, false);
if (physical) await api.setDeckCardPulled(deck.id, oracleId, pulled + 1);
}
async function adjust(oracleId: string, delta: number) {
const qty = await api.addOwned(oracleId, delta);
setRecent((r) => r.map((e) => (e.oracleId === oracleId ? { ...e, qty } : e)));
@@ -83,13 +132,27 @@ export function CardEntry() {
<h2 className="page-header-title">Add Cards</h2>
</div>
<div className="field" style={{ maxWidth: 260, marginBottom: "var(--space-3)" }}>
<label>Location for added cards (optional)</label>
<input
placeholder="e.g. Box 1 — prefilled onto each card"
value={location}
onChange={(e) => setLocation(e.target.value)}
/>
<div className="row" style={{ gap: "var(--space-4)", flexWrap: "wrap", alignItems: "flex-start", marginBottom: "var(--space-3)" }}>
<div className="field" style={{ width: 260 }}>
<label>Location for added cards (optional)</label>
<input
placeholder={intoDeck ? "Not needed — goes into the deck" : "e.g. Box 1 — prefilled onto each card"}
value={intoDeck ? "" : location}
disabled={intoDeck}
onChange={(e) => setLocation(e.target.value)}
/>
</div>
<div className="field" style={{ width: 260 }}>
<label>Send to deck (optional)</label>
<select value={deckId} onChange={(e) => setDeckId(e.target.value === "" ? "" : Number(e.target.value))}>
<option value="">Library only</option>
{decks.map((d) => (
<option key={d.id} value={d.id}>
{d.name} ({DECK_STATUSES.find((s) => s.value === d.status)?.label ?? d.status})
</option>
))}
</select>
</div>
</div>
<CardSearchBox
@@ -110,6 +173,7 @@ export function CardEntry() {
{recent.map((r, i) => (
<li key={i} style={{ cursor: "default" }}>
<span className="card-name">{r.name}</span>
{r.deck && <span className="deck-chip">→ {r.deck}</span>}
{r.location && <span className="deck-chip">{r.location}</span>}
<span className="spacer" />
<QtyStepper