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

170
src/App.tsx Normal file
View File

@@ -0,0 +1,170 @@
import { useEffect, useState, useCallback, ReactNode } from "react";
import { listen } from "@tauri-apps/api/event";
import { open } from "@tauri-apps/plugin-dialog";
import { api, Status } from "./api";
import { HoverPreviewProvider, usePersistedState } from "./components";
import { CardEntry } from "./views/CardEntry";
import { LibraryBrowser } from "./views/LibraryBrowser";
import { DeckView } from "./views/DeckView";
type Tab = "add" | "library" | "decks";
const svg = (children: ReactNode) => (
<svg
viewBox="0 0 24 24"
width="20"
height="20"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{children}
</svg>
);
const NAV: { id: Tab; label: string; icon: ReactNode }[] = [
{ id: "add", label: "Add Cards", icon: svg(<path d="M12 5v14M5 12h14" />) },
{
id: "library",
label: "Library",
icon: svg(
<>
<rect x="3" y="3" width="7" height="7" rx="1.5" />
<rect x="14" y="3" width="7" height="7" rx="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1.5" />
<rect x="14" y="14" width="7" height="7" rx="1.5" />
</>,
),
},
{
id: "decks",
label: "Decks",
icon: svg(
<>
<path d="M12 2.5l8.5 4.5-8.5 4.5L3.5 7z" />
<path d="M3.5 12l8.5 4.5 8.5-4.5" />
<path d="M3.5 16.5l8.5 4.5 8.5-4.5" />
</>,
),
},
];
export default function App() {
const [status, setStatus] = useState<Status | null>(null);
const [tab, setTab] = usePersistedState<Tab>("active-tab", "add");
const [progress, setProgress] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const refreshStatus = useCallback(async () => {
setStatus(await api.getStatus());
}, []);
useEffect(() => {
refreshStatus();
const un = listen<string>("ingest-progress", (e) => setProgress(e.payload));
return () => {
un.then((f) => f());
};
}, [refreshStatus]);
async function updateCardData() {
setBusy(true);
setProgress("Starting…");
try {
const s = await api.updateCardData();
setStatus(s);
setProgress(`Loaded ${s.card_count.toLocaleString()} cards.`);
} catch (err) {
setProgress(`Failed: ${String(err)}`);
} finally {
setBusy(false);
}
}
async function chooseLibrary() {
const path = await open({
title: "Choose or create library.db (put it in a synced folder)",
filters: [{ name: "SQLite", extensions: ["db", "sqlite"] }],
});
if (typeof path === "string") {
setStatus(await api.setLibraryPath(path));
}
}
const ready = status?.reference_ready ?? false;
return (
<HoverPreviewProvider>
<div className="app">
<nav className="nav-rail">
<div className="brand">
<span className="brand-mark">M</span>
<span className="brand-text">MTG Library</span>
</div>
<div className="nav-items">
{NAV.map((n) => (
<button
key={n.id}
className={`nav-item ${tab === n.id ? "active" : ""}`}
onClick={() => setTab(n.id)}
>
<span className="nav-icon">{n.icon}</span>
{n.label}
</button>
))}
</div>
<div className="nav-foot">
<span className={`status-dot ${ready ? "on" : "off"}`} />
{ready ? `${status?.card_count.toLocaleString()} cards` : "No card data"}
</div>
</nav>
<div className="main">
<header className="topbar">
{status && (
<span className="path" title={status.library_path}>
{status.library_path}
</span>
)}
<span className="spacer" />
{progress && <span className="progress">{progress}</span>}
<button onClick={chooseLibrary} disabled={busy}>
Choose library…
</button>
<button onClick={updateCardData} disabled={busy} className="primary">
{ready ? "Update card data" : "Download card data"}
</button>
</header>
<div className="content">
{!ready ? (
<div className="empty-state">
<div className="empty-mark">✦</div>
<h2>Welcome to your MTG library</h2>
<p className="hint">
No card database yet. Click <strong>Download card data</strong> to fetch
the latest cards from Scryfall (a few hundred MB, about a minute). The
reference data stays on this device — only your library file is meant to
be synced.
</p>
<button className="primary big" onClick={updateCardData} disabled={busy}>
{busy ? "Downloading…" : "Download card data"}
</button>
</div>
) : tab === "add" ? (
<CardEntry />
) : tab === "library" ? (
<LibraryBrowser />
) : (
<DeckView />
)}
</div>
</div>
</div>
</HoverPreviewProvider>
);
}

