Add deck legality checking, deck/library import-export, and a command palette
Threads Scryfall's per-format legalities through the reference db and adds a deck-page indicator (structural rules + banned/restricted lists) that lists violation reasons inline. Decks and selected library cards can now be exported to/imported from plain-text decklists, with best-effort name resolution and a report of anything that didn't match. Also adds a shuffle- and-draw sample hand modal, and an Alt+/ command palette for jumping to a tab, deck, or card without leaving the keyboard. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter } from "../api";
|
||||
import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter, LibraryImportResult } from "../api";
|
||||
import {
|
||||
CardThumb,
|
||||
CardTile,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
ColorToggles,
|
||||
GroupBySelect,
|
||||
GroupMode,
|
||||
InfoDialog,
|
||||
LocationField,
|
||||
PromptDialog,
|
||||
QtyStepper,
|
||||
@@ -49,9 +51,11 @@ export function LibraryBrowser() {
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [bulkDeckId, setBulkDeckId] = useState<number | "">("");
|
||||
const [bulkToMaybeboard, setBulkToMaybeboard] = useState(false);
|
||||
const [bulkDelta, setBulkDelta] = useState(1);
|
||||
const [locationDialogOpen, setLocationDialogOpen] = useState(false);
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const [importResult, setImportResult] = useState<LibraryImportResult | null>(null);
|
||||
const selectAllRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -172,7 +176,8 @@ export function LibraryBrowser() {
|
||||
* owned count (e.g. select a handful of basic lands, "Add All" to put
|
||||
* every copy you own into the deck at once) — overwrites the quantity for
|
||||
* cards already in the deck rather than skipping them, so it doubles as a
|
||||
* bulk "fix the land count" tool.
|
||||
* bulk "fix the land count" tool. `bulkToMaybeboard` sends them to the
|
||||
* deck's maybeboard instead of the mainboard.
|
||||
*/
|
||||
async function bulkAddToDeck(mode: "one" | "all") {
|
||||
if (bulkDeckId === "") return;
|
||||
@@ -183,7 +188,13 @@ export function LibraryBrowser() {
|
||||
const targets = rows.filter((r) => selected.has(r.oracle_id));
|
||||
await Promise.all(
|
||||
targets.map((r) =>
|
||||
api.setDeckCard(deck.id, r.oracle_id, mode === "all" ? r.quantity_owned : 1, false),
|
||||
api.setDeckCard(
|
||||
deck.id,
|
||||
r.oracle_id,
|
||||
mode === "all" ? r.quantity_owned : 1,
|
||||
false,
|
||||
bulkToMaybeboard,
|
||||
),
|
||||
),
|
||||
);
|
||||
setRefreshKey((k) => k + 1);
|
||||
@@ -212,6 +223,28 @@ export function LibraryBrowser() {
|
||||
}
|
||||
}
|
||||
|
||||
async function exportSelected() {
|
||||
const targets = rows.filter((r) => selected.has(r.oracle_id)).map((r) => r.oracle_id);
|
||||
if (targets.length === 0) return;
|
||||
const path = await save({
|
||||
title: "Export library cards",
|
||||
defaultPath: "library-export.txt",
|
||||
filters: [{ name: "Decklist", extensions: ["txt"] }],
|
||||
});
|
||||
if (typeof path === "string") await api.exportLibraryCards(targets, path);
|
||||
}
|
||||
|
||||
async function pickImportFile() {
|
||||
const path = await open({
|
||||
title: "Import library cards",
|
||||
filters: [{ name: "Decklist", extensions: ["txt"] }],
|
||||
});
|
||||
if (typeof path !== "string") return;
|
||||
const result = await api.importLibraryCards(path);
|
||||
setImportResult(result);
|
||||
setRefreshKey((k) => k + 1);
|
||||
}
|
||||
|
||||
const deckOrLocation = (c: LibraryCard) =>
|
||||
c.decks.length === 0 ? (
|
||||
<LocationField
|
||||
@@ -342,6 +375,7 @@ export function LibraryBrowser() {
|
||||
</h2>
|
||||
</div>
|
||||
<div className="row" style={{ gap: 16 }}>
|
||||
<button onClick={pickImportFile}>Import cards…</button>
|
||||
<GroupBySelect value={groupBy} onChange={setGroupBy} />
|
||||
<ViewToggle mode={view} onChange={setView} />
|
||||
</div>
|
||||
@@ -378,6 +412,17 @@ export function LibraryBrowser() {
|
||||
>
|
||||
Add All
|
||||
</button>
|
||||
<label className="row" style={{ gap: 4, cursor: "pointer" }} title="Send to the maybeboard instead of the mainboard">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bulkToMaybeboard}
|
||||
onChange={(e) => setBulkToMaybeboard(e.target.checked)}
|
||||
disabled={bulkBusy}
|
||||
/>
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
Maybeboard
|
||||
</span>
|
||||
</label>
|
||||
</span>
|
||||
<button onClick={() => setLocationDialogOpen(true)} disabled={bulkBusy}>
|
||||
Set location…
|
||||
@@ -405,6 +450,13 @@ export function LibraryBrowser() {
|
||||
+
|
||||
</button>
|
||||
</span>
|
||||
<button
|
||||
onClick={exportSelected}
|
||||
disabled={bulkBusy}
|
||||
title="Export the selected cards to a decklist file"
|
||||
>
|
||||
Export selected
|
||||
</button>
|
||||
</div>
|
||||
<span className="spacer" />
|
||||
<button onClick={() => setSelected(new Set())} disabled={bulkBusy}>
|
||||
@@ -424,6 +476,19 @@ export function LibraryBrowser() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{importResult && (
|
||||
<InfoDialog
|
||||
title="Cards imported"
|
||||
message={
|
||||
importResult.unresolved.length > 0
|
||||
? `Added ${importResult.added.length} card${importResult.added.length === 1 ? "" : "s"}. ${importResult.unresolved.length} name${importResult.unresolved.length === 1 ? "" : "s"} couldn't be matched:`
|
||||
: `Added ${importResult.added.length} card${importResult.added.length === 1 ? "" : "s"} to your library.`
|
||||
}
|
||||
items={importResult.unresolved}
|
||||
onClose={() => setImportResult(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="filters">
|
||||
<div className="field">
|
||||
<label>Name contains</label>
|
||||
@@ -516,7 +581,7 @@ export function LibraryBrowser() {
|
||||
groups.map((g) => (
|
||||
<div key={g.label || "all"} style={{ marginBottom: g.label ? 20 : 0 }}>
|
||||
{g.label && (
|
||||
<h3>
|
||||
<h3 className="group-header">
|
||||
{g.label} <span className="badge">{g.cards.length}</span>
|
||||
</h3>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user