Tiles view: +/- control for cards per row
Adjusts the tile grid's column count as an offset from the existing width-based auto-fill default, shared across Library, Decks, Card search, and set card lists. Clicking the count resets to the default.
This commit is contained in:
@@ -1709,6 +1709,106 @@ export function ViewToggle({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Tiles per row --------------------------------------------------------
|
||||||
|
|
||||||
|
// Mirror `.tile-grid`'s `repeat(auto-fill, minmax(190px, 1fr))` and `gap` in
|
||||||
|
// styles.css — used to work out how many columns that default fills.
|
||||||
|
const TILE_MIN_WIDTH = 190;
|
||||||
|
const TILE_GAP = 16;
|
||||||
|
const TILE_MAX_COLUMNS = 12;
|
||||||
|
|
||||||
|
export interface TileColumns {
|
||||||
|
/** Columns currently shown (the width-based default plus the adjustment). */
|
||||||
|
columns: number;
|
||||||
|
/** True while the user has moved away from the width-based default. */
|
||||||
|
adjusted: boolean;
|
||||||
|
adjust: (delta: number) => void;
|
||||||
|
reset: () => void;
|
||||||
|
/** Spread onto each `.tile-grid` (`<div className="tile-grid" {...gridProps}>`). */
|
||||||
|
gridProps: { ref: (el: HTMLDivElement | null) => void; className: string; style?: React.CSSProperties };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-row tile count for the Tiles view, adjustable with `TileColumnsControl`.
|
||||||
|
*
|
||||||
|
* The default stays the CSS auto-fill (as many ~190px tiles as fit), so the
|
||||||
|
* persisted setting is an *offset* from that rather than a fixed count: +1
|
||||||
|
* means "one more than would fit by default" on a phone and a desktop alike,
|
||||||
|
* and it keeps tracking the width across resizes and rotation. One offset is
|
||||||
|
* shared by every tiles view. */
|
||||||
|
export function useTileColumns(): TileColumns {
|
||||||
|
const [offset, setOffset] = usePersistedState<number>("tile-columns-offset", 0);
|
||||||
|
const [auto, setAuto] = useState(0);
|
||||||
|
const observer = useRef<ResizeObserver | null>(null);
|
||||||
|
|
||||||
|
// Every grid in a view (one per group) shares the same width, so whichever
|
||||||
|
// mounted last is the one measured.
|
||||||
|
const ref = useCallback((el: HTMLDivElement | null) => {
|
||||||
|
if (!el) return;
|
||||||
|
observer.current?.disconnect();
|
||||||
|
// A grid that's been unmounted (e.g. switching to List) reports width 0;
|
||||||
|
// keep the last real measurement instead.
|
||||||
|
const measure = (w: number) => {
|
||||||
|
if (w > 0) setAuto(Math.max(1, Math.floor((w + TILE_GAP) / (TILE_MIN_WIDTH + TILE_GAP))));
|
||||||
|
};
|
||||||
|
measure(el.clientWidth);
|
||||||
|
observer.current = new ResizeObserver((entries) => measure(entries[0].contentRect.width));
|
||||||
|
observer.current.observe(el);
|
||||||
|
}, []);
|
||||||
|
useEffect(() => () => observer.current?.disconnect(), []);
|
||||||
|
|
||||||
|
const base = auto || 1;
|
||||||
|
const columns = Math.min(TILE_MAX_COLUMNS, Math.max(1, base + offset));
|
||||||
|
const adjusted = offset !== 0 && auto > 0;
|
||||||
|
return {
|
||||||
|
columns,
|
||||||
|
adjusted,
|
||||||
|
// Clamp against the visible count, so pressing − at one column doesn't
|
||||||
|
// bank an offset that has to be undone before + does anything.
|
||||||
|
adjust: (delta) => {
|
||||||
|
const next = Math.min(TILE_MAX_COLUMNS, Math.max(1, columns + delta));
|
||||||
|
setOffset(next - base);
|
||||||
|
},
|
||||||
|
reset: () => setOffset(0),
|
||||||
|
gridProps: adjusted
|
||||||
|
? {
|
||||||
|
ref,
|
||||||
|
className: columns === 1 ? "tile-grid tile-grid-single" : "tile-grid",
|
||||||
|
style: { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` },
|
||||||
|
}
|
||||||
|
: { ref, className: "tile-grid" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** − N + stepper for the tiles-per-row count; clicking N resets to the default. */
|
||||||
|
export function TileColumnsControl({ tiles, disabled }: { tiles: TileColumns; disabled?: boolean }) {
|
||||||
|
return (
|
||||||
|
<span className="view-toggle tile-columns" style={disabled ? { opacity: 0.5 } : undefined}>
|
||||||
|
<button
|
||||||
|
onClick={() => tiles.adjust(-1)}
|
||||||
|
disabled={disabled || tiles.columns <= 1}
|
||||||
|
title="Fewer cards per row (bigger tiles)"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="tile-columns-count"
|
||||||
|
onClick={tiles.reset}
|
||||||
|
disabled={disabled || !tiles.adjusted}
|
||||||
|
title={tiles.adjusted ? "Cards per row — click to reset to the default" : "Cards per row"}
|
||||||
|
>
|
||||||
|
{tiles.columns}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => tiles.adjust(1)}
|
||||||
|
disabled={disabled || tiles.columns >= TILE_MAX_COLUMNS}
|
||||||
|
title="More cards per row (smaller tiles)"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single card rendered as a full image tile, with optional corner + footer.
|
* A single card rendered as a full image tile, with optional corner + footer.
|
||||||
* Clicking the image opens a larger click-to-preview modal.
|
* Clicking the image opens a larger click-to-preview modal.
|
||||||
|
|||||||
@@ -2304,6 +2304,11 @@ tbody tr:hover {
|
|||||||
.tile-rotated {
|
.tile-rotated {
|
||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
}
|
}
|
||||||
|
/* …except when the per-row control is down to one column, where a span of 2
|
||||||
|
would add an implicit second column and overflow. */
|
||||||
|
.tile-grid-single .tile-rotated {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
.tile-img-wrap {
|
.tile-img-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -2447,6 +2452,11 @@ tbody tr:hover {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
/* Inputs keep an intrinsic ~160px width that would overflow tiles narrowed
|
||||||
|
by the per-row control; let them shrink with the tile instead. */
|
||||||
|
.tile-footer input {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Toolbar controls: group-by + view toggle
|
Toolbar controls: group-by + view toggle
|
||||||
@@ -2491,6 +2501,25 @@ tbody tr:hover {
|
|||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
.view-toggle button:disabled {
|
||||||
|
background: transparent;
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
/* Tiles-per-row stepper (see `TileColumnsControl`): same segmented look as
|
||||||
|
the List/Tiles toggle, with a fixed-width count between − and +. */
|
||||||
|
.tile-columns button {
|
||||||
|
padding: 5px 9px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.tile-columns .tile-columns-count {
|
||||||
|
min-width: 2.2em;
|
||||||
|
padding-left: 4px;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
.tile-columns .tile-columns-count:disabled {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Deck metadata (format + commander)
|
Deck metadata (format + commander)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
SortDir,
|
SortDir,
|
||||||
SortMode,
|
SortMode,
|
||||||
TagInput,
|
TagInput,
|
||||||
|
TileColumnsControl,
|
||||||
ViewToggle,
|
ViewToggle,
|
||||||
CardCriteriaFields,
|
CardCriteriaFields,
|
||||||
applyCardCriteria,
|
applyCardCriteria,
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
sortCards,
|
sortCards,
|
||||||
useCardCriteria,
|
useCardCriteria,
|
||||||
usePersistedState,
|
usePersistedState,
|
||||||
|
useTileColumns,
|
||||||
useViewMode,
|
useViewMode,
|
||||||
} from "../components";
|
} from "../components";
|
||||||
|
|
||||||
@@ -76,6 +78,7 @@ export function CardSearch({
|
|||||||
const selectAllRef = useRef<HTMLInputElement>(null);
|
const selectAllRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [view, setView] = useViewMode("cardsearch-view");
|
const [view, setView] = useViewMode("cardsearch-view");
|
||||||
|
const tiles = useTileColumns();
|
||||||
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");
|
||||||
@@ -335,7 +338,7 @@ export function CardSearch({
|
|||||||
|
|
||||||
function refTiles(cards: LibraryCard[]) {
|
function refTiles(cards: LibraryCard[]) {
|
||||||
return (
|
return (
|
||||||
<div className="tile-grid">
|
<div {...tiles.gridProps}>
|
||||||
{cards.map((c) => (
|
{cards.map((c) => (
|
||||||
<CardTile
|
<CardTile
|
||||||
key={c.oracle_id}
|
key={c.oracle_id}
|
||||||
@@ -572,6 +575,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 === "tiles" && <TileColumnsControl tiles={tiles} />}
|
||||||
<label
|
<label
|
||||||
className="row"
|
className="row"
|
||||||
style={{ gap: 4, cursor: view === "tiles" ? "pointer" : "default", opacity: view === "tiles" ? 1 : 0.5 }}
|
style={{ gap: 4, cursor: view === "tiles" ? "pointer" : "default", opacity: view === "tiles" ? 1 : 0.5 }}
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ import {
|
|||||||
QtyStepper,
|
QtyStepper,
|
||||||
SampleHandModal,
|
SampleHandModal,
|
||||||
StatTile,
|
StatTile,
|
||||||
|
TileColumnsControl,
|
||||||
ViewToggle,
|
ViewToggle,
|
||||||
groupCards,
|
groupCards,
|
||||||
useClickPreview,
|
useClickPreview,
|
||||||
useHoverPreview,
|
useHoverPreview,
|
||||||
usePersistedState,
|
usePersistedState,
|
||||||
|
useTileColumns,
|
||||||
useViewMode,
|
useViewMode,
|
||||||
} from "../components";
|
} from "../components";
|
||||||
import { SuggestionsModal } from "../SuggestionsModal";
|
import { SuggestionsModal } from "../SuggestionsModal";
|
||||||
@@ -363,6 +365,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
|
|||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
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 [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("");
|
||||||
@@ -754,7 +757,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
|
|||||||
|
|
||||||
function deckTiles(entries: DeckEntry[]) {
|
function deckTiles(entries: DeckEntry[]) {
|
||||||
return (
|
return (
|
||||||
<div className="tile-grid">
|
<div {...tiles.gridProps}>
|
||||||
{entries.map((e) => (
|
{entries.map((e) => (
|
||||||
<CardTile
|
<CardTile
|
||||||
key={e.oracle_id}
|
key={e.oracle_id}
|
||||||
@@ -1120,6 +1123,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
|
|||||||
<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 === "tiles" && <TileColumnsControl tiles={tiles} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedCards.size > 0 && (
|
{selectedCards.size > 0 && (
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
SortDir,
|
SortDir,
|
||||||
SortMode,
|
SortMode,
|
||||||
TagInput,
|
TagInput,
|
||||||
|
TileColumnsControl,
|
||||||
ViewToggle,
|
ViewToggle,
|
||||||
CardCriteriaFields,
|
CardCriteriaFields,
|
||||||
applyCardCriteria,
|
applyCardCriteria,
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
sortCards,
|
sortCards,
|
||||||
useCardCriteria,
|
useCardCriteria,
|
||||||
usePersistedState,
|
usePersistedState,
|
||||||
|
useTileColumns,
|
||||||
useViewMode,
|
useViewMode,
|
||||||
} from "../components";
|
} from "../components";
|
||||||
|
|
||||||
@@ -78,6 +80,7 @@ export function LibraryBrowser({
|
|||||||
const [decks, setDecks] = useState<Deck[]>([]);
|
const [decks, setDecks] = useState<Deck[]>([]);
|
||||||
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 [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");
|
||||||
@@ -405,7 +408,7 @@ export function LibraryBrowser({
|
|||||||
|
|
||||||
function libTiles(cards: LibraryCard[]) {
|
function libTiles(cards: LibraryCard[]) {
|
||||||
return (
|
return (
|
||||||
<div className="tile-grid">
|
<div {...tiles.gridProps}>
|
||||||
{cards.map((c) => (
|
{cards.map((c) => (
|
||||||
<CardTile
|
<CardTile
|
||||||
key={c.oracle_id}
|
key={c.oracle_id}
|
||||||
@@ -693,6 +696,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 === "tiles" && <TileColumnsControl tiles={tiles} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import {
|
|||||||
CardTile,
|
CardTile,
|
||||||
ColorToggles,
|
ColorToggles,
|
||||||
QtyStepper,
|
QtyStepper,
|
||||||
|
TileColumnsControl,
|
||||||
applyCardCriteria,
|
applyCardCriteria,
|
||||||
useCardCriteria,
|
useCardCriteria,
|
||||||
|
useTileColumns,
|
||||||
} from "../components";
|
} from "../components";
|
||||||
import { useBackButton } from "../backButton";
|
import { useBackButton } from "../backButton";
|
||||||
|
|
||||||
@@ -75,6 +77,7 @@ export function NewsView() {
|
|||||||
// doing nothing. Null while we work out which.
|
// doing nothing. Null while we work out which.
|
||||||
const [source, setSource] = useState<"local" | "live" | null>(null);
|
const [source, setSource] = useState<"local" | "live" | null>(null);
|
||||||
const [cardsError, setCardsError] = useState<string | null>(null);
|
const [cardsError, setCardsError] = useState<string | null>(null);
|
||||||
|
const tiles = useTileColumns();
|
||||||
// The same criteria the Library and Card Search panels use — the set browser
|
// The same criteria the Library and Card Search panels use — the set browser
|
||||||
// runs through search_reference rather than its own path, so every filter
|
// runs through search_reference rather than its own path, so every filter
|
||||||
// works here identically instead of being reimplemented.
|
// works here identically instead of being reimplemented.
|
||||||
@@ -197,6 +200,7 @@ export function NewsView() {
|
|||||||
{cards && <span className="badge">{cards.length}</span>}
|
{cards && <span className="badge">{cards.length}</span>}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="page-header-actions">
|
<div className="page-header-actions">
|
||||||
|
<TileColumnsControl tiles={tiles} />
|
||||||
<button onClick={() => setOpenSet(null)}>← Back to news</button>
|
<button onClick={() => setOpenSet(null)}>← Back to news</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,7 +257,7 @@ export function NewsView() {
|
|||||||
: "No cards listed for this set yet."}
|
: "No cards listed for this set yet."}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="tile-grid" style={{ marginTop: "var(--space-4)" }}>
|
<div {...tiles.gridProps} style={{ ...tiles.gridProps.style, marginTop: "var(--space-4)" }}>
|
||||||
{cards.map((c) => (
|
{cards.map((c) => (
|
||||||
<CardTile
|
<CardTile
|
||||||
key={c.oracle_id}
|
key={c.oracle_id}
|
||||||
|
|||||||
Reference in New Issue
Block a user