Add /dashboard route, mark-all-watched on complete, keyboard fixes

- Move dashboard page to app/(pages)/dashboard, redirect / to /dashboard
  for authenticated users, update all nav/breadcrumb/auth hrefs
- Mark all episodes watched when a TV show status is set to "completed"
  (markAllEpisodesWatched skips already-watched episodes)
- Refactor KeyboardProvider to store shortcuts in a ref instead of
  state, eliminating unnecessary re-renders; use react-hotkeys-hook for
  Cmd+K so it works reliably in form inputs
- Fix useRegisterShortcut: re-register on every render (stable ref read)
  with a separate cleanup effect, removing brittle key memoization
- Search page: silently navigate to title detail on import instead of
  showing "Added to library" toast
This commit is contained in:
2026-03-01 11:06:26 -05:00
parent b6aaad243f
commit dcb48eaf37
13 changed files with 125 additions and 59 deletions
+5 -10
View File
@@ -35,11 +35,7 @@ export default function SearchPage() {
if (l) setSearched(true); if (l) setSearched(true);
}, []); }, []);
async function handleImport( async function handleOpen(tmdbId: number, type: "movie" | "tv") {
tmdbId: number,
type: "movie" | "tv",
title: string,
) {
setImporting(tmdbId); setImporting(tmdbId);
try { try {
const res = await fetch("/api/titles/import", { const res = await fetch("/api/titles/import", {
@@ -49,11 +45,10 @@ export default function SearchPage() {
}); });
const data = await res.json(); const data = await res.json();
if (data.id) { if (data.id) {
toast.success(`Added "${title}" to library`);
router.push(`/titles/${data.id}`); router.push(`/titles/${data.id}`);
} }
} catch { } catch {
toast.error("Failed to import title"); toast.error("Failed to load title");
} finally { } finally {
setImporting(null); setImporting(null);
} }
@@ -64,7 +59,7 @@ export default function SearchPage() {
<div className="space-y-2"> <div className="space-y-2">
<h1 className="font-display text-3xl tracking-tight">Search</h1> <h1 className="font-display text-3xl tracking-tight">Search</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Find movies and TV shows to add to your library Find movies and TV shows to track
</p> </p>
</div> </div>
@@ -91,14 +86,14 @@ export default function SearchPage() {
posterPath={r.posterPath} posterPath={r.posterPath}
releaseDate={r.releaseDate} releaseDate={r.releaseDate}
voteAverage={r.voteAverage} voteAverage={r.voteAverage}
onImport={() => handleImport(r.tmdbId, r.type, r.title)} onImport={() => handleOpen(r.tmdbId, r.type)}
/> />
{importing === r.tmdbId && ( {importing === r.tmdbId && (
<div className="absolute inset-0 flex items-center justify-center rounded-xl bg-background/80 backdrop-blur-sm"> <div className="absolute inset-0 flex items-center justify-center rounded-xl bg-background/80 backdrop-blur-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" /> <div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="text-sm font-medium text-primary"> <span className="text-sm font-medium text-primary">
Importing Loading
</span> </span>
</div> </div>
</div> </div>
+3 -1
View File
@@ -380,7 +380,9 @@ export default function TitleDetailPage() {
<Breadcrumb className="relative z-20"> <Breadcrumb className="relative z-20">
<BreadcrumbList> <BreadcrumbList>
<BreadcrumbItem> <BreadcrumbItem>
<BreadcrumbLink render={<Link href="/" />}>Home</BreadcrumbLink> <BreadcrumbLink render={<Link href="/dashboard" />}>
Home
</BreadcrumbLink>
</BreadcrumbItem> </BreadcrumbItem>
<BreadcrumbSeparator /> <BreadcrumbSeparator />
<BreadcrumbItem> <BreadcrumbItem>
+14
View File
@@ -2,8 +2,22 @@
import { motion } from "motion/react"; import { motion } from "motion/react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useSession } from "@/lib/auth/client";
export default function Home() { export default function Home() {
const { data: session, isPending } = useSession();
const router = useRouter();
useEffect(() => {
if (!isPending && session?.user) {
router.replace("/dashboard");
}
}, [session, isPending, router]);
if (isPending) return null;
return ( return (
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden"> <div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden">
{/* Background grain texture */} {/* Background grain texture */}
+1 -1
View File
@@ -44,7 +44,7 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
return; return;
} }
} }
router.push("/"); router.push("/dashboard");
router.refresh(); router.refresh();
} catch { } catch {
setError("Something went wrong"); setError("Something went wrong");
+2 -2
View File
@@ -90,7 +90,7 @@ export function CommandPalette() {
registerShortcut("nav-home", { registerShortcut("nav-home", {
keys: ["g", "h"], keys: ["g", "h"],
description: "Go to dashboard", description: "Go to dashboard",
action: () => router.push("/"), action: () => router.push("/dashboard"),
scope: "Navigation", scope: "Navigation",
}); });
registerShortcut("nav-search", { registerShortcut("nav-search", {
@@ -268,7 +268,7 @@ export function CommandPalette() {
<CommandItem <CommandItem
onSelect={() => { onSelect={() => {
setCommandPaletteOpen(false); setCommandPaletteOpen(false);
router.push("/"); router.push("/dashboard");
}} }}
> >
<IconHome size={14} /> <IconHome size={14} />
+2 -2
View File
@@ -10,11 +10,11 @@ import {
import { Kbd } from "@/components/ui/kbd"; import { Kbd } from "@/components/ui/kbd";
export function KeyboardHelpDialog() { export function KeyboardHelpDialog() {
const { shortcuts, helpOpen, setHelpOpen } = useKeyboard(); const { shortcutsRef, helpOpen, setHelpOpen } = useKeyboard();
// Group shortcuts by scope // Group shortcuts by scope
const grouped: Record<string, { description: string; keys: string[] }[]> = {}; const grouped: Record<string, { description: string; keys: string[] }[]> = {};
for (const def of shortcuts.values()) { for (const def of shortcutsRef.current.values()) {
const scope = def.scope ?? "Global"; const scope = def.scope ?? "Global";
if (!grouped[scope]) grouped[scope] = []; if (!grouped[scope]) grouped[scope] = [];
grouped[scope].push({ description: def.description, keys: def.keys }); grouped[scope].push({ description: def.description, keys: def.keys });
+22 -33
View File
@@ -8,6 +8,7 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { useHotkeys } from "react-hotkeys-hook";
export interface ShortcutDef { export interface ShortcutDef {
keys: string[]; keys: string[];
@@ -17,7 +18,7 @@ export interface ShortcutDef {
} }
interface KeyboardContextValue { interface KeyboardContextValue {
shortcuts: Map<string, ShortcutDef>; shortcutsRef: React.RefObject<Map<string, ShortcutDef>>;
registerShortcut: (id: string, def: ShortcutDef) => void; registerShortcut: (id: string, def: ShortcutDef) => void;
unregisterShortcut: (id: string) => void; unregisterShortcut: (id: string) => void;
commandPaletteOpen: boolean; commandPaletteOpen: boolean;
@@ -35,30 +36,30 @@ export function useKeyboard() {
} }
export function KeyboardProvider({ children }: { children: React.ReactNode }) { export function KeyboardProvider({ children }: { children: React.ReactNode }) {
const [shortcuts, setShortcuts] = useState<Map<string, ShortcutDef>>( const shortcutsRef = useRef<Map<string, ShortcutDef>>(new Map());
() => new Map(),
);
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false); const [helpOpen, setHelpOpen] = useState(false);
const commandPaletteOpenRef = useRef(false);
commandPaletteOpenRef.current = commandPaletteOpen;
const pendingKeyRef = useRef<string | null>(null); const pendingKeyRef = useRef<string | null>(null);
const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const registerShortcut = useCallback((id: string, def: ShortcutDef) => { const registerShortcut = useCallback((id: string, def: ShortcutDef) => {
setShortcuts((prev) => { shortcutsRef.current.set(id, def);
const next = new Map(prev);
next.set(id, def);
return next;
});
}, []); }, []);
const unregisterShortcut = useCallback((id: string) => { const unregisterShortcut = useCallback((id: string) => {
setShortcuts((prev) => { shortcutsRef.current.delete(id);
const next = new Map(prev);
next.delete(id);
return next;
});
}, []); }, []);
// Cmd/Ctrl+K: toggle command palette (always works, even in inputs)
useHotkeys("mod+k", () => setCommandPaletteOpen((prev) => !prev), {
preventDefault: true,
enableOnFormTags: ["INPUT", "TEXTAREA"],
enableOnContentEditable: true,
});
// Registered shortcuts handler (single keys + key sequences)
useEffect(() => { useEffect(() => {
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
@@ -68,22 +69,13 @@ export function KeyboardProvider({ children }: { children: React.ReactNode }) {
tagName === "textarea" || tagName === "textarea" ||
target.isContentEditable; target.isContentEditable;
// Cmd+K / Ctrl+K always works
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setCommandPaletteOpen((prev) => !prev);
return;
}
// Don't fire shortcuts when typing in inputs (except Escape)
if (isInput && e.key !== "Escape") return; if (isInput && e.key !== "Escape") return;
if (commandPaletteOpenRef.current && e.key !== "Escape") return;
// Don't fire when command palette is open const shortcuts = shortcutsRef.current;
if (commandPaletteOpen && e.key !== "Escape") return;
// Check for two-key combos
if (pendingKeyRef.current) { if (pendingKeyRef.current) {
const comboKey = `${pendingKeyRef.current}+${e.key}`; const firstKey = pendingKeyRef.current;
pendingKeyRef.current = null; pendingKeyRef.current = null;
if (pendingTimerRef.current) { if (pendingTimerRef.current) {
clearTimeout(pendingTimerRef.current); clearTimeout(pendingTimerRef.current);
@@ -92,25 +84,22 @@ export function KeyboardProvider({ children }: { children: React.ReactNode }) {
for (const def of shortcuts.values()) { for (const def of shortcuts.values()) {
if ( if (
def.keys.length === 2 && def.keys.length === 2 &&
def.keys[0] === comboKey.split("+")[0] && def.keys[0] === firstKey &&
def.keys[1] === comboKey.split("+")[1] def.keys[1] === e.key
) { ) {
e.preventDefault(); e.preventDefault();
def.action(); def.action();
return; return;
} }
} }
// If no combo matched, fall through to single-key check
} }
// Check for single-key shortcuts
for (const def of shortcuts.values()) { for (const def of shortcuts.values()) {
if (def.keys.length === 1 && def.keys[0] === e.key) { if (def.keys.length === 1 && def.keys[0] === e.key) {
e.preventDefault(); e.preventDefault();
def.action(); def.action();
return; return;
} }
// Start combo sequence
if (def.keys.length === 2 && def.keys[0] === e.key) { if (def.keys.length === 2 && def.keys[0] === e.key) {
e.preventDefault(); e.preventDefault();
pendingKeyRef.current = e.key; pendingKeyRef.current = e.key;
@@ -124,12 +113,12 @@ export function KeyboardProvider({ children }: { children: React.ReactNode }) {
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown);
}, [shortcuts, commandPaletteOpen]); }, []);
return ( return (
<KeyboardContext.Provider <KeyboardContext.Provider
value={{ value={{
shortcuts, shortcutsRef,
registerShortcut, registerShortcut,
unregisterShortcut, unregisterShortcut,
commandPaletteOpen, commandPaletteOpen,
+7 -4
View File
@@ -9,7 +9,7 @@ import { Kbd } from "@/components/ui/kbd";
import { signOut, useSession } from "@/lib/auth/client"; import { signOut, useSession } from "@/lib/auth/client";
const navLinks = [ const navLinks = [
{ href: "/", label: "Home" }, { href: "/dashboard", label: "Home" },
{ href: "/search", label: "Search" }, { href: "/search", label: "Search" },
] as const; ] as const;
@@ -23,15 +23,18 @@ export function NavBar() {
<header className="sticky top-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-xl"> <header className="sticky top-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-xl">
<nav className="mx-auto flex h-14 max-w-6xl items-center justify-between px-4 sm:px-6"> <nav className="mx-auto flex h-14 max-w-6xl items-center justify-between px-4 sm:px-6">
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<Link href="/" className="font-display text-xl tracking-tight"> <Link
href="/dashboard"
className="font-display text-xl tracking-tight"
>
Couch Potato Couch Potato
</Link> </Link>
{session?.user && ( {session?.user && (
<div className="hidden items-center gap-1 sm:flex"> <div className="hidden items-center gap-1 sm:flex">
{navLinks.map((link) => { {navLinks.map((link) => {
const isActive = const isActive =
link.href === "/" link.href === "/dashboard"
? pathname === "/" ? pathname === "/dashboard"
: pathname.startsWith(link.href); : pathname.startsWith(link.href);
return ( return (
<Link <Link
+6 -6
View File
@@ -1,14 +1,14 @@
import { useEffect, useMemo } from "react"; import { useEffect } from "react";
import { type ShortcutDef, useKeyboard } from "@/components/keyboard-provider"; import { type ShortcutDef, useKeyboard } from "@/components/keyboard-provider";
export function useRegisterShortcut(id: string, def: ShortcutDef) { export function useRegisterShortcut(id: string, def: ShortcutDef) {
const { registerShortcut, unregisterShortcut } = useKeyboard(); const { registerShortcut, unregisterShortcut } = useKeyboard();
const keysKey = def.keys.join(",");
// biome-ignore lint/correctness/useExhaustiveDependencies: stable memoization on keys/description
const stableDef = useMemo(() => def, [keysKey]);
useEffect(() => { useEffect(() => {
registerShortcut(id, stableDef); registerShortcut(id, def);
});
useEffect(() => {
return () => unregisterShortcut(id); return () => unregisterShortcut(id);
}, [id, registerShortcut, unregisterShortcut, stableDef]); }, [id, unregisterShortcut]);
} }
+48
View File
@@ -3,6 +3,7 @@ import { db } from "@/lib/db/client";
import { import {
episodes, episodes,
seasons, seasons,
titles,
userEpisodeWatches, userEpisodeWatches,
userMovieWatches, userMovieWatches,
userRatings, userRatings,
@@ -22,6 +23,10 @@ export function setTitleStatus(
set: { status, updatedAt: now }, set: { status, updatedAt: now },
}) })
.run(); .run();
if (status === "completed") {
markAllEpisodesWatched(userId, titleId);
}
} }
export function removeTitleStatus(userId: string, titleId: string) { export function removeTitleStatus(userId: string, titleId: string) {
@@ -97,6 +102,49 @@ export function logEpisodeWatch(userId: string, episodeId: string) {
checkAllEpisodesWatched(userId, titleId); checkAllEpisodesWatched(userId, titleId);
} }
function markAllEpisodesWatched(userId: string, titleId: string) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (!title || title.type !== "tv") return;
const now = new Date();
const allSeasons = db
.select()
.from(seasons)
.where(eq(seasons.titleId, titleId))
.all();
for (const s of allSeasons) {
const eps = db
.select()
.from(episodes)
.where(eq(episodes.seasonId, s.id))
.all();
for (const ep of eps) {
const existing = db
.select()
.from(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
eq(userEpisodeWatches.episodeId, ep.id),
),
)
.get();
if (!existing) {
db.insert(userEpisodeWatches)
.values({
userId,
episodeId: ep.id,
watchedAt: now,
source: "manual",
})
.run();
}
}
}
}
function checkAllEpisodesWatched(userId: string, titleId: string) { function checkAllEpisodesWatched(userId: string, titleId: string) {
const allSeasons = db const allSeasons = db
.select() .select()
+1
View File
@@ -29,6 +29,7 @@
"react": "19.2.4", "react": "19.2.4",
"react-day-picker": "^9.14.0", "react-day-picker": "^9.14.0",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-hotkeys-hook": "^5.2.4",
"react-resizable-panels": "^4.6.5", "react-resizable-panels": "^4.6.5",
"recharts": "2.15.4", "recharts": "2.15.4",
"shadcn": "^3.8.5", "shadcn": "^3.8.5",
+14
View File
@@ -53,6 +53,9 @@ importers:
react-dom: react-dom:
specifier: 19.2.4 specifier: 19.2.4
version: 19.2.4(react@19.2.4) version: 19.2.4(react@19.2.4)
react-hotkeys-hook:
specifier: ^5.2.4
version: 5.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react-resizable-panels: react-resizable-panels:
specifier: ^4.6.5 specifier: ^4.6.5
version: 4.6.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 4.6.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -2741,6 +2744,12 @@ packages:
peerDependencies: peerDependencies:
react: ^19.2.4 react: ^19.2.4
react-hotkeys-hook@5.2.4:
resolution: {integrity: sha512-BgKg+A1+TawkYluh5Bo4cTmcgMN5L29uhJbDUQdHwPX+qgXRjIPYU5kIDHyxnAwCkCBiu9V5OpB2mpyeluVF2A==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
react-is@16.13.1: react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -5425,6 +5434,11 @@ snapshots:
react: 19.2.4 react: 19.2.4
scheduler: 0.27.0 scheduler: 0.27.0
react-hotkeys-hook@5.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
react-is@16.13.1: {} react-is@16.13.1: {}
react-is@18.3.1: {} react-is@18.3.1: {}