import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { Bug, Download, Flag, Keyboard, ListVideo, Palette, Sparkles, Square, 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 { requestLinkBox } from "../lib/downloadIntake"; import { openRecorderStart } from "../lib/debugRecorder"; import { useDebugRecording } from "./useDebugRecording"; 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(); const { session: recordingSession, stop: stopRecording, markStep } = useDebugRecording(); const stopMutate = stopRecording.mutate; // 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(() => { 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: , // 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: , 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: , 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. 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: , keywords: t("home.title"), // The token is minted on the entry the push made (see lib/homeIntent), and asks for the // SURPRISE tier: the command is labelled "Meglepetés", and the reroll's plain next press // is answered by whichever tier the rotation reaches — for anyone with a shelf, "A // polcodról" under a command that promised a surprise (owner's call, 2026-09-19). run: () => setPage("home", requestSurprise), }, // Downloading is an action you start anywhere; with nothing pasted yet, the place to paste is // the Files page's link box (lib/downloadIntake), focused on arrival. { id: "action:downloadLink", label: t("palette.actions.downloadLink"), icon: , run: () => setPage("files", requestLinkBox), }, { id: "action:shortcuts", label: t("keys.title"), icon: , run: toggleKeyboardHelp, }, // The debug recorder. While it runs, Stop and a step marker take Start's place: the palette is reachable inside a // player or a dialog where the pill's buttons are a long Tab away. ...(recordingSession ? [ { id: "action:recorder:step", label: t("recorder.palette.step"), icon: , run: () => markStep(""), }, { id: "action:recorder:stop", label: t("recorder.palette.stop"), icon: , run: () => stopMutate(recordingSession.id), }, ] : [ { id: "action:recorder:start", label: t("recorder.palette.start"), icon: , run: openRecorderStart, }, ]), ...SCHEMES.map((s) => ({ id: `action:theme:${s.id}`, label: t("palette.theme", { name: s.name }), icon: , keywords: s.id, run: () => setTheme({ ...theme, scheme: s.id as Scheme }), })), ], }); return out; }, [ t, me, channelsQ.data, playlistsQ.data, savedViews, theme, setTheme, recordingSession, markStep, stopMutate, setPage, openChannel, setSelectedId, ]); return ; }