Extract the filter fields and LibraryFilter-building state into src/cardFilter.tsx (CardFilterPanel + useCardFilter) and use it in the Library, Card Search and News set browser, replacing three diverging copies. search_reference now honors in_deck/location too, so every filter works in every tab. Closes #13
713 lines
24 KiB
TypeScript
713 lines
24 KiB
TypeScript
import { listen } from "@tauri-apps/api/event";
|
||
import { open, save } from "@tauri-apps/plugin-dialog";
|
||
import { useEffect, useRef, useState } from "react";
|
||
import {
|
||
api,
|
||
Deck,
|
||
LibraryCard,
|
||
LibraryFacets,
|
||
LibraryImportResult,
|
||
PreviewContext,
|
||
} from "../api";
|
||
import {
|
||
CardThumb,
|
||
CardTile,
|
||
DialogFrame,
|
||
ColorPips,
|
||
GroupBySelect,
|
||
GroupMode,
|
||
InfoDialog,
|
||
LocationField,
|
||
ProgressDialog,
|
||
PromptDialog,
|
||
QtyStepper,
|
||
SortBySelect,
|
||
SortDir,
|
||
SortMode,
|
||
TileColumnsControl,
|
||
ViewToggle,
|
||
groupCards,
|
||
sortCards,
|
||
usePersistedState,
|
||
useTileColumns,
|
||
useViewMode,
|
||
} from "../components";
|
||
import { CardFilterPanel, NO_FACETS, useCardFilter } from "../cardFilter";
|
||
|
||
// How long a card that's been decremented to 0 lingers (dimmed, still
|
||
// adjustable) before actually dropping out of the library view — gives the
|
||
// user a window to undo an accidental removal by clicking + again.
|
||
const REMOVAL_GRACE_MS = 5000;
|
||
|
||
/**
|
||
* Browse & filter the owned library by name and card criteria.
|
||
*
|
||
* `onOfferUndo` is threaded in from `App` (rather than owning an `UndoBar`
|
||
* here) so the resulting "Undo" is also reachable from the command palette,
|
||
* which is mounted at the app level and outlives this view across tab
|
||
* switches.
|
||
*/
|
||
export function LibraryBrowser({
|
||
onOfferUndo,
|
||
}: {
|
||
onOfferUndo: (message: string, run: () => void | Promise<void>) => void;
|
||
}) {
|
||
const filterState = useCardFilter("library-criteria");
|
||
const { filter, criteria } = filterState;
|
||
const [facets, setFacets] = useState<LibraryFacets>(NO_FACETS);
|
||
|
||
const [decks, setDecks] = useState<Deck[]>([]);
|
||
const [rows, setRows] = useState<LibraryCard[]>([]);
|
||
const [view, setView] = useViewMode("library-view");
|
||
const tiles = useTileColumns();
|
||
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("library-group", "none");
|
||
const [sortBy, setSortBy] = usePersistedState<SortMode>("library-sort", "name");
|
||
const [sortDir, setSortDir] = usePersistedState<SortDir>("library-sort-dir", "asc");
|
||
const [refreshKey, setRefreshKey] = useState(0);
|
||
const [pendingRemoval, setPendingRemoval] = useState<Set<string>>(new Set());
|
||
const removalTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||
|
||
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 [importError, setImportError] = useState<string | null>(null);
|
||
// The "what can I import?" explainer shown before the file picker.
|
||
const [importIntroOpen, setImportIntroOpen] = useState(false);
|
||
// The file picked to import, while asking where its cards are kept.
|
||
const [importPath, setImportPath] = useState<string | null>(null);
|
||
// Lines done / total while an import runs; null when idle.
|
||
const [importProgress, setImportProgress] = useState<{ done: number; total: number } | null>(null);
|
||
// Only show the progress dialog for an import that's taking a while, so a
|
||
// quick one doesn't flash it up.
|
||
const [showImportProgress, setShowImportProgress] = useState(false);
|
||
const selectAllRef = useRef<HTMLInputElement>(null);
|
||
|
||
useEffect(() => {
|
||
api.listDecks().then(setDecks);
|
||
}, []);
|
||
|
||
// Refetch whenever refreshKey bumps too, so newly-owned cards' types
|
||
// become suggestible without restarting the app.
|
||
useEffect(() => {
|
||
api.getLibraryFacets().then(setFacets).catch(() => setFacets(NO_FACETS));
|
||
}, [refreshKey]);
|
||
|
||
// Cancel any in-flight removal timers on unmount so they don't fire against
|
||
// an unmounted component.
|
||
useEffect(() => () => removalTimers.current.forEach(clearTimeout), []);
|
||
|
||
// Keep selection in sync with what's actually visible — drop ids that fall
|
||
// out of the filtered set (filter changed, or a card's qty hit 0 and its
|
||
// grace period expired).
|
||
useEffect(() => {
|
||
setSelected((s) => {
|
||
const visible = new Set(rows.map((r) => r.oracle_id));
|
||
const next = new Set([...s].filter((id) => visible.has(id)));
|
||
return next.size === s.size ? s : next;
|
||
});
|
||
}, [rows]);
|
||
|
||
useEffect(() => {
|
||
if (selectAllRef.current) {
|
||
selectAllRef.current.indeterminate = selected.size > 0 && selected.size < rows.length;
|
||
}
|
||
}, [selected, rows]);
|
||
|
||
useEffect(() => {
|
||
const t = setTimeout(() => {
|
||
api.searchLibrary(filter).then(setRows).catch(() => setRows([]));
|
||
}, 120);
|
||
return () => clearTimeout(t);
|
||
}, [filter, refreshKey]);
|
||
|
||
async function adjust(oracleId: string, delta: number) {
|
||
const qty = await api.addOwned(oracleId, delta);
|
||
setRows((rs) => rs.map((c) => (c.oracle_id === oracleId ? { ...c, quantity_owned: qty } : c)));
|
||
|
||
// Re-adding during the grace period below cancels the pending removal.
|
||
const timer = removalTimers.current.get(oracleId);
|
||
if (timer) {
|
||
clearTimeout(timer);
|
||
removalTimers.current.delete(oracleId);
|
||
setPendingRemoval((p) => {
|
||
const next = new Set(p);
|
||
next.delete(oracleId);
|
||
return next;
|
||
});
|
||
}
|
||
|
||
if (qty <= 0) {
|
||
// search_library only returns owned (>0) cards — rather than dropping
|
||
// the row immediately, dim it and give the user a window to undo a
|
||
// mistaken decrement (via +) before it actually disappears.
|
||
setPendingRemoval((p) => new Set(p).add(oracleId));
|
||
removalTimers.current.set(
|
||
oracleId,
|
||
setTimeout(() => {
|
||
setRows((rs) => rs.filter((c) => c.oracle_id !== oracleId));
|
||
setPendingRemoval((p) => {
|
||
const next = new Set(p);
|
||
next.delete(oracleId);
|
||
return next;
|
||
});
|
||
removalTimers.current.delete(oracleId);
|
||
}, REMOVAL_GRACE_MS),
|
||
);
|
||
}
|
||
}
|
||
|
||
function toggleOne(oracleId: string) {
|
||
setSelected((s) => {
|
||
const next = new Set(s);
|
||
if (next.has(oracleId)) next.delete(oracleId);
|
||
else next.add(oracleId);
|
||
return next;
|
||
});
|
||
}
|
||
|
||
function toggleSelectAll() {
|
||
setSelected((s) => (s.size === rows.length ? new Set() : new Set(rows.map((r) => r.oracle_id))));
|
||
}
|
||
|
||
/**
|
||
* Set the in-deck quantity of every selected card to either 1 or the full
|
||
* 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. `bulkToMaybeboard` sends them to the
|
||
* deck's maybeboard instead of the mainboard.
|
||
*
|
||
* Since this overwrites rather than skips, it can clobber an existing
|
||
* in-deck quantity/board — so it snapshots each target's prior deck_cards
|
||
* row (or absence of one) first and offers an "Undo" that restores exactly
|
||
* that prior state (deleting the row again for cards that weren't in the
|
||
* deck before).
|
||
*/
|
||
async function bulkAddToDeck(mode: "one" | "all") {
|
||
if (bulkDeckId === "") return;
|
||
const deck = decks.find((d) => d.id === bulkDeckId);
|
||
if (!deck) return;
|
||
setBulkBusy(true);
|
||
try {
|
||
const targets = rows.filter((r) => selected.has(r.oracle_id));
|
||
const before = await api.deckContents(deck.id);
|
||
const prevByOracle = new Map(before.map((e) => [e.oracle_id, e]));
|
||
await Promise.all(
|
||
targets.map((r) =>
|
||
api.setDeckCard(
|
||
deck.id,
|
||
r.oracle_id,
|
||
mode === "all" ? r.quantity_owned : 1,
|
||
false,
|
||
bulkToMaybeboard,
|
||
),
|
||
),
|
||
);
|
||
setRefreshKey((k) => k + 1);
|
||
onOfferUndo(
|
||
`Added ${targets.length} card${targets.length === 1 ? "" : "s"} to ${deck.name}.`,
|
||
async () => {
|
||
await Promise.all(
|
||
targets.map((r) => {
|
||
const prev = prevByOracle.get(r.oracle_id);
|
||
return api.setDeckCard(
|
||
deck.id,
|
||
r.oracle_id,
|
||
prev?.quantity ?? 0,
|
||
prev?.is_commander ?? false,
|
||
prev?.is_maybeboard ?? false,
|
||
);
|
||
}),
|
||
);
|
||
setRefreshKey((k) => k + 1);
|
||
},
|
||
);
|
||
} finally {
|
||
setBulkBusy(false);
|
||
}
|
||
}
|
||
|
||
async function bulkSetLocation(value: string) {
|
||
setBulkBusy(true);
|
||
try {
|
||
await Promise.all([...selected].map((id) => api.setLocation(id, value)));
|
||
setLocationDialogOpen(false);
|
||
setRefreshKey((k) => k + 1);
|
||
} finally {
|
||
setBulkBusy(false);
|
||
}
|
||
}
|
||
|
||
async function bulkAdjust(delta: number) {
|
||
setBulkBusy(true);
|
||
try {
|
||
await Promise.all([...selected].map((id) => adjust(id, delta)));
|
||
} finally {
|
||
setBulkBusy(false);
|
||
}
|
||
}
|
||
|
||
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() {
|
||
setImportIntroOpen(false);
|
||
const path = await open({
|
||
title: "Import library cards",
|
||
filters: [{ name: "Card list (decklist or ManaBox export)", extensions: ["txt", "csv"] }],
|
||
});
|
||
if (typeof path === "string") setImportPath(path);
|
||
}
|
||
|
||
async function runImport(path: string, location: string) {
|
||
setImportPath(null);
|
||
setImportProgress({ done: 0, total: 0 });
|
||
const reveal = setTimeout(() => setShowImportProgress(true), 300);
|
||
const unlisten = await listen<{ done: number; total: number }>("library-import-progress", (e) =>
|
||
setImportProgress(e.payload),
|
||
);
|
||
try {
|
||
setImportResult(await api.importLibraryCards(path, location));
|
||
} catch (err) {
|
||
setImportError(String(err));
|
||
} finally {
|
||
unlisten();
|
||
clearTimeout(reveal);
|
||
setShowImportProgress(false);
|
||
setImportProgress(null);
|
||
setRefreshKey((k) => k + 1);
|
||
}
|
||
}
|
||
|
||
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>
|
||
))
|
||
);
|
||
|
||
// Under the spares filter, how many copies are free — only worth saying for
|
||
// a card some deck uses, since otherwise it's every copy shown.
|
||
const spareNote = (c: LibraryCard) =>
|
||
criteria.sparesOnly &&
|
||
c.quantity_assigned > 0 && (
|
||
<span className="muted" title="Copies no deck is using">
|
||
{c.quantity_owned - c.quantity_assigned} spare
|
||
</span>
|
||
);
|
||
|
||
// Lets the click preview track which printings the copies are.
|
||
const preview: PreviewContext = { onChanged: () => setRefreshKey((k) => k + 1) };
|
||
|
||
function libTable(cards: LibraryCard[]) {
|
||
return (
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th></th>
|
||
<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 className="cell-check">
|
||
<input
|
||
type="checkbox"
|
||
checked={selected.has(c.oracle_id)}
|
||
onChange={() => toggleOne(c.oracle_id)}
|
||
/>
|
||
</td>
|
||
<td className="cell-thumb">
|
||
<CardThumb card={c} preview={preview} />
|
||
</td>
|
||
<td>
|
||
<ColorPips mask={c.color_identity} />
|
||
</td>
|
||
<td className="card-name">{c.name}</td>
|
||
<td className="card-type">{c.type_line}</td>
|
||
<td data-label="MV">{c.mana_value}</td>
|
||
<td
|
||
data-label="Owned"
|
||
className={c.quantity_assigned > c.quantity_owned ? "over-assigned" : ""}
|
||
>
|
||
<span className="row" style={{ gap: 8 }}>
|
||
<QtyStepper
|
||
value={c.quantity_owned}
|
||
onDecrement={() => adjust(c.oracle_id, -1)}
|
||
onIncrement={() => adjust(c.oracle_id, 1)}
|
||
warn={c.quantity_assigned > c.quantity_owned}
|
||
pending={pendingRemoval.has(c.oracle_id)}
|
||
/>
|
||
{c.quantity_assigned > c.quantity_owned && (
|
||
<span title="Assigned to more decks than you own">
|
||
⚠ {c.quantity_assigned} assigned
|
||
</span>
|
||
)}
|
||
{spareNote(c)}
|
||
</span>
|
||
</td>
|
||
<td data-label="Decks / Location">{deckOrLocation(c)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
);
|
||
}
|
||
|
||
function libTiles(cards: LibraryCard[]) {
|
||
return (
|
||
<div {...tiles.gridProps}>
|
||
{cards.map((c) => (
|
||
<CardTile
|
||
key={c.oracle_id}
|
||
card={c}
|
||
preview={preview}
|
||
selected={selected.has(c.oracle_id)}
|
||
cornerLeft={
|
||
<span className="tile-select">
|
||
<input
|
||
type="checkbox"
|
||
checked={selected.has(c.oracle_id)}
|
||
onChange={() => toggleOne(c.oracle_id)}
|
||
/>
|
||
</span>
|
||
}
|
||
corner={
|
||
<QtyStepper
|
||
overlay
|
||
value={c.quantity_owned}
|
||
onDecrement={() => adjust(c.oracle_id, -1)}
|
||
onIncrement={() => adjust(c.oracle_id, 1)}
|
||
warn={c.quantity_assigned > c.quantity_owned}
|
||
pending={pendingRemoval.has(c.oracle_id)}
|
||
/>
|
||
}
|
||
footer={
|
||
<>
|
||
{deckOrLocation(c)}
|
||
{spareNote(c)}
|
||
</>
|
||
}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const groups = groupCards(sortCards(rows, sortBy, sortDir), groupBy);
|
||
|
||
return (
|
||
<div>
|
||
<div className="page-header">
|
||
<h2 className="page-header-title">
|
||
Library <span className="badge">{rows.length}</span>
|
||
</h2>
|
||
<div className="page-header-actions">
|
||
<button
|
||
onClick={() => setImportIntroOpen(true)}
|
||
disabled={importProgress !== null}
|
||
>
|
||
Import cards…
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{selected.size > 0 && (
|
||
<div className="bulk-bar">
|
||
<span className="bulk-bar-count">{selected.size} selected</span>
|
||
<div className="bulk-bar-actions">
|
||
<span className="row" style={{ gap: 6 }}>
|
||
<select
|
||
value={bulkDeckId}
|
||
onChange={(e) => setBulkDeckId(e.target.value === "" ? "" : Number(e.target.value))}
|
||
disabled={bulkBusy}
|
||
>
|
||
<option value="">Add to deck…</option>
|
||
{decks.map((d) => (
|
||
<option key={d.id} value={d.id}>
|
||
{d.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<button
|
||
onClick={() => bulkAddToDeck("one")}
|
||
disabled={bulkBusy || bulkDeckId === ""}
|
||
title="Set each selected card to 1 copy in the deck"
|
||
>
|
||
Add 1
|
||
</button>
|
||
<button
|
||
onClick={() => bulkAddToDeck("all")}
|
||
disabled={bulkBusy || bulkDeckId === ""}
|
||
title="Set each selected card to every copy you own"
|
||
>
|
||
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…
|
||
</button>
|
||
<span className="row" style={{ gap: 4 }}>
|
||
<button
|
||
onClick={() => bulkAdjust(-bulkDelta)}
|
||
disabled={bulkBusy}
|
||
title="Remove copies from each selected card"
|
||
>
|
||
−
|
||
</button>
|
||
<input
|
||
className="bulk-bar-delta"
|
||
type="number"
|
||
min={1}
|
||
value={bulkDelta}
|
||
onChange={(e) => setBulkDelta(Math.max(1, Number(e.target.value) || 1))}
|
||
/>
|
||
<button
|
||
onClick={() => bulkAdjust(bulkDelta)}
|
||
disabled={bulkBusy}
|
||
title="Add copies to each selected card"
|
||
>
|
||
+
|
||
</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}>
|
||
Clear
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{locationDialogOpen && (
|
||
<PromptDialog
|
||
title="Set location"
|
||
label={`Location for ${selected.size} card${selected.size === 1 ? "" : "s"}`}
|
||
defaultValue=""
|
||
confirmLabel="Apply"
|
||
onConfirm={bulkSetLocation}
|
||
onCancel={() => setLocationDialogOpen(false)}
|
||
/>
|
||
)}
|
||
|
||
{importIntroOpen && (
|
||
<DialogFrame title="Import cards" onCancel={() => setImportIntroOpen(false)} wide>
|
||
<p className="dialog-message">
|
||
Adds the cards in a file to your library. Nothing you already own is replaced or
|
||
removed. You can import:
|
||
</p>
|
||
<ul className="dialog-list">
|
||
<li>
|
||
<strong>A card list</strong> (.txt), one card per line, like <code>4 Lightning Bolt</code>.
|
||
A line can name a printing, like <code>1 Sol Ring (2X2) 315 *F*</code>, where{" "}
|
||
<code>*F*</code> is foil and <code>*E*</code> etched. Library export, Moxfield and
|
||
Arena all write this format.
|
||
</li>
|
||
<li>
|
||
<strong>A ManaBox export</strong> of a collection or binder, either the .csv or the
|
||
.txt. The CSV records each copy's exact printing and finish, so it's the better
|
||
choice. ManaBox's condition, language and price fields aren't kept.
|
||
</li>
|
||
</ul>
|
||
<p className="dialog-message">
|
||
After you choose a file, you can file its cards under a location, like the binder they
|
||
came from. Any cards that can't be matched are listed afterwards.
|
||
</p>
|
||
<div className="dialog-actions">
|
||
<button onClick={() => setImportIntroOpen(false)}>Cancel</button>
|
||
<button className="primary" onClick={pickImportFile} autoFocus>
|
||
Choose file…
|
||
</button>
|
||
</div>
|
||
</DialogFrame>
|
||
)}
|
||
|
||
{importPath && (
|
||
<ImportLocationDialog
|
||
path={importPath}
|
||
onImport={(location) => runImport(importPath, location)}
|
||
onCancel={() => setImportPath(null)}
|
||
/>
|
||
)}
|
||
|
||
{importProgress && showImportProgress && (
|
||
<ProgressDialog
|
||
title="Importing cards…"
|
||
done={importProgress.done}
|
||
total={importProgress.total}
|
||
unit="lines"
|
||
/>
|
||
)}
|
||
|
||
{importError && (
|
||
<InfoDialog
|
||
title="Couldn't import that file"
|
||
message={importError}
|
||
onClose={() => setImportError(null)}
|
||
/>
|
||
)}
|
||
|
||
{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="panel workspace">
|
||
<CardFilterPanel state={filterState} facets={facets} decks={decks} hideNotOwned />
|
||
|
||
<div className="workspace-bar">
|
||
<label className="workspace-bar-check" title="Select all filtered cards">
|
||
<input
|
||
ref={selectAllRef}
|
||
type="checkbox"
|
||
checked={rows.length > 0 && selected.size === rows.length}
|
||
onChange={toggleSelectAll}
|
||
disabled={rows.length === 0}
|
||
/>
|
||
Select all
|
||
</label>
|
||
<span className="workspace-bar-count">
|
||
{rows.length} card{rows.length === 1 ? "" : "s"}
|
||
</span>
|
||
<span className="spacer" />
|
||
<GroupBySelect value={groupBy} onChange={setGroupBy} />
|
||
<SortBySelect
|
||
value={sortBy}
|
||
dir={sortDir}
|
||
onChangeValue={setSortBy}
|
||
onToggleDir={() => setSortDir(sortDir === "asc" ? "desc" : "asc")}
|
||
/>
|
||
<ViewToggle mode={view} onChange={setView} />
|
||
{view === "tiles" && <TileColumnsControl tiles={tiles} />}
|
||
</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 className="group-header">
|
||
{g.label} <span className="badge">{g.cards.length}</span>
|
||
</h3>
|
||
)}
|
||
{view === "list" ? libTable(g.cards) : libTiles(g.cards)}
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** A picked file's name without its folder or extension — a ManaBox export
|
||
* is named after its binder ("Green Bulk.csv"). Copes with Android content
|
||
* URIs, whose last segment is a percent-encoded "primary:Download/x.csv". */
|
||
function fileStem(path: string): string {
|
||
let name = path;
|
||
try {
|
||
name = decodeURIComponent(path);
|
||
} catch {
|
||
// Not percent-encoded after all; use it as it is.
|
||
}
|
||
name = name.split(/[/\\:]/).pop() ?? "";
|
||
return name.replace(/\.[^.]*$/, "").trim();
|
||
}
|
||
|
||
/** Asks where an import's cards are kept, pre-filled with the file's name.
|
||
* Blank imports them without touching any card's location. */
|
||
function ImportLocationDialog({
|
||
path,
|
||
onImport,
|
||
onCancel,
|
||
}: {
|
||
path: string;
|
||
onImport: (location: string) => void;
|
||
onCancel: () => void;
|
||
}) {
|
||
const [value, setValue] = useState(() => fileStem(path));
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
useEffect(() => inputRef.current?.select(), []);
|
||
const submit = () => onImport(value.trim());
|
||
|
||
return (
|
||
<DialogFrame title="Where are these cards?" onCancel={onCancel}>
|
||
<label className="dialog-field">
|
||
<span className="muted">Location</span>
|
||
<input
|
||
ref={inputRef}
|
||
autoFocus
|
||
value={value}
|
||
placeholder="e.g. Box 1"
|
||
onChange={(e) => setValue(e.target.value)}
|
||
onKeyDown={(e) => e.key === "Enter" && submit()}
|
||
/>
|
||
</label>
|
||
<p className="dialog-message">
|
||
Every imported card is filed under this location, replacing any it already had. Leave it
|
||
blank to keep cards' current locations.
|
||
</p>
|
||
<div className="dialog-actions">
|
||
<button onClick={onCancel}>Cancel</button>
|
||
<button className="primary" onClick={submit}>
|
||
Import
|
||
</button>
|
||
</div>
|
||
</DialogFrame>
|
||
);
|
||
}
|