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:
2026-07-07 19:37:56 -05:00
commit 399c628461
94 changed files with 11626 additions and 0 deletions

83
src/views/CardEntry.tsx Normal file
View File

@@ -0,0 +1,83 @@
import { useEffect, useState } from "react";
import { api, CardRef } from "../api";
import { CardSearchBox } from "../components";
/** Rapid card entry: fuzzy search-as-you-type, ↑/↓ + Enter (or click) adds one copy. */
export function CardEntry() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<CardRef[]>([]);
const [recent, setRecent] = useState<{ name: string; qty: number; location: string }[]>([]);
// Persisted so a "box" location sticks while entering a stack of cards.
const [location, setLocation] = useState(() => localStorage.getItem("add-location") || "");
useEffect(() => {
localStorage.setItem("add-location", location);
}, [location]);
// Debounced fuzzy search.
useEffect(() => {
const q = query.trim();
if (!q) {
setResults([]);
return;
}
const t = setTimeout(async () => {
try {
setResults(await api.searchCards(q, 20));
} catch {
setResults([]);
}
}, 80);
return () => clearTimeout(t);
}, [query]);
async function add(card: CardRef) {
const qty = await api.addOwned(card.oracle_id, 1);
const loc = location.trim();
if (loc) await api.setLocation(card.oracle_id, loc);
setRecent((r) => [{ name: card.name, qty, location: loc }, ...r].slice(0, 12));
setQuery("");
setResults([]);
}
return (
<div>
<h2>Add Cards</h2>
<div className="field" style={{ maxWidth: 260, marginBottom: 12 }}>
<label>Location for added cards (optional)</label>
<input
placeholder="e.g. Box 1 — prefilled onto each card"
value={location}
onChange={(e) => setLocation(e.target.value)}
/>
</div>
<CardSearchBox
big
autoFocus
placeholder="Type a card name… (typos are fine — ↑/↓ then Enter to add)"
value={query}
onChange={setQuery}
results={results}
onPick={add}
/>
{recent.length > 0 && (
<>
<h2 style={{ marginTop: 24 }}>Just added</h2>
<ul className="result-list">
{recent.map((r, i) => (
<li key={i} style={{ cursor: "default" }}>
<span className="card-name">{r.name}</span>
{r.location && <span className="deck-chip">{r.location}</span>}
<span className="spacer" />
<span className="badge">owned ×{r.qty}</span>
</li>
))}
</ul>
</>
)}
</div>
);
}

377
src/views/DeckView.tsx Normal file
View 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>
);
}

View File

