Files
sofa/apps/web/src/components/command-palette.tsx
T
dce333e128 refactor: organize API around operation domains (#24)
* feat: reorganize API around operation domains

Restructure the oRPC contract from 14 resource-oriented routers to 8
domain-oriented routers for a cleaner public API surface.

- Consolidate 7 watch procedures into unified tracking.watch/unwatch
  with scope + ids input (movie, episode, season, series)
- Split dashboard across tracking (stats, history), library
  (continueWatching, upcoming), and discover (recommendations)
- Merge explore + search + discover into single discover router
- Absorb integrations into account.integrations
- Merge system.authConfig into system.publicInfo
- Collapse 6 admin setting endpoints into admin.settings.get/update
- Deduplicate platforms.list + explore.watchProviders into
  discover.platforms
- Add symmetric unwatchMovie/unwatchSeries core functions
- Rename titles.detail→get, titles.recommendations→similar,
  people.detail→get

BREAKING CHANGE: All client API paths have changed. REST paths now
mirror router structure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review feedback on API reorganization

- Fix updateRating invalidation in native app — was only invalidating
  title queries, now calls invalidateTitleQueries() to also refresh
  tracking.userInfo (drives the rating UI)
- Make unwatchMovie status revert consistent with unwatchSeries — revert
  any non-watchlist status, not just "completed"
- Add sync invariant comment on handleWatch/handleUnwatch loops
- Remove unused queryClient import in native use-title-actions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: updateStatus NOT_FOUND error + rate() invalidation in native

- updateStatus now throws NOT_FOUND when quickAddTitle returns null
  (title doesn't exist), instead of silently succeeding
- Add NOT_FOUND error to updateStatus contract definition
- Fix rate() in native title-actions.ts to call invalidateTitleQueries()
  instead of only invalidating orpc.titles.key() — mirrors the fix
  already applied to the hook-based path in d2ddc0f

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: expand mobile app docs and add Play Store badge

- Add the Google Play badge to the README alongside the App Store badge
- Expand the mobile app docs with the Android/Play Store release details
- Refresh docs site dependencies and related package versions for the updated docs stack

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:44:05 -04:00

460 lines
17 KiB
TypeScript

import { msg } from "@lingui/core/macro";
import { Trans, useLingui } from "@lingui/react/macro";
import {
IconDeviceTv,
IconHome,
IconKeyboard,
IconMovie,
IconSearch,
IconUser,
IconX,
} from "@tabler/icons-react";
import { useHotkey, useHotkeySequence } from "@tanstack/react-hotkeys";
import { skipToken, useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useAtom } from "jotai";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Kbd } from "@/components/ui/kbd";
import { Skeleton } from "@/components/ui/skeleton";
import { useDebounce } from "@/hooks/use-debounce";
import {
commandPaletteOpenAtom,
helpOpenAtom,
MAX_RECENT,
recentSearchesAtom,
} from "@/lib/atoms/command-palette";
import { orpc } from "@/lib/orpc/client";
import { i18n } from "@sofa/i18n";
// Static shortcut descriptions for the help dialog, pre-grouped by scope.
// TanStack's HotkeyManager/SequenceManager handle all actual key listening.
const SHORTCUT_GROUPS = [
{
scope: msg`Global`,
shortcuts: [
{ description: msg`Search`, keys: ["/"] },
{ description: msg`Keyboard shortcuts`, keys: ["?"] },
],
},
{
scope: msg`Navigation`,
shortcuts: [
{ description: msg`Go to dashboard`, keys: ["g", "h"] },
{ description: msg`Go to explore`, keys: ["g", "e"] },
{ description: msg`Go to upcoming`, keys: ["g", "u"] },
{ description: msg`Go to settings`, keys: ["g", "s"] },
],
},
{
scope: msg`Title`,
shortcuts: [
{ description: msg`Cycle status`, keys: ["w"] },
{ description: msg`Mark watched`, keys: ["m"] },
{ description: msg`Go back`, keys: ["Escape"] },
{ description: msg`Rate 1 star`, keys: ["1"] },
{ description: msg`Rate 2 stars`, keys: ["2"] },
{ description: msg`Rate 3 stars`, keys: ["3"] },
{ description: msg`Rate 4 stars`, keys: ["4"] },
{ description: msg`Rate 5 stars`, keys: ["5"] },
],
},
];
interface SearchResult {
id?: string;
tmdbId: number;
type: "movie" | "tv" | "person";
title: string;
posterPath: string | null;
profilePath: string | null;
releaseDate: string | null;
voteAverage: number | null;
knownFor: string[] | null;
knownForDepartment: string | null;
}
export function CommandPalette() {
const { t } = useLingui();
const navigate = useNavigate();
const [commandPaletteOpen, setCommandPaletteOpen] = useAtom(commandPaletteOpenAtom);
const [helpOpen, setHelpOpen] = useAtom(helpOpenAtom);
const [recentSearches, setRecentSearches] = useAtom(recentSearchesAtom);
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
const trimmedQuery = debouncedQuery.trim();
const { data: searchData, isLoading: loading } = useQuery(
orpc.discover.search.queryOptions({
input: trimmedQuery ? { query: trimmedQuery } : skipToken,
}),
);
const results: SearchResult[] = searchData?.results?.slice(0, 8) ?? [];
const enabled = !commandPaletteOpen;
const handleOpenChange = useCallback(
(open: boolean | ((prev: boolean) => boolean)) => {
setCommandPaletteOpen((prev) => {
const next = typeof open === "function" ? open(prev) : open;
if (next) setQuery("");
return next;
});
},
[setCommandPaletteOpen],
);
useHotkey("Mod+K", () => handleOpenChange((prev) => !prev));
useHotkey("/", () => handleOpenChange(true), { enabled });
useHotkey({ key: "?", shift: true }, () => setHelpOpen(true), { enabled });
useHotkeySequence(
["G", "H"],
() => {
void navigate({ to: "/dashboard" });
},
{ enabled, timeout: 500 },
);
useHotkeySequence(
["G", "E"],
() => {
void navigate({ to: "/explore" });
},
{ enabled, timeout: 500 },
);
useHotkeySequence(
["G", "U"],
() => {
void navigate({ to: "/upcoming" });
},
{ enabled, timeout: 500 },
);
useHotkeySequence(
["G", "S"],
() => {
void navigate({ to: "/settings" });
},
{ enabled, timeout: 500 },
);
// Save to recent searches after user stops typing for a while
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(null);
useEffect(() => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
const trimmed = debouncedQuery.trim();
if (trimmed && results.length > 0) {
saveTimerRef.current = setTimeout(() => {
setRecentSearches((prev) => {
const filtered = prev.filter((q) => q !== trimmed);
return [trimmed, ...filtered].slice(0, MAX_RECENT);
});
}, 2000);
}
return () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
};
}, [debouncedQuery, results.length, setRecentSearches]);
const handleSelect = useCallback(
(result: SearchResult) => {
if (!result.id) return;
setCommandPaletteOpen(false);
if (result.type === "person") {
void navigate({ to: "/people/$id", params: { id: result.id } });
} else {
void navigate({ to: "/titles/$id", params: { id: result.id } });
}
},
[setCommandPaletteOpen, navigate],
);
const handleRecentSearch = useCallback((q: string) => {
setQuery(q);
}, []);
const handleRemoveRecent = useCallback(
(q: string) => {
setRecentSearches((prev) => prev.filter((s) => s !== q));
},
[setRecentSearches],
);
const handleClearRecent = useCallback(() => {
setRecentSearches([]);
}, [setRecentSearches]);
const hasQuery = query.trim().length > 0;
return (
<>
<Dialog open={commandPaletteOpen} onOpenChange={handleOpenChange}>
<DialogHeader className="sr-only">
<DialogTitle>
<Trans>Command Palette</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Search for movies, TV shows, or run commands</Trans>
</DialogDescription>
</DialogHeader>
<DialogContent
className="top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0 sm:max-w-lg"
showCloseButton={false}
>
<Command shouldFilter={false}>
<CommandInput
placeholder={t`Search movies & TV shows…`}
value={query}
onValueChange={setQuery}
/>
<CommandList className="max-h-80">
{hasQuery && loading && (
<div className="space-y-2 p-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={`skel-${i}`} className="flex items-center gap-3">
<Skeleton className="h-12 w-8 shrink-0 rounded" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3.5 w-3/4" />
<Skeleton className="h-3 w-1/3" />
</div>
</div>
))}
</div>
)}
{hasQuery && !loading && results.length === 0 && (
<CommandEmpty>
<Trans>No results found.</Trans>
</CommandEmpty>
)}
{hasQuery && !loading && results.length > 0 && (
<CommandGroup heading={t`Results`}>
{results.map((r) => (
<CommandItem
key={r.id ?? `${r.type}-${r.tmdbId}`}
onSelect={() => handleSelect(r)}
className="flex items-center gap-3 py-2"
>
{r.type === "person" ? (
<div className="bg-muted size-10 shrink-0 overflow-hidden rounded-full">
{r.profilePath ? (
<img
src={r.profilePath}
alt={r.title}
width={40}
height={40}
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center">
<IconUser
aria-hidden={true}
className="text-muted-foreground size-4"
/>
</div>
)}
</div>
) : (
<div className="bg-muted h-12 w-8 shrink-0 overflow-hidden rounded">
{r.posterPath ? (
<img
src={r.posterPath}
alt={r.title}
width={32}
height={48}
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
/>
) : (
<div className="text-muted-foreground flex h-full items-center justify-center text-[8px]">
?
</div>
)}
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">{r.title}</p>
<div className="text-muted-foreground flex items-center gap-1.5 text-[10px]">
{r.type === "person" ? (
<IconUser aria-hidden={true} className="size-[11px]" />
) : r.type === "movie" ? (
<IconMovie aria-hidden={true} className="size-[11px]" />
) : (
<IconDeviceTv aria-hidden={true} className="size-[11px]" />
)}
<span className="uppercase">
{r.type === "movie" ? t`movie` : r.type === "tv" ? t`tv` : t`person`}
</span>
{r.type !== "person" && r.releaseDate && (
<span>{r.releaseDate.slice(0, 4)}</span>
)}
{r.type === "person" && r.knownFor && r.knownFor.length > 0 && (
<span className="truncate">{r.knownFor.join(", ")}</span>
)}
</div>
</div>
</CommandItem>
))}
</CommandGroup>
)}
{!hasQuery && (
<>
{recentSearches.length > 0 && (
<CommandGroup
heading={
<div className="flex items-center justify-between">
<span>
<Trans>Recent Searches</Trans>
</span>
<button
type="button"
onClick={handleClearRecent}
className="text-muted-foreground hover:text-foreground text-[10px] font-normal transition-colors"
>
<Trans>Clear all</Trans>
</button>
</div>
}
>
{recentSearches.map((q) => (
<CommandItem
key={q}
onSelect={() => handleRecentSearch(q)}
className="group"
>
<IconSearch
aria-hidden={true}
className="text-muted-foreground size-3.5"
/>
<span className="flex-1">{q}</span>
<span data-slot="command-shortcut" className="ml-auto">
<button
type="button"
aria-label={t`Remove from recent searches`}
onClick={(e) => {
e.stopPropagation();
handleRemoveRecent(q);
}}
className="text-muted-foreground hover:text-foreground rounded-sm p-0.5 opacity-0 transition-opacity group-data-[selected=true]:opacity-100"
>
<IconX className="size-3" />
</button>
</span>
</CommandItem>
))}
</CommandGroup>
)}
{recentSearches.length > 0 && <CommandSeparator />}
<CommandGroup heading={t`Quick Actions`}>
<CommandItem
onSelect={() => {
setCommandPaletteOpen(false);
void navigate({ to: "/dashboard" });
}}
>
<IconHome aria-hidden={true} className="size-3.5" />
<Trans>Go to Dashboard</Trans>
<CommandShortcut>G H</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => {
setCommandPaletteOpen(false);
void navigate({ to: "/explore" });
}}
>
<IconSearch aria-hidden={true} className="size-3.5" />
<Trans>Go to Explore</Trans>
<CommandShortcut>G E</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => {
setCommandPaletteOpen(false);
setHelpOpen(true);
}}
>
<IconKeyboard aria-hidden={true} className="size-3.5" />
<Trans>Keyboard Shortcuts</Trans>
<CommandShortcut>?</CommandShortcut>
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</DialogContent>
</Dialog>
<Dialog open={helpOpen} onOpenChange={setHelpOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
<Trans>Keyboard Shortcuts</Trans>
</DialogTitle>
</DialogHeader>
<div className="space-y-5 py-2">
{SHORTCUT_GROUPS.map((group) => (
<div key={i18n._(group.scope)} className="space-y-2">
<h3 className="text-muted-foreground text-[10px] font-semibold tracking-wider uppercase">
{i18n._(group.scope)}
</h3>
<div className="space-y-1">
{group.shortcuts.map((item) => (
<div
key={i18n._(item.description)}
className="flex items-center justify-between rounded-md px-2 py-1.5"
>
<span className="text-foreground text-xs">{i18n._(item.description)}</span>
<div className="flex items-center gap-1">
{item.keys.map((key, i) => (
<span key={key} className="flex items-center gap-1">
{i > 0 && (
<span className="text-muted-foreground text-[10px]">
<Trans>then</Trans>
</span>
)}
<Kbd>{formatKey(key)}</Kbd>
</span>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
</DialogContent>
</Dialog>
</>
);
}
function formatKey(key: string): string {
const map: Record<string, string> = {
" ": "Space",
Escape: "Esc",
ArrowUp: "↑",
ArrowDown: "↓",
ArrowLeft: "←",
ArrowRight: "→",
};
return map[key] ?? key.toUpperCase();
}