Files
siftlode/frontend/src/components/AppCommandPalette.tsx
T
peter 134ec9a68e fix(diagnostics): deep-review round 1 — a send that cannot race, lose or repeat itself
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.
2026-09-20 00:09:52 +02:00

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