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
+17 -4
View File
@@ -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 (