Files
siftlode/frontend/src/components/AppCommandPalette.tsx
T
peter c3d88d9b20 refactor: component files export only components; lint fails on a warning
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.
2026-09-17 02:59:09 +02:00

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} />;
}