- Mirror the Library tab's multi-select: checkboxes on every row/tile, a "Select all" toggle, and a bulk-action bar with a single "Remove from deck" action (reuses CardTile's cornerLeft/selected support and the existing .bulk-bar/.tile-select styling — no new CSS needed). - Replace the deck table's plain number input (which only committed on blur) and the tile view's static "in deck ×N" badge with the same QtyStepper used in the Library tab, so bumping a card's count no longer means removing it and re-adding it. Decrementing to 0 now gets the same grace-period undo as the Library tab (dims the row for 5s, + brings it back) instead of deleting it from the deck instantly. - QtyStepper's `warn` state now flags deck cards you don't own enough copies of (scoped to cards you own at least one of, so every row in the already-labeled "Not owned" section doesn't light up too). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
602 lines
19 KiB
TypeScript
602 lines
19 KiB
TypeScript
import { useEffect, useRef, useState, useCallback } from "react";
|
|
import { api, CardRef, Deck, DeckEntry } from "../api";
|
|
import {
|
|
CardSearchBox,
|
|
CardThumb,
|
|
CardTile,
|
|
ColorPips,
|
|
ConfirmDialog,
|
|
GroupBySelect,
|
|
GroupMode,
|
|
IconButton,
|
|
Icons,
|
|
PromptDialog,
|
|
QtyStepper,
|
|
ViewToggle,
|
|
groupCards,
|
|
usePersistedState,
|
|
useViewMode,
|
|
} from "../components";
|
|
|
|
type DeckDialog =
|
|
| { kind: "delete"; deck: Deck }
|
|
| { kind: "rename"; deck: Deck }
|
|
| { kind: "duplicate"; deck: Deck }
|
|
| null;
|
|
|
|
function friendlyDeckError(err: unknown): string {
|
|
const msg = String(err);
|
|
return msg.includes("UNIQUE constraint failed") ? "A deck with that name already exists." : msg;
|
|
}
|
|
|
|
// 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 FORMATS = [
|
|
"",
|
|
"Commander",
|
|
"Brawl",
|
|
"Standard",
|
|
"Pioneer",
|
|
"Modern",
|
|
"Legacy",
|
|
"Vintage",
|
|
"Pauper",
|
|
"Other",
|
|
];
|
|
const COMMANDER_FORMATS = ["Commander", "Brawl"];
|
|
|
|
/**
|
|
* Deck editor. A deck's 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. Deck-level format + commander metadata sit at the top.
|
|
*/
|
|
export function DeckView() {
|
|
const [decks, setDecks] = useState<Deck[]>([]);
|
|
const [selected, setSelected] = usePersistedState<number | null>("deck-selected", null);
|
|
const [newName, setNewName] = useState("");
|
|
const [contents, setContents] = useState<DeckEntry[]>([]);
|
|
const [view, setView] = useViewMode("deck-view");
|
|
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("deck-group", "none");
|
|
const [dialog, setDialog] = useState<DeckDialog>(null);
|
|
const [dialogError, setDialogError] = useState<string | null>(null);
|
|
|
|
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 [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());
|
|
const [bulkBusy, setBulkBusy] = useState(false);
|
|
const selectAllRef = useRef<HTMLInputElement>(null);
|
|
|
|
const deck = decks.find((d) => d.id === selected) ?? null;
|
|
|
|
const loadDecks = useCallback(async () => {
|
|
const d = await api.listDecks();
|
|
setDecks(d);
|
|
// Keep the persisted selection if it still exists, else fall back to the first.
|
|
if (d.length && (selected === null || !d.some((x) => x.id === selected))) {
|
|
setSelected(d[0].id);
|
|
}
|
|
}, [selected, setSelected]);
|
|
|
|
useEffect(() => {
|
|
loadDecks();
|
|
}, [loadDecks]);
|
|
|
|
const loadContents = useCallback(async () => {
|
|
if (selected === null) {
|
|
setContents([]);
|
|
return;
|
|
}
|
|
setContents(await api.deckContents(selected));
|
|
}, [selected]);
|
|
|
|
useEffect(() => {
|
|
loadContents();
|
|
setSelectedCards(new Set());
|
|
}, [loadContents]);
|
|
|
|
// 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;
|
|
});
|
|
}, [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 = cmdQuery.trim();
|
|
if (!q) return setCmdResults([]);
|
|
const t = setTimeout(() => api.searchCards(q, 12).then(setCmdResults), 80);
|
|
return () => clearTimeout(t);
|
|
}, [cmdQuery]);
|
|
|
|
async function createDeck() {
|
|
if (!newName.trim()) return;
|
|
const d = await api.createDeck(newName.trim(), null);
|
|
setNewName("");
|
|
await loadDecks();
|
|
setSelected(d.id);
|
|
}
|
|
|
|
async function addToDeck(card: CardRef) {
|
|
if (selected === null) return;
|
|
const existing = contents.find((e) => e.oracle_id === card.oracle_id);
|
|
const qty = existing ? existing.quantity + 1 : 1;
|
|
await api.setDeckCard(selected, card.oracle_id, qty, false);
|
|
setLibQuery("");
|
|
setLibResults([]);
|
|
setAnyQuery("");
|
|
setAnyResults([]);
|
|
await loadContents();
|
|
}
|
|
|
|
async function setQty(oracleId: string, qty: number) {
|
|
if (selected === null) return;
|
|
const clamped = Math.max(0, qty);
|
|
await api.setDeckCard(selected, oracleId, clamped, 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) {
|
|
setSelectedCards((s) => {
|
|
const next = new Set(s);
|
|
if (next.has(oracleId)) next.delete(oracleId);
|
|
else next.add(oracleId);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function toggleSelectAllCards() {
|
|
setSelectedCards((s) =>
|
|
s.size === contents.length ? new Set() : new Set(contents.map((e) => e.oracle_id)),
|
|
);
|
|
}
|
|
|
|
async function bulkRemoveFromDeck() {
|
|
setBulkBusy(true);
|
|
try {
|
|
await Promise.all([...selectedCards].map((id) => setQty(id, 0)));
|
|
} finally {
|
|
setBulkBusy(false);
|
|
}
|
|
}
|
|
|
|
async function confirmDelete() {
|
|
if (dialog?.kind !== "delete") return;
|
|
await api.deleteDeck(dialog.deck.id);
|
|
if (selected === dialog.deck.id) setSelected(null);
|
|
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();
|
|
setSelected(d.id);
|
|
} catch (err) {
|
|
setDialogError(friendlyDeckError(err));
|
|
}
|
|
}
|
|
|
|
async function changeFormat(format: string) {
|
|
if (selected === null) return;
|
|
await api.setDeckFormat(selected, format);
|
|
await loadDecks();
|
|
}
|
|
|
|
async function setCommander(card: CardRef) {
|
|
if (selected === null) return;
|
|
// Ensure the commander is in the deck, then flag it.
|
|
if (!contents.find((e) => e.oracle_id === card.oracle_id)) {
|
|
await api.setDeckCard(selected, card.oracle_id, 1, true);
|
|
}
|
|
await api.setDeckCommander(selected, card.oracle_id);
|
|
setCmdQuery("");
|
|
setCmdResults([]);
|
|
await loadContents();
|
|
await loadDecks();
|
|
}
|
|
|
|
async function clearCommander() {
|
|
if (selected === null) return;
|
|
await api.setDeckCommander(selected, null);
|
|
await loadContents();
|
|
await loadDecks();
|
|
}
|
|
|
|
const owned = contents.filter((e) => e.quantity_owned > 0);
|
|
const unowned = contents.filter((e) => e.quantity_owned === 0);
|
|
const commanderName =
|
|
deck?.commander_oracle_id &&
|
|
(contents.find((e) => e.oracle_id === deck.commander_oracle_id)?.name ??
|
|
deck.commander_oracle_id);
|
|
|
|
function deckTiles(entries: DeckEntry[]) {
|
|
return (
|
|
<div className="tile-grid">
|
|
{entries.map((e) => (
|
|
<CardTile
|
|
key={e.oracle_id}
|
|
card={e}
|
|
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)}
|
|
/>
|
|
}
|
|
footer={e.is_commander ? <span className="tile-badge">★ commander</span> : undefined}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function deckTable(entries: DeckEntry[]) {
|
|
return (
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th></th>
|
|
<th></th>
|
|
<th>Colors</th>
|
|
<th>Name</th>
|
|
<th>Type</th>
|
|
<th>In deck</th>
|
|
<th>Owned</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{entries.map((e) => (
|
|
<tr key={e.oracle_id}>
|
|
<td>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedCards.has(e.oracle_id)}
|
|
onChange={() => toggleCard(e.oracle_id)}
|
|
/>
|
|
</td>
|
|
<td>
|
|
<CardThumb card={e} />
|
|
</td>
|
|
<td>
|
|
<ColorPips mask={e.color_identity} />
|
|
</td>
|
|
<td className="card-name">
|
|
{e.is_commander && <span title="Commander">★ </span>}
|
|
{e.name}
|
|
</td>
|
|
<td className="card-type">{e.type_line}</td>
|
|
<td>
|
|
<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>
|
|
<td>{e.quantity_owned}</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>
|
|
{g.label} <span className="badge">{g.cards.length}</span>
|
|
</h4>
|
|
)}
|
|
{view === "tiles" ? deckTiles(g.cards) : deckTable(g.cards)}
|
|
</div>
|
|
));
|
|
}
|
|
|
|
return (
|
|
<div style={{ display: "flex", gap: 20 }}>
|
|
<div style={{ width: 240, flexShrink: 0 }}>
|
|
<h2>Decks</h2>
|
|
<div className="row" style={{ marginBottom: 10 }}>
|
|
<input
|
|
value={newName}
|
|
placeholder="New deck name"
|
|
onChange={(e) => setNewName(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && createDeck()}
|
|
/>
|
|
<button className="primary" onClick={createDeck}>
|
|
+
|
|
</button>
|
|
</div>
|
|
<ul className="result-list">
|
|
{decks.map((d) => (
|
|
<li
|
|
key={d.id}
|
|
style={d.id === selected ? { background: "var(--accent-subtle)" } : undefined}
|
|
onClick={() => setSelected(d.id)}
|
|
>
|
|
<span className="card-name">{d.name}</span>
|
|
{d.format && <span className="deck-chip">{d.format}</span>}
|
|
<span className="spacer" />
|
|
<span className="row-actions">
|
|
<IconButton
|
|
icon={Icons.pencil}
|
|
label="Rename deck"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setDialogError(null);
|
|
setDialog({ kind: "rename", deck: d });
|
|
}}
|
|
/>
|
|
<IconButton
|
|
icon={Icons.copy}
|
|
label="Duplicate deck"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setDialogError(null);
|
|
setDialog({ kind: "duplicate", deck: d });
|
|
}}
|
|
/>
|
|
<IconButton
|
|
icon={Icons.trash}
|
|
label="Delete deck"
|
|
danger
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setDialog({ kind: "delete", deck: d });
|
|
}}
|
|
/>
|
|
</span>
|
|
</li>
|
|
))}
|
|
{decks.length === 0 && (
|
|
<li style={{ cursor: "default" }} className="hint">
|
|
No decks yet.
|
|
</li>
|
|
)}
|
|
</ul>
|
|
</div>
|
|
|
|
<div style={{ flex: 1 }}>
|
|
{selected === null || !deck ? (
|
|
<p className="hint">Select or create a deck.</p>
|
|
) : (
|
|
<>
|
|
<div className="row" style={{ justifyContent: "space-between" }}>
|
|
<div className="row" style={{ gap: 14 }}>
|
|
<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>
|
|
<h2>{deck.name}</h2>
|
|
</div>
|
|
<div className="row" style={{ gap: 16 }}>
|
|
<GroupBySelect value={groupBy} onChange={setGroupBy} />
|
|
<ViewToggle mode={view} onChange={setView} />
|
|
</div>
|
|
</div>
|
|
|
|
{selectedCards.size > 0 && (
|
|
<div className="bulk-bar">
|
|
<span className="bulk-bar-count">{selectedCards.size} selected</span>
|
|
<div className="bulk-bar-actions">
|
|
<button className="danger" onClick={bulkRemoveFromDeck} disabled={bulkBusy}>
|
|
Remove from deck
|
|
</button>
|
|
</div>
|
|
<span className="spacer" />
|
|
<button onClick={() => setSelectedCards(new Set())} disabled={bulkBusy}>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Deck metadata: format + (for Commander/Brawl) the commander card. */}
|
|
<div className="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>
|
|
|
|
{COMMANDER_FORMATS.includes(deck.format) && (
|
|
<div className="commander-box">
|
|
<div className="row" style={{ gap: 8 }}>
|
|
<span className="muted">Commander:</span>
|
|
<strong>{commanderName || "none"}</strong>
|
|
{commanderName && (
|
|
<IconButton icon={Icons.close} label="Clear commander" onClick={clearCommander} />
|
|
)}
|
|
</div>
|
|
<CardSearchBox
|
|
placeholder="Search to set the commander…"
|
|
value={cmdQuery}
|
|
onChange={setCmdQuery}
|
|
results={cmdResults}
|
|
onPick={setCommander}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<section style={{ marginTop: 18 }}>
|
|
<h3>
|
|
In your library <span className="badge">{owned.length}</span>
|
|
</h3>
|
|
<CardSearchBox
|
|
placeholder="Search your library to add owned cards…"
|
|
value={libQuery}
|
|
onChange={setLibQuery}
|
|
results={libResults}
|
|
onPick={addToDeck}
|
|
/>
|
|
{cardsGroup(owned)}
|
|
</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>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{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)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|