A Columns menu next to the List/Tiles toggle hides or shows optional columns in the Library, Card Search, deck builder and Decks list row views. Choices are saved per view on each device. The selection checkbox and name always show. Closes #8 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1639 lines
58 KiB
TypeScript
1639 lines
58 KiB
TypeScript
import { open, save } from "@tauri-apps/plugin-dialog";
|
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from "react";
|
|
import {
|
|
api,
|
|
CardRef,
|
|
Deck,
|
|
DECK_STATUSES,
|
|
DeckEntry,
|
|
DeckHolding,
|
|
DeckImportResult,
|
|
DeckStatus,
|
|
isPhysical,
|
|
libraryKey,
|
|
PreviewContext,
|
|
} from "../api";
|
|
import {
|
|
CardSearchBox,
|
|
CardThumb,
|
|
CardTile,
|
|
ColorDistributionBar,
|
|
ColorPips,
|
|
ColumnDef,
|
|
ColumnsMenu,
|
|
ConfirmDialog,
|
|
DialogFrame,
|
|
GroupBySelect,
|
|
GroupMode,
|
|
IconButton,
|
|
Icons,
|
|
InfoDialog,
|
|
ManaCurve,
|
|
PromptDialog,
|
|
QtyStepper,
|
|
SampleHandModal,
|
|
StatTile,
|
|
TileColumnsControl,
|
|
ViewToggle,
|
|
groupCards,
|
|
useClickPreview,
|
|
useHoverPreview,
|
|
useColumns,
|
|
usePersistedState,
|
|
useTileColumns,
|
|
useViewMode,
|
|
fold,
|
|
} from "../components";
|
|
import {
|
|
COMMANDER_FORMATS,
|
|
computeDeckLegality,
|
|
computeDeckStats,
|
|
formatCost,
|
|
FORMATS,
|
|
illegalSelection,
|
|
LEGALITY_CATEGORIES,
|
|
LegalityCategory,
|
|
pairsWith,
|
|
partnerPlaceholder,
|
|
shortBy,
|
|
} from "../deckAnalysis";
|
|
import { SuggestionsModal } from "../SuggestionsModal";
|
|
import { useBackButton } from "../backButton";
|
|
import { DeckList } from "./DeckList";
|
|
|
|
function libraryAddLabel(e: DeckEntry): string {
|
|
const n = shortBy(e);
|
|
return n === 1 ? "Add to library" : `Add ${n} copies to library`;
|
|
}
|
|
|
|
/** Tooltip/label + icon for the per-card mainboard/maybeboard toggle. */
|
|
function boardToggleProps(e: DeckEntry) {
|
|
return e.is_maybeboard
|
|
? { icon: Icons.arrowLeftCircle, label: "Move to deck" }
|
|
: { icon: Icons.helpCircle, label: "Move to maybeboard" };
|
|
}
|
|
|
|
type DeckColumn = "image" | "colors" | "type" | "qty" | "owned" | "where" | "actions";
|
|
|
|
// The List view's optional columns; the checkbox and name always show.
|
|
const DECK_COLUMNS: ColumnDef<DeckColumn>[] = [
|
|
{ id: "image", label: "Image" },
|
|
{ id: "colors", label: "Colors" },
|
|
{ id: "type", label: "Type" },
|
|
{ id: "qty", label: "In deck" },
|
|
{ id: "owned", label: "Owned" },
|
|
{ id: "where", label: "Where" },
|
|
{ id: "actions", label: "Actions" },
|
|
];
|
|
|
|
type DeckDialog =
|
|
| { kind: "create" }
|
|
| { kind: "delete"; deck: Deck }
|
|
| { kind: "rename"; deck: Deck }
|
|
| { kind: "duplicate"; deck: Deck }
|
|
| { kind: "import-name"; suggestedName: string; path: string }
|
|
| { kind: "import-result"; result: DeckImportResult }
|
|
| null;
|
|
|
|
/** Name a new deck and, optionally, pick its format up front. */
|
|
function NewDeckDialog({
|
|
defaultFormat,
|
|
error,
|
|
onConfirm,
|
|
onCancel,
|
|
}: {
|
|
defaultFormat: string;
|
|
error: string | null;
|
|
onConfirm: (name: string, format: string) => void;
|
|
onCancel: () => void;
|
|
}) {
|
|
const [name, setName] = useState("");
|
|
const [format, setFormat] = useState(FORMATS.includes(defaultFormat) ? defaultFormat : "");
|
|
const confirm = () => {
|
|
if (name.trim()) onConfirm(name.trim(), format);
|
|
};
|
|
return (
|
|
<DialogFrame title="New deck" onCancel={onCancel}>
|
|
<label className="dialog-field">
|
|
<span className="muted">Deck name</span>
|
|
<input
|
|
autoFocus
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && confirm()}
|
|
/>
|
|
</label>
|
|
<label className="dialog-field">
|
|
<span className="muted">Format</span>
|
|
<select value={format} onChange={(e) => setFormat(e.target.value)}>
|
|
{FORMATS.map((f) => (
|
|
<option key={f} value={f}>
|
|
{f || "—"}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
{error && <p className="dialog-error">{error}</p>}
|
|
<div className="dialog-actions">
|
|
<button onClick={onCancel}>Cancel</button>
|
|
<button className="primary" onClick={confirm} disabled={!name.trim()}>
|
|
Create
|
|
</button>
|
|
</div>
|
|
</DialogFrame>
|
|
);
|
|
}
|
|
|
|
function friendlyDeckError(err: unknown): string {
|
|
const msg = String(err);
|
|
return msg.includes("UNIQUE constraint failed") ? "A deck with that name already exists." : msg;
|
|
}
|
|
|
|
/** A prominent, art-forward display for a deck's commander (Commander/Brawl). */
|
|
function CommanderHero({
|
|
card,
|
|
onClear,
|
|
preview,
|
|
}: {
|
|
card: DeckEntry;
|
|
onClear: () => void;
|
|
preview?: PreviewContext;
|
|
}) {
|
|
const { handlers } = useHoverPreview();
|
|
const { open } = useClickPreview();
|
|
return (
|
|
<div className="panel commander-hero">
|
|
{card.image_uri ? (
|
|
<img
|
|
className="commander-hero-img"
|
|
src={card.image_uri}
|
|
alt={card.name}
|
|
onClick={() => open(card, preview)}
|
|
{...handlers(card)}
|
|
/>
|
|
) : (
|
|
<div className="commander-hero-noimg">{card.name}</div>
|
|
)}
|
|
<div className="commander-hero-info">
|
|
<span className="commander-hero-label">Commander</span>
|
|
<span className="commander-hero-name">{card.name}</span>
|
|
<span className="commander-hero-type">{card.type_line}</span>
|
|
<ColorPips mask={card.color_identity} />
|
|
</div>
|
|
<IconButton icon={Icons.close} label={`Clear ${card.name} as commander`} onClick={onClear} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// How long a card decremented to 0 copies lingers (dimmed, still adjustable)
|
|
// before actually dropping out of the deck — same grace-period pattern as
|
|
// the Library tab, so a mistaken removal can be undone with +.
|
|
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;
|
|
|
|
/**
|
|
* Deck editor. A deck's mainboard cards are split into two groups — "In your
|
|
* library" (owned) and "Not owned" — each with its own search box and each
|
|
* groupable by card attribute; "Combine sections" merges these two into one
|
|
* list. The maybeboard is always shown as its own separate section. Deck-level
|
|
* format + commander metadata sit at the top.
|
|
*
|
|
* `refreshSignal` is bumped by `App` when something outside this view's own
|
|
* control changes deck contents — e.g. undoing a Library bulk add-to-deck
|
|
* from the command palette while this view is already mounted — so the
|
|
* card list, mana curve, and legality tile recompute immediately instead of
|
|
* only on the next mount.
|
|
*/
|
|
function DeckBuilder({
|
|
deck,
|
|
refreshSignal,
|
|
onBack,
|
|
onDecksChanged,
|
|
onRename,
|
|
onDuplicate,
|
|
onDelete,
|
|
onExport,
|
|
}: {
|
|
deck: Deck;
|
|
refreshSignal?: number;
|
|
onBack: () => void;
|
|
/** Refetch the deck list after a change to the deck's own fields. */
|
|
onDecksChanged: () => Promise<void>;
|
|
onRename: () => void;
|
|
onDuplicate: () => void;
|
|
onDelete: () => void;
|
|
onExport: () => void;
|
|
}) {
|
|
const deckId = deck.id;
|
|
const [contents, setContents] = useState<DeckEntry[]>([]);
|
|
const [view, setView] = useViewMode("deck-view");
|
|
const tiles = useTileColumns();
|
|
const columns = useColumns("deck-columns", DECK_COLUMNS);
|
|
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("deck-group", "none");
|
|
const [combineSections, setCombineSections] = usePersistedState("deck-combine-sections", false);
|
|
const [filterQuery, setFilterQuery] = useState("");
|
|
|
|
const [libQuery, setLibQuery] = useState("");
|
|
const [libResults, setLibResults] = useState<CardRef[]>([]);
|
|
const [anyQuery, setAnyQuery] = useState("");
|
|
const [anyResults, setAnyResults] = useState<CardRef[]>([]);
|
|
const [cmdQuery, setCmdQuery] = useState("");
|
|
const [cmdResults, setCmdResults] = useState<CardRef[]>([]);
|
|
const [partnerQuery, setPartnerQuery] = useState("");
|
|
const [partnerResults, setPartnerResults] = useState<CardRef[]>([]);
|
|
const [maybeQuery, setMaybeQuery] = useState("");
|
|
const [maybeResults, setMaybeResults] = useState<CardRef[]>([]);
|
|
|
|
const [pendingRemoval, setPendingRemoval] = useState<Set<string>>(new Set());
|
|
const removalTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
|
const [selectedCards, setSelectedCards] = useState<Set<string>>(new Set());
|
|
// Selected cards where only some copies are marked for removal (the extras
|
|
// over a format's copy limit), keyed by oracle id → copies to remove.
|
|
const [partialRemoval, setPartialRemoval] = useState<Map<string, number>>(new Map());
|
|
const [bulkBusy, setBulkBusy] = useState(false);
|
|
const selectAllRef = useRef<HTMLInputElement>(null);
|
|
|
|
const [handOpen, setHandOpen] = useState(false);
|
|
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.
|
|
const loadContents = useCallback(async () => {
|
|
setContents(await api.deckContents(deckId));
|
|
}, [deckId]);
|
|
|
|
useEffect(() => {
|
|
loadContents();
|
|
}, [loadContents]);
|
|
|
|
// Refetch on an external refresh signal without resetting the UI state
|
|
// above (unlike a deck switch, this shouldn't clear the user's filter,
|
|
// selection, or open sample hand). Skips the first render so it doesn't
|
|
// duplicate the mount-time fetch above.
|
|
const skipFirstRefresh = useRef(true);
|
|
useEffect(() => {
|
|
if (skipFirstRefresh.current) {
|
|
skipFirstRefresh.current = false;
|
|
return;
|
|
}
|
|
loadContents();
|
|
}, [refreshSignal]);
|
|
|
|
// Cancel any in-flight removal timers on unmount so they don't fire against
|
|
// an unmounted component.
|
|
useEffect(() => () => removalTimers.current.forEach(clearTimeout), []);
|
|
|
|
// Keep selection in sync with what's actually visible — drop ids that fall
|
|
// out of the deck (deck switched, or a card's qty hit 0 and its grace
|
|
// period expired).
|
|
useEffect(() => {
|
|
setSelectedCards((s) => {
|
|
const visible = new Set(contents.map((e) => e.oracle_id));
|
|
const next = new Set([...s].filter((id) => visible.has(id)));
|
|
return next.size === s.size ? s : next;
|
|
});
|
|
setPartialRemoval((m) => {
|
|
const visible = new Set(contents.map((e) => e.oracle_id));
|
|
const next = new Map([...m].filter(([id]) => visible.has(id)));
|
|
return next.size === m.size ? m : next;
|
|
});
|
|
}, [contents]);
|
|
|
|
useEffect(() => {
|
|
if (selectAllRef.current) {
|
|
selectAllRef.current.indeterminate = selectedCards.size > 0 && selectedCards.size < contents.length;
|
|
}
|
|
}, [selectedCards, contents]);
|
|
|
|
useEffect(() => {
|
|
const q = libQuery.trim();
|
|
if (!q) return setLibResults([]);
|
|
const t = setTimeout(() => api.searchLibrary({ text: q }).then(setLibResults), 100);
|
|
return () => clearTimeout(t);
|
|
}, [libQuery]);
|
|
|
|
useEffect(() => {
|
|
const q = anyQuery.trim();
|
|
if (!q) return setAnyResults([]);
|
|
const t = setTimeout(() => api.searchCards(q, 12).then(setAnyResults), 80);
|
|
return () => clearTimeout(t);
|
|
}, [anyQuery]);
|
|
|
|
useEffect(() => {
|
|
const q = maybeQuery.trim();
|
|
if (!q) return setMaybeResults([]);
|
|
const t = setTimeout(() => api.searchCards(q, 12).then(setMaybeResults), 80);
|
|
return () => clearTimeout(t);
|
|
}, [maybeQuery]);
|
|
|
|
useEffect(() => {
|
|
const q = cmdQuery.trim();
|
|
if (!q) return setCmdResults([]);
|
|
const t = setTimeout(() => api.searchCards(q, 12).then(setCmdResults), 80);
|
|
return () => clearTimeout(t);
|
|
}, [cmdQuery]);
|
|
|
|
// The primary commander, whose partner ability the second search box serves.
|
|
const partnerOf =
|
|
deck?.commander_oracle_id ?? contents.find((e) => e.is_commander && !e.is_maybeboard)?.oracle_id ?? null;
|
|
useEffect(() => {
|
|
const q = partnerQuery.trim();
|
|
if (!q || !partnerOf) return setPartnerResults([]);
|
|
const t = setTimeout(() => api.searchPartners(partnerOf, q, 12).then(setPartnerResults), 80);
|
|
return () => clearTimeout(t);
|
|
}, [partnerQuery, partnerOf]);
|
|
|
|
async function addToDeck(card: CardRef) {
|
|
const existing = contents.find((e) => e.oracle_id === card.oracle_id);
|
|
const qty = existing ? existing.quantity + 1 : 1;
|
|
await api.setDeckCard(deckId, card.oracle_id, qty, false, false);
|
|
setLibQuery("");
|
|
setLibResults([]);
|
|
setAnyQuery("");
|
|
setAnyResults([]);
|
|
await loadContents();
|
|
}
|
|
|
|
async function addToMaybeboard(card: CardRef) {
|
|
const existing = contents.find((e) => e.oracle_id === card.oracle_id);
|
|
const qty = existing ? existing.quantity + 1 : 1;
|
|
await api.setDeckCard(deckId, card.oracle_id, qty, false, true);
|
|
setMaybeQuery("");
|
|
setMaybeResults([]);
|
|
await loadContents();
|
|
}
|
|
|
|
/** Move a card between the mainboard and the maybeboard. Clears commander
|
|
* status (dropping it from the deck's commanders) when maybeboarding. */
|
|
async function setMaybeboard(entry: DeckEntry, isMaybeboard: boolean) {
|
|
const isCommander = isMaybeboard ? false : entry.is_commander;
|
|
await api.setDeckCard(deckId, entry.oracle_id, entry.quantity, isCommander, isMaybeboard);
|
|
if (isMaybeboard && entry.is_commander) {
|
|
await api.setDeckCommanders(
|
|
deckId,
|
|
commanders.filter((e) => e.oracle_id !== entry.oracle_id).map((e) => e.oracle_id),
|
|
);
|
|
await onDecksChanged();
|
|
}
|
|
await loadContents();
|
|
}
|
|
|
|
/** Every mainboard copy (commander/maybeboard excluded), one entry per
|
|
* physical copy so a card at quantity N is N times as likely to be drawn,
|
|
* shuffled in place (Fisher-Yates). */
|
|
function shuffledLibrary(): DeckEntry[] {
|
|
const pool: DeckEntry[] = [];
|
|
for (const e of contents) {
|
|
if (e.is_commander || e.is_maybeboard) continue;
|
|
for (let i = 0; i < e.quantity; i++) pool.push(e);
|
|
}
|
|
for (let i = pool.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[pool[i], pool[j]] = [pool[j], pool[i]];
|
|
}
|
|
return pool;
|
|
}
|
|
|
|
function drawNewHand() {
|
|
const shuffled = shuffledLibrary();
|
|
setHand(shuffled.slice(0, STARTING_HAND_SIZE));
|
|
setLibraryStack(shuffled.slice(STARTING_HAND_SIZE));
|
|
setHandOpen(true);
|
|
}
|
|
|
|
function drawOneMore() {
|
|
if (libraryStack.length === 0) return;
|
|
setHand((h) => [...h, libraryStack[0]]);
|
|
setLibraryStack((stack) => stack.slice(1));
|
|
}
|
|
|
|
async function setQty(oracleId: string, qty: number) {
|
|
const clamped = Math.max(0, qty);
|
|
const existing = contents.find((e) => e.oracle_id === oracleId);
|
|
await api.setDeckCard(
|
|
deckId,
|
|
oracleId,
|
|
clamped,
|
|
existing?.is_commander ?? false,
|
|
existing?.is_maybeboard ?? false,
|
|
);
|
|
setContents((cs) => cs.map((e) => (e.oracle_id === oracleId ? { ...e, quantity: clamped } : e)));
|
|
|
|
// Re-adding during the grace period below cancels the pending removal.
|
|
const timer = removalTimers.current.get(oracleId);
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
removalTimers.current.delete(oracleId);
|
|
setPendingRemoval((p) => {
|
|
const next = new Set(p);
|
|
next.delete(oracleId);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
if (clamped <= 0) {
|
|
// deck_contents wouldn't include this card on a fresh fetch anymore —
|
|
// rather than dropping the row immediately, dim it and give the user a
|
|
// window to undo a mistaken decrement (via +) before it disappears.
|
|
setPendingRemoval((p) => new Set(p).add(oracleId));
|
|
removalTimers.current.set(
|
|
oracleId,
|
|
setTimeout(() => {
|
|
setContents((cs) => cs.filter((e) => e.oracle_id !== oracleId));
|
|
setPendingRemoval((p) => {
|
|
const next = new Set(p);
|
|
next.delete(oracleId);
|
|
return next;
|
|
});
|
|
removalTimers.current.delete(oracleId);
|
|
}, REMOVAL_GRACE_MS),
|
|
);
|
|
}
|
|
}
|
|
|
|
function toggleCard(oracleId: string) {
|
|
// A manual (de)select always means the whole entry.
|
|
dropPartial(oracleId);
|
|
setSelectedCards((s) => {
|
|
const next = new Set(s);
|
|
if (next.has(oracleId)) next.delete(oracleId);
|
|
else next.add(oracleId);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function dropPartial(oracleId: string) {
|
|
setPartialRemoval((m) => {
|
|
if (!m.has(oracleId)) return m;
|
|
const next = new Map(m);
|
|
next.delete(oracleId);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function clearSelection() {
|
|
setSelectedCards(new Set());
|
|
setPartialRemoval(new Map());
|
|
}
|
|
|
|
/** Replace the selection with the cards behind the deck's card-level
|
|
* legality issues (optionally only some categories). Over-limit copies
|
|
* select just the extras, so removing them leaves the legal count. */
|
|
function selectIllegal(categories?: LegalityCategory[]) {
|
|
const sel = illegalSelection(deckLegality.issues, categories);
|
|
setSelectedCards(new Set(sel.keys()));
|
|
setPartialRemoval(
|
|
new Map([...sel].filter((kv): kv is [string, number] => kv[1] !== null)),
|
|
);
|
|
}
|
|
|
|
function toggleSelectAllCards() {
|
|
setPartialRemoval(new Map());
|
|
setSelectedCards((s) =>
|
|
s.size === contents.length ? new Set() : new Set(contents.map((e) => e.oracle_id)),
|
|
);
|
|
}
|
|
|
|
/** Remove every selected card outright. Unlike stepping one card down to 0,
|
|
* this is a deliberate action, so there's no grace period to linger in. */
|
|
async function bulkRemoveFromDeck() {
|
|
// Entries with only some copies selected are trimmed rather than removed.
|
|
const remaining = new Map<string, number>();
|
|
for (const id of selectedCards) {
|
|
const e = contents.find((c) => c.oracle_id === id);
|
|
const n = partialRemoval.get(id);
|
|
remaining.set(id, e && n !== undefined ? Math.max(e.quantity - n, 0) : 0);
|
|
}
|
|
setBulkBusy(true);
|
|
try {
|
|
await Promise.all(
|
|
[...remaining].map(([id, qty]) => {
|
|
const e = contents.find((c) => c.oracle_id === id);
|
|
return api.setDeckCard(deckId, id, qty, e?.is_commander ?? false, e?.is_maybeboard ?? false);
|
|
}),
|
|
);
|
|
const removed = new Set([...remaining].filter(([, qty]) => qty === 0).map(([id]) => id));
|
|
for (const id of removed) {
|
|
const timer = removalTimers.current.get(id);
|
|
if (timer) clearTimeout(timer);
|
|
removalTimers.current.delete(id);
|
|
}
|
|
setPendingRemoval((p) => new Set([...p].filter((id) => !removed.has(id))));
|
|
setContents((cs) =>
|
|
cs
|
|
.filter((e) => !removed.has(e.oracle_id))
|
|
.map((e) => (remaining.has(e.oracle_id) ? { ...e, quantity: remaining.get(e.oracle_id)! } : e)),
|
|
);
|
|
clearSelection();
|
|
} finally {
|
|
setBulkBusy(false);
|
|
}
|
|
}
|
|
|
|
/** Record cards as owned: as many copies as the deck uses beyond those
|
|
* already owned, so an acquired card moves to "In your library". */
|
|
async function addToLibrary(entries: DeckEntry[]) {
|
|
await Promise.all(entries.filter((e) => shortBy(e) > 0).map((e) => api.addOwned(e.oracle_id, shortBy(e))));
|
|
await loadContents();
|
|
}
|
|
|
|
async function bulkAddToLibrary() {
|
|
setBulkBusy(true);
|
|
try {
|
|
await addToLibrary(contents.filter((e) => selectedCards.has(e.oracle_id)));
|
|
clearSelection();
|
|
} finally {
|
|
setBulkBusy(false);
|
|
}
|
|
}
|
|
|
|
async function changeFormat(format: string) {
|
|
await api.setDeckFormat(deckId, format);
|
|
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".
|
|
const existing = contents.find((e) => e.oracle_id === card.oracle_id);
|
|
await api.setDeckCard(deckId, card.oracle_id, existing?.quantity ?? 1, true, false);
|
|
// Keep the partner only if it can still pair with the new commander; if
|
|
// not, it stays in the deck as an ordinary card.
|
|
const picked = (await api.deckContents(deckId)).find((e) => e.oracle_id === card.oracle_id);
|
|
const keepPartner =
|
|
partnerEntry && partnerEntry.oracle_id !== card.oracle_id && picked && pairsWith(picked, partnerEntry);
|
|
await api.setDeckCommanders(
|
|
deckId,
|
|
keepPartner ? [card.oracle_id, partnerEntry.oracle_id] : [card.oracle_id],
|
|
);
|
|
setCmdQuery("");
|
|
setCmdResults([]);
|
|
await loadContents();
|
|
await onDecksChanged();
|
|
}
|
|
|
|
async function addPartner(card: CardRef) {
|
|
if (!commanderEntry) return;
|
|
const existing = contents.find((e) => e.oracle_id === card.oracle_id);
|
|
await api.setDeckCard(deckId, card.oracle_id, existing?.quantity ?? 1, true, false);
|
|
await api.setDeckCommanders(deckId, [commanderEntry.oracle_id, card.oracle_id]);
|
|
setPartnerQuery("");
|
|
setPartnerResults([]);
|
|
await loadContents();
|
|
await onDecksChanged();
|
|
}
|
|
|
|
/** Stop treating a card as a commander; it stays in the deck. Clearing the
|
|
* primary commander promotes its partner. */
|
|
async function clearCommander(entry: DeckEntry) {
|
|
await api.setDeckCommanders(
|
|
deckId,
|
|
commanders.filter((e) => e.oracle_id !== entry.oracle_id).map((e) => e.oracle_id),
|
|
);
|
|
await loadContents();
|
|
await onDecksChanged();
|
|
}
|
|
|
|
// Quick-search over cards already in the deck/maybeboard — a display
|
|
// filter only, distinct from the CardSearchBox boxes below (which search
|
|
// the library/reference db to find NEW cards to add).
|
|
function matchesFilter(e: DeckEntry): boolean {
|
|
const q = fold(filterQuery.trim());
|
|
if (!q) return true;
|
|
return fold(e.name).includes(q) || fold(e.type_line).includes(q);
|
|
}
|
|
|
|
// 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);
|
|
const commanders = contents
|
|
.filter((e) => e.is_commander && !e.is_maybeboard)
|
|
.sort((a, b) => isPrimary(b) - isPrimary(a));
|
|
const commanderEntry: DeckEntry | undefined = commanders[0];
|
|
const partnerEntry: DeckEntry | undefined = commanders[1];
|
|
const deckStats = useMemo(() => computeDeckStats(contents), [contents]);
|
|
const deckLegality = useMemo(() => computeDeckLegality(deck, contents), [deck, contents]);
|
|
|
|
// Lets the click preview track copies and choose each slot's printing.
|
|
const preview: PreviewContext = {
|
|
deckId,
|
|
coverOracleId: deck.cover_oracle_id,
|
|
onChanged: () => {
|
|
loadContents();
|
|
onDecksChanged();
|
|
},
|
|
};
|
|
|
|
/** 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}>
|
|
{entries.map((e) => (
|
|
<CardTile
|
|
key={e.oracle_id}
|
|
card={e}
|
|
preview={preview}
|
|
selected={selectedCards.has(e.oracle_id)}
|
|
cornerLeft={
|
|
<span className="tile-select">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedCards.has(e.oracle_id)}
|
|
onChange={() => toggleCard(e.oracle_id)}
|
|
/>
|
|
</span>
|
|
}
|
|
corner={
|
|
<QtyStepper
|
|
overlay
|
|
value={e.quantity}
|
|
onDecrement={() => setQty(e.oracle_id, e.quantity - 1)}
|
|
onIncrement={() => setQty(e.oracle_id, e.quantity + 1)}
|
|
warn={e.quantity_owned > 0 && e.quantity > e.quantity_owned}
|
|
pending={pendingRemoval.has(e.oracle_id)}
|
|
/>
|
|
}
|
|
cornerBottomLeft={
|
|
<span className="row" style={{ gap: 4 }}>
|
|
<IconButton overlay {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
|
|
{e.quantity_owned === 0 && (
|
|
<IconButton
|
|
overlay
|
|
icon={Icons.libraryAdd}
|
|
label={libraryAddLabel(e)}
|
|
onClick={() => addToLibrary([e])}
|
|
/>
|
|
)}
|
|
</span>
|
|
}
|
|
footer={
|
|
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
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function deckTable(entries: DeckEntry[]) {
|
|
return (
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th></th>
|
|
{columns.shown("image") && <th></th>}
|
|
{columns.shown("colors") && <th>Colors</th>}
|
|
<th>Name</th>
|
|
{columns.shown("type") && <th>Type</th>}
|
|
{columns.shown("qty") && <th>In deck</th>}
|
|
{columns.shown("owned") && <th>Owned</th>}
|
|
{columns.shown("where") && <th>Where</th>}
|
|
{columns.shown("actions") && <th></th>}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{entries.map((e) => (
|
|
<tr key={e.oracle_id}>
|
|
<td className="cell-check">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedCards.has(e.oracle_id)}
|
|
onChange={() => toggleCard(e.oracle_id)}
|
|
/>
|
|
</td>
|
|
{columns.shown("image") && (
|
|
<td className="cell-thumb">
|
|
<CardThumb card={e} preview={preview} />
|
|
</td>
|
|
)}
|
|
{columns.shown("colors") && (
|
|
<td>
|
|
<ColorPips mask={e.color_identity} />
|
|
</td>
|
|
)}
|
|
<td className="card-name">
|
|
{e.is_commander && <span title="Commander">★ </span>}
|
|
{e.name}
|
|
{partialRemoval.has(e.oracle_id) && (
|
|
<span className="badge" style={{ marginLeft: 6 }}>
|
|
{extraCopiesLabel(partialRemoval.get(e.oracle_id)!)}
|
|
</span>
|
|
)}
|
|
</td>
|
|
{columns.shown("type") && <td className="card-type">{e.type_line}</td>}
|
|
{columns.shown("qty") && (
|
|
<td data-label="In deck">
|
|
<QtyStepper
|
|
value={e.quantity}
|
|
onDecrement={() => setQty(e.oracle_id, e.quantity - 1)}
|
|
onIncrement={() => setQty(e.oracle_id, e.quantity + 1)}
|
|
warn={e.quantity_owned > 0 && e.quantity > e.quantity_owned}
|
|
pending={pendingRemoval.has(e.oracle_id)}
|
|
/>
|
|
</td>
|
|
)}
|
|
{columns.shown("owned") && <td data-label="Owned">{e.quantity_owned}</td>}
|
|
{columns.shown("where") && <td data-label="Where">{whereabouts(e)}</td>}
|
|
{columns.shown("actions") && (
|
|
<td>
|
|
<span className="row" style={{ gap: 4 }}>
|
|
<IconButton {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
|
|
{e.quantity_owned === 0 && (
|
|
<IconButton icon={Icons.libraryAdd} label={libraryAddLabel(e)} onClick={() => addToLibrary([e])} />
|
|
)}
|
|
</span>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
);
|
|
}
|
|
|
|
function cardsGroup(entries: DeckEntry[]) {
|
|
if (entries.length === 0) return <p className="hint">None yet.</p>;
|
|
return groupCards(entries, groupBy).map((g) => (
|
|
<div key={g.label || "all"} style={{ marginBottom: g.label ? 14 : 0 }}>
|
|
{g.label && (
|
|
<h4 className="group-header">
|
|
{g.label} <span className="badge">{g.cards.length}</span>
|
|
</h4>
|
|
)}
|
|
{view === "tiles" ? deckTiles(g.cards) : deckTable(g.cards)}
|
|
</div>
|
|
));
|
|
}
|
|
|
|
return (
|
|
<div className="deck-builder">
|
|
<button className="deck-builder-back" onClick={onBack}>
|
|
{Icons.arrowLeft}
|
|
All decks
|
|
</button>
|
|
<div className="row" style={{ justifyContent: "space-between" }}>
|
|
<div className="row deck-builder-title">
|
|
<h2>{deck.name}</h2>
|
|
<IconButton icon={Icons.pencil} label="Rename deck" onClick={onRename} />
|
|
<IconButton icon={Icons.copy} label="Duplicate deck" onClick={onDuplicate} />
|
|
<IconButton icon={Icons.trash} label="Delete deck" danger onClick={onDelete} />
|
|
</div>
|
|
<div className="row" style={{ gap: 8 }}>
|
|
<button
|
|
onClick={() => setSuggestOpen(true)}
|
|
disabled={contents.filter((e) => !e.is_maybeboard).length === 0}
|
|
>
|
|
{Icons.cards}
|
|
Suggest cards
|
|
</button>
|
|
<button onClick={onExport}>
|
|
{Icons.download}
|
|
Export deck
|
|
</button>
|
|
<button
|
|
onClick={drawNewHand}
|
|
disabled={!contents.some((e) => !e.is_commander && !e.is_maybeboard)}
|
|
>
|
|
{Icons.cards}
|
|
Sample hand
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="deck-top-grid">
|
|
{COMMANDER_FORMATS.includes(deck.format) && commanderEntry && (
|
|
<div className="deck-top-commander">
|
|
{commanders.map((c) => (
|
|
<CommanderHero key={c.oracle_id} card={c} onClear={() => clearCommander(c)} preview={preview} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Deck metadata: format + (for Commander/Brawl) the commander card. */}
|
|
<div className="deck-top-meta">
|
|
<div className="panel deck-meta">
|
|
<label className="row" style={{ gap: 6 }}>
|
|
<span className="muted">Format</span>
|
|
<select value={deck.format} onChange={(e) => changeFormat(e.target.value)}>
|
|
{FORMATS.map((f) => (
|
|
<option key={f} value={f}>
|
|
{f || "—"}
|
|
</option>
|
|
))}
|
|
</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">
|
|
{!commanderEntry && <span className="muted">No commander set yet.</span>}
|
|
<CardSearchBox
|
|
placeholder={
|
|
commanderEntry
|
|
? "Search to change the commander…"
|
|
: "Search to set the commander…"
|
|
}
|
|
value={cmdQuery}
|
|
onChange={setCmdQuery}
|
|
results={cmdResults}
|
|
onPick={setCommander}
|
|
/>
|
|
{commanderEntry?.partner && !partnerEntry && (
|
|
<CardSearchBox
|
|
placeholder={partnerPlaceholder(commanderEntry.partner)}
|
|
value={partnerQuery}
|
|
onChange={setPartnerQuery}
|
|
results={partnerResults}
|
|
onPick={addPartner}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{contents.length > 0 && (
|
|
<div className="deck-top-stats">
|
|
<div className="panel deck-stats">
|
|
<div className="deck-stats-head section-label">Deck analysis</div>
|
|
<div className="stat-tiles-row">
|
|
<StatTile value={deckStats.totalCards} label="cards" />
|
|
<StatTile value={deckStats.uniqueCards} label="unique" />
|
|
<StatTile value={deckStats.landCount} label="lands" />
|
|
<StatTile value={deckStats.avgManaValue.toFixed(2)} label="avg MV" />
|
|
<StatTile value={formatCost(deckStats.totalCost)} label="cost" />
|
|
</div>
|
|
<div className="deck-stats-body">
|
|
<div className="deck-stats-numbers">
|
|
<div className="deck-stats-curve-label">Color identity</div>
|
|
<ColorDistributionBar
|
|
colorCounts={deckStats.colorCounts}
|
|
colorlessCount={deckStats.colorlessCount}
|
|
/>
|
|
<div className="deck-stats-curve-label">Mana production</div>
|
|
<ColorDistributionBar
|
|
colorCounts={deckStats.manaProduction}
|
|
colorlessCount={deckStats.colorlessProduction}
|
|
/>
|
|
<div className="deck-stats-curve-label">Mana consumption</div>
|
|
<ColorDistributionBar
|
|
colorCounts={deckStats.manaConsumption}
|
|
colorlessCount={deckStats.colorlessConsumption}
|
|
/>
|
|
</div>
|
|
<div className="deck-stats-curve">
|
|
<div className="deck-stats-curve-label">Mana curve (nonland)</div>
|
|
<ManaCurve buckets={deckStats.curve} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{deck.format !== "Other" && (
|
|
<div className="deck-top-legality">
|
|
<div className={`legality-tile ${deckLegality.status}`}>
|
|
<div className="legality-tile-head">
|
|
<span className="legality-tile-icon">
|
|
{deckLegality.status === "legal" ? "✓" : deckLegality.status === "illegal" ? "✗" : "—"}
|
|
</span>
|
|
<div className="legality-tile-info">
|
|
<span className="legality-tile-status">
|
|
{deckLegality.status === "legal"
|
|
? "Legal"
|
|
: deckLegality.status === "illegal"
|
|
? "Illegal"
|
|
: "Not checked"}
|
|
</span>
|
|
<span className="legality-tile-detail">
|
|
{deckLegality.status === "legal"
|
|
? `Meets all ${deck.format} deck-building rules.`
|
|
: deckLegality.status === "illegal"
|
|
? `${deckLegality.issues.length} issue${deckLegality.issues.length === 1 ? "" : "s"} found:`
|
|
: deck.format
|
|
? `Legality isn't checked for "${deck.format}".`
|
|
: "Set a format to check legality."}
|
|
</span>
|
|
</div>
|
|
{illegalSelection(deckLegality.issues).size > 0 && (
|
|
<>
|
|
<span className="spacer" />
|
|
<button className="legality-tile-select" onClick={() => selectIllegal()}>
|
|
Select illegal cards
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
{deckLegality.status === "illegal" &&
|
|
LEGALITY_CATEGORIES.map((cat) => {
|
|
const issues = deckLegality.issues.filter((i) => i.category === cat.key);
|
|
if (issues.length === 0) return null;
|
|
const cardCount = illegalSelection(issues).size;
|
|
return (
|
|
<div key={cat.key} className="legality-tile-group">
|
|
<div className="legality-tile-group-head">
|
|
<span>{cat.label}</span>
|
|
{cat.selectable && cardCount > 0 && (
|
|
<button className="legality-tile-select" onClick={() => selectIllegal([cat.key])}>
|
|
Select ({cardCount})
|
|
</button>
|
|
)}
|
|
</div>
|
|
<ul className="legality-tile-reasons">
|
|
{issues.map((i, n) => (
|
|
<li key={n}>{i.message}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<hr className="deck-section-divider" />
|
|
|
|
<div className="row" style={{ gap: 16 }}>
|
|
<label className="row" style={{ gap: 6, cursor: "pointer" }} title="Select all cards">
|
|
<input
|
|
ref={selectAllRef}
|
|
type="checkbox"
|
|
checked={contents.length > 0 && selectedCards.size === contents.length}
|
|
onChange={toggleSelectAllCards}
|
|
disabled={contents.length === 0}
|
|
/>
|
|
<span className="muted" style={{ fontSize: 12 }}>
|
|
Select all
|
|
</span>
|
|
</label>
|
|
<label className="row" style={{ gap: 6, cursor: "pointer" }} title="Combine owned/unowned into one section">
|
|
<input
|
|
type="checkbox"
|
|
checked={combineSections}
|
|
onChange={(e) => setCombineSections(e.target.checked)}
|
|
/>
|
|
<span className="muted" style={{ fontSize: 12 }}>
|
|
Combine sections
|
|
</span>
|
|
</label>
|
|
<input
|
|
value={filterQuery}
|
|
onChange={(e) => setFilterQuery(e.target.value)}
|
|
placeholder="Filter cards in deck…"
|
|
style={{ width: 200 }}
|
|
/>
|
|
<span className="spacer" />
|
|
<GroupBySelect value={groupBy} onChange={setGroupBy} />
|
|
<ViewToggle mode={view} onChange={setView} />
|
|
{view === "list" && <ColumnsMenu columns={columns} />}
|
|
{view === "tiles" && <TileColumnsControl tiles={tiles} />}
|
|
</div>
|
|
|
|
{selectedCards.size > 0 && (
|
|
<div className="bulk-bar">
|
|
<span className="bulk-bar-count">
|
|
{selectedCards.size} selected
|
|
{partialRemoval.size > 0 && ` (${partialRemoval.size} only extra copies)`}
|
|
</span>
|
|
<div className="bulk-bar-actions">
|
|
<button className="danger" onClick={bulkRemoveFromDeck} disabled={bulkBusy}>
|
|
Remove from deck
|
|
</button>
|
|
<button
|
|
onClick={bulkAddToLibrary}
|
|
disabled={bulkBusy || !contents.some((e) => selectedCards.has(e.oracle_id) && shortBy(e) > 0)}
|
|
title="Add the copies these cards are missing to your library"
|
|
>
|
|
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}>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{combineSections ? (
|
|
<section style={{ marginTop: 10 }}>
|
|
<h3>
|
|
All cards <span className="badge">{mainboard.length}</span>
|
|
</h3>
|
|
<CardSearchBox
|
|
placeholder="Search all cards to add to your deck…"
|
|
value={anyQuery}
|
|
onChange={setAnyQuery}
|
|
results={anyResults}
|
|
onPick={addToDeck}
|
|
/>
|
|
{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>
|
|
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}
|
|
onChange={setLibQuery}
|
|
results={libResults}
|
|
onPick={addToDeck}
|
|
/>
|
|
{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>
|
|
</h3>
|
|
<CardSearchBox
|
|
placeholder="Search all cards to add ones you don't own…"
|
|
value={anyQuery}
|
|
onChange={setAnyQuery}
|
|
results={anyResults}
|
|
onPick={addToDeck}
|
|
/>
|
|
{cardsGroup(unowned)}
|
|
</section>
|
|
</>
|
|
)}
|
|
|
|
<section style={{ marginTop: 26 }}>
|
|
<h3>
|
|
Maybeboard <span className="badge">{maybeboard.length}</span>
|
|
</h3>
|
|
<p className="hint" style={{ marginTop: -4 }}>
|
|
Cards under consideration — not counted in deck analysis.
|
|
</p>
|
|
<CardSearchBox
|
|
placeholder="Search all cards to add one to the maybeboard…"
|
|
value={maybeQuery}
|
|
onChange={setMaybeQuery}
|
|
results={maybeResults}
|
|
onPick={addToMaybeboard}
|
|
/>
|
|
{cardsGroup(maybeboard)}
|
|
</section>
|
|
|
|
{handOpen && (
|
|
<SampleHandModal
|
|
hand={hand}
|
|
libraryCount={libraryStack.length}
|
|
onDrawOne={drawOneMore}
|
|
onNewHand={drawNewHand}
|
|
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}
|
|
onClose={() => setSuggestOpen(false)}
|
|
onCardAdded={loadContents}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Which screen the Decks tab was last on. Module-level rather than component
|
|
// state so hopping to another tab and back returns to the deck being built,
|
|
// while a fresh launch starts on the deck list.
|
|
let lastScreen: "list" | "builder" = "list";
|
|
// Where the deck list was scrolled to when a deck was opened, so coming back
|
|
// lands where you left off rather than at the top.
|
|
let listScrollTop = 0;
|
|
|
|
/** The app's scrolling page area (the window itself doesn't scroll). */
|
|
const scroller = () => document.querySelector<HTMLElement>(".content");
|
|
|
|
/**
|
|
* The Decks tab: the deck list, or one deck's builder, as separate screens.
|
|
* Owns the deck list itself and every whole-deck action (create, import,
|
|
* rename, duplicate, export, delete), which both screens offer.
|
|
*/
|
|
export function DeckView({
|
|
refreshSignal,
|
|
openDeckId,
|
|
onOpenedDeck,
|
|
}: {
|
|
refreshSignal?: number;
|
|
/** A deck to open, e.g. from a deck chip elsewhere; `onOpenedDeck` clears it. */
|
|
openDeckId?: number | null;
|
|
onOpenedDeck?: () => void;
|
|
}) {
|
|
const [decks, setDecks] = useState<Deck[] | null>(null);
|
|
const [selected, setSelected] = usePersistedState<number | null>(libraryKey("deck-selected"), null);
|
|
// When each deck was last opened, for the list's "Recently opened" sort.
|
|
const [openedAt, setOpenedAt] = usePersistedState<Record<number, number>>(libraryKey("deck-opened"), {});
|
|
// The format the last new deck was created with; null until one has been.
|
|
const [lastNewFormat, setLastNewFormat] = usePersistedState<string | null>("new-deck-format", null);
|
|
const [screen, setScreenState] = useState(lastScreen);
|
|
const [dialog, setDialog] = useState<DeckDialog>(null);
|
|
const [dialogError, setDialogError] = useState<string | null>(null);
|
|
|
|
const setScreen = useCallback((next: "list" | "builder") => {
|
|
if (lastScreen === "list" && next === "builder") listScrollTop = scroller()?.scrollTop ?? 0;
|
|
lastScreen = next;
|
|
setScreenState(next);
|
|
}, []);
|
|
|
|
// A fresh screen starts at the top; the list at wherever it was left.
|
|
useLayoutEffect(() => {
|
|
scroller()?.scrollTo(0, screen === "list" ? listScrollTop : 0);
|
|
}, [screen, selected]);
|
|
|
|
const loadDecks = useCallback(async () => {
|
|
setDecks(await api.listDecks());
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadDecks();
|
|
}, [loadDecks]);
|
|
|
|
function openDeck(id: number) {
|
|
setSelected(id);
|
|
setOpenedAt({ ...openedAt, [id]: Date.now() });
|
|
setScreen("builder");
|
|
}
|
|
|
|
// Open a deck asked for from outside — straight to its building screen.
|
|
useEffect(() => {
|
|
if (openDeckId == null) return;
|
|
openDeck(openDeckId);
|
|
onOpenedDeck?.();
|
|
}, [openDeckId]);
|
|
|
|
// Android back from the builder returns to the deck list, like its ← button.
|
|
useBackButton(() => setScreen("list"), screen === "builder");
|
|
|
|
const deck = decks?.find((d) => d.id === selected) ?? null;
|
|
|
|
// A new deck starts in the format the last one was created with — or,
|
|
// before any has been, the format of the deck opened most recently.
|
|
const newDeckFormat =
|
|
lastNewFormat ??
|
|
(decks ?? []).reduce<Deck | null>(
|
|
(latest, d) => ((openedAt[d.id] ?? 0) > (latest ? openedAt[latest.id] ?? 0 : 0) ? d : latest),
|
|
null,
|
|
)?.format ??
|
|
"";
|
|
|
|
function showDialog(next: DeckDialog) {
|
|
setDialogError(null);
|
|
setDialog(next);
|
|
}
|
|
|
|
async function confirmCreate(name: string, format: string) {
|
|
try {
|
|
const d = await api.createDeck(name, null);
|
|
if (format) await api.setDeckFormat(d.id, format);
|
|
setLastNewFormat(format);
|
|
setDialog(null);
|
|
await loadDecks();
|
|
openDeck(d.id);
|
|
} catch (err) {
|
|
setDialogError(friendlyDeckError(err));
|
|
}
|
|
}
|
|
|
|
async function exportDeck(d: Deck) {
|
|
const path = await save({
|
|
title: "Export deck",
|
|
defaultPath: `${d.name}.txt`,
|
|
filters: [{ name: "Decklist", extensions: ["txt"] }],
|
|
});
|
|
if (typeof path === "string") await api.exportDeck(d.id, path);
|
|
}
|
|
|
|
async function pickImportFile() {
|
|
const path = await open({
|
|
title: "Import deck",
|
|
filters: [{ name: "Decklist", extensions: ["txt"] }],
|
|
});
|
|
if (typeof path !== "string") return;
|
|
const base = path.split(/[/\\]/).pop() ?? "Imported deck";
|
|
const suggestedName = base.replace(/\.txt$/i, "");
|
|
showDialog({ kind: "import-name", suggestedName, path });
|
|
}
|
|
|
|
async function confirmImportName(name: string) {
|
|
if (dialog?.kind !== "import-name") return;
|
|
try {
|
|
const result = await api.importDeck(dialog.path, name);
|
|
setDialog({ kind: "import-result", result });
|
|
setDialogError(null);
|
|
await loadDecks();
|
|
openDeck(result.deck.id);
|
|
} catch (err) {
|
|
setDialogError(friendlyDeckError(err));
|
|
}
|
|
}
|
|
|
|
async function confirmDelete() {
|
|
if (dialog?.kind !== "delete") return;
|
|
await api.deleteDeck(dialog.deck.id);
|
|
if (selected === dialog.deck.id) {
|
|
setSelected(null);
|
|
setScreen("list");
|
|
}
|
|
setDialog(null);
|
|
await loadDecks();
|
|
}
|
|
|
|
async function confirmRename(name: string) {
|
|
if (dialog?.kind !== "rename") return;
|
|
try {
|
|
await api.renameDeck(dialog.deck.id, name);
|
|
setDialog(null);
|
|
setDialogError(null);
|
|
await loadDecks();
|
|
} catch (err) {
|
|
setDialogError(friendlyDeckError(err));
|
|
}
|
|
}
|
|
|
|
async function confirmDuplicate(newName: string) {
|
|
if (dialog?.kind !== "duplicate") return;
|
|
try {
|
|
const d = await api.duplicateDeck(dialog.deck.id, newName);
|
|
setDialog(null);
|
|
setDialogError(null);
|
|
await loadDecks();
|
|
// From the builder, carry on in the copy; from the list, it just appears.
|
|
if (screen === "builder") openDeck(d.id);
|
|
} catch (err) {
|
|
setDialogError(friendlyDeckError(err));
|
|
}
|
|
}
|
|
|
|
// Nothing until the first fetch, so a builder left open doesn't flash the
|
|
// list on the way back in.
|
|
if (decks === null) return null;
|
|
|
|
return (
|
|
<>
|
|
{screen === "builder" && deck ? (
|
|
<DeckBuilder
|
|
key={deck.id}
|
|
deck={deck}
|
|
refreshSignal={refreshSignal}
|
|
onBack={() => setScreen("list")}
|
|
onDecksChanged={loadDecks}
|
|
onRename={() => showDialog({ kind: "rename", deck })}
|
|
onDuplicate={() => showDialog({ kind: "duplicate", deck })}
|
|
onDelete={() => showDialog({ kind: "delete", deck })}
|
|
onExport={() => exportDeck(deck)}
|
|
/>
|
|
) : (
|
|
<DeckList
|
|
decks={decks}
|
|
refreshSignal={refreshSignal}
|
|
openedAt={openedAt}
|
|
onOpen={openDeck}
|
|
onNew={() => showDialog({ kind: "create" })}
|
|
onImport={pickImportFile}
|
|
onRename={(d) => showDialog({ kind: "rename", deck: d })}
|
|
onDuplicate={(d) => showDialog({ kind: "duplicate", deck: d })}
|
|
onDelete={(d) => showDialog({ kind: "delete", deck: d })}
|
|
onExport={exportDeck}
|
|
/>
|
|
)}
|
|
|
|
{dialog?.kind === "create" && (
|
|
<NewDeckDialog
|
|
defaultFormat={newDeckFormat}
|
|
error={dialogError}
|
|
onConfirm={confirmCreate}
|
|
onCancel={() => setDialog(null)}
|
|
/>
|
|
)}
|
|
{dialog?.kind === "delete" && (
|
|
<ConfirmDialog
|
|
title="Delete deck"
|
|
message={`Delete "${dialog.deck.name}"? This can't be undone.`}
|
|
confirmLabel="Delete"
|
|
danger
|
|
onConfirm={confirmDelete}
|
|
onCancel={() => setDialog(null)}
|
|
/>
|
|
)}
|
|
{dialog?.kind === "rename" && (
|
|
<PromptDialog
|
|
title="Rename deck"
|
|
label="Deck name"
|
|
defaultValue={dialog.deck.name}
|
|
confirmLabel="Rename"
|
|
error={dialogError}
|
|
onConfirm={confirmRename}
|
|
onCancel={() => setDialog(null)}
|
|
/>
|
|
)}
|
|
{dialog?.kind === "duplicate" && (
|
|
<PromptDialog
|
|
title="Duplicate deck"
|
|
label="Name for the copy"
|
|
defaultValue={`${dialog.deck.name} (copy)`}
|
|
confirmLabel="Duplicate"
|
|
error={dialogError}
|
|
onConfirm={confirmDuplicate}
|
|
onCancel={() => setDialog(null)}
|
|
/>
|
|
)}
|
|
{dialog?.kind === "import-name" && (
|
|
<PromptDialog
|
|
title="Import deck"
|
|
label="Deck name"
|
|
defaultValue={dialog.suggestedName}
|
|
confirmLabel="Import"
|
|
error={dialogError}
|
|
onConfirm={confirmImportName}
|
|
onCancel={() => setDialog(null)}
|
|
/>
|
|
)}
|
|
{dialog?.kind === "import-result" && (
|
|
<InfoDialog
|
|
title="Deck imported"
|
|
message={
|
|
dialog.result.unresolved.length > 0
|
|
? `Imported "${dialog.result.deck.name}". ${dialog.result.unresolved.length} card name${dialog.result.unresolved.length === 1 ? "" : "s"} couldn't be matched:`
|
|
: `Imported "${dialog.result.deck.name}" — every card matched.`
|
|
}
|
|
items={dialog.result.unresolved}
|
|
onClose={() => setDialog(null)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|