mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
UX overhaul: motion animations, command palette, warm cinema theme
Add premium "Late Night Screening Room" experience with warm indigo/amber palette, framer-motion spring animations, Cmd+K command palette with TMDB search, keyboard shortcuts (G H, G S, ?, W, M, 1-5), sonner toasts with optimistic updates, skeleton loading states, stats dashboard, search autocomplete with filter tabs, cinematic backdrop with film grain, season progress bars, and staggered card reveal animations throughout. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+50
-17
@@ -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 */}
|
||||
<div className="absolute -inset-4 rounded-2xl bg-primary/3 blur-2xl" />
|
||||
|
||||
<div className="relative space-y-8 rounded-xl border border-border/50 bg-card/80 p-8 backdrop-blur-sm">
|
||||
<motion.div
|
||||
className="relative space-y-8 rounded-xl border border-border/50 bg-card/80 p-8 backdrop-blur-sm"
|
||||
initial={{ opacity: 0, y: 20, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ type: "spring" as const, stiffness: 200, damping: 20 }}
|
||||
>
|
||||
<div className="space-y-2 text-center">
|
||||
<Link
|
||||
href="/"
|
||||
@@ -64,9 +79,18 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<motion.form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-4"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={{
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.08 } },
|
||||
}}
|
||||
>
|
||||
{isRegister && (
|
||||
<div className="space-y-1.5">
|
||||
<motion.div variants={fieldVariants} className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="text-xs font-medium uppercase tracking-wider text-muted-foreground"
|
||||
@@ -82,10 +106,10 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
|
||||
className="flex h-11 w-full rounded-lg border border-border/50 bg-background/50 px-4 text-sm transition-colors placeholder:text-muted-foreground/50 focus:border-primary/40 focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
placeholder="Your name"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<motion.div variants={fieldVariants} className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="text-xs font-medium uppercase tracking-wider text-muted-foreground"
|
||||
@@ -101,9 +125,9 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
|
||||
className="flex h-11 w-full rounded-lg border border-border/50 bg-background/50 px-4 text-sm transition-colors placeholder:text-muted-foreground/50 focus:border-primary/40 focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<motion.div variants={fieldVariants} className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="text-xs font-medium uppercase tracking-wider text-muted-foreground"
|
||||
@@ -120,22 +144,31 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
|
||||
className="flex h-11 w-full rounded-lg border border-border/50 bg-background/50 px-4 text-sm transition-colors placeholder:text-muted-foreground/50 focus:border-primary/40 focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
placeholder="Min 8 characters"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="overflow-hidden rounded-lg bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
||||
>
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<button
|
||||
<motion.button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
variants={fieldVariants}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-primary font-medium text-primary-foreground transition-all hover:shadow-lg hover:shadow-primary/20 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Loading..." : isRegister ? "Create account" : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
</motion.button>
|
||||
</motion.form>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{isRegister ? (
|
||||
@@ -160,7 +193,7 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [importing, setImporting] = useState<number | null>(null);
|
||||
const [recentSearches, setRecentSearches] = useState<string[]>([]);
|
||||
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 (
|
||||
<Dialog open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Command Palette</DialogTitle>
|
||||
<DialogDescription>
|
||||
Search for movies, TV shows, or run commands
|
||||
</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="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) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholders
|
||||
<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>No results found.</CommandEmpty>
|
||||
)}
|
||||
|
||||
{hasQuery && !loading && results.length > 0 && (
|
||||
<CommandGroup heading="Results">
|
||||
{results.map((r) => (
|
||||
<CommandItem
|
||||
key={`${r.type}-${r.tmdbId}`}
|
||||
onSelect={() => handleSelect(r)}
|
||||
disabled={importing === r.tmdbId}
|
||||
className="flex items-center gap-3 py-2"
|
||||
>
|
||||
<div className="h-12 w-8 shrink-0 overflow-hidden rounded bg-muted">
|
||||
{r.posterPath ? (
|
||||
<Image
|
||||
src={`https://image.tmdb.org/t/p/w92${r.posterPath}`}
|
||||
alt={r.title}
|
||||
width={32}
|
||||
height={48}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-[8px] text-muted-foreground">
|
||||
?
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium">{r.title}</p>
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
{r.type === "movie" ? (
|
||||
<IconMovie size={11} />
|
||||
) : (
|
||||
<IconDeviceTv size={11} />
|
||||
)}
|
||||
<span className="uppercase">{r.type}</span>
|
||||
{r.releaseDate && (
|
||||
<span>{r.releaseDate.slice(0, 4)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{importing === r.tmdbId && (
|
||||
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{!hasQuery && (
|
||||
<>
|
||||
{recentSearches.length > 0 && (
|
||||
<CommandGroup heading="Recent Searches">
|
||||
{recentSearches.map((q) => (
|
||||
<CommandItem
|
||||
key={q}
|
||||
onSelect={() => handleRecentSearch(q)}
|
||||
>
|
||||
<IconSearch
|
||||
size={14}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
{q}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{recentSearches.length > 0 && <CommandSeparator />}
|
||||
<CommandGroup heading="Quick Actions">
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
setCommandPaletteOpen(false);
|
||||
router.push("/");
|
||||
}}
|
||||
>
|
||||
<IconHome size={14} />
|
||||
Go to Dashboard
|
||||
<CommandShortcut>G H</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
setCommandPaletteOpen(false);
|
||||
router.push("/search");
|
||||
}}
|
||||
>
|
||||
<IconSearch size={14} />
|
||||
Go to Search
|
||||
<CommandShortcut>G S</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
setCommandPaletteOpen(false);
|
||||
setHelpOpen(true);
|
||||
}}
|
||||
>
|
||||
<IconKeyboard size={14} />
|
||||
Keyboard Shortcuts
|
||||
<CommandShortcut>?</CommandShortcut>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<string, { description: string; keys: string[] }[]> = {};
|
||||
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 (
|
||||
<Dialog open={helpOpen} onOpenChange={setHelpOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Keyboard Shortcuts</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-5 py-2">
|
||||
{Object.entries(grouped).map(([scope, items]) => (
|
||||
<div key={scope} className="space-y-2">
|
||||
<h3 className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{scope}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.description}
|
||||
className="flex items-center justify-between rounded-md px-2 py-1.5"
|
||||
>
|
||||
<span className="text-xs text-foreground">
|
||||
{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-[10px] text-muted-foreground">
|
||||
then
|
||||
</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();
|
||||
}
|
||||
@@ -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<string, ShortcutDef>;
|
||||
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<KeyboardContextValue | null>(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<Map<string, ShortcutDef>>(
|
||||
() => new Map(),
|
||||
);
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const pendingKeyRef = useRef<string | null>(null);
|
||||
const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const registerShortcut = useCallback((id: string, def: ShortcutDef) => {
|
||||
setShortcuts((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(id, def);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const unregisterShortcut = useCallback((id: string) => {
|
||||
setShortcuts((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
const tagName = target.tagName.toLowerCase();
|
||||
const isInput =
|
||||
tagName === "input" ||
|
||||
tagName === "textarea" ||
|
||||
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;
|
||||
|
||||
// Don't fire when command palette is open
|
||||
if (commandPaletteOpen && e.key !== "Escape") return;
|
||||
|
||||
// Check for two-key combos
|
||||
if (pendingKeyRef.current) {
|
||||
const comboKey = `${pendingKeyRef.current}+${e.key}`;
|
||||
pendingKeyRef.current = null;
|
||||
if (pendingTimerRef.current) {
|
||||
clearTimeout(pendingTimerRef.current);
|
||||
pendingTimerRef.current = null;
|
||||
}
|
||||
for (const def of shortcuts.values()) {
|
||||
if (
|
||||
def.keys.length === 2 &&
|
||||
def.keys[0] === comboKey.split("+")[0] &&
|
||||
def.keys[1] === comboKey.split("+")[1]
|
||||
) {
|
||||
e.preventDefault();
|
||||
def.action();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// If no combo matched, fall through to single-key check
|
||||
}
|
||||
|
||||
// Check for single-key shortcuts
|
||||
for (const def of shortcuts.values()) {
|
||||
if (def.keys.length === 1 && def.keys[0] === e.key) {
|
||||
e.preventDefault();
|
||||
def.action();
|
||||
return;
|
||||
}
|
||||
// Start combo sequence
|
||||
if (def.keys.length === 2 && def.keys[0] === e.key) {
|
||||
e.preventDefault();
|
||||
pendingKeyRef.current = e.key;
|
||||
pendingTimerRef.current = setTimeout(() => {
|
||||
pendingKeyRef.current = null;
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [shortcuts, commandPaletteOpen]);
|
||||
|
||||
return (
|
||||
<KeyboardContext.Provider
|
||||
value={{
|
||||
shortcuts,
|
||||
registerShortcut,
|
||||
unregisterShortcut,
|
||||
commandPaletteOpen,
|
||||
setCommandPaletteOpen,
|
||||
helpOpen,
|
||||
setHelpOpen,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</KeyboardContext.Provider>
|
||||
);
|
||||
}
|
||||
+60
-40
@@ -1,28 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { IconLogout, IconSearch } from "@tabler/icons-react";
|
||||
import { motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useKeyboard } from "@/components/keyboard-provider";
|
||||
import { Kbd } from "@/components/ui/kbd";
|
||||
import { signOut, useSession } from "@/lib/auth/client";
|
||||
|
||||
const navLinks = [
|
||||
{ href: "/", label: "Home" },
|
||||
{ href: "/search", label: "Search" },
|
||||
] as const;
|
||||
|
||||
export function NavBar() {
|
||||
const { data: session } = useSession();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { setCommandPaletteOpen } = useKeyboard();
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="flex items-center gap-8">
|
||||
<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">
|
||||
<Link href="/" className="font-display text-xl tracking-tight">
|
||||
Couch Potato
|
||||
</Link>
|
||||
{session?.user && (
|
||||
<div className="hidden items-center gap-1 sm:flex">
|
||||
<NavLink href="/search" active={pathname === "/search"}>
|
||||
<IconSearch size={16} />
|
||||
<span>Search</span>
|
||||
</NavLink>
|
||||
{navLinks.map((link) => {
|
||||
const isActive =
|
||||
link.href === "/"
|
||||
? pathname === "/"
|
||||
: pathname.startsWith(link.href);
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="relative inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{link.label}
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="nav-indicator"
|
||||
className="absolute inset-x-2 -bottom-[11px] h-0.5 rounded-full bg-primary"
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 380,
|
||||
damping: 30,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -30,20 +61,32 @@ export function NavBar() {
|
||||
<div className="flex items-center gap-3">
|
||||
{session?.user ? (
|
||||
<>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{session.user.name}
|
||||
</span>
|
||||
{/* Search trigger pill */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await signOut();
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}}
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-md px-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={() => setCommandPaletteOpen(true)}
|
||||
className="hidden items-center gap-2 rounded-lg border border-border/50 bg-card/50 px-3 py-1.5 text-sm text-muted-foreground transition-all hover:border-primary/20 hover:bg-card sm:inline-flex"
|
||||
>
|
||||
<IconLogout size={15} />
|
||||
<IconSearch size={14} />
|
||||
<span>Search...</span>
|
||||
<Kbd className="ml-1">⌘K</Kbd>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border/30 px-2.5 py-1">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{session.user.name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await signOut();
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<IconLogout size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -55,7 +98,7 @@ export function NavBar() {
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="inline-flex h-8 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-all hover:shadow-md hover:shadow-primary/20"
|
||||
className="inline-flex h-8 items-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-all hover:shadow-md hover:shadow-primary/20"
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
@@ -66,26 +109,3 @@ export function NavBar() {
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLink({
|
||||
href,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
active: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm transition-colors ${
|
||||
active
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
|
||||
interface SearchResult {
|
||||
tmdbId: number;
|
||||
type: "movie" | "tv";
|
||||
title: string;
|
||||
overview: string;
|
||||
releaseDate: string | null;
|
||||
posterPath: string | null;
|
||||
popularity: number;
|
||||
voteAverage: number;
|
||||
}
|
||||
|
||||
interface SearchAutocompleteProps {
|
||||
onResults?: (results: SearchResult[]) => void;
|
||||
onLoading?: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
export function SearchAutocomplete({
|
||||
onResults,
|
||||
onLoading,
|
||||
}: SearchAutocompleteProps) {
|
||||
const router = useRouter();
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState<"all" | "movie" | "tv">("all");
|
||||
const [importing, setImporting] = useState<number | null>(null);
|
||||
const debouncedQuery = useDebounce(query, 300);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!debouncedQuery.trim()) {
|
||||
setResults([]);
|
||||
onResults?.([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
onLoading?.(true);
|
||||
const typeParam = filter !== "all" ? `&type=${filter}` : "";
|
||||
fetch(`/api/search?query=${encodeURIComponent(debouncedQuery)}${typeParam}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
const res = data.results ?? [];
|
||||
setResults(res);
|
||||
onResults?.(res);
|
||||
setOpen(res.length > 0);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
onLoading?.(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [debouncedQuery, filter, onResults, onLoading]);
|
||||
|
||||
const handleImport = 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) {
|
||||
toast.success(`Added "${result.title}" to library`);
|
||||
router.push(`/titles/${title.id}`);
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to import title");
|
||||
} finally {
|
||||
setImporting(null);
|
||||
}
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Filter tabs */}
|
||||
<div className="flex gap-1">
|
||||
{(["all", "movie", "tv"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setFilter(t)}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
filter === t
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t === "all" ? "All" : t === "movie" ? "Movies" : "TV Shows"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<CommandPrimitive shouldFilter={false} className="w-full">
|
||||
<div className="relative">
|
||||
<IconSearch
|
||||
size={18}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<CommandPrimitive.Input
|
||||
ref={inputRef}
|
||||
placeholder="Search movies & TV shows..."
|
||||
value={query}
|
||||
onValueChange={(val) => {
|
||||
setQuery(val);
|
||||
if (val.trim()) setOpen(true);
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (results.length > 0) setOpen(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
// Delay to allow click on result
|
||||
setTimeout(() => setOpen(false), 200);
|
||||
}}
|
||||
className="flex h-13 w-full rounded-xl border border-border/50 bg-card/50 pl-11 pr-4 text-base backdrop-blur-sm transition-all placeholder:text-muted-foreground/50 focus:border-primary/40 focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
{loading && (
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && results.length > 0 && (
|
||||
<CommandPrimitive.List className="absolute z-30 mt-2 max-h-96 w-full overflow-y-auto rounded-xl border border-border/50 bg-popover/95 p-1 shadow-xl shadow-black/30 backdrop-blur-xl">
|
||||
{results.map((r) => (
|
||||
<CommandPrimitive.Item
|
||||
key={`${r.type}-${r.tmdbId}`}
|
||||
value={`${r.type}-${r.tmdbId}`}
|
||||
onSelect={() => handleImport(r)}
|
||||
className="flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm data-[selected=true]:bg-accent"
|
||||
>
|
||||
<div className="h-[60px] w-10 shrink-0 overflow-hidden rounded-md bg-muted">
|
||||
{r.posterPath ? (
|
||||
<Image
|
||||
src={`https://image.tmdb.org/t/p/w92${r.posterPath}`}
|
||||
alt={r.title}
|
||||
width={40}
|
||||
height={60}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-[8px] text-muted-foreground">
|
||||
?
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate text-sm font-medium">{r.title}</p>
|
||||
<span className="shrink-0 rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-primary">
|
||||
{r.type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{r.releaseDate && (
|
||||
<span>{r.releaseDate.slice(0, 4)}</span>
|
||||
)}
|
||||
{r.voteAverage > 0 && (
|
||||
<span className="text-primary">
|
||||
★ {r.voteAverage.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{r.overview && (
|
||||
<p className="mt-0.5 line-clamp-1 text-xs text-muted-foreground/70">
|
||||
{r.overview}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{importing === r.tmdbId && (
|
||||
<div className="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
)}
|
||||
</CommandPrimitive.Item>
|
||||
))}
|
||||
</CommandPrimitive.List>
|
||||
)}
|
||||
</CommandPrimitive>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export function TitleCardSkeleton() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="aspect-[2/3] w-full rounded-xl" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContinueWatchingSkeleton() {
|
||||
return (
|
||||
<div className="flex w-56 shrink-0 gap-3 rounded-xl border border-border/30 bg-card/50 p-3">
|
||||
<Skeleton className="h-20 w-14 shrink-0 rounded-md" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatCardSkeleton() {
|
||||
return <Skeleton className="h-24 w-full rounded-xl" />;
|
||||
}
|
||||
|
||||
export function TitleDetailSkeleton() {
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<Skeleton className="-mx-4 -mt-6 h-72 sm:h-96" />
|
||||
<div className="flex flex-col gap-8 sm:flex-row">
|
||||
<Skeleton className="h-[330px] w-[220px] shrink-0 rounded-xl" />
|
||||
<div className="flex-1 space-y-5">
|
||||
<Skeleton className="h-12 w-2/3" />
|
||||
<Skeleton className="h-5 w-1/3" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-4/6" />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="h-9 w-32 rounded-lg" />
|
||||
<Skeleton className="h-9 w-32 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-64" />
|
||||
<Skeleton className="mt-2 h-4 w-48" />
|
||||
</div>
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<StatCardSkeleton />
|
||||
<StatCardSkeleton />
|
||||
<StatCardSkeleton />
|
||||
<StatCardSkeleton />
|
||||
</div>
|
||||
{/* Continue watching */}
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<div className="flex gap-4">
|
||||
<ContinueWatchingSkeleton />
|
||||
<ContinueWatchingSkeleton />
|
||||
<ContinueWatchingSkeleton />
|
||||
<ContinueWatchingSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
{/* Grid */}
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
<TitleCardSkeleton />
|
||||
<TitleCardSkeleton />
|
||||
<TitleCardSkeleton />
|
||||
<TitleCardSkeleton />
|
||||
<TitleCardSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { IconStar, IconStarFilled } from "@tabler/icons-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface StarRatingProps {
|
||||
@@ -21,19 +22,29 @@ export function StarRating({ value, onChange }: StarRatingProps) {
|
||||
{[1, 2, 3, 4, 5].map((star) => {
|
||||
const filled = star <= (hover || value);
|
||||
return (
|
||||
<button
|
||||
<motion.button
|
||||
key={star}
|
||||
type="button"
|
||||
onClick={() => onChange(star === value ? 0 : star)}
|
||||
onMouseEnter={() => setHover(star)}
|
||||
className="p-0.5 transition-transform hover:scale-110"
|
||||
className="p-0.5"
|
||||
whileHover={{ scale: 1.15 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
animate={
|
||||
filled && star === value ? { scale: [1, 1.25, 1] } : { scale: 1 }
|
||||
}
|
||||
transition={{
|
||||
type: "spring" as const,
|
||||
stiffness: 400,
|
||||
damping: 15,
|
||||
}}
|
||||
>
|
||||
{filled ? (
|
||||
<IconStarFilled size={18} className="text-primary" />
|
||||
) : (
|
||||
<IconStar size={18} className="text-muted-foreground/30" />
|
||||
)}
|
||||
</button>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
IconCheck,
|
||||
IconLibrary,
|
||||
IconMovie,
|
||||
IconPlayerPlay,
|
||||
} from "@tabler/icons-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Stats {
|
||||
moviesThisMonth: number;
|
||||
episodesThisWeek: number;
|
||||
librarySize: number;
|
||||
completed: number;
|
||||
}
|
||||
|
||||
const statDefs = [
|
||||
{
|
||||
key: "moviesThisMonth" as const,
|
||||
label: "Movies This Month",
|
||||
icon: IconMovie,
|
||||
},
|
||||
{
|
||||
key: "episodesThisWeek" as const,
|
||||
label: "Episodes This Week",
|
||||
icon: IconPlayerPlay,
|
||||
},
|
||||
{
|
||||
key: "librarySize" as const,
|
||||
label: "In Library",
|
||||
icon: IconLibrary,
|
||||
},
|
||||
{
|
||||
key: "completed" as const,
|
||||
label: "Completed",
|
||||
icon: IconCheck,
|
||||
},
|
||||
];
|
||||
|
||||
export function StatsSummary() {
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/feed/stats")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setStats(data))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (!stats) return null;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{statDefs.map((def, i) => {
|
||||
const Icon = def.icon;
|
||||
const value = stats[def.key];
|
||||
return (
|
||||
<motion.div
|
||||
key={def.key}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 24,
|
||||
delay: i * 0.08,
|
||||
}}
|
||||
className="rounded-xl border border-border/30 bg-card/50 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon size={14} className="text-primary" />
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{def.label}
|
||||
</span>
|
||||
</div>
|
||||
<motion.p
|
||||
className="mt-2 font-display text-2xl tracking-tight"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: i * 0.08 + 0.2 }}
|
||||
>
|
||||
{value}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,12 +7,31 @@ import {
|
||||
IconPlus,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const statuses = [
|
||||
{ value: "watchlist", label: "Watchlist", icon: IconBookmark },
|
||||
{ value: "in_progress", label: "Watching", icon: IconPlayerPlay },
|
||||
{ value: "completed", label: "Completed", icon: IconCheck },
|
||||
{
|
||||
value: "watchlist",
|
||||
label: "Watchlist",
|
||||
icon: IconBookmark,
|
||||
colorClass:
|
||||
"border-status-watchlist/30 bg-status-watchlist/10 text-status-watchlist hover:bg-status-watchlist/15",
|
||||
},
|
||||
{
|
||||
value: "in_progress",
|
||||
label: "Watching",
|
||||
icon: IconPlayerPlay,
|
||||
colorClass:
|
||||
"border-status-watching/30 bg-status-watching/10 text-status-watching hover:bg-status-watching/15",
|
||||
},
|
||||
{
|
||||
value: "completed",
|
||||
label: "Completed",
|
||||
icon: IconCheck,
|
||||
colorClass:
|
||||
"border-status-completed/30 bg-status-completed/10 text-status-completed hover:bg-status-completed/15",
|
||||
},
|
||||
] as const;
|
||||
|
||||
interface StatusButtonProps {
|
||||
@@ -42,9 +61,9 @@ export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className={`inline-flex h-9 items-center gap-2 rounded-lg border px-4 text-sm font-medium transition-all ${
|
||||
className={`inline-flex h-9 items-center gap-2 rounded-lg border px-4 text-sm font-medium transition-all active:scale-[0.97] ${
|
||||
current
|
||||
? "border-primary/30 bg-primary/10 text-primary hover:bg-primary/15"
|
||||
? current.colorClass
|
||||
: "border-border/50 hover:border-primary/30 hover:bg-primary/5"
|
||||
}`}
|
||||
>
|
||||
@@ -52,48 +71,62 @@ export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
|
||||
{current ? current.label : "Add to List"}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute left-0 top-full z-20 mt-1.5 w-44 overflow-hidden rounded-lg border border-border/50 bg-popover/95 p-1 shadow-xl shadow-black/30 backdrop-blur-xl">
|
||||
{statuses.map((s) => {
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
<button
|
||||
key={s.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(s.value === currentStatus ? null : s.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent ${
|
||||
s.value === currentStatus ? "text-primary" : "text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Icon size={15} />
|
||||
{s.label}
|
||||
{s.value === currentStatus && (
|
||||
<IconCheck size={13} className="ml-auto" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{currentStatus && (
|
||||
<>
|
||||
<div className="my-1 border-t border-border/50" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(null);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm text-destructive transition-colors hover:bg-accent"
|
||||
>
|
||||
<IconX size={15} />
|
||||
Remove
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: -4 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: -4 }}
|
||||
transition={{
|
||||
type: "spring" as const,
|
||||
stiffness: 500,
|
||||
damping: 30,
|
||||
}}
|
||||
className="absolute left-0 top-full z-20 mt-1.5 w-44 overflow-hidden rounded-xl border border-border/50 bg-popover/95 p-1 shadow-xl shadow-black/30 backdrop-blur-xl"
|
||||
>
|
||||
{statuses.map((s) => {
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
<button
|
||||
key={s.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(s.value === currentStatus ? null : s.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors hover:bg-accent ${
|
||||
s.value === currentStatus
|
||||
? "text-primary"
|
||||
: "text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Icon size={15} />
|
||||
{s.label}
|
||||
{s.value === currentStatus && (
|
||||
<IconCheck size={13} className="ml-auto" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{currentStatus && (
|
||||
<>
|
||||
<div className="my-1 border-t border-border/50" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(null);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-destructive transition-colors hover:bg-accent"
|
||||
>
|
||||
<IconX size={15} />
|
||||
Remove
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
@@ -29,22 +32,26 @@ export function TitleCard({
|
||||
: null;
|
||||
|
||||
const content = (
|
||||
<div className="group relative overflow-hidden rounded-lg transition-transform duration-200 hover:scale-[1.02]">
|
||||
<div className="aspect-[2/3] overflow-hidden rounded-lg bg-card">
|
||||
<motion.div
|
||||
className="group relative overflow-hidden rounded-xl ring-1 ring-foreground/5 transition-shadow hover:ring-primary/20 hover:shadow-lg hover:shadow-black/25"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
transition={{ type: "spring" as const, stiffness: 400, damping: 25 }}
|
||||
>
|
||||
<div className="aspect-[2/3] overflow-hidden rounded-xl bg-card">
|
||||
{posterUrl ? (
|
||||
<Image
|
||||
src={posterUrl}
|
||||
alt={title}
|
||||
width={300}
|
||||
height={450}
|
||||
className="h-full w-full object-cover transition-all duration-300 group-hover:brightness-110"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center bg-gradient-to-br from-card to-muted text-sm text-muted-foreground">
|
||||
No poster
|
||||
</div>
|
||||
)}
|
||||
{/* Overlay gradient */}
|
||||
{/* Hover gradient overlay with metadata */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100" />
|
||||
</div>
|
||||
<div className="mt-2 space-y-0.5">
|
||||
@@ -59,7 +66,7 @@ export function TitleCard({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
if (href || id) {
|
||||
|
||||
Reference in New Issue
Block a user