diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index d6983e06..d5ea8171 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -39,6 +39,10 @@ import { sortRows } from "../lib/columnSort"; import { filterRows } from "../lib/tableColumns"; import { useCardPager } from "../lib/useCardPager"; import { PageToolbar } from "./ui/PageShell"; +import { PageMenu } from "./ui/PageMenu"; +import { menuGroups } from "./ui/menuGroups"; +import { choiceSubmenu, sortSubmenu } from "./ui/pageMenuItems"; +import { optionsFromColumns } from "../lib/sortOptions"; import ChannelDiscovery from "./ChannelDiscovery"; import TagManager from "./TagManager"; import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext"; @@ -516,8 +520,65 @@ export default function Channels() { ); + // The page's background menu (DESIGN "Background menu"): the toolbar's own controls, no more — the + // layout, the cards' sort, the status and tag filters, the tag editor and the catalog sync. Backfill + // stays in the "…" only: a long quota-heavy run is not a thing to start from a stray right-click. + const syncOptions = SYNC_CHIPS.map((c) => ({ value: c.value, label: t(c.labelKey) })); + const pageMenuItems = menuGroups( + hasLayout + ? [ + choiceSubmenu({ + id: "layout", + label: t("channels.layoutLabel"), + options: layoutOptions.map((o) => ({ value: o.id, label: o.label })), + selected: layout, + onPick: setLayout, + }), + ] + : [], + view === "subscribed" && isCards + ? [ + sortSubmenu({ + id: "sort", + label: t("channels.sort.label"), + options: optionsFromColumns(columns), + sort, + onChange: setSort, + ascLabel: t("channels.sort.asc"), + descLabel: t("channels.sort.desc"), + defaultLabel: t("channels.sort.default"), + }), + ] + : [], + view === "subscribed" + ? [ + choiceSubmenu({ + id: "status", + label: t("channels.filters.syncLabel"), + options: syncOptions, + selected: syncFilter, + onPick: (sync) => setFilters({ ...filters, sync }), + }), + { id: "tags", label: t("channels.tags.edit"), onSelect: () => setTagManagerOpen(true) }, + ...(tagFilter.length > 0 + ? [ + { + id: "clearTags", + label: t("channels.tags.clearFilter"), + onSelect: () => setFilters({ ...filters, tags: [] }), + }, + ] + : []), + ] + : [], + view === "subscribed" ? catalogActions.filter((a) => a.id === "sync") : [], + ); + return ( <> + {pageMenuItems.length > 0 && ( + + )} {/* The tab bar is always fixed chrome (it portals into the shell's fixed band, so it never scrolls and doesn't jump between tabs). On Subscriptions the stats/description/tags ride with it, and the status-chip + pager row lands just below (DataTable's controlsInBand). */} diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 0ab343f6..70fa3af6 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -44,6 +44,9 @@ import SelectedViewChip from "./SelectedViewChip"; import { Button } from "./ui/Button"; import { LoadingState, StateMessage } from "./ui/QueryState"; import { PageToolbar } from "./ui/PageShell"; +import { PageMenu } from "./ui/PageMenu"; +import { menuGroups } from "./ui/menuGroups"; +import { choiceSubmenu, multiSubmenu, sortSubmenu } from "./ui/pageMenuItems"; import { useWizard } from "./wizardContext"; import SortControl from "./ui/SortControl"; import FilterSelect from "./ui/FilterSelect"; @@ -636,29 +639,51 @@ export default function Feed({ // Sort + view mode are tightly bound to the grid below, so they live pinned to the right of the // applied-filter row (directly above the cards), not up in the filter toolbar. Extracted here so the // same controls render on both the feed and channel-scoped rows. + // One setter per toolbar control, shared with the page's background menu (the same choices, the + // same effect, two doors). + const setSort = (next: SortState) => { + if (!next) return; // the feed is always sorted — no "no sort" option is offered + setFilters({ + ...filters, + sort: buildSort(next.key as SortKey, next.dir), + seed: next.key === "shuffle" ? rollSeed() : undefined, + }); + }; + const reshuffle = () => setFilters({ ...filters, seed: rollSeed() }); + const showOptions = SHOW_IDS.map((id) => ({ value: id, label: t("sidebar.show." + id) })); + const setShow = (show: string) => setFilters({ ...filters, show: show as FeedFilters["show"] }); + const contentOptions = CONTENT.map((c) => ({ value: c.key, label: t(c.label) })); + const contentSelected = CONTENT.filter((c) => filters[c.key]).map((c) => c.key); + const setContent = (next: string[]) => { + const patch = { ...filters }; + for (const c of CONTENT) patch[c.key] = next.includes(c.key); + setFilters(patch); + }; + const sourceOptions = [ + { value: "organic", label: t("feed.source.organic") }, + { value: "all", label: t("feed.source.all") }, + { value: "search", label: t("feed.source.only") }, + ]; + const setSource = (source: string) => + setFilters({ ...filters, librarySource: source as FeedFilters["librarySource"] }); + const viewOptions = FEED_VIEWS.map((id) => ({ + id, + label: t("feed.view." + id), + icon: VIEW_ICON[id], + })); + const sortView = ( <> { - if (!next) return; // the feed is always sorted — no "no sort" option is offered - setFilters({ - ...filters, - sort: buildSort(next.key as SortKey, next.dir), - seed: next.key === "shuffle" ? rollSeed() : undefined, - }); - }} + onChange={setSort} label={t("feed.sortLabel")} ascLabel={t("feed.dirAsc")} descLabel={t("feed.dirDesc")} trailing={ sortKey === "shuffle" ? ( - setFilters({ ...filters, seed: rollSeed() })} - label={t("sidebar.reshuffle")} - > + ) : undefined @@ -667,11 +692,7 @@ export default function Feed({