Deck builder: sorting and a Price column
Adds the Library's Sort by control (plus a Quantity in deck option) to the deck builder, applied within each section and group. The card table gets an optional Price column showing the per-copy price, with the total for the deck's copies in its tooltip. The toolbar now wraps on narrow screens. Refs #6 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1750,7 +1750,7 @@ export function GroupBySelect({
|
||||
|
||||
// --- Sorting ----------------------------------------------------------
|
||||
|
||||
export type SortMode = "name" | "mv" | "color" | "type" | "qty" | "price";
|
||||
export type SortMode = "name" | "mv" | "color" | "type" | "deck-qty" | "qty" | "price";
|
||||
|
||||
export const SORT_OPTIONS: { value: SortMode; label: string }[] = [
|
||||
{ value: "name", label: "Name" },
|
||||
@@ -1761,13 +1761,20 @@ export const SORT_OPTIONS: { value: SortMode; label: string }[] = [
|
||||
{ value: "price", label: "Price" },
|
||||
];
|
||||
|
||||
/** The deck builder's: how many copies the deck runs matters there too. */
|
||||
export const DECK_SORT_OPTIONS: { value: SortMode; label: string }[] = [
|
||||
...SORT_OPTIONS.slice(0, 4),
|
||||
{ value: "deck-qty", label: "Quantity in deck" },
|
||||
...SORT_OPTIONS.slice(4),
|
||||
];
|
||||
|
||||
export type SortDir = "asc" | "desc";
|
||||
|
||||
/** Sort cards by the chosen attribute; ties always break by name so the
|
||||
* order stays stable and predictable regardless of `mode`. Grouping (see
|
||||
* [`groupCards`]) determines section order independently — sort this array
|
||||
* first, then group it, and each group keeps this order internally. */
|
||||
export function sortCards<T extends CardRef & { quantity_owned: number }>(
|
||||
export function sortCards<T extends CardRef & { quantity_owned: number; quantity?: number }>(
|
||||
cards: T[],
|
||||
mode: SortMode,
|
||||
dir: SortDir,
|
||||
@@ -1784,9 +1791,11 @@ export function sortCards<T extends CardRef & { quantity_owned: number }>(
|
||||
: mode === "type"
|
||||
? typeRank(primaryType(a.type_line)) - typeRank(primaryType(b.type_line)) ||
|
||||
a.name.localeCompare(b.name)
|
||||
: mode === "qty"
|
||||
? a.quantity_owned - b.quantity_owned || a.name.localeCompare(b.name)
|
||||
: a.price_usd - b.price_usd || a.name.localeCompare(b.name);
|
||||
: mode === "deck-qty"
|
||||
? (a.quantity ?? 0) - (b.quantity ?? 0) || a.name.localeCompare(b.name)
|
||||
: mode === "qty"
|
||||
? a.quantity_owned - b.quantity_owned || a.name.localeCompare(b.name)
|
||||
: a.price_usd - b.price_usd || a.name.localeCompare(b.name);
|
||||
return dir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
return sorted;
|
||||
@@ -1798,18 +1807,20 @@ export function SortBySelect({
|
||||
dir,
|
||||
onChangeValue,
|
||||
onToggleDir,
|
||||
options = SORT_OPTIONS,
|
||||
}: {
|
||||
value: SortMode;
|
||||
dir: SortDir;
|
||||
onChangeValue: (m: SortMode) => void;
|
||||
onToggleDir: () => void;
|
||||
options?: { value: SortMode; label: string }[];
|
||||
}) {
|
||||
return (
|
||||
<span className="row" style={{ gap: 4 }}>
|
||||
<label className="group-by">
|
||||
<span className="muted">Sort by</span>
|
||||
<select value={value} onChange={(e) => onChangeValue(e.target.value as SortMode)}>
|
||||
{SORT_OPTIONS.map((o) => (
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
|
||||
@@ -1704,6 +1704,10 @@ tbody tr:hover {
|
||||
}
|
||||
}
|
||||
|
||||
.cell-price {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.over-assigned {
|
||||
color: var(--warn);
|
||||
font-weight: 600;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ColumnDef,
|
||||
ColumnsMenu,
|
||||
ConfirmDialog,
|
||||
DECK_SORT_OPTIONS,
|
||||
DialogFrame,
|
||||
GroupBySelect,
|
||||
GroupMode,
|
||||
@@ -32,10 +33,14 @@ import {
|
||||
PromptDialog,
|
||||
QtyStepper,
|
||||
SampleHandModal,
|
||||
SortBySelect,
|
||||
SortDir,
|
||||
SortMode,
|
||||
StatTile,
|
||||
TileColumnsControl,
|
||||
ViewToggle,
|
||||
groupCards,
|
||||
sortCards,
|
||||
useClickPreview,
|
||||
useHoverPreview,
|
||||
useColumns,
|
||||
@@ -73,7 +78,7 @@ function boardToggleProps(e: DeckEntry) {
|
||||
: { icon: Icons.helpCircle, label: "Move to maybeboard" };
|
||||
}
|
||||
|
||||
type DeckColumn = "image" | "colors" | "type" | "qty" | "owned" | "where" | "actions";
|
||||
type DeckColumn = "image" | "colors" | "type" | "qty" | "owned" | "price" | "where" | "actions";
|
||||
|
||||
// The List view's optional columns; the checkbox and name always show.
|
||||
const DECK_COLUMNS: ColumnDef<DeckColumn>[] = [
|
||||
@@ -82,6 +87,7 @@ const DECK_COLUMNS: ColumnDef<DeckColumn>[] = [
|
||||
{ id: "type", label: "Type" },
|
||||
{ id: "qty", label: "In deck" },
|
||||
{ id: "owned", label: "Owned" },
|
||||
{ id: "price", label: "Price" },
|
||||
{ id: "where", label: "Where" },
|
||||
{ id: "actions", label: "Actions" },
|
||||
];
|
||||
@@ -342,6 +348,8 @@ function DeckBuilder({
|
||||
const tiles = useTileColumns();
|
||||
const columns = useColumns("deck-columns", DECK_COLUMNS);
|
||||
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("deck-group", "none");
|
||||
const [sortBy, setSortBy] = usePersistedState<SortMode>("deck-sort", "name");
|
||||
const [sortDir, setSortDir] = usePersistedState<SortDir>("deck-sort-dir", "asc");
|
||||
const [combineSections, setCombineSections] = usePersistedState("deck-combine-sections", false);
|
||||
const [filterQuery, setFilterQuery] = useState("");
|
||||
|
||||
@@ -994,6 +1002,7 @@ function DeckBuilder({
|
||||
{columns.shown("type") && <th>Type</th>}
|
||||
{columns.shown("qty") && <th>In deck</th>}
|
||||
{columns.shown("owned") && <th>Owned</th>}
|
||||
{columns.shown("price") && <th>Price</th>}
|
||||
{columns.shown("where") && <th>Where</th>}
|
||||
{columns.shown("actions") && <th></th>}
|
||||
</tr>
|
||||
@@ -1040,6 +1049,19 @@ function DeckBuilder({
|
||||
</td>
|
||||
)}
|
||||
{columns.shown("owned") && <td data-label="Owned">{e.quantity_owned}</td>}
|
||||
{columns.shown("price") && (
|
||||
<td
|
||||
data-label="Price"
|
||||
className="cell-price"
|
||||
title={
|
||||
e.price_usd > 0 && e.quantity > 1
|
||||
? `${formatCost(e.price_usd * e.quantity)} for ${e.quantity} copies`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{e.price_usd > 0 ? `$${e.price_usd.toFixed(2)}` : "—"}
|
||||
</td>
|
||||
)}
|
||||
{columns.shown("where") && <td data-label="Where">{whereabouts(e)}</td>}
|
||||
{columns.shown("actions") && (
|
||||
<td>
|
||||
@@ -1060,7 +1082,7 @@ function DeckBuilder({
|
||||
|
||||
function cardsGroup(entries: DeckEntry[]) {
|
||||
if (entries.length === 0) return <p className="hint">None yet.</p>;
|
||||
return groupCards(entries, groupBy).map((g) => (
|
||||
return groupCards(sortCards(entries, sortBy, sortDir), groupBy).map((g) => (
|
||||
<div key={g.label || "all"} style={{ marginBottom: g.label ? 14 : 0 }}>
|
||||
{g.label && (
|
||||
<h4 className="group-header">
|
||||
@@ -1277,7 +1299,7 @@ function DeckBuilder({
|
||||
|
||||
<hr className="deck-section-divider" />
|
||||
|
||||
<div className="row" style={{ gap: 16 }}>
|
||||
<div className="row" style={{ gap: 16, flexWrap: "wrap" }}>
|
||||
<label className="row" style={{ gap: 6, cursor: "pointer" }} title="Select all cards">
|
||||
<input
|
||||
ref={selectAllRef}
|
||||
@@ -1302,6 +1324,13 @@ function DeckBuilder({
|
||||
</label>
|
||||
<span className="spacer" />
|
||||
<GroupBySelect value={groupBy} onChange={setGroupBy} />
|
||||
<SortBySelect
|
||||
value={sortBy}
|
||||
dir={sortDir}
|
||||
onChangeValue={setSortBy}
|
||||
onToggleDir={() => setSortDir(sortDir === "asc" ? "desc" : "asc")}
|
||||
options={DECK_SORT_OPTIONS}
|
||||
/>
|
||||
<ViewToggle mode={view} onChange={setView} />
|
||||
{view === "list" && <ColumnsMenu columns={columns} />}
|
||||
{view === "tiles" && <TileColumnsControl tiles={tiles} />}
|
||||
|
||||
Reference in New Issue
Block a user