Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with: - Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB architecture (reference.db + library.db via ATTACH), criteria filters, deck ops - Tauri v2 shell exposing the core as typed IPC commands - React/TS frontend: card entry, library browser, deck editor - Features: fuzzy card search, color/CMC/type/commander-identity filters, tile/list view toggle, hover card previews, deck format + commander support, per-card location field, grouping by type/subtype/CMC/color, localStorage persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
377
src/views/DeckView.tsx
Normal file
377
src/views/DeckView.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { api, CardRef, Deck, DeckEntry } from "../api";
|
||||
import {
|
||||
CardSearchBox,
|
||||
CardThumb,
|
||||
CardTile,
|
||||
ColorPips,
|
||||
GroupBySelect,
|
||||
GroupMode,
|
||||
ViewToggle,
|
||||
groupCards,
|
||||
usePersistedState,
|
||||
useViewMode,
|
||||
} from "../components";
|
||||
|
||||
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 [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 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();
|
||||
}, [loadContents]);
|
||||
|
||||
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;
|
||||
await api.setDeckCard(selected, oracleId, qty, false);
|
||||
await loadContents();
|
||||
}
|
||||
|
||||
async function removeDeck(id: number) {
|
||||
await api.deleteDeck(id);
|
||||
if (selected === id) setSelected(null);
|
||||
await loadDecks();
|
||||
}
|
||||
|
||||
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}
|
||||
corner={
|
||||
<button
|
||||
className="tile-remove"
|
||||
title="Remove from deck"
|
||||
onClick={() => setQty(e.oracle_id, 0)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
}
|
||||
footer={
|
||||
<span className="tile-badge">
|
||||
{e.is_commander ? "★ " : ""}in deck ×{e.quantity}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function deckTable(entries: DeckEntry[]) {
|
||||
return (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Colors</th>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>In deck</th>
|
||||
<th>Owned</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => (
|
||||
<tr key={e.oracle_id}>
|
||||
<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>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
style={{ width: 60 }}
|
||||
key={e.quantity}
|
||||
defaultValue={e.quantity}
|
||||
onBlur={(ev) => setQty(e.oracle_id, Number(ev.target.value))}
|
||||
/>
|
||||
</td>
|
||||
<td>{e.quantity_owned}</td>
|
||||
<td>
|
||||
<button onClick={() => setQty(e.oracle_id, 0)}>Remove</button>
|
||||
</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(--panel-2)" } : undefined}
|
||||
onClick={() => setSelected(d.id)}
|
||||
>
|
||||
<span className="card-name">{d.name}</span>
|
||||
{d.format && <span className="deck-chip">{d.format}</span>}
|
||||
<span className="spacer" />
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeDeck(d.id);
|
||||
}}
|
||||
title="Delete deck"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</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" }}>
|
||||
<h2>{deck.name}</h2>
|
||||
<div className="row" style={{ gap: 16 }}>
|
||||
<GroupBySelect value={groupBy} onChange={setGroupBy} />
|
||||
<ViewToggle mode={view} onChange={setView} />
|
||||
</div>
|
||||
</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 && (
|
||||
<button onClick={clearCommander} title="Clear commander">
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user