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:
2026-10-26 20:35:21 -05:00
parent a92b650dab
commit d9f92c74fd
6 changed files with 350 additions and 118 deletions

View File

@@ -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 --------------------------------------------------------
// Mirror `.tile-grid`'s `repeat(auto-fill, minmax(190px, 1fr))` and `gap` in

View File

@@ -2673,6 +2673,31 @@ button.pull-action {
.tile-columns .tile-columns-count:disabled {
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)

View File

@@ -3,6 +3,8 @@ import { api, Deck, LibraryCard, LibraryFacets, PreviewContext } from "../api";
import {
CardThumb,
CardTile,
ColumnDef,
ColumnsMenu,
DeckChip,
ColorPips,
GroupBySelect,
@@ -18,6 +20,7 @@ import {
groupCards,
sortCards,
usePersistedState,
useColumns,
useTileColumns,
useViewMode,
} 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.
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,
* 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 tiles = useTileColumns();
const columns = useColumns("cardsearch-columns", SEARCH_COLUMNS);
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("cardsearch-group", "none");
const [sortBy, setSortBy] = usePersistedState<SortMode>("cardsearch-sort", "name");
const [sortDir, setSortDir] = usePersistedState<SortDir>("cardsearch-sort-dir", "asc");
@@ -216,14 +233,14 @@ export function CardSearch({
<thead>
<tr>
<th></th>
<th></th>
<th>Colors</th>
{columns.shown("image") && <th></th>}
{columns.shown("colors") && <th>Colors</th>}
<th>Name</th>
<th>Type</th>
<th>MV</th>
<th>Owned</th>
<th>In decks</th>
<th></th>
{columns.shown("type") && <th>Type</th>}
{columns.shown("mv") && <th>MV</th>}
{columns.shown("owned") && <th>Owned</th>}
{columns.shown("decks") && <th>In decks</th>}
{columns.shown("add") && <th></th>}
</tr>
</thead>
<tbody>
@@ -236,36 +253,46 @@ export function CardSearch({
onChange={() => toggleOne(c.oracle_id)}
/>
</td>
<td className="cell-thumb">
<CardThumb card={c} preview={preview} />
</td>
<td>
<ColorPips mask={c.color_identity} />
</td>
{columns.shown("image") && (
<td className="cell-thumb">
<CardThumb card={c} preview={preview} />
</td>
)}
{columns.shown("colors") && (
<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">
<QtyStepper
value={c.quantity_owned}
onDecrement={() => adjust(c.oracle_id, -1)}
onIncrement={() => adjust(c.oracle_id, 1)}
/>
</td>
<td data-label="In decks">
{c.decks.map((d) => (
<DeckChip key={d} name={d} idea={!c.physical_decks.includes(d)} />
))}
</td>
<td>
<button
onClick={() => addToDeck(c)}
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>
{columns.shown("type") && <td className="card-type">{c.type_line}</td>}
{columns.shown("mv") && <td data-label="MV">{c.mana_value}</td>}
{columns.shown("owned") && (
<td data-label="Owned">
<QtyStepper
value={c.quantity_owned}
onDecrement={() => adjust(c.oracle_id, -1)}
onIncrement={() => adjust(c.oracle_id, 1)}
/>
</td>
)}
{columns.shown("decks") && (
<td data-label="In decks">
{c.decks.map((d) => (
<DeckChip key={d} name={d} idea={!c.physical_decks.includes(d)} />
))}
</td>
)}
{columns.shown("add") && (
<td>
<button
onClick={() => addToDeck(c)}
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>
))}
</tbody>
@@ -425,6 +452,7 @@ export function CardSearch({
onToggleDir={() => setSortDir(sortDir === "asc" ? "desc" : "asc")}
/>
<ViewToggle mode={view} onChange={setView} />
{view === "list" && <ColumnsMenu columns={columns} />}
{view === "tiles" && <TileColumnsControl tiles={tiles} />}
<label
className="row"

View File

@@ -1,6 +1,19 @@
import { useEffect, useMemo, useRef, useState } from "react";
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";
type SortMode = "recent" | "name" | "format" | "size" | "cost" | "owned";
@@ -14,6 +27,20 @@ const SORTS: { id: SortMode; label: string }[] = [
{ 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 {
onOpen: (id: number) => 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 [menuOpen, setMenuOpen] = useState(false);
return (
@@ -248,20 +283,22 @@ function DeckRow({ row, actions }: { row: Row; actions: DeckActions }) {
<div className="deck-row-main">
<div className="deck-row-name">
<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 className="deck-row-sub">{subtitle(row)}</div>
{columns.shown("subtitle") && <div className="deck-row-sub">{subtitle(row)}</div>}
</div>
<div className="deck-row-stats">
<StatusChip row={row} />
{deck.format && <span className="deck-chip">{deck.format}</span>}
<DeckSize summary={s} />
<span className="deck-row-owned" title={ownedLabel(s)}>
<OwnedBar summary={s} />
<span>{s.size ? `${Math.floor(s.owned * 100)}%` : "—"}</span>
</span>
<span className="deck-row-cost">{formatCost(s.cost)}</span>
<LegalityBadge summary={s} compact />
{columns.shown("status") && <StatusChip row={row} />}
{columns.shown("format") && deck.format && <span className="deck-chip">{deck.format}</span>}
{columns.shown("size") && <DeckSize summary={s} />}
{columns.shown("owned") && (
<span className="deck-row-owned" title={ownedLabel(s)}>
<OwnedBar summary={s} />
<span>{s.size ? `${Math.floor(s.owned * 100)}%` : "—"}</span>
</span>
)}
{columns.shown("cost") && <span className="deck-row-cost">{formatCost(s.cost)}</span>}
{columns.shown("legality") && <LegalityBadge summary={s} compact />}
</div>
<DeckMenu deck={deck} actions={actions} onOpenChange={setMenuOpen} />
</div>
@@ -290,6 +327,7 @@ export function DeckList({
} & DeckActions) {
const [contents, setContents] = useState<Record<number, DeckEntry[]> | null>(null);
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 [query, setQuery] = useState("");
const [format, setFormat] = useState<string | null>(null);
@@ -361,7 +399,7 @@ export function DeckList({
) : (
<div className="deck-gallery-rows">
{list.map((r) => (
<DeckRow key={r.deck.id} row={r} actions={actions} />
<DeckRow key={r.deck.id} row={r} actions={actions} columns={columns} />
))}
</div>
);
@@ -420,6 +458,7 @@ export function DeckList({
))}
</select>
<ViewToggle mode={view} onChange={setView} />
{view === "list" && <ColumnsMenu columns={columns} />}
</div>
</>
)}

View File

@@ -19,6 +19,8 @@ import {
CardTile,
ColorDistributionBar,
ColorPips,
ColumnDef,
ColumnsMenu,
ConfirmDialog,
DialogFrame,
GroupBySelect,
@@ -36,6 +38,7 @@ import {
groupCards,
useClickPreview,
useHoverPreview,
useColumns,
usePersistedState,
useTileColumns,
useViewMode,
@@ -70,6 +73,19 @@ function boardToggleProps(e: DeckEntry) {
: { 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 =
| { kind: "create" }
| { kind: "delete"; deck: Deck }
@@ -227,6 +243,7 @@ function DeckBuilder({
const [contents, setContents] = useState<DeckEntry[]>([]);
const [view, setView] = useViewMode("deck-view");
const tiles = useTileColumns();
const columns = useColumns("deck-columns", DECK_COLUMNS);
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("deck-group", "none");
const [combineSections, setCombineSections] = usePersistedState("deck-combine-sections", false);
const [filterQuery, setFilterQuery] = useState("");
@@ -811,14 +828,14 @@ function DeckBuilder({
<thead>
<tr>
<th></th>
<th></th>
<th>Colors</th>
{columns.shown("image") && <th></th>}
{columns.shown("colors") && <th>Colors</th>}
<th>Name</th>
<th>Type</th>
<th>In deck</th>
<th>Owned</th>
<th>Where</th>
<th></th>
{columns.shown("type") && <th>Type</th>}
{columns.shown("qty") && <th>In deck</th>}
{columns.shown("owned") && <th>Owned</th>}
{columns.shown("where") && <th>Where</th>}
{columns.shown("actions") && <th></th>}
</tr>
</thead>
<tbody>
@@ -831,12 +848,16 @@ function DeckBuilder({
onChange={() => toggleCard(e.oracle_id)}
/>
</td>
<td className="cell-thumb">
<CardThumb card={e} preview={preview} />
</td>
<td>
<ColorPips mask={e.color_identity} />
</td>
{columns.shown("image") && (
<td className="cell-thumb">
<CardThumb card={e} preview={preview} />
</td>
)}
{columns.shown("colors") && (
<td>
<ColorPips mask={e.color_identity} />
</td>
)}
<td className="card-name">
{e.is_commander && <span title="Commander">★ </span>}
{e.name}
@@ -846,26 +867,30 @@ function DeckBuilder({
</span>
)}
</td>
<td className="card-type">{e.type_line}</td>
<td data-label="In deck">
<QtyStepper
value={e.quantity}
onDecrement={() => setQty(e.oracle_id, e.quantity - 1)}
onIncrement={() => setQty(e.oracle_id, e.quantity + 1)}
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 data-label="Where">{whereabouts(e)}</td>
<td>
<span className="row" style={{ gap: 4 }}>
<IconButton {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
{e.quantity_owned === 0 && (
<IconButton icon={Icons.libraryAdd} label={libraryAddLabel(e)} onClick={() => addToLibrary([e])} />
)}
</span>
</td>
{columns.shown("type") && <td className="card-type">{e.type_line}</td>}
{columns.shown("qty") && (
<td data-label="In deck">
<QtyStepper
value={e.quantity}
onDecrement={() => setQty(e.oracle_id, e.quantity - 1)}
onIncrement={() => setQty(e.oracle_id, e.quantity + 1)}
warn={e.quantity_owned > 0 && e.quantity > e.quantity_owned}
pending={pendingRemoval.has(e.oracle_id)}
/>
</td>
)}
{columns.shown("owned") && <td data-label="Owned">{e.quantity_owned}</td>}
{columns.shown("where") && <td data-label="Where">{whereabouts(e)}</td>}
{columns.shown("actions") && (
<td>
<span className="row" style={{ gap: 4 }}>
<IconButton {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
{e.quantity_owned === 0 && (
<IconButton icon={Icons.libraryAdd} label={libraryAddLabel(e)} onClick={() => addToLibrary([e])} />
)}
</span>
</td>
)}
</tr>
))}
</tbody>
@@ -1124,6 +1149,7 @@ function DeckBuilder({
<span className="spacer" />
<GroupBySelect value={groupBy} onChange={setGroupBy} />
<ViewToggle mode={view} onChange={setView} />
{view === "list" && <ColumnsMenu columns={columns} />}
{view === "tiles" && <TileColumnsControl tiles={tiles} />}
</div>

View File

@@ -12,6 +12,8 @@ import {
import {
CardThumb,
CardTile,
ColumnDef,
ColumnsMenu,
DeckChip,
DialogFrame,
ColorPips,
@@ -30,6 +32,7 @@ import {
groupCards,
sortCards,
usePersistedState,
useColumns,
useTileColumns,
useViewMode,
} from "../components";
@@ -40,6 +43,18 @@ import { CardFilterPanel, NO_FACETS, useCardFilter } from "../cardFilter";
// user a window to undo an accidental removal by clicking + again.
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.
*
@@ -61,6 +76,7 @@ export function LibraryBrowser({
const [rows, setRows] = useState<LibraryCard[]>([]);
const [view, setView] = useViewMode("library-view");
const tiles = useTileColumns();
const columns = useColumns("library-columns", LIBRARY_COLUMNS);
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");
@@ -322,13 +338,13 @@ export function LibraryBrowser({
<thead>
<tr>
<th></th>
<th></th>
<th>Colors</th>
{columns.shown("image") && <th></th>}
{columns.shown("colors") && <th>Colors</th>}
<th>Name</th>
<th>Type</th>
<th>MV</th>
<th>Owned</th>
<th>Decks / Location</th>
{columns.shown("type") && <th>Type</th>}
{columns.shown("mv") && <th>MV</th>}
{columns.shown("owned") && <th>Owned</th>}
{columns.shown("decks") && <th>Decks / Location</th>}
</tr>
</thead>
<tbody>
@@ -341,36 +357,42 @@ export function LibraryBrowser({
onChange={() => toggleOne(c.oracle_id)}
/>
</td>
<td className="cell-thumb">
<CardThumb card={c} preview={preview} />
</td>
<td>
<ColorPips mask={c.color_identity} />
</td>
{columns.shown("image") && (
<td className="cell-thumb">
<CardThumb card={c} preview={preview} />
</td>
)}
{columns.shown("colors") && (
<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>
{columns.shown("type") && <td className="card-type">{c.type_line}</td>}
{columns.shown("mv") && <td data-label="MV">{c.mana_value}</td>}
{columns.shown("owned") && (
<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>
)}
{columns.shown("decks") && <td data-label="Decks / Location">{deckOrLocation(c)}</td>}
</tr>
))}
</tbody>
@@ -627,6 +649,7 @@ export function LibraryBrowser({
onToggleDir={() => setSortDir(sortDir === "asc" ? "desc" : "asc")}
/>
<ViewToggle mode={view} onChange={setView} />
{view === "list" && <ColumnsMenu columns={columns} />}
{view === "tiles" && <TileColumnsControl tiles={tiles} />}
</div>
</div>