feat(design): a page's background menu on the five list pages (E28 S3e4c)
A right-click on the EMPTY area of a page opens a menu of that page's own toolbar: the feed (sort, reshuffle, view, watch state, content, source, clear filters), the Plex library grid (sort, watch state, clear), the channel manager (layout, the cards' sort, sync status, edit tags, clear the tag filter, read subscriptions), a playlist (play all, group by, reset view, save order, undo/redo, the list's own menu) and the notification center (mark all read). No destructive row and no backfill. PageShell listens on the page area; lib/backgroundTarget decides empty (not text, a control, a link, media, or a row/card with its own menu). Shift+right- click, a touch long-press and a right-click on selected words keep the browser's menu. ui/PageMenu registers while mounted, so a loading or error state keeps the browser's menu too. The rows mirror each control through ui/pageMenuItems (sortSubmenu via pickSort, choiceSubmenu, multiSubmenu). pageMenuScope.contract.test.ts pins the five pages.
This commit is contained in:
@@ -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 = (
|
||||
<>
|
||||
<SortControl
|
||||
options={sortOptions}
|
||||
sort={{ key: sortKey, dir: sortDir }}
|
||||
onChange={(next) => {
|
||||
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" ? (
|
||||
<IconButton
|
||||
variant="outline"
|
||||
onClick={() => setFilters({ ...filters, seed: rollSeed() })}
|
||||
label={t("sidebar.reshuffle")}
|
||||
>
|
||||
<IconButton variant="outline" onClick={reshuffle} label={t("sidebar.reshuffle")}>
|
||||
<RefreshCw />
|
||||
</IconButton>
|
||||
) : undefined
|
||||
@@ -667,11 +692,7 @@ export default function Feed({
|
||||
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
|
||||
<ViewSwitcher
|
||||
value={view}
|
||||
options={FEED_VIEWS.map((id) => ({
|
||||
id,
|
||||
label: t("feed.view." + id),
|
||||
icon: VIEW_ICON[id],
|
||||
}))}
|
||||
options={viewOptions}
|
||||
onChange={setView}
|
||||
label={t("feed.viewLabel")}
|
||||
// The mode's name only earns its place once the window is wide.
|
||||
@@ -698,21 +719,17 @@ export default function Feed({
|
||||
<FilterSelect
|
||||
label={t("feed.showLabel")}
|
||||
testId="feed-show"
|
||||
options={SHOW_IDS.map((id) => ({ value: id, label: t("sidebar.show." + id) }))}
|
||||
options={showOptions}
|
||||
selected={[filters.show]}
|
||||
onChange={(next) => setFilters({ ...filters, show: next[0] as FeedFilters["show"] })}
|
||||
onChange={(next) => next[0] && setShow(next[0])}
|
||||
/>
|
||||
<FilterSelect
|
||||
label={t("feed.contentLabel")}
|
||||
testId="feed-content"
|
||||
multiple
|
||||
options={CONTENT.map((c) => ({ value: c.key, label: t(c.label) }))}
|
||||
selected={CONTENT.filter((c) => filters[c.key]).map((c) => c.key)}
|
||||
onChange={(next) => {
|
||||
const patch = { ...filters };
|
||||
for (const c of CONTENT) patch[c.key] = next.includes(c.key);
|
||||
setFilters(patch);
|
||||
}}
|
||||
options={contentOptions}
|
||||
selected={contentSelected}
|
||||
onChange={setContent}
|
||||
/>
|
||||
{/* The Source filter (organic / include-search / search-only) is a global-catalog
|
||||
concept — it selects how videos ENTERED the library. On a channel-scoped view we
|
||||
@@ -722,18 +739,9 @@ export default function Feed({
|
||||
<FilterSelect
|
||||
label={t("feed.source.label")}
|
||||
testId="feed-source"
|
||||
options={[
|
||||
{ value: "organic", label: t("feed.source.organic") },
|
||||
{ value: "all", label: t("feed.source.all") },
|
||||
{ value: "search", label: t("feed.source.only") },
|
||||
]}
|
||||
options={sourceOptions}
|
||||
selected={[filters.librarySource ?? "organic"]}
|
||||
onChange={(next) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
librarySource: next[0] as FeedFilters["librarySource"],
|
||||
})
|
||||
}
|
||||
onChange={(next) => next[0] && setSource(next[0])}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -746,8 +754,70 @@ export default function Feed({
|
||||
</div>
|
||||
);
|
||||
|
||||
// The page's background menu (DESIGN "Background menu"): the toolbar's own controls, no more.
|
||||
const pageMenuItems = menuGroups(
|
||||
[
|
||||
sortSubmenu({
|
||||
id: "sort",
|
||||
label: t("feed.sortLabel"),
|
||||
options: sortOptions,
|
||||
sort: { key: sortKey, dir: sortDir },
|
||||
onChange: setSort,
|
||||
ascLabel: t("feed.dirAsc"),
|
||||
descLabel: t("feed.dirDesc"),
|
||||
}),
|
||||
...(sortKey === "shuffle"
|
||||
? [{ id: "reshuffle", label: t("sidebar.reshuffle"), onSelect: reshuffle }]
|
||||
: []),
|
||||
choiceSubmenu({
|
||||
id: "view",
|
||||
label: t("feed.viewLabel"),
|
||||
options: viewOptions.map((o) => ({ value: o.id, label: o.label })),
|
||||
selected: view,
|
||||
onPick: setView,
|
||||
}),
|
||||
],
|
||||
[
|
||||
choiceSubmenu({
|
||||
id: "show",
|
||||
label: t("feed.showLabel"),
|
||||
options: showOptions,
|
||||
selected: filters.show,
|
||||
onPick: setShow,
|
||||
}),
|
||||
multiSubmenu({
|
||||
id: "content",
|
||||
label: t("feed.contentLabel"),
|
||||
options: contentOptions,
|
||||
selected: contentSelected,
|
||||
onChange: setContent,
|
||||
}),
|
||||
...(channelScoped
|
||||
? []
|
||||
: [
|
||||
choiceSubmenu({
|
||||
id: "source",
|
||||
label: t("feed.source.label"),
|
||||
options: sourceOptions,
|
||||
selected: filters.librarySource ?? "organic",
|
||||
onPick: setSource,
|
||||
}),
|
||||
]),
|
||||
],
|
||||
!channelScoped && activeFilters.length > 0
|
||||
? [
|
||||
{
|
||||
id: "clear",
|
||||
label: t("sidebar.clearAll"),
|
||||
onSelect: () => setFilters(clearedFilters(filters)),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={channelScoped ? "px-4 sm:px-6 pb-4" : "px-4 pb-4 pt-1"}>
|
||||
<PageMenu ariaLabel={t("common.pageMenu")} items={pageMenuItems} />
|
||||
{/* The toolbar (+ active-filter chips) is FIXED chrome: it portals into a PageToolbarSlot so
|
||||
it stays put while the cards scroll (bug 4). On the feed page that slot is the shell's top
|
||||
band; on a channel page ChannelPage overrides the slot with its own sticky sub-header, so
|
||||
|
||||
Reference in New Issue
Block a user