The send, the draft and the screenshots take the session's row lock, so an autosave or a paste during a send waits for it and then sees it, and two sends cannot open two issues. A save keeps the delivery's progress; a failed edit that shows the screenshots is finished by the retry; an answer that is not the tracker's API (a redirect, a proxy page) is refused, not a crash that loses the issue's record. Only an attempt that opened an issue counts toward the pace. Screenshots: deleting one after the send is refused, a user's storage is capped across sessions, new sessions and zip exports are paced, images are stored uncompressed in the zip. One liveness rule, one session view, one image type table. Logging: a session's elevation is tracked apart from the user's own, so the browser's own requests (no header) no longer lower the floor a session needs; Stop ends it at once. Composer: an image pasted anywhere is taken; what is still unsaved is saved on close, and "saved" is shown only when it is; the draft is held while it is sent; a failed screenshot read says so instead of reading as none; focus moves to what stays; the preview follows the text size. Pill: the step prompt and a running Stop survive the pill moving between hosts, the prompt keeps the dialog's Tab trap, the toast region lets clicks through, other tabs hear of a Start or Stop, the last seconds are sent before the cap, a capture with no frame is an error.
211 lines
7.9 KiB
TypeScript
211 lines
7.9 KiB
TypeScript
import { useMemo } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { Bug, 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 { 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<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"),
|
|
// 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),
|
|
},
|
|
{
|
|
id: "action:shortcuts",
|
|
label: t("keys.title"),
|
|
icon: <Keyboard className="w-4 h-4" />,
|
|
run: toggleKeyboardHelp,
|
|
},
|
|
// The debug recorder (E16) — not for the shared demo account, which cannot record. 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.
|
|
...(me?.is_demo
|
|
? []
|
|
: recordingSession
|
|
? [
|
|
{
|
|
id: "action:recorder:step",
|
|
label: t("recorder.palette.step"),
|
|
icon: <Flag className="w-4 h-4" />,
|
|
run: () => markStep(""),
|
|
},
|
|
{
|
|
id: "action:recorder:stop",
|
|
label: t("recorder.palette.stop"),
|
|
icon: <Square className="w-4 h-4" />,
|
|
run: () => stopMutate(recordingSession.id),
|
|
},
|
|
]
|
|
: [
|
|
{
|
|
id: "action:recorder:start",
|
|
label: t("recorder.palette.start"),
|
|
icon: <Bug className="w-4 h-4" />,
|
|
run: openRecorderStart,
|
|
},
|
|
]),
|
|
...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,
|
|
recordingSession,
|
|
markStep,
|
|
stopMutate,
|
|
setPage,
|
|
openChannel,
|
|
setSelectedId,
|
|
]);
|
|
|
|
return <CommandPalette open={open} onClose={closePalette} groups={groups} />;
|
|
}
|