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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user