236 lines
7.2 KiB
TypeScript
236 lines
7.2 KiB
TypeScript
|
|
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);
|
|||
|
|
}
|