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",