+
+ {/* Three-layer gradient */}
+
+ {/* Film grain overlay */}
+
)}
{/* Title header */}
-
{posterUrl && (
-
+
Mark Watched
@@ -317,7 +521,7 @@ export default function TitleDetailPage() {
)}
-
+
{/* Seasons & Episodes (TV) */}
{title.type === "tv" && title.seasons.length > 0 && (
@@ -330,11 +534,13 @@ export default function TitleDetailPage() {
title.episodeWatches?.includes(ep.id),
).length;
const totalCount = season.episodes.length;
+ const progressPercent =
+ totalCount > 0 ? (watchedCount / totalCount) * 100 : 0;
return (
-
-
- {totalCount} ep
+
+ {watchedCount}/{totalCount}
+
+
+ {totalCount > 0 && (
+
+ )}
+ {watchedCount < totalCount && (
+
+ )}
{isOpen ? (
- {isOpen && (
-
- {season.episodes.map((ep) => {
- const isWatched = title.episodeWatches?.includes(ep.id);
- return (
-
-
- )}
+ );
+ })}
+
+ )}
+
);
})}
@@ -426,20 +676,26 @@ export default function TitleDetailPage() {
{recommendations.length > 0 && (
Recommended
-
+
{recommendations.slice(0, 12).map((rec) => (
-
+
+
+
))}
-
+
)}
diff --git a/app/api/feed/stats/route.ts b/app/api/feed/stats/route.ts
new file mode 100644
index 0000000..a23fe14
--- /dev/null
+++ b/app/api/feed/stats/route.ts
@@ -0,0 +1,75 @@
+import { and, eq, sql } from "drizzle-orm";
+import { headers } from "next/headers";
+import { NextResponse } from "next/server";
+import { auth } from "@/lib/auth/server";
+import { db } from "@/lib/db/client";
+import {
+ userEpisodeWatches,
+ userMovieWatches,
+ userTitleStatus,
+} from "@/lib/db/schema";
+
+export async function GET() {
+ const session = await auth.api.getSession({
+ headers: await headers(),
+ });
+ if (!session)
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+
+ const userId = session.user.id;
+ const now = new Date();
+
+ // Start of current month
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
+ // Start of current week (Monday)
+ const dayOfWeek = now.getDay();
+ const weekStart = new Date(now);
+ weekStart.setDate(now.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1));
+ weekStart.setHours(0, 0, 0, 0);
+
+ const [moviesThisMonth] = db
+ .select({ count: sql
`count(*)` })
+ .from(userMovieWatches)
+ .where(
+ and(
+ eq(userMovieWatches.userId, userId),
+ sql`${userMovieWatches.watchedAt} >= ${Math.floor(monthStart.getTime() / 1000)}`,
+ ),
+ )
+ .all();
+
+ const [episodesThisWeek] = db
+ .select({ count: sql`count(*)` })
+ .from(userEpisodeWatches)
+ .where(
+ and(
+ eq(userEpisodeWatches.userId, userId),
+ sql`${userEpisodeWatches.watchedAt} >= ${Math.floor(weekStart.getTime() / 1000)}`,
+ ),
+ )
+ .all();
+
+ const [librarySize] = db
+ .select({ count: sql`count(*)` })
+ .from(userTitleStatus)
+ .where(eq(userTitleStatus.userId, userId))
+ .all();
+
+ const [completedCount] = db
+ .select({ count: sql`count(*)` })
+ .from(userTitleStatus)
+ .where(
+ and(
+ eq(userTitleStatus.userId, userId),
+ eq(userTitleStatus.status, "completed"),
+ ),
+ )
+ .all();
+
+ return NextResponse.json({
+ moviesThisMonth: moviesThisMonth?.count ?? 0,
+ episodesThisWeek: episodesThisWeek?.count ?? 0,
+ librarySize: librarySize?.count ?? 0,
+ completed: completedCount?.count ?? 0,
+ });
+}
diff --git a/app/globals.css b/app/globals.css
index dad75b1..5184d12 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -46,42 +46,48 @@
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
+ --color-status-watchlist: var(--status-watchlist);
+ --color-status-watching: var(--status-watching);
+ --color-status-completed: var(--status-completed);
}
-/* Dark cinema — always dark */
+/* Dark cinema — always dark, warm indigo base */
:root {
- --background: oklch(0.12 0.005 250);
+ --background: oklch(0.12 0.008 270);
--foreground: oklch(0.93 0.01 80);
- --card: oklch(0.16 0.005 250);
+ --card: oklch(0.16 0.008 270);
--card-foreground: oklch(0.93 0.01 80);
- --popover: oklch(0.18 0.005 250);
+ --popover: oklch(0.18 0.008 270);
--popover-foreground: oklch(0.93 0.01 80);
- --primary: oklch(0.82 0.12 70);
- --primary-foreground: oklch(0.12 0.005 250);
- --secondary: oklch(0.2 0.005 250);
+ --primary: oklch(0.8 0.14 65);
+ --primary-foreground: oklch(0.12 0.008 270);
+ --secondary: oklch(0.2 0.008 270);
--secondary-foreground: oklch(0.85 0.02 80);
- --muted: oklch(0.2 0.005 250);
+ --muted: oklch(0.2 0.008 270);
--muted-foreground: oklch(0.6 0.02 80);
- --accent: oklch(0.22 0.008 250);
+ --accent: oklch(0.22 0.01 270);
--accent-foreground: oklch(0.93 0.01 80);
--destructive: oklch(0.65 0.2 25);
- --border: oklch(1 0 0 / 8%);
+ --border: oklch(0.8 0.02 65 / 8%);
--input: oklch(1 0 0 / 10%);
- --ring: oklch(0.82 0.12 70);
- --chart-1: oklch(0.82 0.12 70);
+ --ring: oklch(0.8 0.14 65);
+ --chart-1: oklch(0.8 0.14 65);
--chart-2: oklch(0.65 0.15 30);
--chart-3: oklch(0.55 0.12 260);
--chart-4: oklch(0.72 0.1 160);
--chart-5: oklch(0.6 0.15 310);
- --radius: 0.5rem;
- --sidebar: oklch(0.14 0.005 250);
+ --radius: 0.625rem;
+ --sidebar: oklch(0.14 0.008 270);
--sidebar-foreground: oklch(0.93 0.01 80);
- --sidebar-primary: oklch(0.82 0.12 70);
- --sidebar-primary-foreground: oklch(0.12 0.005 250);
- --sidebar-accent: oklch(0.2 0.005 250);
+ --sidebar-primary: oklch(0.8 0.14 65);
+ --sidebar-primary-foreground: oklch(0.12 0.008 270);
+ --sidebar-accent: oklch(0.2 0.008 270);
--sidebar-accent-foreground: oklch(0.93 0.01 80);
- --sidebar-border: oklch(1 0 0 / 8%);
- --sidebar-ring: oklch(0.82 0.12 70);
+ --sidebar-border: oklch(0.8 0.02 65 / 8%);
+ --sidebar-ring: oklch(0.8 0.14 65);
+ --status-watchlist: oklch(0.72 0.12 220);
+ --status-watching: oklch(0.78 0.14 65);
+ --status-completed: oklch(0.75 0.14 155);
}
@layer base {
@@ -93,6 +99,79 @@
}
}
+/* Film grain texture overlay */
+body::before {
+ content: "";
+ position: fixed;
+ inset: 0;
+ z-index: 9999;
+ pointer-events: none;
+ opacity: 0.015;
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
+}
+
+/* Custom keyframe animations */
+@keyframes card-enter {
+ from {
+ opacity: 0;
+ transform: translateY(12px) scale(0.98);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+@keyframes check-pop {
+ 0% {
+ transform: scale(0);
+ }
+ 60% {
+ transform: scale(1.15);
+ }
+ 100% {
+ transform: scale(1);
+ }
+}
+
+@keyframes star-fill {
+ 0% {
+ transform: scale(1);
+ }
+ 50% {
+ transform: scale(1.25);
+ }
+ 100% {
+ transform: scale(1);
+ }
+}
+
+@keyframes gentle-float {
+ 0%,
+ 100% {
+ transform: translateY(0);
+ }
+ 50% {
+ transform: translateY(-6px);
+ }
+}
+
+/* Warm skeleton pulse override */
+@keyframes warm-pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.4;
+ }
+}
+
+[data-slot="skeleton"] {
+ animation: warm-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+ background: oklch(0.22 0.015 270);
+}
+
/* Scrollbar styling */
::-webkit-scrollbar {
width: 6px;
@@ -117,3 +196,14 @@
.feed-scroll::-webkit-scrollbar {
display: none;
}
+
+/* Utility animations */
+.animate-gentle-float {
+ animation: gentle-float 3s ease-in-out infinite;
+}
+.animate-check-pop {
+ animation: check-pop 0.3s ease-out;
+}
+.animate-star-fill {
+ animation: star-fill 0.3s ease-out;
+}
diff --git a/app/page.tsx b/app/page.tsx
index 2bc1f07..92efc73 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,3 +1,6 @@
+"use client";
+
+import { motion } from "motion/react";
import Link from "next/link";
export default function Home() {
@@ -12,24 +15,71 @@ export default function Home() {
/>
{/* Warm primary glow */}
-
+
-
+
Self-hosted movie & TV tracker
-
-
+
+
Couch Potato
-
-
+
+
Track what you watch. Know what's next.
Your library, your data, your rules.
-
+
-
+
Register
-
+
{/* Bottom fade */}
diff --git a/components/auth-form.tsx b/components/auth-form.tsx
index d259159..5d00112 100644
--- a/components/auth-form.tsx
+++ b/components/auth-form.tsx
@@ -1,10 +1,20 @@
"use client";
+import { AnimatePresence, motion } from "motion/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { signIn, signUp } from "@/lib/auth/client";
+const fieldVariants = {
+ hidden: { opacity: 0, y: 10 },
+ visible: {
+ opacity: 1,
+ y: 0,
+ transition: { type: "spring" as const, stiffness: 300, damping: 24 },
+ },
+};
+
export function AuthForm({ mode }: { mode: "login" | "register" }) {
const router = useRouter();
const [name, setName] = useState("");
@@ -48,7 +58,12 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
{/* Subtle glow behind card */}
-
+
-
)}
-
+
-
+
-
+
-
+
- {error && (
-
- {error}
-
- )}
+
+ {error && (
+
+ {error}
+
+ )}
+
-
{loading ? "Loading..." : isRegister ? "Create account" : "Sign in"}
-
-
+
+
{isRegister ? (
@@ -160,7 +193,7 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
>
)}
-
+
);
}
diff --git a/components/command-palette.tsx b/components/command-palette.tsx
new file mode 100644
index 0000000..d529f47
--- /dev/null
+++ b/components/command-palette.tsx
@@ -0,0 +1,306 @@
+"use client";
+
+import {
+ IconDeviceTv,
+ IconHome,
+ IconKeyboard,
+ IconMovie,
+ IconSearch,
+} from "@tabler/icons-react";
+import Image from "next/image";
+import { useRouter } from "next/navigation";
+import { useCallback, useEffect, useState } from "react";
+import { useKeyboard } from "@/components/keyboard-provider";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+ CommandShortcut,
+} from "@/components/ui/command";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useDebounce } from "@/hooks/use-debounce";
+
+interface SearchResult {
+ tmdbId: number;
+ type: "movie" | "tv";
+ title: string;
+ posterPath: string | null;
+ releaseDate: string | null;
+ voteAverage: number;
+}
+
+const RECENT_KEY = "cp:recent-searches";
+const MAX_RECENT = 5;
+
+function getRecentSearches(): string[] {
+ if (typeof window === "undefined") return [];
+ try {
+ return JSON.parse(localStorage.getItem(RECENT_KEY) ?? "[]");
+ } catch {
+ return [];
+ }
+}
+
+function addRecentSearch(query: string) {
+ const recent = getRecentSearches().filter((q) => q !== query);
+ recent.unshift(query);
+ localStorage.setItem(RECENT_KEY, JSON.stringify(recent.slice(0, MAX_RECENT)));
+}
+
+export function CommandPalette() {
+ const router = useRouter();
+ const {
+ commandPaletteOpen,
+ setCommandPaletteOpen,
+ setHelpOpen,
+ registerShortcut,
+ } = useKeyboard();
+ const [query, setQuery] = useState("");
+ const [results, setResults] = useState
([]);
+ const [loading, setLoading] = useState(false);
+ const [importing, setImporting] = useState(null);
+ const [recentSearches, setRecentSearches] = useState([]);
+ const debouncedQuery = useDebounce(query, 300);
+
+ // Register global shortcuts
+ useEffect(() => {
+ registerShortcut("cmd-palette-slash", {
+ keys: ["/"],
+ description: "Search",
+ action: () => setCommandPaletteOpen(true),
+ scope: "Global",
+ });
+ registerShortcut("cmd-palette-help", {
+ keys: ["?"],
+ description: "Keyboard shortcuts",
+ action: () => setHelpOpen(true),
+ scope: "Global",
+ });
+ registerShortcut("nav-home", {
+ keys: ["g", "h"],
+ description: "Go to dashboard",
+ action: () => router.push("/"),
+ scope: "Navigation",
+ });
+ registerShortcut("nav-search", {
+ keys: ["g", "s"],
+ description: "Go to search",
+ action: () => router.push("/search"),
+ scope: "Navigation",
+ });
+ }, [registerShortcut, setCommandPaletteOpen, setHelpOpen, router]);
+
+ // Load recent searches when palette opens
+ useEffect(() => {
+ if (commandPaletteOpen) {
+ setRecentSearches(getRecentSearches());
+ setQuery("");
+ setResults([]);
+ }
+ }, [commandPaletteOpen]);
+
+ // Search TMDB
+ useEffect(() => {
+ if (!debouncedQuery.trim()) {
+ setResults([]);
+ return;
+ }
+ let cancelled = false;
+ setLoading(true);
+ fetch(`/api/search?query=${encodeURIComponent(debouncedQuery)}`)
+ .then((r) => r.json())
+ .then((data) => {
+ if (!cancelled) {
+ setResults((data.results ?? []).slice(0, 8));
+ addRecentSearch(debouncedQuery.trim());
+ }
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [debouncedQuery]);
+
+ const handleSelect = useCallback(
+ async (result: SearchResult) => {
+ setImporting(result.tmdbId);
+ try {
+ const res = await fetch("/api/titles/import", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ tmdbId: result.tmdbId, type: result.type }),
+ });
+ const title = await res.json();
+ if (title.id) {
+ setCommandPaletteOpen(false);
+ router.push(`/titles/${title.id}`);
+ }
+ } finally {
+ setImporting(null);
+ }
+ },
+ [router, setCommandPaletteOpen],
+ );
+
+ const handleRecentSearch = useCallback((q: string) => {
+ setQuery(q);
+ }, []);
+
+ const hasQuery = query.trim().length > 0;
+
+ return (
+
+ );
+}
diff --git a/components/keyboard-help-dialog.tsx b/components/keyboard-help-dialog.tsx
new file mode 100644
index 0000000..8eb35e6
--- /dev/null
+++ b/components/keyboard-help-dialog.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import { useKeyboard } from "@/components/keyboard-provider";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Kbd } from "@/components/ui/kbd";
+
+export function KeyboardHelpDialog() {
+ const { shortcuts, helpOpen, setHelpOpen } = useKeyboard();
+
+ // Group shortcuts by scope
+ const grouped: Record = {};
+ for (const def of shortcuts.values()) {
+ const scope = def.scope ?? "Global";
+ if (!grouped[scope]) grouped[scope] = [];
+ grouped[scope].push({ description: def.description, keys: def.keys });
+ }
+
+ return (
+
+ );
+}
+
+function formatKey(key: string): string {
+ const map: Record = {
+ " ": "Space",
+ Escape: "Esc",
+ ArrowUp: "↑",
+ ArrowDown: "↓",
+ ArrowLeft: "←",
+ ArrowRight: "→",
+ };
+ return map[key] ?? key.toUpperCase();
+}
diff --git a/components/keyboard-provider.tsx b/components/keyboard-provider.tsx
new file mode 100644
index 0000000..708f730
--- /dev/null
+++ b/components/keyboard-provider.tsx
@@ -0,0 +1,144 @@
+"use client";
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+
+export interface ShortcutDef {
+ keys: string[];
+ description: string;
+ action: () => void;
+ scope?: string;
+}
+
+interface KeyboardContextValue {
+ shortcuts: Map;
+ registerShortcut: (id: string, def: ShortcutDef) => void;
+ unregisterShortcut: (id: string) => void;
+ commandPaletteOpen: boolean;
+ setCommandPaletteOpen: (open: boolean) => void;
+ helpOpen: boolean;
+ setHelpOpen: (open: boolean) => void;
+}
+
+const KeyboardContext = createContext(null);
+
+export function useKeyboard() {
+ const ctx = useContext(KeyboardContext);
+ if (!ctx) throw new Error("useKeyboard must be used within KeyboardProvider");
+ return ctx;
+}
+
+export function KeyboardProvider({ children }: { children: React.ReactNode }) {
+ const [shortcuts, setShortcuts] = useState