List views: choose which columns to show
A Columns menu next to the List/Tiles toggle hides or shows optional columns in the Library, Card Search, deck builder and Decks list row views. Choices are saved per view on each device. The selection checkbox and name always show. Closes #8 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1994,6 +1994,97 @@ export function ViewToggle({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- List columns ----------------------------------------------------------
|
||||||
|
|
||||||
|
/** An optional column of a list view — one the user can hide. */
|
||||||
|
export interface ColumnDef<K extends string> {
|
||||||
|
id: K;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Columns<K extends string> {
|
||||||
|
defs: ColumnDef<K>[];
|
||||||
|
shown: (id: K) => boolean;
|
||||||
|
toggle: (id: K) => void;
|
||||||
|
reset: () => void;
|
||||||
|
/** Whether any column is hidden. */
|
||||||
|
customized: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which optional columns a list view shows, persisted per view on this
|
||||||
|
* device. Stores the *hidden* ids, so a column added later shows by default. */
|
||||||
|
export function useColumns<K extends string>(key: string, defs: ColumnDef<K>[]): Columns<K> {
|
||||||
|
const [stored, setStored] = usePersistedState<K[]>(key, []);
|
||||||
|
// Drop ids for columns that no longer exist.
|
||||||
|
const hidden = stored.filter((id) => defs.some((d) => d.id === id));
|
||||||
|
return {
|
||||||
|
defs,
|
||||||
|
shown: (id) => !hidden.includes(id),
|
||||||
|
toggle: (id) => setStored(hidden.includes(id) ? hidden.filter((h) => h !== id) : [...hidden, id]),
|
||||||
|
reset: () => setStored([]),
|
||||||
|
customized: hidden.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Columns" button opening a checklist of a list view's optional columns.
|
||||||
|
* Stays open while toggling, so several can change in one go. */
|
||||||
|
export function ColumnsMenu<K extends string>({ columns }: { columns: Columns<K> }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
||||||
|
document.addEventListener("mousedown", onDown);
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onDown);
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="columns-menu" ref={ref}>
|
||||||
|
<button
|
||||||
|
className={columns.customized ? "columns-menu-btn customized" : "columns-menu-btn"}
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
title="Choose which columns to show"
|
||||||
|
>
|
||||||
|
Columns ▾
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="library-menu columns-menu-list" role="menu">
|
||||||
|
{columns.defs.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d.id}
|
||||||
|
role="menuitemcheckbox"
|
||||||
|
aria-checked={columns.shown(d.id)}
|
||||||
|
onClick={() => columns.toggle(d.id)}
|
||||||
|
>
|
||||||
|
<span className="library-menu-check">{columns.shown(d.id) ? "✓" : ""}</span>
|
||||||
|
{d.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{columns.customized && (
|
||||||
|
<>
|
||||||
|
<hr />
|
||||||
|
<button role="menuitem" onClick={columns.reset}>
|
||||||
|
<span className="library-menu-check" />
|
||||||
|
Show all
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Tiles per row --------------------------------------------------------
|
// --- Tiles per row --------------------------------------------------------
|
||||||
|
|
||||||
// Mirror `.tile-grid`'s `repeat(auto-fill, minmax(190px, 1fr))` and `gap` in
|
// Mirror `.tile-grid`'s `repeat(auto-fill, minmax(190px, 1fr))` and `gap` in
|
||||||
|
|||||||
@@ -2673,6 +2673,31 @@ button.pull-action {
|
|||||||
.tile-columns .tile-columns-count:disabled {
|
.tile-columns .tile-columns-count:disabled {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
/* List-view column picker (see `ColumnsMenu`): its checklist opens under the
|
||||||
|
button, right-aligned since it sits at the toolbar's right end. */
|
||||||
|
.columns-menu {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.columns-menu-btn {
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
}
|
||||||
|
.columns-menu-btn.customized {
|
||||||
|
border-color: var(--accent-glow);
|
||||||
|
}
|
||||||
|
.columns-menu .columns-menu-list {
|
||||||
|
left: auto;
|
||||||
|
right: 0;
|
||||||
|
min-width: 190px;
|
||||||
|
}
|
||||||
|
/* On a phone the toolbar wraps and the button can land at the left edge. */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.columns-menu .columns-menu-list {
|
||||||
|
left: 0;
|
||||||
|
right: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Deck list (the Decks tab's front page)
|
Deck list (the Decks tab's front page)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { api, Deck, LibraryCard, LibraryFacets, PreviewContext } from "../api";
|
|||||||
import {
|
import {
|
||||||
CardThumb,
|
CardThumb,
|
||||||
CardTile,
|
CardTile,
|
||||||
|
ColumnDef,
|
||||||
|
ColumnsMenu,
|
||||||
DeckChip,
|
DeckChip,
|
||||||
ColorPips,
|
ColorPips,
|
||||||
GroupBySelect,
|
GroupBySelect,
|
||||||
@@ -18,6 +20,7 @@ import {
|
|||||||
groupCards,
|
groupCards,
|
||||||
sortCards,
|
sortCards,
|
||||||
usePersistedState,
|
usePersistedState,
|
||||||
|
useColumns,
|
||||||
useTileColumns,
|
useTileColumns,
|
||||||
useViewMode,
|
useViewMode,
|
||||||
} from "../components";
|
} from "../components";
|
||||||
@@ -27,6 +30,19 @@ import { CardFilterPanel, NO_FACETS, useCardFilter } from "../cardFilter";
|
|||||||
// db, not just what's owned, so an unbounded/loose filter needs a limit.
|
// db, not just what's owned, so an unbounded/loose filter needs a limit.
|
||||||
const CAP = 300;
|
const CAP = 300;
|
||||||
|
|
||||||
|
type SearchColumn = "image" | "colors" | "type" | "mv" | "owned" | "decks" | "add";
|
||||||
|
|
||||||
|
// The List view's optional columns; the checkbox and name always show.
|
||||||
|
const SEARCH_COLUMNS: ColumnDef<SearchColumn>[] = [
|
||||||
|
{ id: "image", label: "Image" },
|
||||||
|
{ id: "colors", label: "Colors" },
|
||||||
|
{ id: "type", label: "Type" },
|
||||||
|
{ id: "mv", label: "MV" },
|
||||||
|
{ id: "owned", label: "Owned" },
|
||||||
|
{ id: "decks", label: "In decks" },
|
||||||
|
{ id: "add", label: "Add to deck" },
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Browse the whole downloaded card universe (every card in reference.db,
|
* Browse the whole downloaded card universe (every card in reference.db,
|
||||||
* owned or not) — the Library tab's sibling, reusing its filter/group/sort/
|
* owned or not) — the Library tab's sibling, reusing its filter/group/sort/
|
||||||
@@ -58,6 +74,7 @@ export function CardSearch({
|
|||||||
|
|
||||||
const [view, setView] = useViewMode("cardsearch-view");
|
const [view, setView] = useViewMode("cardsearch-view");
|
||||||
const tiles = useTileColumns();
|
const tiles = useTileColumns();
|
||||||
|
const columns = useColumns("cardsearch-columns", SEARCH_COLUMNS);
|
||||||
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("cardsearch-group", "none");
|
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("cardsearch-group", "none");
|
||||||
const [sortBy, setSortBy] = usePersistedState<SortMode>("cardsearch-sort", "name");
|
const [sortBy, setSortBy] = usePersistedState<SortMode>("cardsearch-sort", "name");
|
||||||
const [sortDir, setSortDir] = usePersistedState<SortDir>("cardsearch-sort-dir", "asc");
|
const [sortDir, setSortDir] = usePersistedState<SortDir>("cardsearch-sort-dir", "asc");
|
||||||
@@ -216,14 +233,14 @@ export function CardSearch({
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th></th>
|
<th></th>
|
||||||
<th></th>
|
{columns.shown("image") && <th></th>}
|
||||||
<th>Colors</th>
|
{columns.shown("colors") && <th>Colors</th>}
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Type</th>
|
{columns.shown("type") && <th>Type</th>}
|
||||||
<th>MV</th>
|
{columns.shown("mv") && <th>MV</th>}
|
||||||
<th>Owned</th>
|
{columns.shown("owned") && <th>Owned</th>}
|
||||||
<th>In decks</th>
|
{columns.shown("decks") && <th>In decks</th>}
|
||||||
<th></th>
|
{columns.shown("add") && <th></th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -236,36 +253,46 @@ export function CardSearch({
|
|||||||
onChange={() => toggleOne(c.oracle_id)}
|
onChange={() => toggleOne(c.oracle_id)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="cell-thumb">
|
{columns.shown("image") && (
|
||||||
<CardThumb card={c} preview={preview} />
|
<td className="cell-thumb">
|
||||||
</td>
|
<CardThumb card={c} preview={preview} />
|
||||||
<td>
|
</td>
|
||||||
<ColorPips mask={c.color_identity} />
|
)}
|
||||||
</td>
|
{columns.shown("colors") && (
|
||||||
|
<td>
|
||||||
|
<ColorPips mask={c.color_identity} />
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
<td className="card-name">{c.name}</td>
|
<td className="card-name">{c.name}</td>
|
||||||
<td className="card-type">{c.type_line}</td>
|
{columns.shown("type") && <td className="card-type">{c.type_line}</td>}
|
||||||
<td data-label="MV">{c.mana_value}</td>
|
{columns.shown("mv") && <td data-label="MV">{c.mana_value}</td>}
|
||||||
<td data-label="Owned">
|
{columns.shown("owned") && (
|
||||||
<QtyStepper
|
<td data-label="Owned">
|
||||||
value={c.quantity_owned}
|
<QtyStepper
|
||||||
onDecrement={() => adjust(c.oracle_id, -1)}
|
value={c.quantity_owned}
|
||||||
onIncrement={() => adjust(c.oracle_id, 1)}
|
onDecrement={() => adjust(c.oracle_id, -1)}
|
||||||
/>
|
onIncrement={() => adjust(c.oracle_id, 1)}
|
||||||
</td>
|
/>
|
||||||
<td data-label="In decks">
|
</td>
|
||||||
{c.decks.map((d) => (
|
)}
|
||||||
<DeckChip key={d} name={d} idea={!c.physical_decks.includes(d)} />
|
{columns.shown("decks") && (
|
||||||
))}
|
<td data-label="In decks">
|
||||||
</td>
|
{c.decks.map((d) => (
|
||||||
<td>
|
<DeckChip key={d} name={d} idea={!c.physical_decks.includes(d)} />
|
||||||
<button
|
))}
|
||||||
onClick={() => addToDeck(c)}
|
</td>
|
||||||
disabled={deckId === "" || busy.has(c.oracle_id)}
|
)}
|
||||||
title={deckId === "" ? "Choose a deck above first" : "Add 1 copy to the selected deck"}
|
{columns.shown("add") && (
|
||||||
>
|
<td>
|
||||||
Add to deck
|
<button
|
||||||
</button>
|
onClick={() => addToDeck(c)}
|
||||||
</td>
|
disabled={deckId === "" || busy.has(c.oracle_id)}
|
||||||
|
title={deckId === "" ? "Choose a deck above first" : "Add 1 copy to the selected deck"}
|
||||||
|
>
|
||||||
|
Add to deck
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -425,6 +452,7 @@ export function CardSearch({
|
|||||||
onToggleDir={() => setSortDir(sortDir === "asc" ? "desc" : "asc")}
|
onToggleDir={() => setSortDir(sortDir === "asc" ? "desc" : "asc")}
|
||||||
/>
|
/>
|
||||||
<ViewToggle mode={view} onChange={setView} />
|
<ViewToggle mode={view} onChange={setView} />
|
||||||
|
{view === "list" && <ColumnsMenu columns={columns} />}
|
||||||
{view === "tiles" && <TileColumnsControl tiles={tiles} />}
|
{view === "tiles" && <TileColumnsControl tiles={tiles} />}
|
||||||
<label
|
<label
|
||||||
className="row"
|
className="row"
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { api, Deck, DECK_STATUSES, DeckEntry, DeckStatus } from "../api";
|
import { api, Deck, DECK_STATUSES, DeckEntry, DeckStatus } from "../api";
|
||||||
import { artCrop, ColorPips, fold, IconButton, Icons, usePersistedState, useViewMode, ViewToggle } from "../components";
|
import {
|
||||||
|
artCrop,
|
||||||
|
ColorPips,
|
||||||
|
ColumnDef,
|
||||||
|
Columns,
|
||||||
|
ColumnsMenu,
|
||||||
|
fold,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
useColumns,
|
||||||
|
usePersistedState,
|
||||||
|
useViewMode,
|
||||||
|
ViewToggle,
|
||||||
|
} from "../components";
|
||||||
import { DeckSummary, formatCost, summarizeDeck } from "../deckAnalysis";
|
import { DeckSummary, formatCost, summarizeDeck } from "../deckAnalysis";
|
||||||
|
|
||||||
type SortMode = "recent" | "name" | "format" | "size" | "cost" | "owned";
|
type SortMode = "recent" | "name" | "format" | "size" | "cost" | "owned";
|
||||||
@@ -14,6 +27,20 @@ const SORTS: { id: SortMode; label: string }[] = [
|
|||||||
{ id: "owned", label: "Owned %" },
|
{ id: "owned", label: "Owned %" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type DeckListColumn = "colors" | "subtitle" | "status" | "format" | "size" | "owned" | "cost" | "legality";
|
||||||
|
|
||||||
|
// The row view's optional columns; the art, name and ⋯ menu always show.
|
||||||
|
const DECK_LIST_COLUMNS: ColumnDef<DeckListColumn>[] = [
|
||||||
|
{ id: "colors", label: "Colors" },
|
||||||
|
{ id: "subtitle", label: "Commander" },
|
||||||
|
{ id: "status", label: "Status" },
|
||||||
|
{ id: "format", label: "Format" },
|
||||||
|
{ id: "size", label: "Size" },
|
||||||
|
{ id: "owned", label: "Owned" },
|
||||||
|
{ id: "cost", label: "Cost" },
|
||||||
|
{ id: "legality", label: "Legality" },
|
||||||
|
];
|
||||||
|
|
||||||
interface DeckActions {
|
interface DeckActions {
|
||||||
onOpen: (id: number) => void;
|
onOpen: (id: number) => void;
|
||||||
onRename: (deck: Deck) => void;
|
onRename: (deck: Deck) => void;
|
||||||
@@ -239,7 +266,15 @@ function DeckTile({ row, actions }: { row: Row; actions: DeckActions }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DeckRow({ row, actions }: { row: Row; actions: DeckActions }) {
|
function DeckRow({
|
||||||
|
row,
|
||||||
|
actions,
|
||||||
|
columns,
|
||||||
|
}: {
|
||||||
|
row: Row;
|
||||||
|
actions: DeckActions;
|
||||||
|
columns: Columns<DeckListColumn>;
|
||||||
|
}) {
|
||||||
const { deck, summary: s } = row;
|
const { deck, summary: s } = row;
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
return (
|
return (
|
||||||
@@ -248,20 +283,22 @@ function DeckRow({ row, actions }: { row: Row; actions: DeckActions }) {
|
|||||||
<div className="deck-row-main">
|
<div className="deck-row-main">
|
||||||
<div className="deck-row-name">
|
<div className="deck-row-name">
|
||||||
<span className="card-name">{deck.name}</span>
|
<span className="card-name">{deck.name}</span>
|
||||||
{s.size > 0 && <ColorPips mask={s.colors} />}
|
{columns.shown("colors") && s.size > 0 && <ColorPips mask={s.colors} />}
|
||||||
</div>
|
</div>
|
||||||
<div className="deck-row-sub">{subtitle(row)}</div>
|
{columns.shown("subtitle") && <div className="deck-row-sub">{subtitle(row)}</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="deck-row-stats">
|
<div className="deck-row-stats">
|
||||||
<StatusChip row={row} />
|
{columns.shown("status") && <StatusChip row={row} />}
|
||||||
{deck.format && <span className="deck-chip">{deck.format}</span>}
|
{columns.shown("format") && deck.format && <span className="deck-chip">{deck.format}</span>}
|
||||||
<DeckSize summary={s} />
|
{columns.shown("size") && <DeckSize summary={s} />}
|
||||||
<span className="deck-row-owned" title={ownedLabel(s)}>
|
{columns.shown("owned") && (
|
||||||
<OwnedBar summary={s} />
|
<span className="deck-row-owned" title={ownedLabel(s)}>
|
||||||
<span>{s.size ? `${Math.floor(s.owned * 100)}%` : "—"}</span>
|
<OwnedBar summary={s} />
|
||||||
</span>
|
<span>{s.size ? `${Math.floor(s.owned * 100)}%` : "—"}</span>
|
||||||
<span className="deck-row-cost">{formatCost(s.cost)}</span>
|
</span>
|
||||||
<LegalityBadge summary={s} compact />
|
)}
|
||||||
|
{columns.shown("cost") && <span className="deck-row-cost">{formatCost(s.cost)}</span>}
|
||||||
|
{columns.shown("legality") && <LegalityBadge summary={s} compact />}
|
||||||
</div>
|
</div>
|
||||||
<DeckMenu deck={deck} actions={actions} onOpenChange={setMenuOpen} />
|
<DeckMenu deck={deck} actions={actions} onOpenChange={setMenuOpen} />
|
||||||
</div>
|
</div>
|
||||||
@@ -290,6 +327,7 @@ export function DeckList({
|
|||||||
} & DeckActions) {
|
} & DeckActions) {
|
||||||
const [contents, setContents] = useState<Record<number, DeckEntry[]> | null>(null);
|
const [contents, setContents] = useState<Record<number, DeckEntry[]> | null>(null);
|
||||||
const [view, setView] = useViewMode("deck-list-view", "tiles");
|
const [view, setView] = useViewMode("deck-list-view", "tiles");
|
||||||
|
const columns = useColumns("deck-list-columns", DECK_LIST_COLUMNS);
|
||||||
const [sort, setSort] = usePersistedState<SortMode>("deck-list-sort", "recent");
|
const [sort, setSort] = usePersistedState<SortMode>("deck-list-sort", "recent");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [format, setFormat] = useState<string | null>(null);
|
const [format, setFormat] = useState<string | null>(null);
|
||||||
@@ -361,7 +399,7 @@ export function DeckList({
|
|||||||
) : (
|
) : (
|
||||||
<div className="deck-gallery-rows">
|
<div className="deck-gallery-rows">
|
||||||
{list.map((r) => (
|
{list.map((r) => (
|
||||||
<DeckRow key={r.deck.id} row={r} actions={actions} />
|
<DeckRow key={r.deck.id} row={r} actions={actions} columns={columns} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -420,6 +458,7 @@ export function DeckList({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<ViewToggle mode={view} onChange={setView} />
|
<ViewToggle mode={view} onChange={setView} />
|
||||||
|
{view === "list" && <ColumnsMenu columns={columns} />}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
CardTile,
|
CardTile,
|
||||||
ColorDistributionBar,
|
ColorDistributionBar,
|
||||||
ColorPips,
|
ColorPips,
|
||||||
|
ColumnDef,
|
||||||
|
ColumnsMenu,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
DialogFrame,
|
DialogFrame,
|
||||||
GroupBySelect,
|
GroupBySelect,
|
||||||
@@ -36,6 +38,7 @@ import {
|
|||||||
groupCards,
|
groupCards,
|
||||||
useClickPreview,
|
useClickPreview,
|
||||||
useHoverPreview,
|
useHoverPreview,
|
||||||
|
useColumns,
|
||||||
usePersistedState,
|
usePersistedState,
|
||||||
useTileColumns,
|
useTileColumns,
|
||||||
useViewMode,
|
useViewMode,
|
||||||
@@ -70,6 +73,19 @@ function boardToggleProps(e: DeckEntry) {
|
|||||||
: { icon: Icons.helpCircle, label: "Move to maybeboard" };
|
: { icon: Icons.helpCircle, label: "Move to maybeboard" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DeckColumn = "image" | "colors" | "type" | "qty" | "owned" | "where" | "actions";
|
||||||
|
|
||||||
|
// The List view's optional columns; the checkbox and name always show.
|
||||||
|
const DECK_COLUMNS: ColumnDef<DeckColumn>[] = [
|
||||||
|
{ id: "image", label: "Image" },
|
||||||
|
{ id: "colors", label: "Colors" },
|
||||||
|
{ id: "type", label: "Type" },
|
||||||
|
{ id: "qty", label: "In deck" },
|
||||||
|
{ id: "owned", label: "Owned" },
|
||||||
|
{ id: "where", label: "Where" },
|
||||||
|
{ id: "actions", label: "Actions" },
|
||||||
|
];
|
||||||
|
|
||||||
type DeckDialog =
|
type DeckDialog =
|
||||||
| { kind: "create" }
|
| { kind: "create" }
|
||||||
| { kind: "delete"; deck: Deck }
|
| { kind: "delete"; deck: Deck }
|
||||||
@@ -227,6 +243,7 @@ function DeckBuilder({
|
|||||||
const [contents, setContents] = useState<DeckEntry[]>([]);
|
const [contents, setContents] = useState<DeckEntry[]>([]);
|
||||||
const [view, setView] = useViewMode("deck-view");
|
const [view, setView] = useViewMode("deck-view");
|
||||||
const tiles = useTileColumns();
|
const tiles = useTileColumns();
|
||||||
|
const columns = useColumns("deck-columns", DECK_COLUMNS);
|
||||||
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("deck-group", "none");
|
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("deck-group", "none");
|
||||||
const [combineSections, setCombineSections] = usePersistedState("deck-combine-sections", false);
|
const [combineSections, setCombineSections] = usePersistedState("deck-combine-sections", false);
|
||||||
const [filterQuery, setFilterQuery] = useState("");
|
const [filterQuery, setFilterQuery] = useState("");
|
||||||
@@ -811,14 +828,14 @@ function DeckBuilder({
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th></th>
|
<th></th>
|
||||||
<th></th>
|
{columns.shown("image") && <th></th>}
|
||||||
<th>Colors</th>
|
{columns.shown("colors") && <th>Colors</th>}
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Type</th>
|
{columns.shown("type") && <th>Type</th>}
|
||||||
<th>In deck</th>
|
{columns.shown("qty") && <th>In deck</th>}
|
||||||
<th>Owned</th>
|
{columns.shown("owned") && <th>Owned</th>}
|
||||||
<th>Where</th>
|
{columns.shown("where") && <th>Where</th>}
|
||||||
<th></th>
|
{columns.shown("actions") && <th></th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -831,12 +848,16 @@ function DeckBuilder({
|
|||||||
onChange={() => toggleCard(e.oracle_id)}
|
onChange={() => toggleCard(e.oracle_id)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="cell-thumb">
|
{columns.shown("image") && (
|
||||||
<CardThumb card={e} preview={preview} />
|
<td className="cell-thumb">
|
||||||
</td>
|
<CardThumb card={e} preview={preview} />
|
||||||
<td>
|
</td>
|
||||||
<ColorPips mask={e.color_identity} />
|
)}
|
||||||
</td>
|
{columns.shown("colors") && (
|
||||||
|
<td>
|
||||||
|
<ColorPips mask={e.color_identity} />
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
<td className="card-name">
|
<td className="card-name">
|
||||||
{e.is_commander && <span title="Commander">★ </span>}
|
{e.is_commander && <span title="Commander">★ </span>}
|
||||||
{e.name}
|
{e.name}
|
||||||
@@ -846,26 +867,30 @@ function DeckBuilder({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="card-type">{e.type_line}</td>
|
{columns.shown("type") && <td className="card-type">{e.type_line}</td>}
|
||||||
<td data-label="In deck">
|
{columns.shown("qty") && (
|
||||||
<QtyStepper
|
<td data-label="In deck">
|
||||||
value={e.quantity}
|
<QtyStepper
|
||||||
onDecrement={() => setQty(e.oracle_id, e.quantity - 1)}
|
value={e.quantity}
|
||||||
onIncrement={() => setQty(e.oracle_id, e.quantity + 1)}
|
onDecrement={() => setQty(e.oracle_id, e.quantity - 1)}
|
||||||
warn={e.quantity_owned > 0 && e.quantity > e.quantity_owned}
|
onIncrement={() => setQty(e.oracle_id, e.quantity + 1)}
|
||||||
pending={pendingRemoval.has(e.oracle_id)}
|
warn={e.quantity_owned > 0 && e.quantity > e.quantity_owned}
|
||||||
/>
|
pending={pendingRemoval.has(e.oracle_id)}
|
||||||
</td>
|
/>
|
||||||
<td data-label="Owned">{e.quantity_owned}</td>
|
</td>
|
||||||
<td data-label="Where">{whereabouts(e)}</td>
|
)}
|
||||||
<td>
|
{columns.shown("owned") && <td data-label="Owned">{e.quantity_owned}</td>}
|
||||||
<span className="row" style={{ gap: 4 }}>
|
{columns.shown("where") && <td data-label="Where">{whereabouts(e)}</td>}
|
||||||
<IconButton {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
|
{columns.shown("actions") && (
|
||||||
{e.quantity_owned === 0 && (
|
<td>
|
||||||
<IconButton icon={Icons.libraryAdd} label={libraryAddLabel(e)} onClick={() => addToLibrary([e])} />
|
<span className="row" style={{ gap: 4 }}>
|
||||||
)}
|
<IconButton {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
|
||||||
</span>
|
{e.quantity_owned === 0 && (
|
||||||
</td>
|
<IconButton icon={Icons.libraryAdd} label={libraryAddLabel(e)} onClick={() => addToLibrary([e])} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -1124,6 +1149,7 @@ function DeckBuilder({
|
|||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<GroupBySelect value={groupBy} onChange={setGroupBy} />
|
<GroupBySelect value={groupBy} onChange={setGroupBy} />
|
||||||
<ViewToggle mode={view} onChange={setView} />
|
<ViewToggle mode={view} onChange={setView} />
|
||||||
|
{view === "list" && <ColumnsMenu columns={columns} />}
|
||||||
{view === "tiles" && <TileColumnsControl tiles={tiles} />}
|
{view === "tiles" && <TileColumnsControl tiles={tiles} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
CardThumb,
|
CardThumb,
|
||||||
CardTile,
|
CardTile,
|
||||||
|
ColumnDef,
|
||||||
|
ColumnsMenu,
|
||||||
DeckChip,
|
DeckChip,
|
||||||
DialogFrame,
|
DialogFrame,
|
||||||
ColorPips,
|
ColorPips,
|
||||||
@@ -30,6 +32,7 @@ import {
|
|||||||
groupCards,
|
groupCards,
|
||||||
sortCards,
|
sortCards,
|
||||||
usePersistedState,
|
usePersistedState,
|
||||||
|
useColumns,
|
||||||
useTileColumns,
|
useTileColumns,
|
||||||
useViewMode,
|
useViewMode,
|
||||||
} from "../components";
|
} from "../components";
|
||||||
@@ -40,6 +43,18 @@ import { CardFilterPanel, NO_FACETS, useCardFilter } from "../cardFilter";
|
|||||||
// user a window to undo an accidental removal by clicking + again.
|
// user a window to undo an accidental removal by clicking + again.
|
||||||
const REMOVAL_GRACE_MS = 5000;
|
const REMOVAL_GRACE_MS = 5000;
|
||||||
|
|
||||||
|
type LibraryColumn = "image" | "colors" | "type" | "mv" | "owned" | "decks";
|
||||||
|
|
||||||
|
// The List view's optional columns; the checkbox and name always show.
|
||||||
|
const LIBRARY_COLUMNS: ColumnDef<LibraryColumn>[] = [
|
||||||
|
{ id: "image", label: "Image" },
|
||||||
|
{ id: "colors", label: "Colors" },
|
||||||
|
{ id: "type", label: "Type" },
|
||||||
|
{ id: "mv", label: "MV" },
|
||||||
|
{ id: "owned", label: "Owned" },
|
||||||
|
{ id: "decks", label: "Decks / Location" },
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Browse & filter the owned library by name and card criteria.
|
* Browse & filter the owned library by name and card criteria.
|
||||||
*
|
*
|
||||||
@@ -61,6 +76,7 @@ export function LibraryBrowser({
|
|||||||
const [rows, setRows] = useState<LibraryCard[]>([]);
|
const [rows, setRows] = useState<LibraryCard[]>([]);
|
||||||
const [view, setView] = useViewMode("library-view");
|
const [view, setView] = useViewMode("library-view");
|
||||||
const tiles = useTileColumns();
|
const tiles = useTileColumns();
|
||||||
|
const columns = useColumns("library-columns", LIBRARY_COLUMNS);
|
||||||
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("library-group", "none");
|
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("library-group", "none");
|
||||||
const [sortBy, setSortBy] = usePersistedState<SortMode>("library-sort", "name");
|
const [sortBy, setSortBy] = usePersistedState<SortMode>("library-sort", "name");
|
||||||
const [sortDir, setSortDir] = usePersistedState<SortDir>("library-sort-dir", "asc");
|
const [sortDir, setSortDir] = usePersistedState<SortDir>("library-sort-dir", "asc");
|
||||||
@@ -322,13 +338,13 @@ export function LibraryBrowser({
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th></th>
|
<th></th>
|
||||||
<th></th>
|
{columns.shown("image") && <th></th>}
|
||||||
<th>Colors</th>
|
{columns.shown("colors") && <th>Colors</th>}
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Type</th>
|
{columns.shown("type") && <th>Type</th>}
|
||||||
<th>MV</th>
|
{columns.shown("mv") && <th>MV</th>}
|
||||||
<th>Owned</th>
|
{columns.shown("owned") && <th>Owned</th>}
|
||||||
<th>Decks / Location</th>
|
{columns.shown("decks") && <th>Decks / Location</th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -341,36 +357,42 @@ export function LibraryBrowser({
|
|||||||
onChange={() => toggleOne(c.oracle_id)}
|
onChange={() => toggleOne(c.oracle_id)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="cell-thumb">
|
{columns.shown("image") && (
|
||||||
<CardThumb card={c} preview={preview} />
|
<td className="cell-thumb">
|
||||||
</td>
|
<CardThumb card={c} preview={preview} />
|
||||||
<td>
|
</td>
|
||||||
<ColorPips mask={c.color_identity} />
|
)}
|
||||||
</td>
|
{columns.shown("colors") && (
|
||||||
|
<td>
|
||||||
|
<ColorPips mask={c.color_identity} />
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
<td className="card-name">{c.name}</td>
|
<td className="card-name">{c.name}</td>
|
||||||
<td className="card-type">{c.type_line}</td>
|
{columns.shown("type") && <td className="card-type">{c.type_line}</td>}
|
||||||
<td data-label="MV">{c.mana_value}</td>
|
{columns.shown("mv") && <td data-label="MV">{c.mana_value}</td>}
|
||||||
<td
|
{columns.shown("owned") && (
|
||||||
data-label="Owned"
|
<td
|
||||||
className={c.quantity_assigned > c.quantity_owned ? "over-assigned" : ""}
|
data-label="Owned"
|
||||||
>
|
className={c.quantity_assigned > c.quantity_owned ? "over-assigned" : ""}
|
||||||
<span className="row" style={{ gap: 8 }}>
|
>
|
||||||
<QtyStepper
|
<span className="row" style={{ gap: 8 }}>
|
||||||
value={c.quantity_owned}
|
<QtyStepper
|
||||||
onDecrement={() => adjust(c.oracle_id, -1)}
|
value={c.quantity_owned}
|
||||||
onIncrement={() => adjust(c.oracle_id, 1)}
|
onDecrement={() => adjust(c.oracle_id, -1)}
|
||||||
warn={c.quantity_assigned > c.quantity_owned}
|
onIncrement={() => adjust(c.oracle_id, 1)}
|
||||||
pending={pendingRemoval.has(c.oracle_id)}
|
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 > c.quantity_owned && (
|
||||||
⚠ {c.quantity_assigned} assigned
|
<span title="Assigned to more decks than you own">
|
||||||
</span>
|
⚠ {c.quantity_assigned} assigned
|
||||||
)}
|
</span>
|
||||||
{spareNote(c)}
|
)}
|
||||||
</span>
|
{spareNote(c)}
|
||||||
</td>
|
</span>
|
||||||
<td data-label="Decks / Location">{deckOrLocation(c)}</td>
|
</td>
|
||||||
|
)}
|
||||||
|
{columns.shown("decks") && <td data-label="Decks / Location">{deckOrLocation(c)}</td>}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -627,6 +649,7 @@ export function LibraryBrowser({
|
|||||||
onToggleDir={() => setSortDir(sortDir === "asc" ? "desc" : "asc")}
|
onToggleDir={() => setSortDir(sortDir === "asc" ? "desc" : "asc")}
|
||||||
/>
|
/>
|
||||||
<ViewToggle mode={view} onChange={setView} />
|
<ViewToggle mode={view} onChange={setView} />
|
||||||
|
{view === "list" && <ColumnsMenu columns={columns} />}
|
||||||
{view === "tiles" && <TileColumnsControl tiles={tiles} />}
|
{view === "tiles" && <TileColumnsControl tiles={tiles} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user