The 43 react-refresh/only-export-components warnings the gate let through meant an edit to a provider (or AnchoredPopover, Toaster, plexDetailUi, …) could not be hot-swapped: Fast Refresh reloads a module that also exports hooks or helpers. - Each provider's contexts, the types its hooks return and the hooks themselves live in a sibling context module (navigationContext.ts, channelsContext.ts, …); the provider file keeps the component. loadAccountFilters moved next to the feed filters' defaults (feedFiltersInit.ts), following navigationInit/playlistsInit. - Helpers that sat in component files have their own modules: welcomeMode, downloadTitle, plexPlayable, channelSync, ui/anchoredPopoverHooks, ui/pageToolbarSlot, ui/pagerContent, ui/toastLevels, ui/overlayOrder, ui/tableAlign, lib/plexDetail; the cells of the channel columns and the registry's page wrappers are components files of their own (channelCells, modulePages). - usePersistedTab, an alias of useAccountPersistedState, is gone; its callers use the original. npm run lint is now eslint --max-warnings 0, so a warning fails the gate like an error does.
175 lines
6.1 KiB
TypeScript
175 lines
6.1 KiB
TypeScript
import { useMemo } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { Keyboard, ListVideo, Palette, Sparkles, Tv } from "lucide-react";
|
|
import CommandPalette, { type CommandGroup } from "./ui/CommandPalette";
|
|
import { api } from "../lib/api";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { useMe } from "../lib/useMe";
|
|
import { moduleDef, moduleOrder } from "../lib/modules";
|
|
import { SCHEMES, type Scheme } from "../lib/theme";
|
|
import { closePalette, toggleKeyboardHelp, usePaletteOpen } from "../lib/keymap";
|
|
import { requestSurprise } from "../lib/homeIntent";
|
|
import { useNavigationActions } from "./navigationContext";
|
|
import { usePlaylistsActions } from "./playlistsContext";
|
|
import { usePrefs } from "./prefsContext";
|
|
import { useSavedViews } from "./savedViewsContext";
|
|
|
|
// What Ctrl/Cmd+K can reach. The generic list/filter/keyboard shell is `ui/CommandPalette`; this is
|
|
// the domain half — the layer boundary says a primitive may not import `lib/api` or a module.
|
|
//
|
|
// Scope decided with the product owner (2026-09-05): navigation + app-wide ACTIONS. The actions here
|
|
// are the reversible, local ones — the shortcuts sheet and the theme. Deliberately NOT here: "Sync
|
|
// from YouTube". It is a long-running job against a quota, it wants the progress and error surface
|
|
// the Channels page already gives it, and one mistyped Enter is a worse failure than the walk it
|
|
// would have saved.
|
|
//
|
|
// Saved views come from whichever module owns the current SavedViewsProvider source (feed on the
|
|
// feed, Plex on Plex), i.e. exactly the views that are applicable where you stand.
|
|
export default function AppCommandPalette() {
|
|
const { t } = useTranslation();
|
|
const open = usePaletteOpen();
|
|
const me = useMe();
|
|
const { setPage, openChannel } = useNavigationActions();
|
|
const { setSelectedId } = usePlaylistsActions();
|
|
const { theme, setTheme } = usePrefs();
|
|
const savedViews = useSavedViews();
|
|
|
|
// Only fetch the lists while the palette is actually open — it is a rarely-opened surface and
|
|
// these are otherwise the feed's and the playlist rail's own queries, shared through the cache.
|
|
const channelsQ = useQuery({
|
|
queryKey: qk.channels(),
|
|
queryFn: () => api.channels(),
|
|
enabled: open,
|
|
});
|
|
const playlistsQ = useQuery({
|
|
queryKey: qk.playlists(),
|
|
queryFn: () => api.playlists(),
|
|
enabled: open,
|
|
});
|
|
|
|
const groups = useMemo<CommandGroup[]>(() => {
|
|
const out: CommandGroup[] = [];
|
|
|
|
// Modules, in nav-rail order and gated by the account — the registry decides, not this file.
|
|
if (me) {
|
|
const pages = [...moduleOrder(me), "settings" as const];
|
|
out.push({
|
|
id: "modules",
|
|
label: t("palette.groups.modules"),
|
|
items: pages.map((page) => {
|
|
const def = moduleDef(page);
|
|
const Icon = def.icon;
|
|
return {
|
|
id: `page:${page}`,
|
|
label: t(def.labelKey),
|
|
icon: <Icon className="w-4 h-4" />,
|
|
// The longer tab title is a second name for the same place ("Channel manager" for
|
|
// "Channels"), so match on it even though the row shows the short one.
|
|
keywords: def.titleKey ? t(def.titleKey) : "",
|
|
run: () => setPage(page),
|
|
};
|
|
}),
|
|
});
|
|
}
|
|
|
|
const channels = channelsQ.data ?? [];
|
|
if (channels.length > 0) {
|
|
out.push({
|
|
id: "channels",
|
|
label: t("palette.groups.channels"),
|
|
items: channels
|
|
.filter((c) => !c.hidden)
|
|
.map((c) => ({
|
|
id: `channel:${c.id}`,
|
|
label: c.title ?? c.id,
|
|
hint: c.handle ?? undefined,
|
|
icon: <Tv className="w-4 h-4" />,
|
|
keywords: c.handle ?? "",
|
|
run: () => openChannel(c.id, c.title ?? undefined),
|
|
})),
|
|
});
|
|
}
|
|
|
|
const playlists = playlistsQ.data ?? [];
|
|
if (playlists.length > 0) {
|
|
out.push({
|
|
id: "playlists",
|
|
label: t("palette.groups.playlists"),
|
|
items: playlists.map((p) => ({
|
|
id: `playlist:${p.id}`,
|
|
label: p.name,
|
|
hint: t("palette.videoCount", { count: p.item_count }),
|
|
icon: <ListVideo className="w-4 h-4" />,
|
|
run: () => {
|
|
setSelectedId(p.id);
|
|
setPage("playlists");
|
|
},
|
|
})),
|
|
});
|
|
}
|
|
|
|
if (savedViews.views.length > 0) {
|
|
out.push({
|
|
id: "views",
|
|
label: t("palette.groups.views"),
|
|
items: savedViews.views.map((v) => ({
|
|
id: `view:${v.id}`,
|
|
label: v.name,
|
|
run: () => savedViews.apply(v),
|
|
})),
|
|
});
|
|
}
|
|
|
|
out.push({
|
|
id: "actions",
|
|
label: t("palette.groups.actions"),
|
|
items: [
|
|
// "Meglepetés" — Home's reroll, reachable from anywhere (E24). There is deliberately no
|
|
// second "Mit nézzek?" row beside it: the Home module row above ALREADY matches that phrase,
|
|
// because the palette searches a module's descriptive `titleKey` too and Home's is literally
|
|
// "Mit nézzek most?". A row that navigates where an existing row already navigates would be
|
|
// duplication in the one list whose whole job is to be scanned quickly.
|
|
{
|
|
id: "action:surprise",
|
|
label: t("palette.actions.surprise"),
|
|
icon: <Sparkles className="w-4 h-4" />,
|
|
keywords: t("home.title"),
|
|
run: () => {
|
|
setPage("home");
|
|
requestSurprise();
|
|
},
|
|
},
|
|
{
|
|
id: "action:shortcuts",
|
|
label: t("keys.title"),
|
|
icon: <Keyboard className="w-4 h-4" />,
|
|
run: toggleKeyboardHelp,
|
|
},
|
|
...SCHEMES.map((s) => ({
|
|
id: `action:theme:${s.id}`,
|
|
label: t("palette.theme", { name: s.name }),
|
|
icon: <Palette className="w-4 h-4" />,
|
|
keywords: s.id,
|
|
run: () => setTheme({ ...theme, scheme: s.id as Scheme }),
|
|
})),
|
|
],
|
|
});
|
|
|
|
return out;
|
|
}, [
|
|
t,
|
|
me,
|
|
channelsQ.data,
|
|
playlistsQ.data,
|
|
savedViews,
|
|
theme,
|
|
setTheme,
|
|
setPage,
|
|
openChannel,
|
|
setSelectedId,
|
|
]);
|
|
|
|
return <CommandPalette open={open} onClose={closePalette} groups={groups} />;
|
|
}
|