@@ -0,0 +1,235 @@
import { useEffect, useState } from "react";
import { api, Deck, LibraryCard, LibraryFilter } from "../api";
import {
CardThumb,
CardTile,
ColorPips,
ColorToggles,
GroupBySelect,
GroupMode,
LocationField,
ViewToggle,
groupCards,
usePersistedState,
useViewMode,
} from "../components";
/** Browse & filter the owned library by name and card criteria. */
export function LibraryBrowser() {
const [text, setText] = useState("");
const [commander, setCommander] = useState(0);
const [colorsInclude, setColorsInclude] = useState(0);
const [cmcMin, setCmcMin] = useState("");
const [cmcMax, setCmcMax] = useState("");
const [type, setType] = useState("");
const [subtype, setSubtype] = useState("");
const [keyword, setKeyword] = useState("");
const [inDeck, setInDeck] = useState<number | "">("");
const [decks, setDecks] = useState<Deck[]>([]);
const [rows, setRows] = useState<LibraryCard[]>([]);
const [view, setView] = useViewMode("library-view");
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("library-group", "none");
const [refreshKey, setRefreshKey] = useState(0);
useEffect(() => {
api.listDecks().then(setDecks);
}, []);
useEffect(() => {
const filter: LibraryFilter = {
text: text.trim() || null,
commander_identity: commander || null,
colors_include: colorsInclude || null,
cmc_min: cmcMin === "" ? null : Number(cmcMin),
cmc_max: cmcMax === "" ? null : Number(cmcMax),
types: splitCsv(type),
subtypes: splitCsv(subtype),
keywords: splitCsv(keyword),
in_deck: inDeck === "" ? null : Number(inDeck),
};
const t = setTimeout(() => {
api.searchLibrary(filter).then(setRows).catch(() => setRows([]));
}, 120);
return () => clearTimeout(t);
}, [text, commander, colorsInclude, cmcMin, cmcMax, type, subtype, keyword, inDeck, refreshKey]);
const deckOrLocation = (c: LibraryCard) =>
c.decks.length === 0 ? (
<LocationField
oracleId={c.oracle_id}
location={c.location}
onSaved={() => setRefreshKey((k) => k + 1)}
/>
) : (
c.decks.map((d) => (
<span key={d} className="deck-chip">
{d}
</span>
))
);
function libTable(cards: LibraryCard[]) {
return (
<table>
<thead>
<tr>
<th></th>
<th>Colors</th>
<th>Name</th>
<th>Type</th>
<th>MV</th>
<th>Owned</th>
<th>Decks / Location</th>
</tr>
</thead>
<tbody>
{cards.map((c) => (
<tr key={c.oracle_id}>
<td>
<CardThumb card={c} />
</td>
<td>
<ColorPips mask={c.color_identity} />
</td>
<td className="card-name">{c.name}</td>
<td className="card-type">{c.type_line}</td>
<td>{c.mana_value}</td>
<td className={c.quantity_assigned > c.quantity_owned ? "over-assigned" : ""}>
{c.quantity_owned}
{c.quantity_assigned > c.quantity_owned && (
<span title="Assigned to more decks than you own">
{" "}
⚠ {c.quantity_assigned} assigned
</span>
)}
</td>
<td>{deckOrLocation(c)}</td>
</tr>
))}
</tbody>
</table>
);
}
function libTiles(cards: LibraryCard[]) {
return (
<div className="tile-grid">
{cards.map((c) => (
<CardTile
key={c.oracle_id}
card={c}
corner={
<span
className={`tile-badge ${c.quantity_assigned > c.quantity_owned ? "warn" : ""}`}
title="Copies owned"
>
×{c.quantity_owned}
{c.quantity_assigned > c.quantity_owned ? " ⚠" : ""}
</span>
}
footer={deckOrLocation(c)}
/>
))}
</div>
);
}
const groups = groupCards(rows, groupBy);
return (
<div>
<div className="row" style={{ justifyContent: "space-between", marginBottom: 12 }}>
<h2 style={{ margin: 0 }}>
Library <span className="badge">{rows.length}</span>
</h2>
<div className="row" style={{ gap: 16 }}>
<GroupBySelect value={groupBy} onChange={setGroupBy} />
<ViewToggle mode={view} onChange={setView} />
</div>
</div>
<div className="filters">
<div className="field">
<label>Name contains</label>
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="e.g. bolt" />
</div>
<div className="field">
<label>Playable in commander (color identity ⊆)</label>
<ColorToggles value={commander} onChange={setCommander} />
</div>
<div className="field">
<label>Includes colors</label>
<ColorToggles value={colorsInclude} onChange={setColorsInclude} />
</div>
<div className="field">
<label>Mana value</label>
<div className="row">
<input
type="number"
style={{ width: 70 }}
placeholder="min"
value={cmcMin}
onChange={(e) => setCmcMin(e.target.value)}
/>
<span className="muted">–</span>
<input
type="number"
style={{ width: 70 }}
placeholder="max"
value={cmcMax}
onChange={(e) => setCmcMax(e.target.value)}
/>
</div>
</div>
<div className="field">
<label>Type(s)</label>
<input value={type} onChange={(e) => setType(e.target.value)} placeholder="Creature, Legendary" />
</div>
<div className="field">
<label>Subtype(s)</label>
<input value={subtype} onChange={(e) => setSubtype(e.target.value)} placeholder="Elf" />
</div>
<div className="field">
<label>Keyword(s)</label>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="Flying" />
</div>
<div className="field">
<label>In deck</label>
<select value={inDeck} onChange={(e) => setInDeck(e.target.value === "" ? "" : Number(e.target.value))}>
<option value="">Any</option>
{decks.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
</div>
</div>
{rows.length === 0 ? (
<p className="hint">
No matching cards owned. Add some in the Add Cards tab, or loosen the filters.
</p>
) : (
groups.map((g) => (
<div key={g.label || "all"} style={{ marginBottom: g.label ? 20 : 0 }}>
{g.label && (
<h3>
{g.label} <span className="badge">{g.cards.length}</span>
</h3>
)}
{view === "list" ? libTable(g.cards) : libTiles(g.cards)}
</div>
))
)}
</div>
);
}
function splitCsv(s: string): string[] {
return s
.split(",")
.map((x) => x.trim())
.filter(Boolean);
}