The viewport contract has two ends, and only the floor was enforced. An auto-fill grid answers "more room" with more columns, never bigger ones, so it settles just above its minimum column at any width: measured at 3440px, the feed laid out 11 columns of 264px. A feed diluted across an ultrawide fails the contract as surely as a broken one at the floor. Multi-column feed views now carry a `maxCols`; past it the leftover width flows into the items. At 3440: cards 11x264 -> 8x370, cardsSmall 15 -> 11x264 (still visibly the dense card, and never as wide as cards gets), tiles 7 -> 6x498. Each cap is set so the item tops out near 1.5x its own minCol, which is what keeps a view recognisably itself. A cap can only bind ABOVE the width at which that many columns already fit, so it is a pure ultrawide affordance — 1024, 1920 and 2560 lay out exactly as before. Verified in the browser at all three widths and across all five views. The Channels grid needs no cap: the page's own 96rem --content-mw already bounds it. Also renames VirtualGrid's `maxCol` to `colMaxWidth`. It was never a column count — it is the single-column reading-width cap — and `maxCol`/`maxCols` one letter apart is the kind of drift that has bitten this repo before. The column maths moves to ui/gridColumns.ts as a pure function so both ends of the contract are unit-testable without a layout engine; the tests assert the cap's numbers, that it never binds at the floor, and that the dense card cannot grow into the standard one.
93 lines
3.5 KiB
TypeScript
93 lines
3.5 KiB
TypeScript
import { Fragment } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { type Column } from "./ui/DataTable";
|
|
import VirtualGrid from "./ui/VirtualGrid";
|
|
import { CHANNEL_LAYOUT_SPEC } from "../lib/channelLayout";
|
|
|
|
// The channel manager's `cards` layout (E4 S3), built by reusing the SAME `Column<T>` render closures
|
|
// the DataTable draws — so the subscribed and discovery tables (which have different columns) both get
|
|
// the card view for free, and a cell's behaviour (priority bump, tag toggle, sync, actions) is defined
|
|
// once. The `table` layout keeps DataTable; this renders only when the manager's layout is `cards`.
|
|
//
|
|
// Named slots place the columns that need bespoke positions; everything else flows in the meta line.
|
|
// Unlike DataTable's cramped narrow-screen card fallback, the desktop card is roomy, so it shows the
|
|
// `hideInCard` columns too (About, priority) — the user wants those affordances in every layout.
|
|
|
|
interface ChannelLayoutGridProps<T> {
|
|
rows: T[];
|
|
columns: Column<T>[];
|
|
rowKey: (row: T) => string;
|
|
/** Column key docked at the top-right of the card (the priority stepper). */
|
|
leadKey?: string;
|
|
/** Column key rendered as a full-width description line (the About cell + its hover overlay). */
|
|
aboutKey?: string;
|
|
/** Column key rendered as the action cluster, pinned to the card's bottom edge. */
|
|
actionsKey?: string;
|
|
rowClassName?: (row: T) => string;
|
|
emptyText?: string;
|
|
}
|
|
|
|
export default function ChannelLayoutGrid<T>({
|
|
rows,
|
|
columns,
|
|
rowKey,
|
|
leadKey,
|
|
aboutKey,
|
|
actionsKey,
|
|
rowClassName,
|
|
emptyText,
|
|
}: ChannelLayoutGridProps<T>) {
|
|
const { t } = useTranslation();
|
|
const spec = CHANNEL_LAYOUT_SPEC.cards;
|
|
|
|
const slotted = new Set([leadKey, aboutKey, actionsKey].filter(Boolean) as string[]);
|
|
const primary = columns.filter((c) => c.cardPrimary);
|
|
const lead = leadKey ? columns.find((c) => c.key === leadKey) : undefined;
|
|
const about = aboutKey ? columns.find((c) => c.key === aboutKey) : undefined;
|
|
const actions = actionsKey ? columns.find((c) => c.key === actionsKey) : undefined;
|
|
// Everything else flows in the meta line: not the heading, not a slotted column.
|
|
const meta = columns.filter((c) => !c.cardPrimary && !slotted.has(c.key));
|
|
|
|
const renderCard = (row: T) => (
|
|
<div
|
|
className={`glass-card rounded-xl p-3 h-full flex flex-col gap-2 ${rowClassName?.(row) ?? ""}`}
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0 flex-1 font-medium">
|
|
{primary.map((col) => (
|
|
<Fragment key={col.key}>{col.render(row)}</Fragment>
|
|
))}
|
|
</div>
|
|
{lead && <div className="shrink-0">{lead.render(row)}</div>}
|
|
</div>
|
|
{about && <div className="min-w-0 text-sm">{about.render(row)}</div>}
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-sm min-w-0">
|
|
{meta.map((col) => (
|
|
<span key={col.key} className="inline-flex items-center gap-1">
|
|
{col.cardLabel !== false && <span className="text-muted text-xs">{col.header}</span>}
|
|
{col.render(row)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
{actions && <div className="mt-auto pt-1">{actions.render(row)}</div>}
|
|
</div>
|
|
);
|
|
|
|
if (rows.length === 0) {
|
|
return (
|
|
<div className="text-muted py-8 text-center text-sm">{emptyText ?? t("datatable.empty")}</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<VirtualGrid
|
|
items={rows}
|
|
getKey={rowKey}
|
|
minCol={spec.minCol}
|
|
colMaxWidth={null}
|
|
rowEst={spec.rowEst}
|
|
renderItem={renderCard}
|
|
/>
|
|
);
|
|
}
|