diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx
index cebf912d..3bdc8029 100644
--- a/frontend/src/components/ChannelDiscovery.tsx
+++ b/frontend/src/components/ChannelDiscovery.tsx
@@ -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({
{query.isLoading ? (
{t("channels.discovery.loading")}
- ) : (
+ ) : layout === "table" ? (
+ ) : (
+
c.id}
+ layout={layout}
+ actionsKey="actions"
+ emptyText={t("channels.discovery.empty")}
+ />
)}
>
diff --git a/frontend/src/components/ChannelLayoutGrid.tsx b/frontend/src/components/ChannelLayoutGrid.tsx
new file mode 100644
index 00000000..31a681e9
--- /dev/null
+++ b/frontend/src/components/ChannelLayoutGrid.tsx
@@ -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` 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 {
+ rows: T[];
+ columns: Column[];
+ 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({
+ rows,
+ columns,
+ rowKey,
+ layout,
+ actionsKey,
+ rowClassName,
+ emptyText,
+}: ChannelLayoutGridProps) {
+ 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) => (
+
+ {meta.map((col) => (
+
+ {col.cardLabel !== false && {col.header}}
+ {col.render(row)}
+
+ ))}
+
+ );
+
+ const renderCard = (row: T) => (
+
+ {primary.map((col) => (
+
+ {col.render(row)}
+
+ ))}
+
{metaFlow(row)}
+ {actions &&
{actions.render(row)}
}
+
+ );
+
+ const renderRow = (row: T) => (
+
+
{primary.map((col) => col.render(row))}
+ {metaFlow(row)}
+ {actions &&
{actions.render(row)}
}
+
+ );
+
+ if (rows.length === 0) {
+ return (
+ {emptyText ?? t("datatable.empty")}
+ );
+ }
+
+ return (
+ (layout === "cards" ? renderCard(row) : renderRow(row))}
+ />
+ );
+}
diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx
index 12d53b69..31a55752 100644
--- a/frontend/src/components/Channels.tsx
+++ b/frontend/src/components/Channels.tsx
@@ -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 = {
+ 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() {
);
+ // 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[] = CHANNEL_LAYOUTS.map((id) => ({
+ id,
+ label: t("channels.layout." + id),
+ icon: LAYOUT_ICON[id],
+ }));
+ const layoutSwitcher = (
+
+ );
+ const hasLayout = view === "subscribed" || view === "discovery";
+
const tabs = (
@@ -480,7 +512,10 @@ export default function Channels() {
))}
- {view === "subscribed" && statsBlock}
+
+ {view === "subscribed" && statsBlock}
+ {hasLayout && layoutSwitcher}
+
);
@@ -541,6 +576,11 @@ export default function Channels() {
)}
+ {/* 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" && (
+ {statusChips}
+ )}
{tagManagerOpen && (
setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
@@ -594,10 +634,11 @@ export default function Channels() {
visual gap). */
{/* 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 ? (
{t("channels.loading")}
- ) : (
+ ) : layout === "table" ? (
(c.hidden ? "opacity-60" : "")}
emptyText={t("channels.empty")}
/>
+ ) : (
+ c.id}
+ layout={layout}
+ actionsKey="actions"
+ rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
+ emptyText={t("channels.empty")}
+ />
)}
)}
diff --git a/frontend/src/components/ChannelsProvider.tsx b/frontend/src/components/ChannelsProvider.tsx
index 0ccdb6b1..136334bc 100644
--- a/frontend/src/components/ChannelsProvider.tsx
+++ b/frontend/src/components/ChannelsProvider.tsx
@@ -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({
search: "",
statusFilter: "all",
view: "subscribed",
+ layout: "table",
filtersResetToken: 0,
focusName: null,
focusToken: 0,
@@ -73,6 +78,7 @@ const ActionsContext = createContext({
setSearch: () => {},
setStatusFilter: () => {},
setView: () => {},
+ setLayout: () => {},
focusChannel: () => {},
goToFullHistory: () => {},
});
@@ -95,6 +101,9 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
const [view, setViewState] = useState(() =>
clampView(getAccountRaw(LS.channelsView)),
);
+ const [layout, setLayoutState] = useState(() =>
+ parseChannelLayout(getAccountRaw(LS.channelLayout)),
+ );
const [filtersResetToken, setFiltersResetToken] = useState(0);
const [focusName, setFocusName] = useState(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(
- () => ({ 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(
- () => ({ setSearch, setStatusFilter, setView, focusChannel, goToFullHistory }),
- [setSearch, setStatusFilter, setView, focusChannel, goToFullHistory],
+ () => ({ setSearch, setStatusFilter, setView, setLayout, focusChannel, goToFullHistory }),
+ [setSearch, setStatusFilter, setView, setLayout, focusChannel, goToFullHistory],
);
return (
diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json
index 2888f8aa..900db26b 100644
--- a/frontend/src/i18n/locales/en/channels.json
+++ b/frontend/src/i18n/locales/en/channels.json
@@ -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…",
diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json
index 6233482d..7a1e68d5 100644
--- a/frontend/src/i18n/locales/hu/channels.json
+++ b/frontend/src/i18n/locales/hu/channels.json
@@ -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…",
diff --git a/frontend/src/lib/channelLayout.ts b/frontend/src/lib/channelLayout.ts
new file mode 100644
index 00000000..25bd9f1f
--- /dev/null
+++ b/frontend/src/lib/channelLayout.ts
@@ -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, 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;
+}
diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts
index 8487310f..4c98082c 100644
--- a/frontend/src/lib/storage.ts
+++ b/frontend/src/lib/storage.ts
@@ -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",