feat(managers): table / cards / rows layouts for the channel manager (E4 S3)

A generic ViewSwitcher (reused from the feed) drives a per-account layout
shared by the Subscriptions + Discovery tabs. `table` keeps the DataTable
(numbered pager); `cards` and `rows` render a virtualized grid over the same
client-side list via the new ChannelLayoutGrid, which composes cells straight
from each table's Column<T> render closures (cardPrimary/hideInCard/cardLabel),
so both tabs — with their different columns — get the views for free. Status
chips surface in the fixed band when the table's own controls row is gone.
This commit is contained in:
2026-07-19 21:52:16 +02:00
parent 5ea82095a1
commit 1703d4ab5e
8 changed files with 226 additions and 9 deletions
@@ -0,0 +1,93 @@
import { useTranslation } from "react-i18next";
import { type Column } from "./DataTable";
import VirtualGrid from "./VirtualGrid";
import { CHANNEL_LAYOUT_SPEC } from "../lib/channelLayout";
// The channel manager's non-table layouts (E4 S3): the `cards` and `rows` densities, built by
// reusing the SAME `Column<T>` render closures the DataTable draws — so the subscribed and discovery
// tables (which have different columns) both get card/row views 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 or rows.
//
// Column flags drive the composition, exactly like DataTable's narrow-screen card fallback:
// `cardPrimary` → the heading; `hideInCard` → omitted; `cardLabel:false` → value without its header.
// `actionsKey` (optional) is docked to the right edge in `rows`, and shown last in `cards`.
interface ChannelLayoutGridProps<T> {
rows: T[];
columns: Column<T>[];
rowKey: (row: T) => string;
layout: "cards" | "rows";
/** Column key whose cell is the action cluster — docked right in `rows`. */
actionsKey?: string;
rowClassName?: (row: T) => string;
emptyText?: string;
}
export default function ChannelLayoutGrid<T>({
rows,
columns,
rowKey,
layout,
actionsKey,
rowClassName,
emptyText,
}: ChannelLayoutGridProps<T>) {
const { t } = useTranslation();
const spec = CHANNEL_LAYOUT_SPEC[layout];
const primary = columns.filter((c) => c.cardPrimary);
const actions = actionsKey ? columns.find((c) => c.key === actionsKey) : undefined;
// Everything shown in the meta flow: not the heading, not hidden, not the docked action cluster.
const meta = columns.filter(
(c) => !c.cardPrimary && !c.hideInCard && c.key !== actionsKey
);
const metaFlow = (row: T) => (
<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>
);
const renderCard = (row: T) => (
<div className={`glass-card rounded-xl p-3 h-full flex flex-col ${rowClassName?.(row) ?? ""}`}>
{primary.map((col) => (
<div key={col.key} className="font-medium">
{col.render(row)}
</div>
))}
<div className="mt-2">{metaFlow(row)}</div>
{actions && <div className="mt-auto pt-2">{actions.render(row)}</div>}
</div>
);
const renderRow = (row: T) => (
<div className={`glass-card rounded-xl p-3 flex items-center gap-4 ${rowClassName?.(row) ?? ""}`}>
<div className="shrink-0 font-medium">{primary.map((col) => col.render(row))}</div>
{metaFlow(row)}
{actions && <div className="ml-auto shrink-0">{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}
maxCol={spec.maxCol}
rowEst={spec.rowEst}
renderItem={(row) => (layout === "cards" ? renderCard(row) : renderRow(row))}
/>
);
}