109
src/api.ts Normal file
View File

@@ -0,0 +1,109 @@
// Typed wrappers over the Tauri command bridge into mtgc-core.
import { invoke } from "@tauri-apps/api/core";
// --- Types (mirror the serde-serialized core structs) ---------------------
export interface CardRef {
oracle_id: string;
name: string;
mana_value: number;
colors: number; // WUBRG bitmask
color_identity: number; // WUBRG bitmask
type_line: string;
image_uri: string; // Scryfall "normal" image URL, or "" if unavailable
}
export interface LibraryCard extends CardRef {
quantity_owned: number;
decks: string[];
quantity_assigned: number;
location: string;
}
export interface DeckEntry extends CardRef {
quantity: number;
is_commander: boolean;
quantity_owned: number;
}
export interface Deck {
id: number;
name: string;
format: string;
commander_oracle_id: string | null;
notes: string;
}
export interface LibraryFilter {
text?: string | null;
colors_exact?: number | null;
colors_include?: number | null;
commander_identity?: number | null;
cmc_min?: number | null;
cmc_max?: number | null;
types?: string[];
subtypes?: string[];
keywords?: string[];
in_deck?: number | null;
}
export interface Status {
library_path: string;
reference_path: string;
reference_ready: boolean;
card_count: number;
}
// --- WUBRG bitmask helpers ------------------------------------------------
export const COLORS: { bit: number; code: string; label: string }[] = [
{ bit: 1, code: "W", label: "White" },
{ bit: 2, code: "U", label: "Blue" },
{ bit: 4, code: "B", label: "Black" },
{ bit: 8, code: "R", label: "Red" },
{ bit: 16, code: "G", label: "Green" },
];
export function maskToLetters(mask: number): string {
return COLORS.filter((c) => mask & c.bit)
.map((c) => c.code)
.join("");
}
// --- Commands -------------------------------------------------------------
export const api = {
getStatus: () => invoke<Status>("get_status"),
setLibraryPath: (path: string) => invoke<Status>("set_library_path", { path }),
updateCardData: () => invoke<Status>("update_card_data"),
searchCards: (query: string, limit = 20) =>
invoke<CardRef[]>("search_cards", { query, limit }),
addOwned: (oracleId: string, delta: number) =>
invoke<number>("add_owned", { oracleId, delta }),
setOwned: (oracleId: string, quantity: number) =>
invoke<number>("set_owned", { oracleId, quantity }),
setLocation: (oracleId: string, location: string) =>
invoke<void>("set_location", { oracleId, location }),
searchLibrary: (filter: LibraryFilter) =>
invoke<LibraryCard[]>("search_library", { filter }),
listDecks: () => invoke<Deck[]>("list_decks"),
createDeck: (name: string, commanderOracleId: string | null) =>
invoke<Deck>("create_deck", { name, commanderOracleId }),
deleteDeck: (deckId: number) => invoke<void>("delete_deck", { deckId }),
setDeckFormat: (deckId: number, format: string) =>
invoke<void>("set_deck_format", { deckId, format }),
setDeckCommander: (deckId: number, oracleId: string | null) =>
invoke<void>("set_deck_commander", { deckId, oracleId }),
setDeckCard: (
deckId: number,
oracleId: string,
quantity: number,
isCommander = false,
) => invoke<void>("set_deck_card", { deckId, oracleId, quantity, isCommander }),
deckContents: (deckId: number) => invoke<DeckEntry[]>("deck_contents", { deckId }),
cardDecks: (oracleId: string) => invoke<Deck[]>("card_decks", { oracleId }),
};

