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 { 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";
|
import { CardSearchBox, QtyStepper } from "../components";
|
||||||
|
|
||||||
/** Rapid card entry: fuzzy search-as-you-type, ↑/↓ + Enter (or click) adds one copy. */
|
/** 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 [query, setQuery] = useState("");
|
||||||
const [results, setResults] = useState<CardRef[]>([]);
|
const [results, setResults] = useState<CardRef[]>([]);
|
||||||
const [recent, setRecent] = useState<
|
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.
|
// Persisted so a "box" location sticks while entering a stack of cards.
|
||||||
// Each library has its own boxes.
|
// Each library has its own boxes.
|
||||||
const [location, setLocation] = useState(() => localStorage.getItem(libraryKey("add-location")) || "");
|
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);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(libraryKey("add-location"), location);
|
localStorage.setItem(libraryKey("add-location"), 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
|
// Typing anywhere on this tab — without having clicked into another field
|
||||||
// first — jumps focus to the search box and keeps the keystroke that
|
// first — jumps focus to the search box and keeps the keystroke that
|
||||||
// triggered it, instead of it going nowhere.
|
// triggered it, instead of it going nowhere.
|
||||||
@@ -62,16 +87,40 @@ export function CardEntry() {
|
|||||||
}, [query]);
|
}, [query]);
|
||||||
|
|
||||||
async function add(card: CardRef) {
|
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 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 (loc) await api.setLocation(card.oracle_id, loc);
|
||||||
|
if (deck) await sendToDeck(deck, card.oracle_id, entry);
|
||||||
setRecent((r) =>
|
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("");
|
setQuery("");
|
||||||
setResults([]);
|
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) {
|
async function adjust(oracleId: string, delta: number) {
|
||||||
const qty = await api.addOwned(oracleId, delta);
|
const qty = await api.addOwned(oracleId, delta);
|
||||||
setRecent((r) => r.map((e) => (e.oracleId === oracleId ? { ...e, qty } : e)));
|
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>
|
<h2 className="page-header-title">Add Cards</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field" style={{ maxWidth: 260, marginBottom: "var(--space-3)" }}>
|
<div className="row" style={{ gap: "var(--space-4)", flexWrap: "wrap", alignItems: "flex-start", marginBottom: "var(--space-3)" }}>
|
||||||
<label>Location for added cards (optional)</label>
|
<div className="field" style={{ width: 260 }}>
|
||||||
<input
|
<label>Location for added cards (optional)</label>
|
||||||
placeholder="e.g. Box 1 — prefilled onto each card"
|
<input
|
||||||
value={location}
|
placeholder={intoDeck ? "Not needed — goes into the deck" : "e.g. Box 1 — prefilled onto each card"}
|
||||||
onChange={(e) => setLocation(e.target.value)}
|
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>
|
</div>
|
||||||
|
|
||||||
<CardSearchBox
|
<CardSearchBox
|
||||||
@@ -110,6 +173,7 @@ export function CardEntry() {
|
|||||||
{recent.map((r, i) => (
|
{recent.map((r, i) => (
|
||||||
<li key={i} style={{ cursor: "default" }}>
|
<li key={i} style={{ cursor: "default" }}>
|
||||||
<span className="card-name">{r.name}</span>
|
<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>}
|
{r.location && <span className="deck-chip">{r.location}</span>}
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<QtyStepper
|
<QtyStepper
|
||||||
|
|||||||
Reference in New Issue
Block a user