Deck status (Idea/Building/Built/Retired) and pulled-card tracking
Only building and built decks now claim owned copies, so several idea decks can share one copy without an over-assignment warning. Deck cards track how many copies are physically pulled: physical decks list cards to pull with their source (a storage box, or another deck to take a copy from), and idea decks split cards into available / in other decks / not owned. The deck list shows status chips with to-pull counts, filters by status, and collapses retired decks. Status and pulled counts sync, which bumps the library document to version 4. Existing decks migrate as ideas. Closes #25 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
53
src/api.ts
53
src/api.ts
@@ -25,7 +25,12 @@ export interface CardRef {
|
||||
export interface LibraryCard extends CardRef {
|
||||
quantity_owned: number;
|
||||
decks: string[];
|
||||
/** The subset of `decks` that physically exist (building or built). */
|
||||
physical_decks: string[];
|
||||
/** Copies claimed by building/built decks (idea/retired decks claim none). */
|
||||
quantity_assigned: number;
|
||||
/** Copies physically pulled into decks; the rest are at `location`. */
|
||||
quantity_in_decks: number;
|
||||
location: string;
|
||||
}
|
||||
|
||||
@@ -46,8 +51,41 @@ export interface DeckEntry extends CardRef {
|
||||
/** The printing whose art this slot shows; `image_uri` already reflects it. */
|
||||
preferred_scryfall_id: string | null;
|
||||
partner: Partner | null;
|
||||
/** Copies physically in this deck; meaningful for building/built decks. */
|
||||
quantity_pulled: number;
|
||||
/** Owned copies not claimed by any *other* building/built deck. */
|
||||
quantity_spare: number;
|
||||
/** Owned copies not pulled into any deck — still in their box. */
|
||||
quantity_in_box: number;
|
||||
/** The *other* building/built decks claiming copies; those with copies
|
||||
* pulled are where to take one from when the box is empty. */
|
||||
claimed_by: DeckHolding[];
|
||||
/** Where the card's library copies are stored ("" if unset). */
|
||||
location: string;
|
||||
}
|
||||
|
||||
export interface DeckHolding {
|
||||
deck_id: number;
|
||||
name: string;
|
||||
/** Copies the deck's main board lists. */
|
||||
quantity: number;
|
||||
/** Of those, copies physically in the deck. */
|
||||
pulled: number;
|
||||
}
|
||||
|
||||
/** Where a deck stands physically. Building and built decks hold cards and
|
||||
* claim owned copies; ideas and retired decks are just lists. */
|
||||
export type DeckStatus = "idea" | "building" | "built" | "retired";
|
||||
|
||||
export const DECK_STATUSES: { value: DeckStatus; label: string }[] = [
|
||||
{ value: "idea", label: "Idea" },
|
||||
{ value: "building", label: "Building" },
|
||||
{ value: "built", label: "Built" },
|
||||
{ value: "retired", label: "Retired" },
|
||||
];
|
||||
|
||||
export const isPhysical = (s: DeckStatus) => s === "building" || s === "built";
|
||||
|
||||
/** One printing of a card, for the printing picker and copy labels. */
|
||||
export interface PrintingRef {
|
||||
scryfall_id: string;
|
||||
@@ -82,6 +120,9 @@ export interface LibraryCopy {
|
||||
export type PreviewCard = CardRef & {
|
||||
quantity_owned?: number;
|
||||
decks?: string[];
|
||||
physical_decks?: string[];
|
||||
quantity_in_decks?: number;
|
||||
location?: string;
|
||||
preferred_scryfall_id?: string | null;
|
||||
};
|
||||
|
||||
@@ -107,6 +148,7 @@ export interface Deck {
|
||||
notes: string;
|
||||
/** The card chosen to headline the deck in the deck list; null = automatic. */
|
||||
cover_oracle_id: string | null;
|
||||
status: DeckStatus;
|
||||
}
|
||||
|
||||
export interface DeckImportResult {
|
||||
@@ -497,6 +539,17 @@ export const api = {
|
||||
invoke<Deck>("duplicate_deck", { deckId, newName }),
|
||||
setDeckFormat: (deckId: number, format: string) =>
|
||||
invoke<void>("set_deck_format", { deckId, format }),
|
||||
/** Change a deck's status. Leaving building/built returns pulled copies to
|
||||
* their boxes; entering it with `assembled` marks the copies in the box as
|
||||
* already in the deck. */
|
||||
setDeckStatus: (deckId: number, status: DeckStatus, assembled = false) =>
|
||||
invoke<void>("set_deck_status", { deckId, status, assembled }),
|
||||
/** Record copies physically in a deck; returns the (clamped) count stored. */
|
||||
setDeckCardPulled: (deckId: number, oracleId: string, pulled: number) =>
|
||||
invoke<number>("set_deck_card_pulled", { deckId, oracleId, pulled }),
|
||||
/** Move one pulled copy between decks; false if there was nothing to move. */
|
||||
takeDeckCardCopy: (fromDeckId: number, toDeckId: number, oracleId: string) =>
|
||||
invoke<boolean>("take_deck_card_copy", { fromDeckId, toDeckId, oracleId }),
|
||||
/** Choose the card headlining the deck in the deck list; null = automatic. */
|
||||
setDeckCover: (deckId: number, oracleId: string | null) =>
|
||||
invoke<void>("set_deck_cover", { deckId, oracleId }),
|
||||
|
||||
@@ -816,18 +816,23 @@ export function DeckChip({
|
||||
name,
|
||||
className = "deck-chip",
|
||||
onOpen,
|
||||
idea,
|
||||
}: {
|
||||
name: string;
|
||||
className?: string;
|
||||
onOpen?: () => void;
|
||||
/** The deck is an idea or retired: listed, but holding no copies. */
|
||||
idea?: boolean;
|
||||
}) {
|
||||
const openDeck = useContext(OpenDeckCtx);
|
||||
if (!openDeck) return <span className={className}>{name}</span>;
|
||||
if (idea) className += " deck-chip-idea";
|
||||
const note = idea ? " (not built — holds no copies)" : "";
|
||||
if (!openDeck) return <span className={className} title={note ? `${name}${note}` : undefined}>{name}</span>;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`${className} deck-chip-link`}
|
||||
title={`Open ${name}`}
|
||||
title={`Open ${name}${note}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpen?.();
|
||||
@@ -1037,11 +1042,27 @@ const CardInfoTile = forwardRef<
|
||||
<span className="preview-info-label">In decks</span>
|
||||
<div className="preview-info-deck-chips">
|
||||
{card.decks.map((d) => (
|
||||
<DeckChip key={d} name={d} className="badge" onOpen={onClose} />
|
||||
<DeckChip
|
||||
key={d}
|
||||
name={d}
|
||||
className="badge"
|
||||
onOpen={onClose}
|
||||
idea={card.physical_decks && !card.physical_decks.includes(d)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!!card.quantity_owned && !!card.quantity_in_decks && (
|
||||
<div className="preview-info-section">
|
||||
<span className="preview-info-label">Where</span>
|
||||
<span>
|
||||
{card.quantity_in_decks} in decks
|
||||
{card.quantity_owned > card.quantity_in_decks &&
|
||||
` · ${card.quantity_owned - card.quantity_in_decks} in ${card.location || "your library"}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="preview-info-section">
|
||||
<span className="preview-info-label">Legalities</span>
|
||||
<LegalityPills legalities={card.legalities} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Pure deck analysis shared by the deck builder and the deck list: composition
|
||||
// stats, format legality, commander pairing, and the deck list's per-deck
|
||||
// summary.
|
||||
import { COLORS, Deck, DeckEntry, legalStatusFor, Partner } from "./api";
|
||||
import { COLORS, Deck, DeckEntry, isPhysical, legalStatusFor, Partner } from "./api";
|
||||
|
||||
/** Copies of a deck card you'd need to acquire to cover the deck. */
|
||||
export const shortBy = (e: DeckEntry) => Math.max(0, e.quantity - e.quantity_owned);
|
||||
@@ -391,6 +391,8 @@ export interface DeckSummary {
|
||||
cost: number;
|
||||
legality: DeckLegality;
|
||||
maybeboardCount: number;
|
||||
/** Mainboard cards not yet physically in the deck (building/built decks). */
|
||||
toPull: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -435,5 +437,6 @@ export function summarizeDeck(deck: Deck, contents: DeckEntry[]): DeckSummary {
|
||||
cost: main.reduce((sum, e) => sum + e.price_usd * e.quantity, 0),
|
||||
legality: computeDeckLegality(deck, contents),
|
||||
maybeboardCount: contents.length - main.length,
|
||||
toPull: isPhysical(deck.status) ? main.filter((e) => e.quantity_pulled < e.quantity).length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1715,6 +1715,78 @@ tbody tr:hover {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
/* A deck that's an idea or retired: listed, but holding no copies. */
|
||||
.deck-chip.deck-chip-idea,
|
||||
.badge.deck-chip-idea {
|
||||
border-style: dashed;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Deck status: Idea / Building / Built / Retired
|
||||
============================================================ */
|
||||
|
||||
.deck-status-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 1px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
margin-right: 4px;
|
||||
}
|
||||
.deck-status-chip.idea {
|
||||
border-style: dashed;
|
||||
}
|
||||
.deck-status-chip.building {
|
||||
border-color: var(--warn);
|
||||
color: var(--warn);
|
||||
}
|
||||
.deck-status-chip.built {
|
||||
border-color: var(--success);
|
||||
background: var(--success);
|
||||
color: #fff;
|
||||
}
|
||||
.deck-status-chip.retired {
|
||||
color: var(--faint);
|
||||
}
|
||||
.deck-status-hint {
|
||||
flex-basis: 100%;
|
||||
font-size: var(--fs-caption);
|
||||
}
|
||||
.deck-gallery-retired {
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
.deck-gallery-retired > summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
/* Where a deck card's copies are: the "To pull" source and its action. */
|
||||
.whereabouts {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.whereabouts.compact {
|
||||
font-size: 11px;
|
||||
}
|
||||
.pull-source {
|
||||
color: var(--text);
|
||||
}
|
||||
button.pull-action {
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Bulk-selection action bar (Library tab)
|
||||
============================================================ */
|
||||
|
||||
@@ -254,7 +254,7 @@ export function CardSearch({
|
||||
</td>
|
||||
<td data-label="In decks">
|
||||
{c.decks.map((d) => (
|
||||
<DeckChip key={d} name={d} />
|
||||
<DeckChip key={d} name={d} idea={!c.physical_decks.includes(d)} />
|
||||
))}
|
||||
</td>
|
||||
<td>
|
||||
@@ -309,7 +309,7 @@ export function CardSearch({
|
||||
}
|
||||
footer={
|
||||
c.decks.length > 0
|
||||
? c.decks.map((d) => <DeckChip key={d} name={d} />)
|
||||
? c.decks.map((d) => <DeckChip key={d} name={d} idea={!c.physical_decks.includes(d)} />)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { api, Deck, DeckEntry } from "../api";
|
||||
import { api, Deck, DECK_STATUSES, DeckEntry, DeckStatus } from "../api";
|
||||
import { artCrop, ColorPips, fold, IconButton, Icons, usePersistedState, useViewMode, ViewToggle } from "../components";
|
||||
import { DeckSummary, formatCost, summarizeDeck } from "../deckAnalysis";
|
||||
|
||||
@@ -82,6 +82,19 @@ function LegalityBadge({ summary: s, compact }: { summary: DeckSummary; compact?
|
||||
);
|
||||
}
|
||||
|
||||
/** "Built", "Building · 5 to pull", etc. Ideas — the default — say so too,
|
||||
* so it's clear at a glance which decks are real. */
|
||||
function StatusChip({ row }: { row: Row }) {
|
||||
const { deck, summary: s } = row;
|
||||
const label = DECK_STATUSES.find((d) => d.value === deck.status)?.label ?? deck.status;
|
||||
return (
|
||||
<span className={`deck-status-chip ${deck.status}`}>
|
||||
{label}
|
||||
{s.toPull > 0 && <span> · {s.toPull} to pull</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function OwnedBar({ summary: s }: { summary: DeckSummary }) {
|
||||
return (
|
||||
<span className="deck-owned-bar" aria-hidden="true">
|
||||
@@ -210,6 +223,7 @@ function DeckTile({ row, actions }: { row: Row; actions: DeckActions }) {
|
||||
<div className="deck-tile-body">
|
||||
<div className="deck-tile-sub">{subtitle(row)}</div>
|
||||
<div className="deck-tile-meta">
|
||||
<StatusChip row={row} />
|
||||
{deck.format ? <span className="deck-chip">{deck.format}</span> : <span className="muted">No format</span>}
|
||||
{s.maybeboardCount > 0 && <span className="muted">+{s.maybeboardCount} maybe</span>}
|
||||
<span className="spacer" />
|
||||
@@ -239,6 +253,7 @@ function DeckRow({ row, actions }: { row: Row; actions: DeckActions }) {
|
||||
<div className="deck-row-sub">{subtitle(row)}</div>
|
||||
</div>
|
||||
<div className="deck-row-stats">
|
||||
<StatusChip row={row} />
|
||||
{deck.format && <span className="deck-chip">{deck.format}</span>}
|
||||
<DeckSize summary={s} />
|
||||
<span className="deck-row-owned" title={ownedLabel(s)}>
|
||||
@@ -278,6 +293,7 @@ export function DeckList({
|
||||
const [sort, setSort] = usePersistedState<SortMode>("deck-list-sort", "recent");
|
||||
const [query, setQuery] = useState("");
|
||||
const [format, setFormat] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<DeckStatus | null>(null);
|
||||
|
||||
// `decks` is refetched after anything that adds a deck, so it doubles as
|
||||
// the signal that contents may have changed.
|
||||
@@ -298,6 +314,7 @@ export function DeckList({
|
||||
);
|
||||
|
||||
const formats = useMemo(() => [...new Set(decks.map((d) => d.format))].sort(), [decks]);
|
||||
const statuses = useMemo(() => DECK_STATUSES.filter((s) => decks.some((d) => d.status === s.value)), [decks]);
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const q = fold(query.trim());
|
||||
@@ -321,11 +338,34 @@ export function DeckList({
|
||||
owned: desc((r) => (r.summary.size ? r.summary.owned : -1)),
|
||||
};
|
||||
return rows
|
||||
.filter((r) => (format === null || r.deck.format === format) && matches(r))
|
||||
.filter(
|
||||
(r) => (format === null || r.deck.format === format) && (status === null || r.deck.status === status) && matches(r),
|
||||
)
|
||||
.sort(compare[sort] ?? byName);
|
||||
}, [rows, query, format, sort, openedAt]);
|
||||
}, [rows, query, format, status, sort, openedAt]);
|
||||
|
||||
const filtering = query.trim() !== "" || format !== null;
|
||||
// Retired decks are kept for reference, so unless they're asked for they
|
||||
// sit collapsed below the rest.
|
||||
const active = status === null ? shown.filter((r) => r.deck.status !== "retired") : shown;
|
||||
const retired = status === null ? shown.filter((r) => r.deck.status === "retired") : [];
|
||||
|
||||
const filtering = query.trim() !== "" || format !== null || status !== null;
|
||||
|
||||
function renderDecks(list: Row[]) {
|
||||
return view === "tiles" ? (
|
||||
<div className="deck-gallery-grid">
|
||||
{list.map((r) => (
|
||||
<DeckTile key={r.deck.id} row={r} actions={actions} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="deck-gallery-rows">
|
||||
{list.map((r) => (
|
||||
<DeckRow key={r.deck.id} row={r} actions={actions} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="deck-gallery">
|
||||
@@ -358,6 +398,20 @@ export function DeckList({
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{statuses.length > 1 && (
|
||||
<select
|
||||
value={status ?? "*"}
|
||||
onChange={(e) => setStatus(e.target.value === "*" ? null : (e.target.value as DeckStatus))}
|
||||
title="Filter by status"
|
||||
>
|
||||
<option value="*">All statuses</option>
|
||||
{statuses.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<select value={sort} onChange={(e) => setSort(e.target.value as SortMode)} title="Sort decks">
|
||||
{SORTS.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
@@ -402,24 +456,25 @@ export function DeckList({
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setFormat(null);
|
||||
setStatus(null);
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : view === "tiles" ? (
|
||||
<div className="deck-gallery-grid">
|
||||
{shown.map((r) => (
|
||||
<DeckTile key={r.deck.id} row={r} actions={actions} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="deck-gallery-rows">
|
||||
{shown.map((r) => (
|
||||
<DeckRow key={r.deck.id} row={r} actions={actions} />
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
{renderDecks(active)}
|
||||
{retired.length > 0 && (
|
||||
<details className="deck-gallery-retired">
|
||||
<summary>
|
||||
Retired <span className="badge">{retired.length}</span>
|
||||
</summary>
|
||||
{renderDecks(retired)}
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from "react";
|
||||
import { api, CardRef, Deck, DeckEntry, DeckImportResult, libraryKey, PreviewContext } from "../api";
|
||||
import {
|
||||
api,
|
||||
CardRef,
|
||||
Deck,
|
||||
DECK_STATUSES,
|
||||
DeckEntry,
|
||||
DeckHolding,
|
||||
DeckImportResult,
|
||||
DeckStatus,
|
||||
isPhysical,
|
||||
libraryKey,
|
||||
PreviewContext,
|
||||
} from "../api";
|
||||
import {
|
||||
CardSearchBox,
|
||||
CardThumb,
|
||||
@@ -164,6 +176,18 @@ const REMOVAL_GRACE_MS = 5000;
|
||||
|
||||
const extraCopiesLabel = (n: number) => `${n} extra ${n === 1 ? "copy" : "copies"}`;
|
||||
|
||||
const copies = (n: number) => `${n} ${n === 1 ? "copy" : "copies"}`;
|
||||
|
||||
const statusLabel = (s: DeckStatus) => DECK_STATUSES.find((d) => d.value === s)?.label ?? s;
|
||||
|
||||
/** One line under the status select saying what the status means. */
|
||||
const STATUS_HINTS: Record<DeckStatus, string> = {
|
||||
idea: "Just a list — it claims none of your cards.",
|
||||
building: "Being assembled: pull its cards as you go.",
|
||||
built: "Assembled: its cards are in the deck box.",
|
||||
retired: "Taken apart, kept for reference.",
|
||||
};
|
||||
|
||||
const STARTING_HAND_SIZE = 7;
|
||||
|
||||
/**
|
||||
@@ -231,6 +255,9 @@ function DeckBuilder({
|
||||
const [hand, setHand] = useState<DeckEntry[]>([]);
|
||||
const [libraryStack, setLibraryStack] = useState<DeckEntry[]>([]);
|
||||
const [suggestOpen, setSuggestOpen] = useState(false);
|
||||
const [statusDialog, setStatusDialog] = useState<{ to: DeckStatus; kind: "assemble" | "release" } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Keyed by deck id in `DeckView`, so switching decks remounts with fresh
|
||||
// state rather than resetting it here.
|
||||
@@ -528,6 +555,53 @@ function DeckBuilder({
|
||||
await onDecksChanged();
|
||||
}
|
||||
|
||||
async function applyStatus(status: DeckStatus, assembled = false) {
|
||||
setStatusDialog(null);
|
||||
await api.setDeckStatus(deckId, status, assembled);
|
||||
await onDecksChanged();
|
||||
await loadContents();
|
||||
}
|
||||
|
||||
/** Change status, first asking how to treat the cards when the deck starts
|
||||
* or stops physically holding them. */
|
||||
function changeStatus(status: DeckStatus) {
|
||||
if (status === deck.status) return;
|
||||
if (isPhysical(status) && !isPhysical(deck.status) && contents.some((e) => !e.is_maybeboard)) {
|
||||
setStatusDialog({ to: status, kind: "assemble" });
|
||||
} else if (!isPhysical(status) && pulledCopies > 0) {
|
||||
setStatusDialog({ to: status, kind: "release" });
|
||||
} else {
|
||||
applyStatus(status);
|
||||
}
|
||||
}
|
||||
|
||||
/** Mark as many copies pulled as the deck needs (the backend clamps to
|
||||
* what's actually in the box). */
|
||||
async function pullCard(e: DeckEntry) {
|
||||
await api.setDeckCardPulled(deckId, e.oracle_id, e.quantity);
|
||||
await loadContents();
|
||||
}
|
||||
|
||||
async function takeCard(e: DeckEntry, from: DeckHolding) {
|
||||
await api.takeDeckCardCopy(from.deck_id, deckId, e.oracle_id);
|
||||
await loadContents();
|
||||
}
|
||||
|
||||
async function bulkSetPulled(pulled: boolean) {
|
||||
setBulkBusy(true);
|
||||
try {
|
||||
await Promise.all(
|
||||
contents
|
||||
.filter((e) => selectedCards.has(e.oracle_id) && !e.is_maybeboard)
|
||||
.map((e) => api.setDeckCardPulled(deckId, e.oracle_id, pulled ? e.quantity : 0)),
|
||||
);
|
||||
clearSelection();
|
||||
await loadContents();
|
||||
} finally {
|
||||
setBulkBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function setCommander(card: CardRef) {
|
||||
// Ensure the card is in the deck (mainboard, not maybeboard) and flagged
|
||||
// commander — covers both "not in the deck yet" and "was maybeboarded".
|
||||
@@ -579,8 +653,17 @@ function DeckBuilder({
|
||||
return fold(e.name).includes(q) || fold(e.type_line).includes(q);
|
||||
}
|
||||
|
||||
const owned = contents.filter((e) => !e.is_maybeboard && e.quantity_owned > 0 && matchesFilter(e));
|
||||
const unowned = contents.filter((e) => !e.is_maybeboard && e.quantity_owned === 0 && matchesFilter(e));
|
||||
// Building/built decks split by what's physically in them; ideas and
|
||||
// retired decks by whether the cards are free to use.
|
||||
const physical = isPhysical(deck.status);
|
||||
const mainboard = contents.filter((e) => !e.is_maybeboard && matchesFilter(e));
|
||||
const toPull = mainboard.filter((e) => e.quantity_pulled < e.quantity);
|
||||
const inDeck = mainboard.filter((e) => e.quantity_pulled >= e.quantity);
|
||||
const available = mainboard.filter((e) => e.quantity_owned > 0 && e.quantity_spare >= e.quantity);
|
||||
const elsewhere = mainboard.filter((e) => e.quantity_owned > 0 && e.quantity_spare < e.quantity);
|
||||
const unowned = mainboard.filter((e) => e.quantity_owned === 0);
|
||||
const pulledCopies = contents.reduce((n, e) => n + (e.is_maybeboard ? 0 : e.quantity_pulled), 0);
|
||||
const leftToPull = contents.filter((e) => !e.is_maybeboard && e.quantity_pulled < e.quantity).length;
|
||||
const maybeboard = contents.filter((e) => e.is_maybeboard && matchesFilter(e));
|
||||
// The primary commander first, then its partner, if any.
|
||||
const isPrimary = (e: DeckEntry) => Number(e.oracle_id === deck?.commander_oracle_id);
|
||||
@@ -602,6 +685,68 @@ function DeckBuilder({
|
||||
},
|
||||
};
|
||||
|
||||
/** Where a card's copies are, when that's worth saying: for a deck being
|
||||
* built, where to pull what's missing from; for an idea, which real decks
|
||||
* already use the copies. Null when there's nothing to act on. */
|
||||
function whereabouts(e: DeckEntry, compact = false) {
|
||||
if (e.is_maybeboard) return null;
|
||||
if (physical) {
|
||||
if (e.quantity_pulled >= e.quantity) return null;
|
||||
const progress = e.quantity_pulled > 0 && (
|
||||
<span className="muted">
|
||||
{e.quantity_pulled}/{e.quantity} pulled
|
||||
</span>
|
||||
);
|
||||
const holders = e.claimed_by.filter((h) => h.pulled > 0);
|
||||
let source;
|
||||
if (e.quantity_in_box > 0) {
|
||||
source = (
|
||||
<>
|
||||
<span className="pull-source">In {e.location || "your library"}</span>
|
||||
<button className="pull-action" onClick={() => pullCard(e)}>
|
||||
Pulled
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
} else if (holders.length > 0) {
|
||||
source = holders.map((h) => (
|
||||
<span key={h.deck_id} className="row" style={{ gap: 4 }}>
|
||||
<span className="pull-source">In {h.name}</span>
|
||||
<button className="pull-action" onClick={() => takeCard(e, h)} title={`Move a copy from ${h.name}`}>
|
||||
Take
|
||||
</button>
|
||||
</span>
|
||||
));
|
||||
} else if (e.quantity_owned === 0) {
|
||||
source = <span className="pull-source muted">Not owned</span>;
|
||||
} else {
|
||||
source = (
|
||||
<span className="pull-source muted">Need {copies(e.quantity - e.quantity_pulled)} more</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className={`whereabouts ${compact ? "compact" : ""}`}>
|
||||
{progress}
|
||||
{source}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (e.quantity_owned === 0 || e.quantity_spare >= e.quantity) return null;
|
||||
const names = e.claimed_by.map((h) => h.name).join(", ");
|
||||
return (
|
||||
<span className={`whereabouts ${compact ? "compact" : ""}`}>
|
||||
<span className="pull-source" title={`Copies claimed by: ${names}`}>
|
||||
In {names}
|
||||
</span>
|
||||
{e.quantity_spare > 0 && (
|
||||
<span className="muted">
|
||||
{e.quantity_spare} of {e.quantity} free
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function deckTiles(entries: DeckEntry[]) {
|
||||
return (
|
||||
<div {...tiles.gridProps}>
|
||||
@@ -644,12 +789,13 @@ function DeckBuilder({
|
||||
</span>
|
||||
}
|
||||
footer={
|
||||
e.is_commander || partialRemoval.has(e.oracle_id) ? (
|
||||
<span className="row" style={{ gap: 4 }}>
|
||||
e.is_commander || partialRemoval.has(e.oracle_id) || whereabouts(e) ? (
|
||||
<span className="row" style={{ gap: 4, flexWrap: "wrap" }}>
|
||||
{e.is_commander && <span className="tile-badge">★ commander</span>}
|
||||
{partialRemoval.has(e.oracle_id) && (
|
||||
<span className="tile-badge">{extraCopiesLabel(partialRemoval.get(e.oracle_id)!)}</span>
|
||||
)}
|
||||
{whereabouts(e, true)}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
@@ -671,6 +817,7 @@ function DeckBuilder({
|
||||
<th>Type</th>
|
||||
<th>In deck</th>
|
||||
<th>Owned</th>
|
||||
<th>Where</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -710,6 +857,7 @@ function DeckBuilder({
|
||||
/>
|
||||
</td>
|
||||
<td data-label="Owned">{e.quantity_owned}</td>
|
||||
<td data-label="Where">{whereabouts(e)}</td>
|
||||
<td>
|
||||
<span className="row" style={{ gap: 4 }}>
|
||||
<IconButton {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
|
||||
@@ -796,6 +944,23 @@ function DeckBuilder({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="row" style={{ gap: 6 }}>
|
||||
<span className="muted">Status</span>
|
||||
<select value={deck.status} onChange={(e) => changeStatus(e.target.value as DeckStatus)}>
|
||||
{DECK_STATUSES.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span className="muted deck-status-hint">
|
||||
{deck.status === "building" && leftToPull > 0
|
||||
? `Being assembled: ${leftToPull} card${leftToPull === 1 ? "" : "s"} left to pull.`
|
||||
: deck.status === "built" && leftToPull > 0
|
||||
? `Assembled, with ${leftToPull} card${leftToPull === 1 ? "" : "s"} still to pull.`
|
||||
: STATUS_HINTS[deck.status]}
|
||||
</span>
|
||||
|
||||
{COMMANDER_FORMATS.includes(deck.format) && (
|
||||
<div className="commander-box">
|
||||
@@ -979,6 +1144,35 @@ function DeckBuilder({
|
||||
>
|
||||
Add to library
|
||||
</button>
|
||||
{physical && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => bulkSetPulled(true)}
|
||||
disabled={
|
||||
bulkBusy ||
|
||||
!contents.some(
|
||||
(e) =>
|
||||
selectedCards.has(e.oracle_id) &&
|
||||
!e.is_maybeboard &&
|
||||
e.quantity_pulled < e.quantity &&
|
||||
e.quantity_in_box > 0,
|
||||
)
|
||||
}
|
||||
title="Record that these cards' copies have been taken from their boxes"
|
||||
>
|
||||
Mark pulled
|
||||
</button>
|
||||
<button
|
||||
onClick={() => bulkSetPulled(false)}
|
||||
disabled={
|
||||
bulkBusy || !contents.some((e) => selectedCards.has(e.oracle_id) && e.quantity_pulled > 0)
|
||||
}
|
||||
title="Record that these cards' copies are back in their boxes"
|
||||
>
|
||||
Mark not pulled
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span className="spacer" />
|
||||
<button onClick={clearSelection} disabled={bulkBusy}>
|
||||
@@ -990,7 +1184,7 @@ function DeckBuilder({
|
||||
{combineSections ? (
|
||||
<section style={{ marginTop: 10 }}>
|
||||
<h3>
|
||||
All cards <span className="badge">{owned.length + unowned.length}</span>
|
||||
All cards <span className="badge">{mainboard.length}</span>
|
||||
</h3>
|
||||
<CardSearchBox
|
||||
placeholder="Search all cards to add to your deck…"
|
||||
@@ -999,14 +1193,52 @@ function DeckBuilder({
|
||||
results={anyResults}
|
||||
onPick={addToDeck}
|
||||
/>
|
||||
{cardsGroup([...owned, ...unowned])}
|
||||
{cardsGroup(physical ? [...toPull, ...inDeck] : [...available, ...elsewhere, ...unowned])}
|
||||
</section>
|
||||
) : physical ? (
|
||||
<>
|
||||
<section style={{ marginTop: 10 }}>
|
||||
<h3>
|
||||
To pull <span className="badge">{toPull.length}</span>
|
||||
</h3>
|
||||
<p className="hint" style={{ marginTop: -4 }}>
|
||||
In the deck's list, but not physically in it yet. New cards land here.
|
||||
</p>
|
||||
<CardSearchBox
|
||||
placeholder="Search all cards to add to your deck…"
|
||||
value={anyQuery}
|
||||
onChange={setAnyQuery}
|
||||
results={anyResults}
|
||||
onPick={addToDeck}
|
||||
/>
|
||||
{toPull.length === 0 && deck.status === "building" && mainboard.length > 0 ? (
|
||||
<p className="hint row" style={{ gap: 8 }}>
|
||||
Everything's pulled.
|
||||
<button className="primary" onClick={() => applyStatus("built")}>
|
||||
Mark as Built
|
||||
</button>
|
||||
</p>
|
||||
) : (
|
||||
cardsGroup(toPull)
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section style={{ marginTop: 26 }}>
|
||||
<h3>
|
||||
In deck <span className="badge">{inDeck.length}</span>
|
||||
</h3>
|
||||
{cardsGroup(inDeck)}
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<section style={{ marginTop: 10 }}>
|
||||
<h3>
|
||||
In your library <span className="badge">{owned.length}</span>
|
||||
Available <span className="badge">{available.length}</span>
|
||||
</h3>
|
||||
<p className="hint" style={{ marginTop: -4 }}>
|
||||
Owned, with copies no built deck is using.
|
||||
</p>
|
||||
<CardSearchBox
|
||||
placeholder="Search your library to add owned cards…"
|
||||
value={libQuery}
|
||||
@@ -1014,9 +1246,21 @@ function DeckBuilder({
|
||||
results={libResults}
|
||||
onPick={addToDeck}
|
||||
/>
|
||||
{cardsGroup(owned)}
|
||||
{cardsGroup(available)}
|
||||
</section>
|
||||
|
||||
{elsewhere.length > 0 && (
|
||||
<section style={{ marginTop: 26 }}>
|
||||
<h3>
|
||||
In other decks <span className="badge">{elsewhere.length}</span>
|
||||
</h3>
|
||||
<p className="hint" style={{ marginTop: -4 }}>
|
||||
Owned, but your copies are used by decks you're building or have built.
|
||||
</p>
|
||||
{cardsGroup(elsewhere)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section style={{ marginTop: 26 }}>
|
||||
<h3>
|
||||
Not owned <span className="badge">{unowned.length}</span>
|
||||
@@ -1059,6 +1303,32 @@ function DeckBuilder({
|
||||
onClose={() => setHandOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{statusDialog?.kind === "assemble" && (
|
||||
<DialogFrame title={`Mark as ${statusLabel(statusDialog.to)}`} onCancel={() => setStatusDialog(null)}>
|
||||
<p className="dialog-message">
|
||||
Are this deck's cards already together? If so, the copies in your library are recorded as in the
|
||||
deck. Otherwise, they're listed under "To pull" until you take them out of their boxes.
|
||||
</p>
|
||||
<div className="dialog-actions">
|
||||
<button onClick={() => setStatusDialog(null)}>Cancel</button>
|
||||
<button onClick={() => applyStatus(statusDialog.to, false)}>Start pulling</button>
|
||||
<button className="primary" onClick={() => applyStatus(statusDialog.to, true)} autoFocus>
|
||||
Already assembled
|
||||
</button>
|
||||
</div>
|
||||
</DialogFrame>
|
||||
)}
|
||||
{statusDialog?.kind === "release" && (
|
||||
<ConfirmDialog
|
||||
title={`Mark as ${statusLabel(statusDialog.to)}`}
|
||||
message={`${copies(pulledCopies)} in this deck will be recorded as back in ${
|
||||
pulledCopies === 1 ? "its box" : "their boxes"
|
||||
}.`}
|
||||
confirmLabel={`Mark as ${statusLabel(statusDialog.to)}`}
|
||||
onConfirm={() => applyStatus(statusDialog.to)}
|
||||
onCancel={() => setStatusDialog(null)}
|
||||
/>
|
||||
)}
|
||||
{suggestOpen && (
|
||||
<SuggestionsModal
|
||||
deckId={deck.id}
|
||||
|
||||
@@ -300,7 +300,7 @@ export function LibraryBrowser({
|
||||
onSaved={() => setRefreshKey((k) => k + 1)}
|
||||
/>
|
||||
) : (
|
||||
c.decks.map((d) => <DeckChip key={d} name={d} />)
|
||||
c.decks.map((d) => <DeckChip key={d} name={d} idea={!c.physical_decks.includes(d)} />)
|
||||
);
|
||||
|
||||
// Under the spares filter, how many copies are free — only worth saying for
|
||||
@@ -308,7 +308,7 @@ export function LibraryBrowser({
|
||||
const spareNote = (c: LibraryCard) =>
|
||||
criteria.sparesOnly &&
|
||||
c.quantity_assigned > 0 && (
|
||||
<span className="muted" title="Copies no deck is using">
|
||||
<span className="muted" title="Copies no deck you're building or have built is using">
|
||||
{c.quantity_owned - c.quantity_assigned} spare
|
||||
</span>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user