feat(managers): richer channel cards + drop the rows layout, cap the channel column
UAT round 1 feedback: - Retire the `rows` layout — it barely differed from the table; keep Table + Cards. - The card now shows the About cell (+ its hover overlay) and the priority stepper in every layout, not just the table: ChannelLayoutGrid gains named slots (lead / about / actions) and stops hiding the `hideInCard` columns, since the roomy desktop card has space the cramped mobile fallback did not. - Fix the table losing its centering with "Rows per page: All": a long-titled channel stretched the uncapped Channel column (~289→429px) and overflowed the centered table. Cap it with a max-w element inside the cell (table-layout:auto ignores a cell max-width), the same trick AboutCell already uses. Gaps now 163/162.
This commit is contained in:
@@ -89,14 +89,18 @@ export default function ChannelDiscovery({
|
|||||||
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
|
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
|
||||||
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
|
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
|
||||||
cardPrimary: true,
|
cardPrimary: true,
|
||||||
|
// Cap the width (a max-w element inside the cell; table-layout:auto ignores a cell max-width) so
|
||||||
|
// a long-titled channel can't stretch the column and overflow the centered table.
|
||||||
render: (c) => (
|
render: (c) => (
|
||||||
<ChannelLink
|
<div className="max-w-[18rem]">
|
||||||
id={c.id}
|
<ChannelLink
|
||||||
title={c.title}
|
id={c.id}
|
||||||
handle={c.handle}
|
title={c.title}
|
||||||
thumbnailUrl={c.thumbnail_url}
|
handle={c.handle}
|
||||||
onView={() => onViewChannel(c.id, c.title ?? c.id)}
|
thumbnailUrl={c.thumbnail_url}
|
||||||
/>
|
onView={() => onViewChannel(c.id, c.title ?? c.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -192,7 +196,7 @@ export default function ChannelDiscovery({
|
|||||||
rows={rows}
|
rows={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowKey={(c) => c.id}
|
rowKey={(c) => c.id}
|
||||||
layout={layout}
|
aboutKey="about"
|
||||||
actionsKey="actions"
|
actionsKey="actions"
|
||||||
emptyText={t("channels.discovery.empty")}
|
emptyText={t("channels.discovery.empty")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,22 +4,24 @@ import { type Column } from "./DataTable";
|
|||||||
import VirtualGrid from "./VirtualGrid";
|
import VirtualGrid from "./VirtualGrid";
|
||||||
import { CHANNEL_LAYOUT_SPEC } from "../lib/channelLayout";
|
import { CHANNEL_LAYOUT_SPEC } from "../lib/channelLayout";
|
||||||
|
|
||||||
// The channel manager's non-table layouts (E4 S3): the `cards` and `rows` densities, built by
|
// The channel manager's `cards` layout (E4 S3), built by reusing the SAME `Column<T>` render closures
|
||||||
// reusing the SAME `Column<T>` render closures the DataTable draws — so the subscribed and discovery
|
// the DataTable draws — so the subscribed and discovery tables (which have different columns) both get
|
||||||
// tables (which have different columns) both get card/row views for free, and a cell's behaviour
|
// the card view for free, and a cell's behaviour (priority bump, tag toggle, sync, actions) is defined
|
||||||
// (priority bump, tag toggle, sync, actions) is defined once. The `table` layout keeps DataTable;
|
// once. The `table` layout keeps DataTable; this renders only when the manager's layout is `cards`.
|
||||||
// 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:
|
// Named slots place the columns that need bespoke positions; everything else flows in the meta line.
|
||||||
// `cardPrimary` → the heading; `hideInCard` → omitted; `cardLabel:false` → value without its header.
|
// Unlike DataTable's cramped narrow-screen card fallback, the desktop card is roomy, so it shows the
|
||||||
// `actionsKey` (optional) is docked to the right edge in `rows`, and shown last in `cards`.
|
// `hideInCard` columns too (About, priority) — the user wants those affordances in every layout.
|
||||||
|
|
||||||
interface ChannelLayoutGridProps<T> {
|
interface ChannelLayoutGridProps<T> {
|
||||||
rows: T[];
|
rows: T[];
|
||||||
columns: Column<T>[];
|
columns: Column<T>[];
|
||||||
rowKey: (row: T) => string;
|
rowKey: (row: T) => string;
|
||||||
layout: "cards" | "rows";
|
/** Column key docked at the top-right of the card (the priority stepper). */
|
||||||
/** Column key whose cell is the action cluster — docked right in `rows`. */
|
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;
|
actionsKey?: string;
|
||||||
rowClassName?: (row: T) => string;
|
rowClassName?: (row: T) => string;
|
||||||
emptyText?: string;
|
emptyText?: string;
|
||||||
@@ -29,53 +31,43 @@ export default function ChannelLayoutGrid<T>({
|
|||||||
rows,
|
rows,
|
||||||
columns,
|
columns,
|
||||||
rowKey,
|
rowKey,
|
||||||
layout,
|
leadKey,
|
||||||
|
aboutKey,
|
||||||
actionsKey,
|
actionsKey,
|
||||||
rowClassName,
|
rowClassName,
|
||||||
emptyText,
|
emptyText,
|
||||||
}: ChannelLayoutGridProps<T>) {
|
}: ChannelLayoutGridProps<T>) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const spec = CHANNEL_LAYOUT_SPEC[layout];
|
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 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;
|
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.
|
// Everything else flows in the meta line: not the heading, not a slotted column.
|
||||||
const meta = columns.filter(
|
const meta = columns.filter((c) => !c.cardPrimary && !slotted.has(c.key));
|
||||||
(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) => (
|
const renderCard = (row: T) => (
|
||||||
<div className={`glass-card rounded-xl p-3 h-full flex flex-col ${rowClassName?.(row) ?? ""}`}>
|
<div className={`glass-card rounded-xl p-3 h-full flex flex-col gap-2 ${rowClassName?.(row) ?? ""}`}>
|
||||||
{primary.map((col) => (
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div key={col.key} className="font-medium">
|
<div className="min-w-0 flex-1 font-medium">
|
||||||
{col.render(row)}
|
{primary.map((col) => (
|
||||||
|
<Fragment key={col.key}>{col.render(row)}</Fragment>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
{lead && <div className="shrink-0">{lead.render(row)}</div>}
|
||||||
<div className="mt-2">{metaFlow(row)}</div>
|
</div>
|
||||||
{actions && <div className="mt-auto pt-2">{actions.render(row)}</div>}
|
{about && <div className="min-w-0 text-sm">{about.render(row)}</div>}
|
||||||
</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">
|
||||||
const renderRow = (row: T) => (
|
{col.cardLabel !== false && <span className="text-muted text-xs">{col.header}</span>}
|
||||||
<div className={`glass-card rounded-xl p-3 flex items-center gap-4 ${rowClassName?.(row) ?? ""}`}>
|
{col.render(row)}
|
||||||
<div className="shrink-0 font-medium">
|
</span>
|
||||||
{primary.map((col) => (
|
|
||||||
<Fragment key={col.key}>{col.render(row)}</Fragment>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{metaFlow(row)}
|
{actions && <div className="mt-auto pt-1">{actions.render(row)}</div>}
|
||||||
{actions && <div className="ml-auto shrink-0">{actions.render(row)}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -90,9 +82,9 @@ export default function ChannelLayoutGrid<T>({
|
|||||||
items={rows}
|
items={rows}
|
||||||
getKey={rowKey}
|
getKey={rowKey}
|
||||||
minCol={spec.minCol}
|
minCol={spec.minCol}
|
||||||
maxCol={spec.maxCol}
|
maxCol={null}
|
||||||
rowEst={spec.rowEst}
|
rowEst={spec.rowEst}
|
||||||
renderItem={(row) => (layout === "cards" ? renderCard(row) : renderRow(row))}
|
renderItem={renderCard}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Rows3,
|
|
||||||
Table,
|
Table,
|
||||||
UserMinus,
|
UserMinus,
|
||||||
X,
|
X,
|
||||||
@@ -57,7 +56,6 @@ const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
|
|||||||
const LAYOUT_ICON: Record<ChannelLayout, typeof Table> = {
|
const LAYOUT_ICON: Record<ChannelLayout, typeof Table> = {
|
||||||
table: Table,
|
table: Table,
|
||||||
cards: LayoutGrid,
|
cards: LayoutGrid,
|
||||||
rows: Rows3,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Channels() {
|
export default function Channels() {
|
||||||
@@ -306,14 +304,19 @@ export default function Channels() {
|
|||||||
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
|
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
|
||||||
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
|
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
|
||||||
cardPrimary: true,
|
cardPrimary: true,
|
||||||
|
// Cap the width so a long-titled channel can't stretch the column (table-layout:auto ignores a
|
||||||
|
// cell max-width, but a max-w element INSIDE the cell bounds it — same trick AboutCell uses).
|
||||||
|
// Without this a single long name blows the column ~289→429px and overflows the centered table.
|
||||||
render: (c) => (
|
render: (c) => (
|
||||||
<ChannelLink
|
<div className="max-w-[18rem]">
|
||||||
id={c.id}
|
<ChannelLink
|
||||||
title={c.title}
|
id={c.id}
|
||||||
handle={c.handle}
|
title={c.title}
|
||||||
thumbnailUrl={c.thumbnail_url}
|
handle={c.handle}
|
||||||
onView={() => onView(c)}
|
thumbnailUrl={c.thumbnail_url}
|
||||||
/>
|
onView={() => onView(c)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -656,7 +659,8 @@ export default function Channels() {
|
|||||||
rows={visibleChannels}
|
rows={visibleChannels}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowKey={(c) => c.id}
|
rowKey={(c) => c.id}
|
||||||
layout={layout}
|
leadKey="priority"
|
||||||
|
aboutKey="about"
|
||||||
actionsKey="actions"
|
actionsKey="actions"
|
||||||
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
||||||
emptyText={t("channels.empty")}
|
emptyText={t("channels.empty")}
|
||||||
|
|||||||
@@ -11,8 +11,7 @@
|
|||||||
"layoutLabel": "Layout",
|
"layoutLabel": "Layout",
|
||||||
"layout": {
|
"layout": {
|
||||||
"table": "Table",
|
"table": "Table",
|
||||||
"cards": "Cards",
|
"cards": "Cards"
|
||||||
"rows": "Rows"
|
|
||||||
},
|
},
|
||||||
"discovery": {
|
"discovery": {
|
||||||
"intro": "Channels that appear in your playlists but that you don't subscribe to. Subscribe to follow them — their new uploads will start showing in your feed (subscribing costs a little YouTube quota; existing videos aren't re-fetched).",
|
"intro": "Channels that appear in your playlists but that you don't subscribe to. Subscribe to follow them — their new uploads will start showing in your feed (subscribing costs a little YouTube quota; existing videos aren't re-fetched).",
|
||||||
|
|||||||
@@ -11,8 +11,7 @@
|
|||||||
"layoutLabel": "Nézet",
|
"layoutLabel": "Nézet",
|
||||||
"layout": {
|
"layout": {
|
||||||
"table": "Táblázat",
|
"table": "Táblázat",
|
||||||
"cards": "Kártya",
|
"cards": "Kártya"
|
||||||
"rows": "Sor"
|
|
||||||
},
|
},
|
||||||
"discovery": {
|
"discovery": {
|
||||||
"intro": "Csatornák, amelyek megjelennek a lejátszási listáidban, de még nem iratkoztál fel rájuk. Iratkozz fel, hogy kövesd őket — az új feltöltéseik megjelennek a hírfolyamodban (a feliratkozás kevés YouTube-kvótát használ; a meglévő videókat nem tölti le újra).",
|
"intro": "Csatornák, amelyek megjelennek a lejátszási listáidban, de még nem iratkoztál fel rájuk. Iratkozz fel, hogy kövesd őket — az új feltöltéseik megjelennek a hírfolyamodban (a feliratkozás kevés YouTube-kvótát használ; a meglévő videókat nem tölti le újra).",
|
||||||
|
|||||||
@@ -1,33 +1,30 @@
|
|||||||
// The channel manager's layout vocabulary (E4 S3). Named `ChannelLayout` — NOT `ChannelView` — on
|
// The channel manager's layout vocabulary (E4 S3). Named `ChannelLayout` — NOT `ChannelView` — on
|
||||||
// purpose: the manager already has a `ChannelsView` (the subscribed/discovery/blocked TAB), and a
|
// purpose: the manager already has a `ChannelsView` (the subscribed/discovery/blocked TAB), and a
|
||||||
// one-letter-apart type would be a footgun. This is the table-vs-card-vs-row density switch, the
|
// one-letter-apart type would be a footgun. This is the table-vs-card density switch, riding the
|
||||||
// sibling of the feed's `FeedView`; it rides the same generic `ViewSwitcher`.
|
// same generic `ViewSwitcher` as the feed. Two layouts: the DataTable, and a virtualized card grid.
|
||||||
export type ChannelLayout = "table" | "cards" | "rows";
|
// (A `rows` variant existed briefly but was retired — it barely differed from the table.)
|
||||||
|
export type ChannelLayout = "table" | "cards";
|
||||||
|
|
||||||
export const CHANNEL_LAYOUTS: readonly ChannelLayout[] = ["table", "cards", "rows"] as const;
|
export const CHANNEL_LAYOUTS: readonly ChannelLayout[] = ["table", "cards"] as const;
|
||||||
|
|
||||||
const CHANNEL_LAYOUT_DEFAULT: ChannelLayout = "table";
|
const CHANNEL_LAYOUT_DEFAULT: ChannelLayout = "table";
|
||||||
|
|
||||||
/** The card/row layouts' grid spec, consumed by VirtualGrid (S3.3). `table` has no entry — it keeps
|
/** The `cards` layout's grid spec, consumed by VirtualGrid. `table` has no entry — it keeps the
|
||||||
* the numbered-pager DataTable, not the virtualized grid. Tuned by measuring the real layouts. */
|
* numbered-pager DataTable, not the virtualized grid. */
|
||||||
export interface ChannelLayoutSpec {
|
export interface ChannelLayoutSpec {
|
||||||
/** Narrowest grid column in px, or null for ONE full-width column (the row list). */
|
/** Narrowest grid column in px. */
|
||||||
minCol: number | null;
|
minCol: number;
|
||||||
/** Single-column reading-width cap in px (null = fill). */
|
/** First-paint row-height estimate; real heights are measured per-row after. Measured in the
|
||||||
maxCol: number | null;
|
* browser (channel heading + About line + meta + tags + actions), not guessed. */
|
||||||
/** First-paint row-height estimate; real heights are measured per-row after. */
|
|
||||||
rowEst: number;
|
rowEst: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// rowEst measured in the browser (cards ~176 at 3 columns, the one-line row ~62) — not guessed; a
|
|
||||||
// bad estimate makes the scrollbar jump as you first scroll in.
|
|
||||||
export const CHANNEL_LAYOUT_SPEC: Record<Exclude<ChannelLayout, "table">, ChannelLayoutSpec> = {
|
export const CHANNEL_LAYOUT_SPEC: Record<Exclude<ChannelLayout, "table">, ChannelLayoutSpec> = {
|
||||||
cards: { minCol: 300, maxCol: null, rowEst: 176 },
|
cards: { minCol: 320, rowEst: 232 },
|
||||||
rows: { minCol: null, maxCol: 1100, rowEst: 62 },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Coerce a stored value to a layout; anything unknown (corrupt, a rolled-back future layout) falls
|
/** Coerce a stored value to a layout; anything unknown (corrupt, a rolled-back future layout, or the
|
||||||
* back to the default rather than rendering nothing. */
|
* retired "rows") falls back to the default rather than rendering nothing. */
|
||||||
export function parseChannelLayout(raw: unknown): ChannelLayout {
|
export function parseChannelLayout(raw: unknown): ChannelLayout {
|
||||||
return (CHANNEL_LAYOUTS as readonly string[]).includes(raw as string)
|
return (CHANNEL_LAYOUTS as readonly string[]).includes(raw as string)
|
||||||
? (raw as ChannelLayout)
|
? (raw as ChannelLayout)
|
||||||
|
|||||||
Reference in New Issue
Block a user