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:
@@ -11,8 +11,10 @@ import Tooltip from "./Tooltip";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
import AboutCell from "./AboutCell";
|
||||
import DataTable, { type Column } from "./DataTable";
|
||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { useChannels } from "./ChannelsProvider";
|
||||
|
||||
// The Channel manager's "Discovery" tab: channels that turn up in the user's playlists but
|
||||
// that they don't subscribe to. Subscribing here only creates the subscription + enriches
|
||||
@@ -32,6 +34,8 @@ export default function ChannelDiscovery({
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
// The table/cards/rows density is shared with the Subscriptions tab (ChannelsProvider).
|
||||
const { layout } = useChannels();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["discovered-channels"],
|
||||
@@ -172,7 +176,7 @@ export default function ChannelDiscovery({
|
||||
<div className="px-4 pb-4 max-w-[96rem] mx-auto">
|
||||
{query.isLoading ? (
|
||||
<div className="text-muted py-8">{t("channels.discovery.loading")}</div>
|
||||
) : (
|
||||
) : layout === "table" ? (
|
||||
<DataTable
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
@@ -183,6 +187,15 @@ export default function ChannelDiscovery({
|
||||
controlsBandClassName="px-4 max-w-[96rem] mx-auto"
|
||||
emptyText={t("channels.discovery.empty")}
|
||||
/>
|
||||
) : (
|
||||
<ChannelLayoutGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
rowKey={(c) => c.id}
|
||||
layout={layout}
|
||||
actionsKey="actions"
|
||||
emptyText={t("channels.discovery.empty")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -9,9 +9,12 @@ import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
History,
|
||||
LayoutGrid,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Rows3,
|
||||
Table,
|
||||
UserMinus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
@@ -25,6 +28,9 @@ import { subsColumn } from "./channelColumns";
|
||||
import { notify } from "../lib/notifications";
|
||||
import Tooltip from "./Tooltip";
|
||||
import DataTable, { type Column } from "./DataTable";
|
||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||
import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
import AboutCell from "./AboutCell";
|
||||
@@ -48,6 +54,12 @@ const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
|
||||
{ id: "hidden", labelKey: "channels.filters.hidden" },
|
||||
];
|
||||
|
||||
const LAYOUT_ICON: Record<ChannelLayout, typeof Table> = {
|
||||
table: Table,
|
||||
cards: LayoutGrid,
|
||||
rows: Rows3,
|
||||
};
|
||||
|
||||
export default function Channels() {
|
||||
const me = useMe();
|
||||
const canWrite = me.can_write;
|
||||
@@ -61,11 +73,13 @@ export default function Channels() {
|
||||
search,
|
||||
statusFilter,
|
||||
view,
|
||||
layout,
|
||||
filtersResetToken,
|
||||
focusName: focusChannelName,
|
||||
focusToken: focusChannelToken,
|
||||
} = useChannels();
|
||||
const { setSearch, setStatusFilter, setView, focusChannel: onFocusChannel } = useChannelsActions();
|
||||
const { setSearch, setStatusFilter, setView, setLayout, focusChannel: onFocusChannel } =
|
||||
useChannelsActions();
|
||||
const { openChannel: onViewChannel, setPage } = useNavigationActions();
|
||||
const feedFilters = useFeedFilters();
|
||||
const { setFilters: setFeedFilters } = useFeedFiltersActions();
|
||||
@@ -463,6 +477,24 @@ export default function Channels() {
|
||||
</div>
|
||||
);
|
||||
|
||||
// Table / cards / rows density — shared by the subscribed + discovery tables (blocked is a chip
|
||||
// list, no layout to switch). Rides the same generic ViewSwitcher the feed uses.
|
||||
const layoutOptions: ViewOption<ChannelLayout>[] = CHANNEL_LAYOUTS.map((id) => ({
|
||||
id,
|
||||
label: t("channels.layout." + id),
|
||||
icon: LAYOUT_ICON[id],
|
||||
}));
|
||||
const layoutSwitcher = (
|
||||
<ViewSwitcher
|
||||
value={layout}
|
||||
options={layoutOptions}
|
||||
onChange={setLayout}
|
||||
label={t("channels.layoutLabel")}
|
||||
labelClassName="hidden lg:inline"
|
||||
/>
|
||||
);
|
||||
const hasLayout = view === "subscribed" || view === "discovery";
|
||||
|
||||
const tabs = (
|
||||
<div className="px-4 pt-4 max-w-[96rem] mx-auto flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
@@ -480,7 +512,10 @@ export default function Channels() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{view === "subscribed" && statsBlock}
|
||||
<div className="flex items-center gap-3">
|
||||
{view === "subscribed" && statsBlock}
|
||||
{hasLayout && layoutSwitcher}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -541,6 +576,11 @@ export default function Channels() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* In table mode the status chips ride in DataTable's own in-band controls row (beside the
|
||||
pager). The card/row layouts have no such row, so surface the same chips here. */}
|
||||
{view === "subscribed" && layout !== "table" && (
|
||||
<div className="px-4 pb-2 max-w-[96rem] mx-auto">{statusChips}</div>
|
||||
)}
|
||||
</PageToolbar>
|
||||
{tagManagerOpen && (
|
||||
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
|
||||
@@ -594,10 +634,11 @@ export default function Channels() {
|
||||
visual gap). */
|
||||
<div className="px-4 pb-4 max-w-[96rem] mx-auto">
|
||||
{/* Channel table — only the rows scroll; the header row is sticky, and the controls row
|
||||
(status chips + pager) rides in the fixed band right above it (controlsInBand). */}
|
||||
(status chips + pager) rides in the fixed band right above it (controlsInBand). The
|
||||
card/row layouts drop the table + pager for a virtualized grid over the same rows. */}
|
||||
{channelsQuery.isLoading ? (
|
||||
<div className="text-muted py-8">{t("channels.loading")}</div>
|
||||
) : (
|
||||
) : layout === "table" ? (
|
||||
<DataTable
|
||||
rows={visibleChannels}
|
||||
columns={columns}
|
||||
@@ -610,6 +651,16 @@ export default function Channels() {
|
||||
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
||||
emptyText={t("channels.empty")}
|
||||
/>
|
||||
) : (
|
||||
<ChannelLayoutGrid
|
||||
rows={visibleChannels}
|
||||
columns={columns}
|
||||
rowKey={(c) => c.id}
|
||||
layout={layout}
|
||||
actionsKey="actions"
|
||||
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
||||
emptyText={t("channels.empty")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||
import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout";
|
||||
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||
import type { ChannelStatusFilter, ChannelsView } from "./Channels";
|
||||
|
||||
@@ -41,6 +42,8 @@ type ChannelsState = {
|
||||
search: string;
|
||||
statusFilter: ChannelStatusFilter;
|
||||
view: ChannelsView;
|
||||
// The table/cards/rows density (shared by the subscribed + discovery tabs).
|
||||
layout: ChannelLayout;
|
||||
// Bumped to tell the manager to drop a stale column filter when an intent sends the user to a
|
||||
// specific set (the header's "without full history" link).
|
||||
filtersResetToken: number;
|
||||
@@ -54,6 +57,7 @@ interface ChannelsActions {
|
||||
setSearch: (q: string) => void;
|
||||
setStatusFilter: (f: ChannelStatusFilter) => void;
|
||||
setView: (v: ChannelsView) => void;
|
||||
setLayout: (l: ChannelLayout) => void;
|
||||
// "Focus this channel in the manager": jump to Channels (subscriptions tab) and seed its name
|
||||
// filter so the channel is isolated. Called from the feed sidebar and the notifications inbox.
|
||||
focusChannel: (name: string) => void;
|
||||
@@ -65,6 +69,7 @@ const StateContext = createContext<ChannelsState>({
|
||||
search: "",
|
||||
statusFilter: "all",
|
||||
view: "subscribed",
|
||||
layout: "table",
|
||||
filtersResetToken: 0,
|
||||
focusName: null,
|
||||
focusToken: 0,
|
||||
@@ -73,6 +78,7 @@ const ActionsContext = createContext<ChannelsActions>({
|
||||
setSearch: () => {},
|
||||
setStatusFilter: () => {},
|
||||
setView: () => {},
|
||||
setLayout: () => {},
|
||||
focusChannel: () => {},
|
||||
goToFullHistory: () => {},
|
||||
});
|
||||
@@ -95,6 +101,9 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
const [view, setViewState] = useState<ChannelsView>(() =>
|
||||
clampView(getAccountRaw(LS.channelsView)),
|
||||
);
|
||||
const [layout, setLayoutState] = useState<ChannelLayout>(() =>
|
||||
parseChannelLayout(getAccountRaw(LS.channelLayout)),
|
||||
);
|
||||
const [filtersResetToken, setFiltersResetToken] = useState(0);
|
||||
const [focusName, setFocusName] = useState<string | null>(null);
|
||||
const [focusToken, setFocusToken] = useState(0);
|
||||
@@ -108,6 +117,10 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
setViewState(v);
|
||||
setAccountRaw(LS.channelsView, v);
|
||||
}, []);
|
||||
const setLayout = useCallback((l: ChannelLayout) => {
|
||||
setLayoutState(l);
|
||||
setAccountRaw(LS.channelLayout, l);
|
||||
}, []);
|
||||
|
||||
const focusChannel = useCallback(
|
||||
(name: string) => {
|
||||
@@ -131,12 +144,12 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
}, [page]);
|
||||
|
||||
const state = useMemo<ChannelsState>(
|
||||
() => ({ search, statusFilter, view, filtersResetToken, focusName, focusToken }),
|
||||
[search, statusFilter, view, filtersResetToken, focusName, focusToken],
|
||||
() => ({ search, statusFilter, view, layout, filtersResetToken, focusName, focusToken }),
|
||||
[search, statusFilter, view, layout, filtersResetToken, focusName, focusToken],
|
||||
);
|
||||
const actions = useMemo<ChannelsActions>(
|
||||
() => ({ setSearch, setStatusFilter, setView, focusChannel, goToFullHistory }),
|
||||
[setSearch, setStatusFilter, setView, focusChannel, goToFullHistory],
|
||||
() => ({ setSearch, setStatusFilter, setView, setLayout, focusChannel, goToFullHistory }),
|
||||
[setSearch, setStatusFilter, setView, setLayout, focusChannel, goToFullHistory],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
"discovery": "Discover from playlists",
|
||||
"blocked": "Blocked"
|
||||
},
|
||||
"layoutLabel": "Layout",
|
||||
"layout": {
|
||||
"table": "Table",
|
||||
"cards": "Cards",
|
||||
"rows": "Rows"
|
||||
},
|
||||
"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).",
|
||||
"loading": "Finding channels…",
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
"discovery": "Felfedezés a lejátszási listákból",
|
||||
"blocked": "Letiltott"
|
||||
},
|
||||
"layoutLabel": "Nézet",
|
||||
"layout": {
|
||||
"table": "Táblázat",
|
||||
"cards": "Kártya",
|
||||
"rows": "Sor"
|
||||
},
|
||||
"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).",
|
||||
"loading": "Csatornák keresése…",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// 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
|
||||
// one-letter-apart type would be a footgun. This is the table-vs-card-vs-row density switch, the
|
||||
// sibling of the feed's `FeedView`; it rides the same generic `ViewSwitcher`.
|
||||
export type ChannelLayout = "table" | "cards" | "rows";
|
||||
|
||||
export const CHANNEL_LAYOUTS: readonly ChannelLayout[] = ["table", "cards", "rows"] as const;
|
||||
|
||||
const CHANNEL_LAYOUT_DEFAULT: ChannelLayout = "table";
|
||||
|
||||
/** The card/row layouts' grid spec, consumed by VirtualGrid (S3.3). `table` has no entry — it keeps
|
||||
* the numbered-pager DataTable, not the virtualized grid. Tuned by measuring the real layouts. */
|
||||
export interface ChannelLayoutSpec {
|
||||
/** Narrowest grid column in px, or null for ONE full-width column (the row list). */
|
||||
minCol: number | null;
|
||||
/** Single-column reading-width cap in px (null = fill). */
|
||||
maxCol: number | null;
|
||||
/** First-paint row-height estimate; real heights are measured per-row after. */
|
||||
rowEst: number;
|
||||
}
|
||||
|
||||
export const CHANNEL_LAYOUT_SPEC: Record<Exclude<ChannelLayout, "table">, ChannelLayoutSpec> = {
|
||||
cards: { minCol: 300, maxCol: null, rowEst: 210 },
|
||||
rows: { minCol: null, maxCol: 1100, rowEst: 88 },
|
||||
};
|
||||
|
||||
/** Coerce a stored value to a layout; anything unknown (corrupt, a rolled-back future layout) falls
|
||||
* back to the default rather than rendering nothing. */
|
||||
export function parseChannelLayout(raw: unknown): ChannelLayout {
|
||||
return (CHANNEL_LAYOUTS as readonly string[]).includes(raw as string)
|
||||
? (raw as ChannelLayout)
|
||||
: CHANNEL_LAYOUT_DEFAULT;
|
||||
}
|
||||
@@ -24,6 +24,8 @@ export const LS = {
|
||||
feedView: "siftlode.feedView",
|
||||
channelFilter: "siftlode.channelFilter",
|
||||
channelsView: "siftlode.channelsView",
|
||||
// The channel manager's layout (table / cards / rows — see lib/channelLayout.ts), per-account.
|
||||
channelLayout: "siftlode.channelLayout",
|
||||
channelsTable: "siftlode.channelsTable",
|
||||
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
|
||||
settingsTab: "siftlode.settingsTab",
|
||||
|
||||
Reference in New Issue
Block a user