mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
Implement full Couch Potato movie & TV tracking app
Add all 10 milestones: Drizzle ORM + SQLite database with WAL mode, Better Auth email/password authentication, TMDB API integration for search and metadata import, TV season/episode caching, user tracking (watchlist/status/watches/ratings with auto-transitions), discovery feeds (continue watching, library, recommendations), US streaming availability via TMDB providers, background job scheduler with instrumentation hook, and dark cinema-themed frontend with DM Serif Display + DM Sans typography and amber accent design system. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import { NavBar } from "@/components/nav-bar";
|
||||
|
||||
export default function PagesLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<NavBar />
|
||||
<main className="mx-auto max-w-6xl px-4 py-6">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { AuthForm } from "@/components/auth-form";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="flex min-h-[80vh] items-center justify-center px-4">
|
||||
<AuthForm mode="login" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
IconDeviceTv,
|
||||
IconPlayerPlay,
|
||||
IconSparkles,
|
||||
} from "@tabler/icons-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { TitleCard } from "@/components/title-card";
|
||||
import { useSession } from "@/lib/auth/client";
|
||||
|
||||
interface ContinueWatchingItem {
|
||||
title: {
|
||||
id: string;
|
||||
title: string;
|
||||
posterPath: string | null;
|
||||
type: string;
|
||||
};
|
||||
nextEpisode: {
|
||||
id: string;
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
name: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface FeedTitle {
|
||||
id?: string;
|
||||
titleId?: string;
|
||||
title: string;
|
||||
type: string;
|
||||
tmdbId?: number;
|
||||
posterPath: string | null;
|
||||
releaseDate?: string | null;
|
||||
firstAirDate?: string | null;
|
||||
voteAverage?: number | null;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data: session, isPending } = useSession();
|
||||
const router = useRouter();
|
||||
const [continueWatching, setContinueWatching] = useState<
|
||||
ContinueWatchingItem[]
|
||||
>([]);
|
||||
const [newAvailable, setNewAvailable] = useState<FeedTitle[]>([]);
|
||||
const [recommendations, setRecommendations] = useState<FeedTitle[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchFeeds = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [cwRes, naRes, recRes] = await Promise.all([
|
||||
fetch("/api/feed/continue-watching"),
|
||||
fetch("/api/feed/new-available"),
|
||||
fetch("/api/feed/recommendations"),
|
||||
]);
|
||||
if (cwRes.ok) setContinueWatching(await cwRes.json());
|
||||
if (naRes.ok) setNewAvailable(await naRes.json());
|
||||
if (recRes.ok) setRecommendations(await recRes.json());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending) return;
|
||||
if (!session?.user) {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
fetchFeeds();
|
||||
}, [session, isPending, router, fetchFeeds]);
|
||||
|
||||
if (isPending || loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-amber border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isEmpty =
|
||||
continueWatching.length === 0 &&
|
||||
newAvailable.length === 0 &&
|
||||
recommendations.length === 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl tracking-tight">
|
||||
Welcome back{session?.user?.name ? `, ${session.user.name}` : ""}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Here's what's happening with your library
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isEmpty && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed border-border/50 py-16 text-center">
|
||||
<div className="rounded-full bg-amber/10 p-4">
|
||||
<IconDeviceTv size={32} className="text-amber" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">Your library is empty</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Search for movies and TV shows to start tracking
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/search"
|
||||
className="inline-flex h-9 items-center rounded-lg bg-amber px-4 text-sm font-medium text-background transition-all hover:shadow-md hover:shadow-amber/20"
|
||||
>
|
||||
Start searching
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Continue Watching */}
|
||||
{continueWatching.length > 0 && (
|
||||
<FeedSection
|
||||
title="Continue Watching"
|
||||
icon={<IconPlayerPlay size={20} className="text-amber" />}
|
||||
>
|
||||
<div className="feed-scroll flex gap-4 overflow-x-auto pb-2">
|
||||
{continueWatching.map((item) => (
|
||||
<ContinueWatchingCard key={item.title.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</FeedSection>
|
||||
)}
|
||||
|
||||
{/* New on Streaming */}
|
||||
{newAvailable.length > 0 && (
|
||||
<FeedSection
|
||||
title="In Your Library"
|
||||
icon={<IconSparkles size={20} className="text-amber" />}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
{newAvailable.slice(0, 10).map((t) => (
|
||||
<TitleCard
|
||||
key={t.titleId ?? t.id}
|
||||
id={t.titleId ?? t.id}
|
||||
tmdbId={t.tmdbId ?? 0}
|
||||
type={t.type}
|
||||
title={t.title}
|
||||
posterPath={t.posterPath}
|
||||
releaseDate={t.releaseDate ?? t.firstAirDate}
|
||||
voteAverage={t.voteAverage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</FeedSection>
|
||||
)}
|
||||
|
||||
{/* Recommendations */}
|
||||
{recommendations.length > 0 && (
|
||||
<FeedSection
|
||||
title="Recommended for You"
|
||||
icon={<IconSparkles size={20} className="text-amber" />}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
{recommendations.slice(0, 10).map((t) => (
|
||||
<TitleCard
|
||||
key={t.id}
|
||||
id={t.id}
|
||||
tmdbId={t.tmdbId ?? 0}
|
||||
type={t.type}
|
||||
title={t.title}
|
||||
posterPath={t.posterPath}
|
||||
releaseDate={t.releaseDate ?? t.firstAirDate}
|
||||
voteAverage={t.voteAverage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</FeedSection>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedSection({
|
||||
title,
|
||||
icon,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<h2 className="font-display text-xl tracking-tight">{title}</h2>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
|
||||
const posterUrl = item.title.posterPath
|
||||
? `https://image.tmdb.org/t/p/w300${item.title.posterPath}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/titles/${item.title.id}`}
|
||||
className="group flex w-56 shrink-0 gap-3 rounded-lg border border-border/30 bg-card/50 p-3 transition-all hover:border-amber/20 hover:bg-card"
|
||||
>
|
||||
<div className="h-20 w-14 shrink-0 overflow-hidden rounded-md bg-muted">
|
||||
{posterUrl ? (
|
||||
<Image
|
||||
src={posterUrl}
|
||||
alt={item.title.title}
|
||||
width={56}
|
||||
height={80}
|
||||
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 space-y-1">
|
||||
<p className="line-clamp-2 text-sm font-medium leading-snug">
|
||||
{item.title.title}
|
||||
</p>
|
||||
{item.nextEpisode && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
S{item.nextEpisode.seasonNumber} E{item.nextEpisode.episodeNumber}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[10px] font-medium uppercase tracking-wider text-amber">
|
||||
Up next
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { AuthForm } from "@/components/auth-form";
|
||||
|
||||
export default function RegisterPage() {
|
||||
return (
|
||||
<div className="flex min-h-[80vh] items-center justify-center px-4">
|
||||
<AuthForm mode="register" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { IconDeviceTv, IconMovie } from "@tabler/icons-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useState } from "react";
|
||||
import { SearchBar } from "@/components/search-bar";
|
||||
import { TitleCard } from "@/components/title-card";
|
||||
|
||||
interface SearchResult {
|
||||
tmdbId: number;
|
||||
type: "movie" | "tv";
|
||||
title: string;
|
||||
overview: string;
|
||||
releaseDate: string | null;
|
||||
posterPath: string | null;
|
||||
popularity: number;
|
||||
voteAverage: number;
|
||||
}
|
||||
|
||||
export default function SearchPage() {
|
||||
const router = useRouter();
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [importing, setImporting] = useState<number | null>(null);
|
||||
const [searched, setSearched] = useState(false);
|
||||
|
||||
const handleSearch = useCallback(async (query: string) => {
|
||||
setLoading(true);
|
||||
setSearched(true);
|
||||
try {
|
||||
const res = await fetch(`/api/search?query=${encodeURIComponent(query)}`);
|
||||
const data = await res.json();
|
||||
setResults(data.results ?? []);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function handleImport(tmdbId: number, type: "movie" | "tv") {
|
||||
setImporting(tmdbId);
|
||||
try {
|
||||
const res = await fetch("/api/titles/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tmdbId, type }),
|
||||
});
|
||||
const title = await res.json();
|
||||
if (title.id) {
|
||||
router.push(`/titles/${title.id}`);
|
||||
}
|
||||
} finally {
|
||||
setImporting(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-2">
|
||||
<h1 className="font-display text-3xl tracking-tight">Search</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Find movies and TV shows to add to your library
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SearchBar onSearch={handleSearch} />
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-amber border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && searched && results.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-3 py-16 text-center">
|
||||
<div className="flex gap-2 text-muted-foreground/40">
|
||||
<IconMovie size={32} />
|
||||
<IconDeviceTv size={32} />
|
||||
</div>
|
||||
<p className="text-muted-foreground">No results found</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && results.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
{results.map((r) => (
|
||||
<div key={`${r.type}-${r.tmdbId}`} className="relative">
|
||||
<TitleCard
|
||||
tmdbId={r.tmdbId}
|
||||
type={r.type}
|
||||
title={r.title}
|
||||
posterPath={r.posterPath}
|
||||
releaseDate={r.releaseDate}
|
||||
voteAverage={r.voteAverage}
|
||||
onImport={() => handleImport(r.tmdbId, r.type)}
|
||||
/>
|
||||
{importing === r.tmdbId && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-background/80 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-amber border-t-transparent" />
|
||||
<span className="text-sm font-medium text-amber">
|
||||
Importing
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconPlayerPlay,
|
||||
} from "@tabler/icons-react";
|
||||
import Image from "next/image";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { StarRating } from "@/components/star-rating";
|
||||
import { StatusButton } from "@/components/status-button";
|
||||
import { TitleCard } from "@/components/title-card";
|
||||
|
||||
interface Episode {
|
||||
id: string;
|
||||
episodeNumber: number;
|
||||
name: string | null;
|
||||
overview: string | null;
|
||||
airDate: string | null;
|
||||
runtimeMinutes: number | null;
|
||||
}
|
||||
|
||||
interface Season {
|
||||
id: string;
|
||||
seasonNumber: number;
|
||||
name: string | null;
|
||||
episodes: Episode[];
|
||||
}
|
||||
|
||||
interface AvailabilityOffer {
|
||||
providerId: number;
|
||||
providerName: string;
|
||||
logoPath: string | null;
|
||||
offerType: string;
|
||||
}
|
||||
|
||||
interface RecommendedTitle {
|
||||
id: string;
|
||||
tmdbId: number;
|
||||
type: "movie" | "tv";
|
||||
title: string;
|
||||
posterPath: string | null;
|
||||
releaseDate: string | null;
|
||||
firstAirDate: string | null;
|
||||
voteAverage: number | null;
|
||||
}
|
||||
|
||||
interface Title {
|
||||
id: string;
|
||||
tmdbId: number;
|
||||
type: "movie" | "tv";
|
||||
title: string;
|
||||
originalTitle: string | null;
|
||||
overview: string | null;
|
||||
releaseDate: string | null;
|
||||
firstAirDate: string | null;
|
||||
posterPath: string | null;
|
||||
backdropPath: string | null;
|
||||
popularity: number | null;
|
||||
voteAverage: number | null;
|
||||
voteCount: number | null;
|
||||
status: string | null;
|
||||
seasons: Season[];
|
||||
availability: AvailabilityOffer[];
|
||||
userStatus?: string | null;
|
||||
userRating?: number | null;
|
||||
episodeWatches?: string[];
|
||||
}
|
||||
|
||||
export default function TitleDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [title, setTitle] = useState<Title | null>(null);
|
||||
const [recommendations, setRecommendations] = useState<RecommendedTitle[]>(
|
||||
[],
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [openSeason, setOpenSeason] = useState<number | null>(null);
|
||||
const [watchingEp, setWatchingEp] = useState<string | null>(null);
|
||||
|
||||
const fetchTitle = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [titleRes, statusRes] = await Promise.all([
|
||||
fetch(`/api/titles/${id}`),
|
||||
fetch(`/api/titles/${id}/status`),
|
||||
]);
|
||||
const titleData = await titleRes.json();
|
||||
let statusData = { status: null, rating: null, episodeWatches: [] };
|
||||
if (statusRes.ok) {
|
||||
statusData = await statusRes.json();
|
||||
}
|
||||
setTitle({
|
||||
...titleData,
|
||||
userStatus: statusData.status,
|
||||
userRating: statusData.rating,
|
||||
episodeWatches: statusData.episodeWatches ?? [],
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const fetchRecommendations = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/titles/${id}/recommendations`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setRecommendations(data ?? []);
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTitle();
|
||||
fetchRecommendations();
|
||||
}, [fetchTitle, fetchRecommendations]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-amber border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!title) {
|
||||
return (
|
||||
<p className="py-24 text-center text-muted-foreground">Title not found</p>
|
||||
);
|
||||
}
|
||||
|
||||
const posterUrl = title.posterPath
|
||||
? `https://image.tmdb.org/t/p/w500${title.posterPath}`
|
||||
: null;
|
||||
const backdropUrl = title.backdropPath
|
||||
? `https://image.tmdb.org/t/p/w1280${title.backdropPath}`
|
||||
: null;
|
||||
const dateStr = title.releaseDate ?? title.firstAirDate;
|
||||
const year = dateStr?.slice(0, 4);
|
||||
|
||||
async function handleStatusChange(status: string | null) {
|
||||
await fetch(`/api/titles/${id}/status`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
setTitle((t) => (t ? { ...t, userStatus: status } : t));
|
||||
}
|
||||
|
||||
async function handleRating(ratingStars: number) {
|
||||
await fetch(`/api/titles/${id}/rating`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ratingStars }),
|
||||
});
|
||||
setTitle((t) => (t ? { ...t, userRating: ratingStars } : t));
|
||||
}
|
||||
|
||||
async function handleWatchMovie() {
|
||||
await fetch(`/api/movies/${id}/watch`, { method: "POST" });
|
||||
setTitle((t) => (t ? { ...t, userStatus: "completed" } : t));
|
||||
}
|
||||
|
||||
async function handleWatchEpisode(episodeId: string) {
|
||||
setWatchingEp(episodeId);
|
||||
await fetch(`/api/episodes/${episodeId}/watch`, { method: "POST" });
|
||||
setTitle((t) => {
|
||||
if (!t) return t;
|
||||
const watches = [...(t.episodeWatches ?? [])];
|
||||
if (!watches.includes(episodeId)) watches.push(episodeId);
|
||||
return {
|
||||
...t,
|
||||
episodeWatches: watches,
|
||||
userStatus: t.userStatus ?? "in_progress",
|
||||
};
|
||||
});
|
||||
setWatchingEp(null);
|
||||
}
|
||||
|
||||
// Group availability by offerType
|
||||
const availByType: Record<string, AvailabilityOffer[]> = {};
|
||||
for (const offer of title.availability ?? []) {
|
||||
if (!availByType[offer.offerType]) availByType[offer.offerType] = [];
|
||||
availByType[offer.offerType].push(offer);
|
||||
}
|
||||
|
||||
const offerLabels: Record<string, string> = {
|
||||
flatrate: "Stream",
|
||||
rent: "Rent",
|
||||
buy: "Buy",
|
||||
free: "Free",
|
||||
ads: "With Ads",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{/* Backdrop hero */}
|
||||
{backdropUrl && (
|
||||
<div className="relative -mx-4 -mt-6 h-72 overflow-hidden sm:h-96">
|
||||
<Image
|
||||
src={backdropUrl}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/60 to-background/20" />
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-background/80 to-transparent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title header */}
|
||||
<div
|
||||
className={`flex flex-col gap-8 sm:flex-row ${backdropUrl ? "-mt-32 relative z-10" : ""}`}
|
||||
>
|
||||
{posterUrl && (
|
||||
<div className="shrink-0">
|
||||
<div className="overflow-hidden rounded-xl shadow-2xl shadow-black/40">
|
||||
<Image
|
||||
src={posterUrl}
|
||||
alt={title.title}
|
||||
width={220}
|
||||
height={330}
|
||||
className="h-auto w-[180px] sm:w-[220px]"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 space-y-5">
|
||||
<div>
|
||||
<h1 className="font-display text-4xl tracking-tight sm:text-5xl">
|
||||
{title.title}
|
||||
</h1>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||||
<span className="rounded bg-amber/10 px-2 py-0.5 text-xs font-semibold uppercase tracking-wider text-amber">
|
||||
{title.type}
|
||||
</span>
|
||||
{year && <span>{year}</span>}
|
||||
{title.voteAverage != null && title.voteAverage > 0 && (
|
||||
<span className="flex items-center gap-1 text-amber">
|
||||
★ {title.voteAverage.toFixed(1)}
|
||||
{title.voteCount != null && (
|
||||
<span className="text-muted-foreground">
|
||||
({title.voteCount.toLocaleString()})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{title.status && (
|
||||
<span className="rounded border border-border/50 px-2 py-0.5 text-xs">
|
||||
{title.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{title.overview && (
|
||||
<p className="max-w-2xl leading-relaxed text-muted-foreground">
|
||||
{title.overview}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<StatusButton
|
||||
currentStatus={title.userStatus ?? null}
|
||||
onChange={handleStatusChange}
|
||||
/>
|
||||
{title.type === "movie" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleWatchMovie}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg bg-amber px-4 text-sm font-medium text-background transition-all hover:shadow-md hover:shadow-amber/20"
|
||||
>
|
||||
<IconPlayerPlay size={15} />
|
||||
Mark Watched
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Rate:</span>
|
||||
<StarRating
|
||||
value={title.userRating ?? 0}
|
||||
onChange={handleRating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Availability */}
|
||||
{Object.keys(availByType).length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Where to Watch
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{Object.entries(availByType).map(([type, offers]) => (
|
||||
<div key={type} className="space-y-1.5">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
|
||||
{offerLabels[type] ?? type}
|
||||
</span>
|
||||
<div className="flex gap-1.5">
|
||||
{offers.map((offer) => (
|
||||
<ProviderBadge
|
||||
key={offer.providerId}
|
||||
name={offer.providerName}
|
||||
logoPath={offer.logoPath}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Seasons & Episodes (TV) */}
|
||||
{title.type === "tv" && title.seasons.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="font-display text-2xl tracking-tight">Seasons</h2>
|
||||
<div className="space-y-2">
|
||||
{title.seasons.map((season) => {
|
||||
const isOpen = openSeason === season.seasonNumber;
|
||||
const watchedCount = season.episodes.filter((ep) =>
|
||||
title.episodeWatches?.includes(ep.id),
|
||||
).length;
|
||||
const totalCount = season.episodes.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={season.id}
|
||||
className="overflow-hidden rounded-lg border border-border/50 bg-card/50"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setOpenSeason(isOpen ? null : season.seasonNumber)
|
||||
}
|
||||
className="flex w-full items-center justify-between p-4 text-left transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium">
|
||||
{season.name ?? `Season ${season.seasonNumber}`}
|
||||
</span>
|
||||
{watchedCount > 0 && (
|
||||
<span className="text-xs text-amber">
|
||||
{watchedCount}/{totalCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{totalCount} ep
|
||||
</span>
|
||||
{isOpen ? (
|
||||
<IconChevronUp
|
||||
size={16}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
) : (
|
||||
<IconChevronDown
|
||||
size={16}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="border-t border-border/50">
|
||||
{season.episodes.map((ep) => {
|
||||
const isWatched = title.episodeWatches?.includes(ep.id);
|
||||
return (
|
||||
<div
|
||||
key={ep.id}
|
||||
className="flex items-center gap-3 border-b border-border/30 px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleWatchEpisode(ep.id)}
|
||||
disabled={watchingEp === ep.id}
|
||||
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-md border transition-all ${
|
||||
isWatched
|
||||
? "border-amber bg-amber text-background"
|
||||
: "border-border/50 hover:border-amber/50 hover:bg-amber/5"
|
||||
}`}
|
||||
>
|
||||
{isWatched && <IconCheck size={14} />}
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
E{String(ep.episodeNumber).padStart(2, "0")}
|
||||
</span>{" "}
|
||||
<span className="font-medium">
|
||||
{ep.name ?? "Untitled"}
|
||||
</span>
|
||||
</p>
|
||||
{ep.airDate && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ep.airDate}
|
||||
{ep.runtimeMinutes
|
||||
? ` · ${ep.runtimeMinutes}m`
|
||||
: ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommendations */}
|
||||
{recommendations.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="font-display text-2xl tracking-tight">Recommended</h2>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||
{recommendations.slice(0, 12).map((rec) => (
|
||||
<TitleCard
|
||||
key={rec.id}
|
||||
id={rec.id}
|
||||
tmdbId={rec.tmdbId}
|
||||
type={rec.type}
|
||||
title={rec.title}
|
||||
posterPath={rec.posterPath}
|
||||
releaseDate={rec.releaseDate ?? rec.firstAirDate}
|
||||
voteAverage={rec.voteAverage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderBadge({
|
||||
name,
|
||||
logoPath,
|
||||
}: {
|
||||
name: string;
|
||||
logoPath: string | null;
|
||||
}) {
|
||||
const logoUrl = logoPath ? `https://image.tmdb.org/t/p/w92${logoPath}` : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-lg border border-border/30 bg-card transition-transform hover:scale-105"
|
||||
title={name}
|
||||
>
|
||||
{logoUrl ? (
|
||||
<Image
|
||||
src={logoUrl}
|
||||
alt={name}
|
||||
width={40}
|
||||
height={40}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[8px] font-medium text-muted-foreground">
|
||||
{name.slice(0, 2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { badRequest } from "@/lib/api/errors";
|
||||
import { registerJobs } from "@/lib/jobs/registry";
|
||||
import { scheduler } from "@/lib/jobs/scheduler";
|
||||
|
||||
// Ensure jobs are registered for manual triggering
|
||||
registerJobs();
|
||||
|
||||
export async function POST(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
const { name } = await params;
|
||||
const jobNames = scheduler.getJobNames();
|
||||
|
||||
if (!jobNames.includes(name)) {
|
||||
return badRequest(
|
||||
`Unknown job: ${name}. Available: ${jobNames.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
await scheduler.runNow(name);
|
||||
return NextResponse.json({ ok: true, job: name });
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth);
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { AuthError, requireAuth } from "@/lib/api/auth-guard";
|
||||
import { unauthorized } from "@/lib/api/errors";
|
||||
import { logEpisodeWatch } from "@/lib/services/tracking";
|
||||
|
||||
export async function POST(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
let userId: string;
|
||||
try {
|
||||
userId = await requireAuth();
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return unauthorized();
|
||||
throw e;
|
||||
}
|
||||
const { id } = await params;
|
||||
logEpisodeWatch(userId, id);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { AuthError, requireAuth } from "@/lib/api/auth-guard";
|
||||
import { unauthorized } from "@/lib/api/errors";
|
||||
import { getContinueWatchingFeed } from "@/lib/services/discovery";
|
||||
|
||||
export async function GET() {
|
||||
let userId: string;
|
||||
try {
|
||||
userId = await requireAuth();
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return unauthorized();
|
||||
throw e;
|
||||
}
|
||||
const feed = getContinueWatchingFeed(userId);
|
||||
return NextResponse.json(feed);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { AuthError, requireAuth } from "@/lib/api/auth-guard";
|
||||
import { unauthorized } from "@/lib/api/errors";
|
||||
import { getNewAvailableFeed } from "@/lib/services/discovery";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
let userId: string;
|
||||
try {
|
||||
userId = await requireAuth();
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return unauthorized();
|
||||
throw e;
|
||||
}
|
||||
const days = Number(req.nextUrl.searchParams.get("days") ?? 14);
|
||||
const feed = getNewAvailableFeed(userId, days);
|
||||
return NextResponse.json(feed);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { AuthError, requireAuth } from "@/lib/api/auth-guard";
|
||||
import { unauthorized } from "@/lib/api/errors";
|
||||
import { getRecommendationsFeed } from "@/lib/services/discovery";
|
||||
|
||||
export async function GET() {
|
||||
let userId: string;
|
||||
try {
|
||||
userId = await requireAuth();
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return unauthorized();
|
||||
throw e;
|
||||
}
|
||||
const feed = getRecommendationsFeed(userId);
|
||||
return NextResponse.json(feed);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { AuthError, requireAuth } from "@/lib/api/auth-guard";
|
||||
import { unauthorized } from "@/lib/api/errors";
|
||||
import { logMovieWatch } from "@/lib/services/tracking";
|
||||
|
||||
export async function POST(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
let userId: string;
|
||||
try {
|
||||
userId = await requireAuth();
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return unauthorized();
|
||||
throw e;
|
||||
}
|
||||
const { id } = await params;
|
||||
logMovieWatch(userId, id);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { badRequest } from "@/lib/api/errors";
|
||||
import { searchMovies, searchMulti, searchTv } from "@/lib/tmdb/client";
|
||||
import type { TmdbSearchResponse } from "@/lib/tmdb/types";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const query = req.nextUrl.searchParams.get("query");
|
||||
const type = req.nextUrl.searchParams.get("type");
|
||||
|
||||
if (!query) return badRequest("query parameter is required");
|
||||
|
||||
let results: TmdbSearchResponse;
|
||||
if (type === "movie") {
|
||||
results = await searchMovies(query);
|
||||
} else if (type === "tv") {
|
||||
results = await searchTv(query);
|
||||
} else {
|
||||
results = await searchMulti(query);
|
||||
}
|
||||
|
||||
// Filter out person results from multi search
|
||||
const filtered = results.results.filter(
|
||||
(r) => r.media_type !== "person" || type,
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
results: filtered.map((r) => ({
|
||||
tmdbId: r.id,
|
||||
type: r.media_type ?? type,
|
||||
title: r.title ?? r.name,
|
||||
overview: r.overview,
|
||||
releaseDate: r.release_date ?? r.first_air_date,
|
||||
posterPath: r.poster_path,
|
||||
popularity: r.popularity,
|
||||
voteAverage: r.vote_average,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { AuthError, requireAuth } from "@/lib/api/auth-guard";
|
||||
import { badRequest, unauthorized } from "@/lib/api/errors";
|
||||
import { rateTitleStars } from "@/lib/services/tracking";
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
let userId: string;
|
||||
try {
|
||||
userId = await requireAuth();
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return unauthorized();
|
||||
throw e;
|
||||
}
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
const { ratingStars } = body;
|
||||
|
||||
if (typeof ratingStars !== "number" || ratingStars < 0 || ratingStars > 5) {
|
||||
return badRequest("ratingStars must be 0-5");
|
||||
}
|
||||
|
||||
rateTitleStars(userId, id, ratingStars);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { notFound } from "@/lib/api/errors";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { titleRecommendations, titles } from "@/lib/db/schema";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const title = db.select().from(titles).where(eq(titles.id, id)).get();
|
||||
if (!title) return notFound("Title not found");
|
||||
|
||||
const recs = db
|
||||
.select({
|
||||
recommendedTitleId: titleRecommendations.recommendedTitleId,
|
||||
source: titleRecommendations.source,
|
||||
rank: titleRecommendations.rank,
|
||||
})
|
||||
.from(titleRecommendations)
|
||||
.where(eq(titleRecommendations.titleId, id))
|
||||
.orderBy(titleRecommendations.rank)
|
||||
.all();
|
||||
|
||||
const results = recs
|
||||
.map((rec) => {
|
||||
const recTitle = db
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(eq(titles.id, rec.recommendedTitleId))
|
||||
.get();
|
||||
return recTitle
|
||||
? { ...recTitle, source: rec.source, rank: rec.rank }
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return NextResponse.json(results);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { notFound } from "@/lib/api/errors";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { availabilityOffers, episodes, seasons, titles } from "@/lib/db/schema";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const title = db.select().from(titles).where(eq(titles.id, id)).get();
|
||||
if (!title) return notFound("Title not found");
|
||||
|
||||
let titleSeasons: Array<{
|
||||
id: string;
|
||||
seasonNumber: number;
|
||||
name: string | null;
|
||||
overview: string | null;
|
||||
posterPath: string | null;
|
||||
airDate: string | null;
|
||||
episodes: Array<{
|
||||
id: string;
|
||||
episodeNumber: number;
|
||||
name: string | null;
|
||||
overview: string | null;
|
||||
stillPath: string | null;
|
||||
airDate: string | null;
|
||||
runtimeMinutes: number | null;
|
||||
}>;
|
||||
}> = [];
|
||||
|
||||
if (title.type === "tv") {
|
||||
const seasonRows = db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.titleId, title.id))
|
||||
.orderBy(seasons.seasonNumber)
|
||||
.all();
|
||||
|
||||
titleSeasons = seasonRows.map((s) => ({
|
||||
...s,
|
||||
episodes: db
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(eq(episodes.seasonId, s.id))
|
||||
.orderBy(episodes.episodeNumber)
|
||||
.all(),
|
||||
}));
|
||||
}
|
||||
|
||||
const availability = db
|
||||
.select()
|
||||
.from(availabilityOffers)
|
||||
.where(eq(availabilityOffers.titleId, title.id))
|
||||
.all();
|
||||
|
||||
return NextResponse.json({
|
||||
...title,
|
||||
seasons: titleSeasons,
|
||||
availability,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { AuthError, requireAuth } from "@/lib/api/auth-guard";
|
||||
import { unauthorized } from "@/lib/api/errors";
|
||||
import {
|
||||
getUserTitleInfo,
|
||||
removeTitleStatus,
|
||||
setTitleStatus,
|
||||
} from "@/lib/services/tracking";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
let userId: string;
|
||||
try {
|
||||
userId = await requireAuth();
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return unauthorized();
|
||||
throw e;
|
||||
}
|
||||
const { id } = await params;
|
||||
const info = getUserTitleInfo(userId, id);
|
||||
return NextResponse.json(info);
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
let userId: string;
|
||||
try {
|
||||
userId = await requireAuth();
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return unauthorized();
|
||||
throw e;
|
||||
}
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
const { status } = body;
|
||||
|
||||
if (status === null || status === undefined) {
|
||||
removeTitleStatus(userId, id);
|
||||
} else {
|
||||
setTitleStatus(userId, id, status);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { AuthError, requireAuth } from "@/lib/api/auth-guard";
|
||||
import { badRequest, unauthorized } from "@/lib/api/errors";
|
||||
import { importTitle } from "@/lib/services/metadata";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
await requireAuth();
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return unauthorized();
|
||||
throw e;
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { tmdbId, type } = body;
|
||||
|
||||
if (!tmdbId || !type || !["movie", "tv"].includes(type)) {
|
||||
return badRequest("tmdbId and type (movie|tv) are required");
|
||||
}
|
||||
|
||||
const title = await importTitle(tmdbId, type);
|
||||
return NextResponse.json(title);
|
||||
}
|
||||
+66
-69
@@ -7,8 +7,9 @@
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-sans: var(--font-dm-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-display: var(--font-dm-serif);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
@@ -38,6 +39,8 @@
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-amber: var(--amber);
|
||||
--color-amber-muted: var(--amber-muted);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
@@ -47,73 +50,42 @@
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
}
|
||||
|
||||
/* Dark cinema — always dark */
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.58 0.22 27);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
--chart-3: oklch(0.546 0.245 262.881);
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.87 0.00 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.371 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
--chart-3: oklch(0.546 0.245 262.881);
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--background: oklch(0.12 0.005 250);
|
||||
--foreground: oklch(0.93 0.01 80);
|
||||
--card: oklch(0.16 0.005 250);
|
||||
--card-foreground: oklch(0.93 0.01 80);
|
||||
--popover: oklch(0.18 0.005 250);
|
||||
--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);
|
||||
--secondary-foreground: oklch(0.85 0.02 80);
|
||||
--muted: oklch(0.2 0.005 250);
|
||||
--muted-foreground: oklch(0.6 0.02 80);
|
||||
--accent: oklch(0.22 0.008 250);
|
||||
--accent-foreground: oklch(0.93 0.01 80);
|
||||
--destructive: oklch(0.65 0.2 25);
|
||||
--border: oklch(1 0 0 / 8%);
|
||||
--input: oklch(1 0 0 / 10%);
|
||||
--ring: oklch(0.82 0.12 70);
|
||||
--amber: oklch(0.82 0.12 70);
|
||||
--amber-muted: oklch(0.82 0.12 70 / 15%);
|
||||
--chart-1: oklch(0.82 0.12 70);
|
||||
--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);
|
||||
--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-accent-foreground: oklch(0.93 0.01 80);
|
||||
--sidebar-border: oklch(1 0 0 / 8%);
|
||||
--sidebar-ring: oklch(0.82 0.12 70);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -121,6 +93,31 @@
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background text-foreground antialiased;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: oklch(1 0 0 / 15%);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(1 0 0 / 25%);
|
||||
}
|
||||
|
||||
/* Horizontal scroll row for feeds */
|
||||
.feed-scroll {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.feed-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
+12
-6
@@ -1,9 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { DM_Sans, DM_Serif_Display, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
const dmSans = DM_Sans({
|
||||
variable: "--font-dm-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const dmSerif = DM_Serif_Display({
|
||||
variable: "--font-dm-serif",
|
||||
weight: "400",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
@@ -13,8 +19,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Couch Potato",
|
||||
description: "Track your movies and TV shows",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -25,7 +31,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
className={`${dmSans.variable} ${dmSerif.variable} ${geistMono.variable} font-sans antialiased`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
|
||||
+40
-52
@@ -1,65 +1,53 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden">
|
||||
{/* Background grain texture */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.03]"
|
||||
style={{
|
||||
backgroundImage: `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")`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Warm amber glow */}
|
||||
<div className="pointer-events-none absolute left-1/2 top-1/3 h-[600px] w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber/5 blur-[120px]" />
|
||||
|
||||
<main className="relative z-10 flex flex-col items-center gap-10 px-6 text-center">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm font-medium uppercase tracking-[0.3em] text-amber">
|
||||
Self-hosted movie & TV tracker
|
||||
</p>
|
||||
<h1 className="font-display text-6xl tracking-tight sm:text-7xl md:text-8xl">
|
||||
Couch Potato
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
<p className="mx-auto max-w-md text-lg leading-relaxed text-muted-foreground">
|
||||
Track what you watch. Know what's next.
|
||||
<br />
|
||||
Your library, your data, your rules.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Link
|
||||
href="/login"
|
||||
className="group relative inline-flex h-12 items-center justify-center overflow-hidden rounded-lg bg-amber px-8 font-medium text-background transition-all hover:shadow-lg hover:shadow-amber/20"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<span className="relative z-10">Sign In</span>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="inline-flex h-12 items-center justify-center rounded-lg border border-border px-8 font-medium transition-all hover:border-amber/40 hover:bg-amber/5"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
Register
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Bottom fade */}
|
||||
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-background to-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user