455
src/components.tsx Normal file
View File

@@ -0,0 +1,455 @@
import {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { api, CardRef, COLORS, maskToLetters } from "./api";
/**
* Inline editor for a card's free-text storage location (e.g. "Box 1").
* Saves on blur; used for owned cards that aren't in any deck.
*/
export function LocationField({
oracleId,
location,
onSaved,
}: {
oracleId: string;
location: string;
onSaved?: () => void;
}) {
const [val, setVal] = useState(location);
useEffect(() => setVal(location), [location]);
const save = () => {
if (val !== location) api.setLocation(oracleId, val).then(() => onSaved?.());
};
return (
<input
className="location-input"
placeholder="Location…"
value={val}
onChange={(e) => setVal(e.target.value)}
onBlur={save}
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
/>
);
}
/** Render a color-identity/colors bitmask as WUBRG pips (colorless = a "C" pip). */
export function ColorPips({ mask }: { mask: number }) {
const letters = maskToLetters(mask);
if (letters.length === 0) {
return (
<span className="pips">
<span className="pip C">C</span>
</span>
);
}
return (
<span className="pips">
{letters.split("").map((c) => (
<span key={c} className={`pip ${c}`}>
{c}
</span>
))}
</span>
);
}
/** Small card-image thumbnail. Hovering it shows the full-size preview. */
export function CardThumb({ card }: { card: CardRef }) {
const { handlers } = useHoverPreview();
if (!card.image_uri) {
return (
<span className="thumb-empty" title="No image">
🂠
</span>
);
}
return (
<img
className="card-thumb"
src={card.image_uri}
alt={card.name}
loading="lazy"
{...handlers(card)}
/>
);
}
/**
* A search input plus a results dropdown with keyboard navigation:
* ↑/↓ move the highlight, Enter picks, mouse hover/click also work.
* `value`/`onChange` are controlled by the caller (which runs the search).
*/
export function CardSearchBox({
placeholder,
value,
onChange,
results,
onPick,
autoFocus,
big,
}: {
placeholder: string;
value: string;
onChange: (v: string) => void;
results: CardRef[];
onPick: (card: CardRef) => void;
autoFocus?: boolean;
/** Larger, more prominent input (used for the primary Add Cards field). */
big?: boolean;
}) {
const [sel, setSel] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => setSel(0), [results]);
// Keep focus in the box after a pick so cards can be added in a rapid streak.
function pick(card: CardRef) {
onPick(card);
inputRef.current?.focus();
}
function onKeyDown(e: React.KeyboardEvent) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSel((s) => Math.min(s + 1, results.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSel((s) => Math.max(s - 1, 0));
} else if (e.key === "Enter" && results[sel]) {
e.preventDefault();
pick(results[sel]);
}
}
return (
<>
<input
ref={inputRef}
autoFocus={autoFocus}
style={{
width: "100%",
marginBottom: 8,
...(big ? { fontSize: 16, padding: "10px 12px" } : {}),
}}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
/>
{results.length > 0 && (
<ul className="result-list" style={{ marginBottom: 14 }}>
{results.map((c, i) => (
<li
key={c.oracle_id}
style={i === sel ? { background: "var(--panel-2)" } : undefined}
onMouseEnter={() => setSel(i)}
onClick={() => pick(c)}
>
<ColorPips mask={c.color_identity} />
<span className="card-name">{c.name}</span>
<span className="card-type">{c.type_line}</span>
</li>
))}
</ul>
)}
</>
);
}
// --- Hover preview --------------------------------------------------------
interface HoverApi {
enter: (card: CardRef, e: React.MouseEvent) => void;
move: (e: React.MouseEvent) => void;
leave: () => void;
}
const HoverCtx = createContext<HoverApi | null>(null);
/**
* Provides a single floating card-image preview that appears while hovering a
* card and tracks the cursor. Wrap the app once; attach `handlers(card)` from
* [`useHoverPreview`] to any element that should trigger it.
*/
export function HoverPreviewProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<{ card: CardRef; x: number; y: number } | null>(null);
const enter = useCallback((card: CardRef, e: React.MouseEvent) => {
if (card.image_uri) setState({ card, x: e.clientX, y: e.clientY });
}, []);
const move = useCallback((e: React.MouseEvent) => {
setState((s) => (s ? { ...s, x: e.clientX, y: e.clientY } : s));
}, []);
const leave = useCallback(() => setState(null), []);
return (
<HoverCtx.Provider value={{ enter, move, leave }}>
{children}
{state && <FloatingCard card={state.card} x={state.x} y={state.y} />}
</HoverCtx.Provider>
);
}
/** Returns a `handlers(card)` factory to spread onto a row/cell for hover preview. */
export function useHoverPreview() {
const api = useContext(HoverCtx);
return {
handlers: (card: CardRef) =>
api
? {
onMouseEnter: (e: React.MouseEvent) => api.enter(card, e),
onMouseMove: (e: React.MouseEvent) => api.move(e),
onMouseLeave: () => api.leave(),
}
: {},
};
}
const PREVIEW_W = 320;
const PREVIEW_H = 446; // ~ card aspect for the normal image
const GAP = 24;
const PAD = 12;
function FloatingCard({ card, x, y }: { card: CardRef; x: number; y: number }) {
// Prefer the right of the cursor; flip left if it would overflow.
let left = x + GAP;
if (left + PREVIEW_W + PAD > window.innerWidth) left = x - PREVIEW_W - GAP;
const top = Math.max(PAD, Math.min(y - PREVIEW_H / 2, window.innerHeight - PREVIEW_H - PAD));
return (
<img
className="hover-card"
src={card.image_uri}
alt={card.name}
style={{ left, top, width: PREVIEW_W }}
/>
);
}
// --- Grouping -------------------------------------------------------------
export type GroupMode = "none" | "type" | "subtype" | "cmc" | "colors";
export const GROUP_OPTIONS: { value: GroupMode; label: string }[] = [
{ value: "none", label: "No grouping" },
{ value: "type", label: "Card type" },
{ value: "subtype", label: "Subtype" },
{ value: "cmc", label: "Mana value" },
{ value: "colors", label: "Color identity" },
];
// Priority for choosing a card's primary type from its (multi-type) type line.
const TYPE_PRIORITY = [
"Creature",
"Planeswalker",
"Battle",
"Land",
"Artifact",
"Enchantment",
"Instant",
"Sorcery",
];
function primaryType(typeLine: string): string {
const left = typeLine.split("—")[0];
for (const t of TYPE_PRIORITY) if (left.includes(t)) return t;
const words = left.trim().split(/\s+/);
return words[words.length - 1] || "Other";
}
function firstSubtype(typeLine: string): string {
const parts = typeLine.split("—");
if (parts.length < 2) return "No subtype";
return parts[1].trim().split(/\s+/)[0] || "No subtype";
}
function colorLabel(mask: number): string {
const letters = maskToLetters(mask);
return letters === "" ? "Colorless" : letters;
}
/** Group cards by the chosen attribute, returning ordered groups. */
export function groupCards<T extends CardRef>(
cards: T[],
mode: GroupMode,
): { label: string; cards: T[] }[] {
if (mode === "none") return [{ label: "", cards }];
const keyOf = (c: T) =>
mode === "type"
? primaryType(c.type_line)
: mode === "subtype"
? firstSubtype(c.type_line)
: mode === "cmc"
? `Mana value ${c.mana_value}`
: colorLabel(c.color_identity);
const map = new Map<string, T[]>();
for (const c of cards) {
const k = keyOf(c);
const arr = map.get(k);
if (arr) arr.push(c);
else map.set(k, [c]);
}
const groups = [...map.entries()].map(([label, cs]) => ({ label, cards: cs }));
if (mode === "type") {
const rank = (l: string) => {
const i = TYPE_PRIORITY.indexOf(l);
return i === -1 ? TYPE_PRIORITY.length : i;
};
groups.sort((a, b) => rank(a.label) - rank(b.label) || a.label.localeCompare(b.label));
} else if (mode === "cmc") {
groups.sort((a, b) => a.cards[0].mana_value - b.cards[0].mana_value);
} else {
groups.sort((a, b) => a.label.localeCompare(b.label));
}
return groups;
}
/** Dropdown for choosing a [`GroupMode`]. */
export function GroupBySelect({
value,
onChange,
}: {
value: GroupMode;
onChange: (m: GroupMode) => void;
}) {
return (
<label className="group-by">
<span className="muted">Group by</span>
<select value={value} onChange={(e) => onChange(e.target.value as GroupMode)}>
{GROUP_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
);
}
// --- Persistence ----------------------------------------------------------
/** useState that mirrors its value to localStorage (JSON), so it survives
* remounts and app restarts. */
export function usePersistedState<T>(key: string, initial: T): [T, (v: T) => void] {
const [val, setVal] = useState<T>(() => {
const raw = localStorage.getItem(key);
if (raw === null) return initial;
try {
return JSON.parse(raw) as T;
} catch {
return initial;
}
});
const set = useCallback(
(v: T) => {
localStorage.setItem(key, JSON.stringify(v));
setVal(v);
},
[key],
);
return [val, set];
}
// --- View mode (list vs. image tiles) ------------------------------------
export type ViewMode = "list" | "tiles";
/** View-mode state persisted to localStorage so it survives tab switches. */
export function useViewMode(key: string): [ViewMode, (m: ViewMode) => void] {
const [mode, setMode] = useState<ViewMode>(
() => (localStorage.getItem(key) as ViewMode) || "list",
);
const set = useCallback(
(m: ViewMode) => {
localStorage.setItem(key, m);
setMode(m);
},
[key],
);
return [mode, set];
}
/** Segmented control switching between the list and image-tile layouts. */
export function ViewToggle({
mode,
onChange,
}: {
mode: ViewMode;
onChange: (m: ViewMode) => void;
}) {
return (
<span className="view-toggle">
<button
className={mode === "list" ? "active" : ""}
onClick={() => onChange("list")}
title="List view"
>
☰ List
</button>
<button
className={mode === "tiles" ? "active" : ""}
onClick={() => onChange("tiles")}
title="Image tiles"
>
▦ Tiles
</button>
</span>
);
}
/** A single card rendered as a full image tile, with optional corner + footer. */
export function CardTile({
card,
corner,
footer,
}: {
card: CardRef;
corner?: ReactNode;
footer?: ReactNode;
}) {
return (
<div className="tile">
<div className="tile-img-wrap">
{card.image_uri ? (
<img className="tile-img" src={card.image_uri} alt={card.name} loading="lazy" />
) : (
<div className="tile-noimg">{card.name}</div>
)}
{corner && <div className="tile-corner">{corner}</div>}
</div>
{footer && <div className="tile-footer">{footer}</div>}
</div>
);
}
/** Clickable WUBRG toggles that edit a bitmask value. */
export function ColorToggles({
value,
onChange,
}: {
value: number;
onChange: (mask: number) => void;
}) {
return (
<span className="color-toggles">
{COLORS.map((c) => (
<span
key={c.code}
title={c.label}
className={`pip ${c.code} ${value & c.bit ? "on" : ""}`}
onClick={() => onChange(value ^ c.bit)}
>
{c.code}
</span>
))}
</span>
);
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

715
src/styles.css Normal file
View File

@@ -0,0 +1,715 @@
/* ============================================================
mtgc — design system
Terracotta accent (#C8553D) on a layered, Discord-inspired dark UI.
============================================================ */
:root {
/* Layered surfaces (darkest → lightest) */
--bg-rail: #16171a;
--bg-app: #1a1b1f;
--bg-topbar: #1e1f24;
--bg-content: #212228;
--bg-panel: #282a31;
--bg-input: #2f313a;
--bg-hover: #363841;
--bg-elev: #3a3c46;
--border: #34363e;
--border-soft: rgba(255, 255, 255, 0.055);
--text: #dbdee4;
--text-strong: #f5f6f8;
--muted: #9a9ea8;
--faint: #71757f;
/* Terracotta accent */
--accent: #c8553d;
--accent-hover: #d86e56;
--accent-active: #a8412d;
--accent-subtle: rgba(200, 85, 61, 0.15);
--accent-glow: rgba(200, 85, 61, 0.35);
/* Complementary / status */
--info: #46a6bd; /* teal complement */
--gold: #e0b24a;
--success: #4bb073;
--warn: #e0a34a;
--danger: #d8503f;
--radius: 8px;
--radius-lg: 12px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 6px 20px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 14px 44px rgba(0, 0, 0, 0.55);
color-scheme: dark;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
font-family: "Inter", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif;
background: var(--bg-app);
color: var(--text);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
::selection {
background: var(--accent-subtle);
}
/* Thin, unobtrusive scrollbars */
*::-webkit-scrollbar {
width: 11px;
height: 11px;
}
*::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.35);
border-radius: 8px;
border: 3px solid transparent;
background-clip: padding-box;
}
*::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.5);
background-clip: padding-box;
}
*::-webkit-scrollbar-track {
background: transparent;
}
/* -------- Controls -------- */
button {
background: var(--bg-input);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 7px 13px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
font-family: inherit;
transition: background 0.12s, border-color 0.12s, transform 0.05s, filter 0.12s;
}
button:hover {
background: var(--bg-hover);
border-color: var(--bg-elev);
}
button:active {
transform: translateY(1px);
}
button:disabled {
opacity: 0.5;
cursor: default;
transform: none;
}
button.primary {
background: linear-gradient(180deg, var(--accent-hover), var(--accent));
border: none;
color: #fff;
box-shadow: var(--shadow-sm);
}
button.primary:hover {
filter: brightness(1.07);
background: linear-gradient(180deg, var(--accent-hover), var(--accent));
}
button.primary:active {
background: var(--accent-active);
}
button.big {
padding: 11px 22px;
font-size: 15px;
border-radius: 10px;
}
input,
select {
background: var(--bg-input);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px 11px;
font-size: 13px;
font-family: inherit;
transition: border-color 0.12s, box-shadow 0.12s;
}
input::placeholder {
color: var(--faint);
}
input:focus,
select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-subtle);
}
/* ============================================================
App shell: nav rail + main
============================================================ */
.app {
display: flex;
height: 100vh;
overflow: hidden;
}
.nav-rail {
width: 220px;
flex-shrink: 0;
background: var(--bg-rail);
border-right: 1px solid var(--border-soft);
display: flex;
flex-direction: column;
padding: 14px 10px 10px;
}
.brand {
display: flex;
align-items: center;
gap: 11px;
padding: 4px 8px 16px;
}
.brand-mark {
width: 34px;
height: 34px;
border-radius: 10px;
background: linear-gradient(150deg, var(--accent-hover), var(--accent-active));
color: #fff;
font-weight: 800;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-sm);
}
.brand-text {
font-weight: 700;
font-size: 15px;
color: var(--text-strong);
letter-spacing: 0.2px;
}
.nav-items {
display: flex;
flex-direction: column;
gap: 3px;
}
.nav-item {
position: relative;
display: flex;
align-items: center;
gap: 12px;
width: 100%;
text-align: left;
padding: 9px 12px;
border: none;
border-radius: var(--radius);
background: transparent;
color: var(--muted);
font-size: 14px;
font-weight: 550;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.04);
color: var(--text);
}
.nav-item.active {
background: var(--accent-subtle);
color: var(--text-strong);
}
.nav-item.active::before {
content: "";
position: absolute;
left: -10px;
top: 9px;
bottom: 9px;
width: 4px;
border-radius: 0 4px 4px 0;
background: var(--accent);
}
.nav-icon {
display: inline-flex;
color: inherit;
}
.nav-foot {
margin-top: auto;
display: flex;
align-items: center;
gap: 8px;
padding: 12px 10px 6px;
font-size: 12px;
color: var(--muted);
border-top: 1px solid var(--border-soft);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-dot.on {
background: var(--success);
box-shadow: 0 0 6px var(--success);
}
.status-dot.off {
background: var(--faint);
}
.main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: var(--bg-content);
}
.topbar {
height: 52px;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 10px;
padding: 0 18px;
background: var(--bg-topbar);
border-bottom: 1px solid var(--border-soft);
}
.path {
font-size: 12px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
color: var(--faint);
background: rgba(0, 0, 0, 0.25);
padding: 4px 10px;
border-radius: 6px;
max-width: 460px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress {
color: var(--accent-hover);
font-size: 12px;
font-weight: 500;
}
.spacer {
flex: 1;
}
.content {
flex: 1;
overflow: auto;
padding: 22px 26px;
}
/* Empty / welcome state */
.empty-state {
max-width: 480px;
margin: 9vh auto 0;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
.empty-mark {
width: 72px;
height: 72px;
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 34px;
color: #fff;
background: linear-gradient(150deg, var(--accent-hover), var(--accent-active));
box-shadow: 0 10px 30px var(--accent-glow);
}
.empty-state h2 {
margin: 4px 0 0;
}
/* ============================================================
Typography helpers
============================================================ */
h2 {
margin: 2px 0 14px;
font-size: 19px;
font-weight: 700;
color: var(--text-strong);
letter-spacing: -0.2px;
}
h3 {
margin: 18px 0 10px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--faint);
border-bottom: 1px solid var(--border-soft);
padding-bottom: 8px;
}
h4 {
margin: 14px 0 7px;
font-size: 13px;
font-weight: 600;
color: var(--accent-hover);
}
.muted {
color: var(--muted);
}
.hint {
color: var(--muted);
font-size: 13px;
line-height: 1.65;
}
.row {
display: flex;
gap: 8px;
align-items: center;
}
.badge {
display: inline-flex;
align-items: center;
background: var(--accent-subtle);
color: var(--accent-hover);
border-radius: 999px;
padding: 1px 9px;
font-size: 11px;
font-weight: 700;
}
/* ============================================================
Lists (search results, deck list)
============================================================ */
.result-list {
list-style: none;
margin: 8px 0 0;
padding: 0;
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--bg-panel);
}
.result-list li {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-bottom: 1px solid var(--border-soft);
cursor: pointer;
transition: background 0.1s;
}
.result-list li:last-child {
border-bottom: none;
}
.result-list li:hover {
background: var(--bg-hover);
}
.card-name {
font-weight: 600;
color: var(--text-strong);
}
.card-type {
color: var(--muted);
font-size: 12px;
}
/* ============================================================
Color pips (WUBRG)
============================================================ */
.pips {
display: inline-flex;
gap: 3px;
}
.pip {
width: 18px;
height: 18px;
border-radius: 50%;
font-size: 10px;
font-weight: 800;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.25);
}
.pip.W { background: #f4efdd; color: #4a3f2a; }
.pip.U { background: #3a8fd0; color: #fff; }
.pip.B { background: #38343d; color: #cbc3d4; }
.pip.R { background: var(--accent); color: #fff; }
.pip.G { background: #479b63; color: #fff; }
.pip.C { background: #9aa0ab; color: #23242a; }
.color-toggles {
display: flex;
gap: 5px;
}
.color-toggles .pip {
cursor: pointer;
opacity: 0.32;
transition: opacity 0.1s, box-shadow 0.1s;
}
.color-toggles .pip.on {
opacity: 1;
box-shadow: 0 0 0 2px var(--accent-hover);
}
/* ============================================================
Tables
============================================================ */
table {
width: 100%;
border-collapse: collapse;
}
th {
text-align: left;
padding: 8px 12px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--faint);
position: sticky;
top: 0;
background: var(--bg-content);
border-bottom: 1px solid var(--border);
z-index: 1;
}
td {
padding: 8px 12px;
border-bottom: 1px solid var(--border-soft);
vertical-align: middle;
}
tbody tr {
transition: background 0.08s;
}
tbody tr:hover {
background: rgba(255, 255, 255, 0.022);
}
.over-assigned {
color: var(--warn);
font-weight: 600;
}
.deck-chip {
display: inline-flex;
align-items: center;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 6px;
padding: 2px 8px;
font-size: 11px;
color: var(--text);
margin-right: 4px;
}
/* ============================================================
Filters panel
============================================================ */
.filters {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 14px;
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
padding: 16px;
margin-bottom: 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field > label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--faint);
}
.location-input {
padding: 4px 8px;
font-size: 12px;
max-width: 160px;
}
/* ============================================================
Card images: thumbnails, hover preview, tiles
============================================================ */
.card-thumb {
width: 40px;
height: 56px;
object-fit: cover;
border-radius: 5px;
display: block;
background: var(--bg-elev);
cursor: zoom-in;
box-shadow: var(--shadow-sm);
transition: transform 0.1s, box-shadow 0.1s;
}
.card-thumb:hover {
transform: scale(1.05);
box-shadow: 0 0 0 2px var(--accent-hover), var(--shadow-md);
}
.thumb-empty {
display: inline-flex;
width: 40px;
height: 56px;
align-items: center;
justify-content: center;
color: var(--faint);
border: 1px dashed var(--border);
border-radius: 5px;
font-size: 18px;
}
.hover-card {
position: fixed;
z-index: 200;
border-radius: 14px;
box-shadow: var(--shadow-lg);
border: 1px solid rgba(255, 255, 255, 0.08);
pointer-events: none;
}
.tile-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 16px;
}
.tile {
display: flex;
flex-direction: column;
gap: 7px;
}
.tile-img-wrap {
position: relative;
}
.tile-img {
width: 100%;
display: block;
border-radius: 12px;
box-shadow: var(--shadow-sm);
transition: transform 0.12s, box-shadow 0.12s;
}
.tile:hover .tile-img {
transform: translateY(-3px);
box-shadow: var(--shadow-md);
}
.tile-noimg {
aspect-ratio: 488 / 680;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--bg-panel);
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 14px;
font-weight: 600;
color: var(--muted);
}
.tile-corner {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 6px;
}
.tile-badge {
background: rgba(0, 0, 0, 0.78);
color: #fff;
border-radius: 8px;
padding: 2px 9px;
font-size: 12px;
font-weight: 700;
backdrop-filter: blur(2px);
}
.tile-badge.warn {
background: var(--warn);
color: #241700;
}
.tile-remove {
background: rgba(0, 0, 0, 0.78);
border: none;
color: #fff;
border-radius: 50%;
width: 24px;
height: 24px;
padding: 0;
line-height: 1;
font-size: 13px;
}
.tile-remove:hover {
background: var(--danger);
}
.tile-footer {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
}
/* ============================================================
Toolbar controls: group-by + view toggle
============================================================ */
.group-by {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 12px;
color: var(--muted);
}
.view-toggle {
display: inline-flex;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.view-toggle button {
border: none;
border-radius: 0;
background: transparent;
font-size: 12px;
padding: 5px 12px;
color: var(--muted);
}
.view-toggle button:hover {
background: rgba(255, 255, 255, 0.04);
}
.view-toggle button.active {
background: var(--accent);
color: #fff;
}
/* ============================================================
Deck metadata (format + commander)
============================================================ */
.deck-meta {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 20px;
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
padding: 14px 16px;
}
.commander-box {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 280px;
flex: 1;
}

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);
}