diff --git a/app/(auth)/setup/_components/setup-form.tsx b/app/(auth)/setup/_components/setup-form.tsx new file mode 100644 index 0000000..0924aab --- /dev/null +++ b/app/(auth)/setup/_components/setup-form.tsx @@ -0,0 +1,262 @@ +"use client"; + +import { + IconCheck, + IconCopy, + IconExternalLink, + IconKey, +} from "@tabler/icons-react"; +import { motion } from "motion/react"; +import { useRouter } from "next/navigation"; +import { useActionState, useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import { checkTmdbConfigured } from "@/lib/actions/setup"; + +const steps = [ + { + number: "1", + title: "Create a TMDB account", + description: ( + <> + Head to{" "} + + themoviedb.org + + {" "} + and sign up for a free account. + + ), + }, + { + number: "2", + title: "Request an API key", + description: ( + <> + Go to{" "} + + Settings → API + + {" "} + and request an API key. Choose “Developer” when asked. You + need the{" "} + + API Read Access Token + {" "} + (the long one). + + ), + }, + { + number: "3", + title: "Add it to your environment", + description: + "Set the TMDB_API_READ_ACCESS_TOKEN environment variable and restart Sofa.", + }, +]; + +const envSnippets = [ + { + label: ".env file", + code: "TMDB_API_READ_ACCESS_TOKEN=your_api_read_access_token_here", + }, + { + label: "Docker Compose", + code: `environment: + - TMDB_API_READ_ACCESS_TOKEN=your_api_read_access_token_here`, + }, + { + label: "Docker run", + code: "docker run -e TMDB_API_READ_ACCESS_TOKEN=your_token ...", + }, +]; + +const sectionVariants = { + hidden: { opacity: 0, y: 24 }, + visible: { + opacity: 1, + y: 0, + transition: { type: "spring" as const, stiffness: 200, damping: 24 }, + }, +}; + +export function SetupForm() { + const router = useRouter(); + const [configured, checkAction, isPending] = useActionState( + () => checkTmdbConfigured(), + false, + ); + const [copiedIdx, setCopiedIdx] = useState(null); + + useEffect(() => { + if (configured) { + const t = setTimeout(() => router.push("/"), 1500); + return () => clearTimeout(t); + } + }, [configured, router]); + + function copySnippet(idx: number, code: string) { + navigator.clipboard.writeText(code); + setCopiedIdx(idx); + setTimeout(() => setCopiedIdx(null), 2000); + } + + return ( + + {/* Header */} + +
+ + Setup required +
+

+ Connect to TMDB +

+

+ Sofa uses{" "} + + The Movie Database + {" "} + for movie & TV metadata, posters, and streaming availability. + You'll need a free API key to get started. +

+
+ + {/* Steps */} + + {steps.map((step, i) => ( +
+
+ + {step.number} + +
+
+

{step.title}

+

+ {step.description} +

+ + {/* Show env snippets for step 3 */} + {i === 2 && ( +
+ {envSnippets.map((snippet, idx) => ( +
+
+ + {snippet.label} + + +
+
+                        {snippet.code}
+                      
+
+ ))} +
+ )} +
+
+ ))} +
+ + {/* Status check */} + +
+ {configured ? ( +
+
+ +
+
+

+ TMDB API key detected +

+

+ Redirecting you to Sofa… +

+
+
+ ) : ( +
+
+

+ After setting the key and restarting: +

+

+ Click the button to verify your configuration +

+
+
+ +
+
+ )} +
+
+
+ ); +} diff --git a/app/(auth)/setup/page.tsx b/app/(auth)/setup/page.tsx index 5f79e5c..0e5d44f 100644 --- a/app/(auth)/setup/page.tsx +++ b/app/(auth)/setup/page.tsx @@ -1,271 +1,8 @@ -"use client"; - -import { - IconCheck, - IconCopy, - IconExternalLink, - IconKey, -} from "@tabler/icons-react"; -import { motion } from "motion/react"; -import { useRouter } from "next/navigation"; -import { useCallback, useEffect, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Spinner } from "@/components/ui/spinner"; - -const steps = [ - { - number: "1", - title: "Create a TMDB account", - description: ( - <> - Head to{" "} - - themoviedb.org - - {" "} - and sign up for a free account. - - ), - }, - { - number: "2", - title: "Request an API key", - description: ( - <> - Go to{" "} - - Settings → API - - {" "} - and request an API key. Choose “Developer” when asked. You - need the{" "} - - API Read Access Token - {" "} - (the long one). - - ), - }, - { - number: "3", - title: "Add it to your environment", - description: - "Set the TMDB_API_READ_ACCESS_TOKEN environment variable and restart Sofa.", - }, -]; - -const envSnippets = [ - { - label: ".env file", - code: "TMDB_API_READ_ACCESS_TOKEN=your_api_read_access_token_here", - }, - { - label: "Docker Compose", - code: `environment: - - TMDB_API_READ_ACCESS_TOKEN=your_api_read_access_token_here`, - }, - { - label: "Docker run", - code: "docker run -e TMDB_API_READ_ACCESS_TOKEN=your_token ...", - }, -]; - -const sectionVariants = { - hidden: { opacity: 0, y: 24 }, - visible: { - opacity: 1, - y: 0, - transition: { type: "spring" as const, stiffness: 200, damping: 24 }, - }, -}; +import { redirect } from "next/navigation"; +import { isTmdbConfigured } from "@/lib/config"; +import { SetupForm } from "./_components/setup-form"; export default function SetupPage() { - const router = useRouter(); - const [checking, setChecking] = useState(false); - const [configured, setConfigured] = useState(null); - const [copiedIdx, setCopiedIdx] = useState(null); - - const checkStatus = useCallback(async () => { - setChecking(true); - try { - const res = await fetch("/api/setup/status"); - if (res.ok) { - const data = await res.json(); - setConfigured(data.tmdbConfigured); - if (data.tmdbConfigured) { - // Key is now set — redirect to landing after a beat - setTimeout(() => router.push("/"), 1500); - } - } - } finally { - setChecking(false); - } - }, [router]); - - useEffect(() => { - checkStatus(); - }, [checkStatus]); - - function copySnippet(idx: number, code: string) { - navigator.clipboard.writeText(code); - setCopiedIdx(idx); - setTimeout(() => setCopiedIdx(null), 2000); - } - - return ( - - {/* Header */} - -
- - Setup required -
-

- Connect to TMDB -

-

- Sofa uses{" "} - - The Movie Database - {" "} - for movie & TV metadata, posters, and streaming availability. - You'll need a free API key to get started. -

-
- - {/* Steps */} - - {steps.map((step, i) => ( -
-
- - {step.number} - -
-
-

{step.title}

-

- {step.description} -

- - {/* Show env snippets for step 3 */} - {i === 2 && ( -
- {envSnippets.map((snippet, idx) => ( -
-
- - {snippet.label} - - -
-
-                        {snippet.code}
-                      
-
- ))} -
- )} -
-
- ))} -
- - {/* Status check */} - -
- {configured === true ? ( -
-
- -
-
-

- TMDB API key detected -

-

- Redirecting you to Sofa… -

-
-
- ) : ( -
-
-

- After setting the key and restarting: -

-

- Click the button to verify your configuration -

-
- -
- )} -
-
-
- ); + if (isTmdbConfigured()) redirect("/"); + return ; } diff --git a/app/(pages)/layout.tsx b/app/(pages)/layout.tsx index ed97bbf..8b34ac5 100644 --- a/app/(pages)/layout.tsx +++ b/app/(pages)/layout.tsx @@ -19,7 +19,7 @@ export default async function PagesLayout({
- + {/* Ambient glow — hidden on mobile where it overwhelms the viewport */}
- + {session.user.role === "admin" && } ); diff --git a/app/(pages)/people/[id]/_components/filmography-grid.tsx b/app/(pages)/people/[id]/_components/filmography-grid.tsx index 8086026..b3ea61d 100644 --- a/app/(pages)/people/[id]/_components/filmography-grid.tsx +++ b/app/(pages)/people/[id]/_components/filmography-grid.tsx @@ -42,7 +42,7 @@ export function FilmographyGrid({ return true; }); - return list.sort((a, b) => { + return [...list].sort((a, b) => { if (sort === "rating") { return (b.voteAverage ?? 0) - (a.voteAverage ?? 0); } diff --git a/app/(pages)/settings/_components/backup-schedule-section.tsx b/app/(pages)/settings/_components/backup-schedule-section.tsx index 43d339c..7045b7b 100644 --- a/app/(pages)/settings/_components/backup-schedule-section.tsx +++ b/app/(pages)/settings/_components/backup-schedule-section.tsx @@ -2,9 +2,10 @@ import { IconCalendarWeek } from "@tabler/icons-react"; import { format, formatDistanceToNow } from "date-fns"; -import { useAtomValue } from "jotai"; +import { useAtomValue, useSetAtom } from "jotai"; import { useHydrateAtoms } from "jotai/utils"; import { AnimatePresence, motion } from "motion/react"; +import { useEffect } from "react"; import { Button } from "@/components/ui/button"; import { ButtonGroup } from "@/components/ui/button-group"; import { CardContent, CardDescription, CardTitle } from "@/components/ui/card"; @@ -129,6 +130,23 @@ export function BackupScheduleSection({ }, ], ]); + const setSchedule = useSetAtom(backupScheduleAtom); + useEffect(() => { + setSchedule({ + enabled: initialScheduledEnabled, + maxRetention: initialMaxRetention, + frequency: initialFrequency, + time: initialTime, + dow: initialDow, + }); + }, [ + setSchedule, + initialScheduledEnabled, + initialMaxRetention, + initialFrequency, + initialTime, + initialDow, + ]); return ; } diff --git a/app/(pages)/settings/_components/integrations-section.tsx b/app/(pages)/settings/_components/integrations-section.tsx index 552f9e7..0a23e82 100644 --- a/app/(pages)/settings/_components/integrations-section.tsx +++ b/app/(pages)/settings/_components/integrations-section.tsx @@ -1,7 +1,9 @@ "use client"; import { IconWebhook } from "@tabler/icons-react"; +import { useSetAtom } from "jotai"; import { useHydrateAtoms } from "jotai/utils"; +import { useEffect } from "react"; import { connectionsAtom } from "@/lib/atoms/integrations"; import { IntegrationCard, @@ -15,6 +17,10 @@ export function IntegrationsSection({ initialConnections: IntegrationConnection[]; }) { useHydrateAtoms([[connectionsAtom, initialConnections]]); + const setConnections = useSetAtom(connectionsAtom); + useEffect(() => { + setConnections(initialConnections); + }, [initialConnections, setConnections]); return (
diff --git a/app/(pages)/titles/[id]/_components/use-title-actions.ts b/app/(pages)/titles/[id]/_components/use-title-actions.ts index e6a3d06..56f5b07 100644 --- a/app/(pages)/titles/[id]/_components/use-title-actions.ts +++ b/app/(pages)/titles/[id]/_components/use-title-actions.ts @@ -151,7 +151,7 @@ export function useTitleActions() { await watchEpisode(episodeId); const seasons = store.get(seasonsAtom); - const episodeWatches = store.get(episodeWatchesAtom); + const watchedSet = new Set(store.get(episodeWatchesAtom)); const previousUnwatched: string[] = []; for (const s of seasons) { for (const ep of s.episodes) { @@ -159,7 +159,7 @@ export function useTitleActions() { s.seasonNumber < seasonNum || (s.seasonNumber === seasonNum && ep.episodeNumber < epNum) ) { - if (!episodeWatches.includes(ep.id) && ep.id !== episodeId) { + if (!watchedSet.has(ep.id) && ep.id !== episodeId) { previousUnwatched.push(ep.id); } } @@ -198,13 +198,11 @@ export function useTitleActions() { async (season: Season) => { const prevWatches = store.get(episodeWatchesAtom); const prevStatus = store.get(userStatusAtom); - const episodeWatches = store.get(episodeWatchesAtom); - const unwatched = season.episodes.filter( - (ep) => !episodeWatches.includes(ep.id), - ); + const watchedSet = new Set(prevWatches); + const unwatched = season.episodes.filter((ep) => !watchedSet.has(ep.id)); if (unwatched.length === 0) return; - const newWatchSet = new Set(episodeWatches); + const newWatchSet = new Set(watchedSet); for (const ep of unwatched) newWatchSet.add(ep.id); store.set(episodeWatchesAtom, [...newWatchSet]); diff --git a/app/api/admin/jobs/trigger/route.ts b/app/api/admin/jobs/trigger/route.ts index 267b630..3948ad8 100644 --- a/app/api/admin/jobs/trigger/route.ts +++ b/app/api/admin/jobs/trigger/route.ts @@ -1,10 +1,9 @@ -import { headers } from "next/headers"; import { NextResponse } from "next/server"; -import { auth } from "@/lib/auth/server"; +import { getSession } from "@/lib/auth/session"; import { triggerJob } from "@/lib/cron"; export async function POST(request: Request) { - const session = await auth.api.getSession({ headers: await headers() }); + const session = await getSession(); if (!session) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/app/api/admin/system-health/route.ts b/app/api/admin/system-health/route.ts index 71970bd..44b2fa0 100644 --- a/app/api/admin/system-health/route.ts +++ b/app/api/admin/system-health/route.ts @@ -1,10 +1,9 @@ -import { headers } from "next/headers"; import { NextResponse } from "next/server"; -import { auth } from "@/lib/auth/server"; +import { getSession } from "@/lib/auth/session"; import { getSystemHealth } from "@/lib/services/system-health"; export async function GET() { - const session = await auth.api.getSession({ headers: await headers() }); + const session = await getSession(); if (!session) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/app/api/admin/update-check/route.ts b/app/api/admin/update-check/route.ts index 550b8b9..1bb6255 100644 --- a/app/api/admin/update-check/route.ts +++ b/app/api/admin/update-check/route.ts @@ -1,10 +1,9 @@ -import { headers } from "next/headers"; import { NextResponse } from "next/server"; -import { auth } from "@/lib/auth/server"; +import { getSession } from "@/lib/auth/session"; import { getCachedUpdateCheck } from "@/lib/services/update-check"; export async function GET() { - const session = await auth.api.getSession({ headers: await headers() }); + const session = await getSession(); if (!session) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/app/api/backup/[filename]/route.ts b/app/api/backup/[filename]/route.ts index 41e679e..825663f 100644 --- a/app/api/backup/[filename]/route.ts +++ b/app/api/backup/[filename]/route.ts @@ -1,14 +1,13 @@ import path from "node:path"; -import { headers } from "next/headers"; import { type NextRequest, NextResponse } from "next/server"; -import { auth } from "@/lib/auth/server"; +import { getSession } from "@/lib/auth/session"; import { getBackupPath } from "@/lib/services/backup"; export async function GET( _req: NextRequest, { params }: { params: Promise<{ filename: string }> }, ) { - const session = await auth.api.getSession({ headers: await headers() }); + const session = await getSession(); if (!session) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/app/api/backup/restore/route.ts b/app/api/backup/restore/route.ts index 2acb6bb..b1e1331 100644 --- a/app/api/backup/restore/route.ts +++ b/app/api/backup/restore/route.ts @@ -1,12 +1,11 @@ -import { headers } from "next/headers"; import { NextResponse } from "next/server"; -import { auth } from "@/lib/auth/server"; +import { getSession } from "@/lib/auth/session"; import { restoreFromBackup } from "@/lib/services/backup"; const MAX_SIZE = 500 * 1024 * 1024; // 500MB export async function POST(req: Request) { - const session = await auth.api.getSession({ headers: await headers() }); + const session = await getSession(); if (!session) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/app/api/explore/discover/route.ts b/app/api/explore/discover/route.ts index 47ee295..733eed8 100644 --- a/app/api/explore/discover/route.ts +++ b/app/api/explore/discover/route.ts @@ -1,7 +1,6 @@ -import { headers } from "next/headers"; import { type NextRequest, NextResponse } from "next/server"; import { z } from "zod"; -import { auth } from "@/lib/auth/server"; +import { getSession } from "@/lib/auth/session"; import { isTmdbConfigured } from "@/lib/config"; import { discover } from "@/lib/tmdb/client"; import { tmdbImageUrl } from "@/lib/tmdb/image"; @@ -20,9 +19,7 @@ const querySchema = z.object({ }); export async function GET(req: NextRequest) { - const session = await auth.api.getSession({ - headers: await headers(), - }); + const session = await getSession(); if (!session) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/app/api/person/[id]/filmography/route.ts b/app/api/person/[id]/filmography/route.ts index e1d3454..51ee3a0 100644 --- a/app/api/person/[id]/filmography/route.ts +++ b/app/api/person/[id]/filmography/route.ts @@ -1,13 +1,12 @@ -import { headers } from "next/headers"; import { type NextRequest, NextResponse } from "next/server"; -import { auth } from "@/lib/auth/server"; +import { getSession } from "@/lib/auth/session"; import { fetchFullFilmography } from "@/lib/services/person"; export async function GET( _req: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { - const session = await auth.api.getSession({ headers: await headers() }); + const session = await getSession(); if (!session) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/app/api/person/[id]/route.ts b/app/api/person/[id]/route.ts index 03b4a41..9568aae 100644 --- a/app/api/person/[id]/route.ts +++ b/app/api/person/[id]/route.ts @@ -1,6 +1,5 @@ -import { headers } from "next/headers"; import { type NextRequest, NextResponse } from "next/server"; -import { auth } from "@/lib/auth/server"; +import { getSession } from "@/lib/auth/session"; import { getLocalFilmography, getOrFetchPerson, @@ -13,7 +12,7 @@ export async function GET( _req: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { - const session = await auth.api.getSession({ headers: await headers() }); + const session = await getSession(); if (!session) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/app/api/search/route.ts b/app/api/search/route.ts index f429e35..3d73951 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -1,6 +1,5 @@ -import { headers } from "next/headers"; import { type NextRequest, NextResponse } from "next/server"; -import { auth } from "@/lib/auth/server"; +import { getSession } from "@/lib/auth/session"; import { isTmdbConfigured } from "@/lib/config"; import { searchMovies, @@ -12,9 +11,7 @@ import { tmdbImageUrl } from "@/lib/tmdb/image"; import type { TmdbSearchResponse } from "@/lib/tmdb/types"; export async function GET(req: NextRequest) { - const session = await auth.api.getSession({ - headers: await headers(), - }); + const session = await getSession(); if (!session) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/app/api/setup/status/route.ts b/app/api/setup/status/route.ts deleted file mode 100644 index 44f1d29..0000000 --- a/app/api/setup/status/route.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { NextResponse } from "next/server"; -import { isTmdbConfigured } from "@/lib/config"; - -export async function GET() { - return NextResponse.json({ - tmdbConfigured: isTmdbConfigured(), - }); -} diff --git a/app/api/stats/route.ts b/app/api/stats/route.ts index 7a1791d..4293dc8 100644 --- a/app/api/stats/route.ts +++ b/app/api/stats/route.ts @@ -1,6 +1,5 @@ -import { headers } from "next/headers"; import { NextResponse } from "next/server"; -import { auth } from "@/lib/auth/server"; +import { getSession } from "@/lib/auth/session"; import { getWatchCount, getWatchHistory, @@ -16,7 +15,7 @@ const validPeriods: TimePeriod[] = [ ]; export async function GET(request: Request) { - const session = await auth.api.getSession({ headers: await headers() }); + const session = await getSession(); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/app/api/titles/import/route.ts b/app/api/titles/import/route.ts index 0ab5ea8..b134b2b 100644 --- a/app/api/titles/import/route.ts +++ b/app/api/titles/import/route.ts @@ -1,8 +1,7 @@ -import { headers } from "next/headers"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { z } from "zod"; -import { auth } from "@/lib/auth/server"; +import { getSession } from "@/lib/auth/session"; import { importTitle } from "@/lib/services/metadata"; const bodySchema = z.object({ @@ -11,9 +10,7 @@ const bodySchema = z.object({ }); export async function POST(req: NextRequest) { - const session = await auth.api.getSession({ - headers: await headers(), - }); + const session = await getSession(); if (!session) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/app/api/titles/resolve/route.ts b/app/api/titles/resolve/route.ts index 5d51e9a..bbe5b08 100644 --- a/app/api/titles/resolve/route.ts +++ b/app/api/titles/resolve/route.ts @@ -1,8 +1,7 @@ -import { headers } from "next/headers"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { z } from "zod"; -import { auth } from "@/lib/auth/server"; +import { getSession } from "@/lib/auth/session"; import { importTitle } from "@/lib/services/metadata"; const bodySchema = z.object({ @@ -11,9 +10,7 @@ const bodySchema = z.object({ }); export async function POST(req: NextRequest) { - const session = await auth.api.getSession({ - headers: await headers(), - }); + const session = await getSession(); if (!session) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/app/page.tsx b/app/page.tsx index ad3e010..e1bb301 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,8 @@ +import { redirect } from "next/navigation"; import { connection } from "next/server"; import { LandingPage } from "@/components/landing-page"; +import { getSession } from "@/lib/auth/session"; +import { isTmdbConfigured } from "@/lib/config"; import { getUserCount, isRegistrationOpen } from "@/lib/services/settings"; import { tmdbImageUrl } from "@/lib/tmdb/image"; @@ -25,6 +28,11 @@ const posterUrls = posterPaths export default async function Home() { await connection(); + + const session = await getSession(); + if (session?.user) redirect("/dashboard"); + if (!isTmdbConfigured()) redirect("/setup"); + const userCount = getUserCount(); return ( { - try { - const res = await fetch("/api/setup/status"); - if (!res.ok) return true; // assume configured on error - const data = await res.json(); - return !!data.tmdbConfigured; - } catch { - return true; - } -} // Poster positions arranged in angled columns behind the hero const posterLayout = [ @@ -49,23 +34,6 @@ export function LandingPage({ freshInstall: boolean; registrationOpen: boolean; }) { - const { data: session, isPending } = useSession(); - const router = useRouter(); - - useEffect(() => { - if (isPending) return; - if (session?.user) { - router.replace("/dashboard"); - return; - } - // If not logged in, check whether TMDB is configured - fetchSetupStatus().then((configured) => { - if (!configured) router.replace("/setup"); - }); - }, [session, isPending, router]); - - if (isPending) return null; - return (
{/* Background grain texture */} diff --git a/components/mobile-tab-bar.tsx b/components/mobile-tab-bar.tsx index 4dffeac..ea1dabd 100644 --- a/components/mobile-tab-bar.tsx +++ b/components/mobile-tab-bar.tsx @@ -4,7 +4,6 @@ import { IconCompass, IconHome, IconSettings } from "@tabler/icons-react"; import { motion } from "motion/react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { useSession } from "@/lib/auth/client"; const tabs = [ { href: "/dashboard", label: "Home", icon: IconHome }, @@ -13,11 +12,8 @@ const tabs = [ ] as const; export function MobileTabBar() { - const { data: session } = useSession(); const pathname = usePathname(); - if (!session?.user) return null; - return (