diff --git a/src/components.tsx b/src/components.tsx index 6f9840e..8b41c07 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -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` (`
`). */ + 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("tile-columns-offset", 0); + const [auto, setAuto] = useState(0); + const observer = useRef(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 ( + + + + + + ); +} + /** * A single card rendered as a full image tile, with optional corner + footer. * Clicking the image opens a larger click-to-preview modal. diff --git a/src/styles.css b/src/styles.css index bb2a6a0..3f83762 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2304,6 +2304,11 @@ tbody tr:hover { .tile-rotated { 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 { position: relative; } @@ -2447,6 +2452,11 @@ tbody tr:hover { gap: 4px; 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 @@ -2491,6 +2501,25 @@ tbody tr:hover { background: var(--accent); 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) diff --git a/src/views/CardSearch.tsx b/src/views/CardSearch.tsx index 2bde51c..91a2c9b 100644 --- a/src/views/CardSearch.tsx +++ b/src/views/CardSearch.tsx @@ -14,6 +14,7 @@ import { SortDir, SortMode, TagInput, + TileColumnsControl, ViewToggle, CardCriteriaFields, applyCardCriteria, @@ -22,6 +23,7 @@ import { sortCards, useCardCriteria, usePersistedState, + useTileColumns, useViewMode, } from "../components"; @@ -76,6 +78,7 @@ export function CardSearch({ const selectAllRef = useRef(null); const [view, setView] = useViewMode("cardsearch-view"); + const tiles = useTileColumns(); const [groupBy, setGroupBy] = usePersistedState("cardsearch-group", "none"); const [sortBy, setSortBy] = usePersistedState("cardsearch-sort", "name"); const [sortDir, setSortDir] = usePersistedState("cardsearch-sort-dir", "asc"); @@ -335,7 +338,7 @@ export function CardSearch({ function refTiles(cards: LibraryCard[]) { return ( -
+
{cards.map((c) => ( setSortDir(sortDir === "asc" ? "desc" : "asc")} /> + {view === "tiles" && }