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:
170
src/App.tsx
Normal file
170
src/App.tsx
Normal 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
109
src/api.ts
Normal 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
455
src/components.tsx
Normal 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
10
src/main.tsx
Normal 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
715
src/styles.css
Normal 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
83
src/views/CardEntry.tsx
Normal 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
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>
|
||||
);
|
||||
}
|
||||
235
src/views/LibraryBrowser.tsx
Normal file
235
src/views/LibraryBrowser.tsx
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user