mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
* 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>
460 lines
17 KiB
TypeScript
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();
|
|
}
|