diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx index 55cea4c..bf0c7b1 100644 --- a/app/(auth)/layout.tsx +++ b/app/(auth)/layout.tsx @@ -7,7 +7,9 @@ export default function AuthLayout({ }) { return (
- {children} +
+ {children} +
); } diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index cf0d831..efd2eb4 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,7 +1,6 @@ -import { headers } from "next/headers"; import { redirect } from "next/navigation"; import { AuthForm } from "@/components/auth-form"; -import { auth } from "@/lib/auth/server"; +import { getSession } from "@/lib/auth/session"; import { getOidcProviderName, isOidcConfigured, @@ -10,7 +9,7 @@ import { import { getUserCount, isRegistrationOpen } from "@/lib/services/settings"; export default async function LoginPage() { - const session = await auth.api.getSession({ headers: await headers() }); + const session = await getSession(); if (session) redirect("/dashboard"); if (getUserCount() === 0) { @@ -20,16 +19,14 @@ export default async function LoginPage() { const oidcEnabled = isOidcConfigured(); return ( -
- -
+ ); } diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 2ba9194..8c927e4 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -1,9 +1,8 @@ import { IconLock } from "@tabler/icons-react"; -import { headers } from "next/headers"; import Link from "next/link"; import { redirect } from "next/navigation"; import { AuthForm } from "@/components/auth-form"; -import { auth } from "@/lib/auth/server"; +import { getSession } from "@/lib/auth/session"; import { getOidcProviderName, isOidcConfigured, @@ -12,34 +11,32 @@ import { import { isRegistrationOpen } from "@/lib/services/settings"; export default async function RegisterPage() { - const session = await auth.api.getSession({ headers: await headers() }); + const session = await getSession(); if (session) redirect("/dashboard"); if (!isRegistrationOpen()) { return ( -
-
-
-
-
- -
-
-

- Registration Closed -

-

- New accounts are not being accepted right now. Contact the admin - if you need access. -

-
- - Sign in instead - +
+
+
+
+
+
+

+ Registration Closed +

+

+ New accounts are not being accepted right now. Contact the admin + if you need access. +

+
+ + Sign in instead +
); @@ -48,15 +45,13 @@ export default async function RegisterPage() { const oidcEnabled = isOidcConfigured(); return ( -
- -
+ ); } diff --git a/app/(auth)/setup/_components/setup-form.tsx b/app/(auth)/setup/_components/setup-form.tsx deleted file mode 100644 index 640886b..0000000 --- a/app/(auth)/setup/_components/setup-form.tsx +++ /dev/null @@ -1,262 +0,0 @@ -"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 deleted file mode 100644 index 84879b1..0000000 --- a/app/(auth)/setup/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { redirect } from "next/navigation"; -import { connection } from "next/server"; -import { isTmdbConfigured } from "@/lib/config"; -import { SetupForm } from "./_components/setup-form"; - -export default function SetupPage() { - return ; -} - -async function SetupContent() { - await connection(); - if (isTmdbConfigured()) redirect("/"); - return ; -} diff --git a/app/(pages)/dashboard/_components/stats-display.tsx b/app/(pages)/dashboard/_components/stats-display.tsx index 69934b4..5767414 100644 --- a/app/(pages)/dashboard/_components/stats-display.tsx +++ b/app/(pages)/dashboard/_components/stats-display.tsx @@ -6,7 +6,7 @@ import { IconMovie, IconPlayerPlay, } from "@tabler/icons-react"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Select, SelectContent, @@ -14,7 +14,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { getStatsAction } from "@/lib/actions/watchlist"; +import { useStats } from "@/hooks/use-stats"; import type { DashboardStats, HistoryBucket, @@ -123,22 +123,9 @@ function PeriodSelector({ export function StatsDisplay({ stats }: { stats: DashboardStats }) { const [moviePeriod, setMoviePeriod] = useState("this_month"); const [episodePeriod, setEpisodePeriod] = useState("this_week"); - const [movieStats, setMovieStats] = useState<{ - count: number; - history: HistoryBucket[]; - } | null>(null); - const [episodeStats, setEpisodeStats] = useState<{ - count: number; - history: HistoryBucket[]; - } | null>(null); - useEffect(() => { - void getStatsAction("movies", moviePeriod).then(setMovieStats); - }, [moviePeriod]); - - useEffect(() => { - void getStatsAction("episodes", episodePeriod).then(setEpisodeStats); - }, [episodePeriod]); + const movieStats = useStats("movies", moviePeriod); + const episodeStats = useStats("episodes", episodePeriod); const movieCount = movieStats?.count ?? stats.moviesThisMonth; const movieHistory = movieStats?.history; diff --git a/app/(pages)/explore/_components/filterable-title-row.tsx b/app/(pages)/explore/_components/filterable-title-row.tsx index e716ceb..933ff4b 100644 --- a/app/(pages)/explore/_components/filterable-title-row.tsx +++ b/app/(pages)/explore/_components/filterable-title-row.tsx @@ -1,15 +1,11 @@ "use client"; -import { useState, useTransition } from "react"; +import { useState } from "react"; import { TitleCardSkeleton } from "@/components/skeletons"; import { TitleCard } from "@/components/title-card"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { discoverByGenre } from "@/lib/actions/explore"; -import { - fetchEpisodeProgress, - fetchUserStatuses, -} from "@/lib/actions/watchlist"; +import { useDiscover } from "@/hooks/use-discover"; interface Genre { id: number; @@ -47,48 +43,24 @@ export function FilterableTitleRow({ episodeProgress: initialProgress = {}, }: FilterableTitleRowProps) { const [selectedGenre, setSelectedGenre] = useState(null); - const [genreResults, setGenreResults] = useState(null); - const [genreStatuses, setGenreStatuses] = useState< - Record - >({}); - const [genreProgress, setGenreProgress] = useState< - Record - >({}); - const [isPending, startTransition] = useTransition(); + const { data: discoverData, isLoading: isPending } = useDiscover( + mediaType, + selectedGenre, + ); - const items = selectedGenre === null ? defaultItems : (genreResults ?? []); - const userStatuses = selectedGenre === null ? initialStatuses : genreStatuses; + const items = + selectedGenre === null ? defaultItems : (discoverData?.items ?? []); + const userStatuses = + selectedGenre === null + ? initialStatuses + : (discoverData?.userStatuses ?? {}); const episodeProgress = - selectedGenre === null ? initialProgress : genreProgress; + selectedGenre === null + ? initialProgress + : (discoverData?.episodeProgress ?? {}); function toggleGenre(genreId: number) { - if (selectedGenre === genreId) { - setSelectedGenre(null); - setGenreResults(null); - return; - } - - setSelectedGenre(genreId); - startTransition(async () => { - const results = await discoverByGenre(mediaType, genreId); - setGenreResults(results); - - if (results.length > 0) { - const lookups = results.map((r) => ({ - tmdbId: r.tmdbId, - type: r.type, - })); - const [statuses, progress] = await Promise.all([ - fetchUserStatuses(lookups), - fetchEpisodeProgress(lookups), - ]); - setGenreStatuses(statuses); - setGenreProgress(progress); - } else { - setGenreStatuses({}); - setGenreProgress({}); - } - }); + setSelectedGenre(genreId === selectedGenre ? null : genreId); } return ( diff --git a/app/(pages)/explore/_components/hero-banner.tsx b/app/(pages)/explore/_components/hero-banner.tsx index 1e9c08e..7a7c419 100644 --- a/app/(pages)/explore/_components/hero-banner.tsx +++ b/app/(pages)/explore/_components/hero-banner.tsx @@ -9,6 +9,7 @@ import { import Image from "next/image"; import { useRouter } from "next/navigation"; import { useTransition } from "react"; +import { toast } from "sonner"; import { useProgress } from "@/components/navigation-progress"; import { resolveTitle } from "@/lib/actions/titles"; @@ -37,8 +38,14 @@ export function HeroBanner({ if (isPending) return; progress.start(); startTransition(async () => { - const id = await resolveTitle(tmdbId, type); - if (id) router.push(`/titles/${id}`); + try { + const id = await resolveTitle(tmdbId, type); + if (id) router.push(`/titles/${id}`); + else progress.done(); + } catch { + progress.done(); + toast.error("Failed to load title"); + } }); } diff --git a/app/(pages)/layout.tsx b/app/(pages)/layout.tsx index ce22a46..07c516d 100644 --- a/app/(pages)/layout.tsx +++ b/app/(pages)/layout.tsx @@ -5,6 +5,7 @@ import { MobileTabBar, NavBar } from "@/components/nav-bar"; import { ProgressProvider } from "@/components/navigation-progress"; import { UpdateToast } from "@/components/update-toast"; import { getSession } from "@/lib/auth/session"; +import { getCachedUpdateCheck } from "@/lib/services/update-check"; export default function PagesLayout({ children, @@ -44,7 +45,9 @@ async function AuthenticatedShell({ children }: { children: React.ReactNode }) {
- {session.user.role === "admin" && } + {session.user.role === "admin" && ( + + )} ); } diff --git a/app/(pages)/not-found.tsx b/app/(pages)/not-found.tsx deleted file mode 100644 index e21207c..0000000 --- a/app/(pages)/not-found.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import Link from "next/link"; - -export default function PagesNotFound() { - return ( -
- {/* Ghosted 404 */} -

- 404 -

- -
-

- Page not found -

-

- This page doesn't exist or may have been moved. Try searching for - what you're looking for instead. -

-
- -
- - Dashboard -
- - - Explore - -
-
- ); -} diff --git a/app/(pages)/settings/_components/system-health-section.tsx b/app/(pages)/settings/_components/system-health-section.tsx index 23e3ee8..10e3978 100644 --- a/app/(pages)/settings/_components/system-health-section.tsx +++ b/app/(pages)/settings/_components/system-health-section.tsx @@ -34,11 +34,9 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { useSystemHealth } from "@/hooks/use-system-health"; import { useTimeAgo } from "@/hooks/use-time-ago"; -import { - getSystemHealthAction, - triggerJobAction, -} from "@/lib/actions/settings"; +import { triggerJobAction } from "@/lib/actions/settings"; import type { SystemHealthData } from "@/lib/services/system-health"; const JOB_LABELS: Record = { @@ -137,20 +135,7 @@ export function SystemHealthCards({ }: { initialData: SystemHealthData; }) { - const [data, setData] = useState(initialData); - const [isRefreshing, setRefreshing] = useState(false); - - async function refresh() { - setRefreshing(true); - try { - const newData = await getSystemHealthAction(); - setData(newData); - } catch { - toast.error("Failed to refresh system health"); - } finally { - setRefreshing(false); - } - } + const { data, isRefreshing, refresh } = useSystemHealth(initialData); return (
diff --git a/app/api/avatars/[userId]/route.ts b/app/api/avatars/[userId]/route.ts index a957144..6491008 100644 --- a/app/api/avatars/[userId]/route.ts +++ b/app/api/avatars/[userId]/route.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { type NextRequest, NextResponse } from "next/server"; +import { getSession } from "@/lib/auth/session"; import { AVATAR_DIR } from "@/lib/constants"; const IMMUTABLE_CACHE = "public, max-age=31536000, immutable"; @@ -8,6 +9,11 @@ export async function GET( _req: NextRequest, { params }: { params: Promise<{ userId: string }> }, ) { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { userId } = await params; // Sanitize userId to prevent path traversal diff --git a/app/api/discover/route.ts b/app/api/discover/route.ts new file mode 100644 index 0000000..97ae949 --- /dev/null +++ b/app/api/discover/route.ts @@ -0,0 +1,83 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getSession } from "@/lib/auth/session"; +import { isTmdbConfigured } from "@/lib/config"; +import { + getEpisodeProgressByTmdbIds, + getUserStatusesByTmdbIds, +} from "@/lib/services/tracking"; +import { discover } from "@/lib/tmdb/client"; +import { tmdbImageUrl } from "@/lib/tmdb/image"; + +export async function GET(req: NextRequest) { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (!isTmdbConfigured()) { + return NextResponse.json( + { error: "TMDB API key is not configured." }, + { status: 503 }, + ); + } + + const mediaType = req.nextUrl.searchParams.get("mediaType"); + const genreId = req.nextUrl.searchParams.get("genreId"); + + if (mediaType !== "movie" && mediaType !== "tv") { + return NextResponse.json( + { error: "mediaType must be movie or tv" }, + { status: 400 }, + ); + } + + const parsedGenreId = Number(genreId); + if (!genreId || !Number.isFinite(parsedGenreId)) { + return NextResponse.json( + { error: "genreId must be a number" }, + { status: 400 }, + ); + } + + try { + const results = await discover(mediaType, { + sort_by: "popularity.desc", + "vote_count.gte": "50", + with_genres: String(parsedGenreId), + }); + + type DiscoverResult = NonNullable[number] & { + title?: string; + name?: string; + release_date?: string; + first_air_date?: string; + }; + + const items = ((results.results ?? []) as DiscoverResult[]) + .filter((r) => r.poster_path) + .map((r) => ({ + tmdbId: r.id, + type: mediaType, + title: r.title ?? r.name ?? "", + posterPath: tmdbImageUrl(r.poster_path ?? null, "posters"), + releaseDate: r.release_date ?? r.first_air_date ?? null, + voteAverage: r.vote_average, + })); + + const lookups = items.map((r) => ({ tmdbId: r.tmdbId, type: r.type })); + const [userStatuses, episodeProgress] = + lookups.length > 0 + ? await Promise.all([ + getUserStatusesByTmdbIds(session.user.id, lookups), + getEpisodeProgressByTmdbIds(session.user.id, lookups), + ]) + : [{}, {}]; + + return NextResponse.json({ items, userStatuses, episodeProgress }); + } catch { + return NextResponse.json( + { error: "Failed to fetch discover results" }, + { status: 502 }, + ); + } +} diff --git a/app/api/stats/route.ts b/app/api/stats/route.ts new file mode 100644 index 0000000..4946230 --- /dev/null +++ b/app/api/stats/route.ts @@ -0,0 +1,34 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getSession } from "@/lib/auth/session"; +import { getWatchCount, getWatchHistory } from "@/lib/services/discovery"; + +const paramsSchema = z.object({ + type: z.enum(["movies", "episodes"]), + period: z.enum(["today", "this_week", "this_month", "this_year"]), +}); + +export async function GET(req: NextRequest) { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const parsed = paramsSchema.safeParse({ + type: req.nextUrl.searchParams.get("type"), + period: req.nextUrl.searchParams.get("period"), + }); + + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid type or period parameter" }, + { status: 400 }, + ); + } + + const { type, period } = parsed.data; + const count = getWatchCount(session.user.id, type, period); + const history = getWatchHistory(session.user.id, type, period); + + return NextResponse.json({ count, history }); +} diff --git a/app/api/status/route.ts b/app/api/status/route.ts new file mode 100644 index 0000000..2238594 --- /dev/null +++ b/app/api/status/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { getSession } from "@/lib/auth/session"; +import { isTmdbConfigured } from "@/lib/config"; +import { getSystemHealth } from "@/lib/services/system-health"; + +export async function GET() { + const tmdbConfigured = isTmdbConfigured(); + + const session = await getSession(); + if (session?.user.role === "admin") { + const health = await getSystemHealth(); + return NextResponse.json({ tmdbConfigured, health }); + } + + return NextResponse.json({ tmdbConfigured }); +} diff --git a/app/(pages)/error.tsx b/app/error.tsx similarity index 100% rename from app/(pages)/error.tsx rename to app/error.tsx diff --git a/app/not-found.tsx b/app/not-found.tsx index e0ee2fc..c256414 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -7,39 +7,22 @@ export default function NotFound() { {/* Warm projector glow */}
- {/* Subtle vertical light beam */} -
-
- {/* Frame counter label */} -

- Scene not found -

- {/* Large ghosted 404 */}

404

{/* Message */}

- Lost in the credits + Scene not found

This page was left on the cutting room floor. It may have been @@ -50,7 +33,7 @@ export default function NotFound() { {/* Actions */}

diff --git a/app/setup/_components/copy-button.tsx b/app/setup/_components/copy-button.tsx new file mode 100644 index 0000000..663201f --- /dev/null +++ b/app/setup/_components/copy-button.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { IconCheck, IconCopy } from "@tabler/icons-react"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; + +export function CopyButton({ code }: { code: string }) { + const [copied, setCopied] = useState(false); + + function handleCopy() { + navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + return ( + + ); +} diff --git a/app/setup/_components/refresh-button.tsx b/app/setup/_components/refresh-button.tsx new file mode 100644 index 0000000..fcd0976 --- /dev/null +++ b/app/setup/_components/refresh-button.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { IconRefresh } from "@tabler/icons-react"; +import { useRouter } from "next/navigation"; +import { useTransition } from "react"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; + +export function RefreshButton() { + const router = useRouter(); + const [isRefreshing, startTransition] = useTransition(); + + function handleRefresh() { + startTransition(async () => { + router.refresh(); + }); + } + + return ( + + ); +} diff --git a/app/setup/page.tsx b/app/setup/page.tsx new file mode 100644 index 0000000..a82ae24 --- /dev/null +++ b/app/setup/page.tsx @@ -0,0 +1,197 @@ +import { IconExternalLink, IconKey } from "@tabler/icons-react"; +import { redirect } from "next/navigation"; +import { connection } from "next/server"; +import { Suspense } from "react"; +import { TmdbLogo } from "@/components/tmdb-logo"; +import { isTmdbConfigured } from "@/lib/config"; +import { CopyButton } from "./_components/copy-button"; +import { RefreshButton } from "./_components/refresh-button"; + +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_api_read_access_token_here ...", + }, +]; + +export default function SetupPage() { + return ( + + + + ); +} + +export async function SetupContent() { + await connection(); + if (isTmdbConfigured()) redirect("/"); + + 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) => ( +
+
+ + {snippet.label} + + +
+
+                        {snippet.code}
+                      
+
+ ))} +
+ )} +
+
+ ))} +
+ + {/* Status check */} +
+
+
+

+ After setting the key and restarting: +

+

+ Click the button to verify your configuration +

+
+ +
+
+
+ + + +

+ This product uses the TMDB API but is not endorsed or certified by + TMDB. +

+
+
+ ); +} diff --git a/components/auth-form.tsx b/components/auth-form.tsx index d2305bc..44fc412 100644 --- a/components/auth-form.tsx +++ b/components/auth-form.tsx @@ -70,7 +70,6 @@ export function AuthForm({ } } router.push("/dashboard"); - router.refresh(); } catch { setError("Something went wrong"); } finally { @@ -88,6 +87,7 @@ export function AuthForm({ }); } catch { setError("Failed to start SSO login"); + } finally { setOidcLoading(false); } } diff --git a/components/command-palette.tsx b/components/command-palette.tsx index edaca43..fe041b3 100644 --- a/components/command-palette.tsx +++ b/components/command-palette.tsx @@ -14,6 +14,7 @@ import { useAtom } from "jotai"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; import { useProgress } from "@/components/navigation-progress"; import { Command, @@ -136,13 +137,25 @@ export function CommandPalette() { setCommandPaletteOpen(false); progress.start(); if (result.type === "person") { - void resolvePerson(result.tmdbId).then((id) => { - if (id) router.push(`/people/${id}`); - }); + void resolvePerson(result.tmdbId) + .then((id) => { + if (id) router.push(`/people/${id}`); + else progress.done(); + }) + .catch(() => { + progress.done(); + toast.error("Failed to load person"); + }); } else { - void resolveTitle(result.tmdbId, result.type).then((id) => { - if (id) router.push(`/titles/${id}`); - }); + void resolveTitle(result.tmdbId, result.type) + .then((id) => { + if (id) router.push(`/titles/${id}`); + else progress.done(); + }) + .catch(() => { + progress.done(); + toast.error("Failed to load title"); + }); } }, [router, setCommandPaletteOpen, progress], diff --git a/components/title-card.tsx b/components/title-card.tsx index 7274fa2..aa39c96 100644 --- a/components/title-card.tsx +++ b/components/title-card.tsx @@ -16,6 +16,7 @@ import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useState, useTransition } from "react"; +import { toast } from "sonner"; import { useProgress } from "@/components/navigation-progress"; import { Tooltip, @@ -324,11 +325,17 @@ export function TitleCard({ onClick={() => { progress.start(); startTransition(async () => { - const resolvedId = await resolveTitle( - tmdbId, - type as "movie" | "tv", - ); - if (resolvedId) router.push(`/titles/${resolvedId}`); + try { + const resolvedId = await resolveTitle( + tmdbId, + type as "movie" | "tv", + ); + if (resolvedId) router.push(`/titles/${resolvedId}`); + else progress.done(); + } catch { + progress.done(); + toast.error("Failed to load title"); + } }); }} > diff --git a/components/update-toast.tsx b/components/update-toast.tsx index bd1743f..c26202c 100644 --- a/components/update-toast.tsx +++ b/components/update-toast.tsx @@ -3,32 +3,30 @@ import { useAtom } from "jotai"; import { useEffect } from "react"; import { toast } from "sonner"; -import { getUpdateCheckAction } from "@/lib/actions/settings"; import { updateToastDismissedVersionAtom } from "@/lib/atoms/update-check"; +import type { UpdateCheckResult } from "@/lib/services/update-check"; -export function UpdateToast() { +export function UpdateToast({ data }: { data: UpdateCheckResult | null }) { const [dismissedVersion, setDismissedVersion] = useAtom( updateToastDismissedVersionAtom, ); useEffect(() => { - void getUpdateCheckAction().then((data) => { - if (!data?.updateAvailable) return; - if (dismissedVersion === data.latestVersion) return; + if (!data?.updateAvailable) return; + if (dismissedVersion === data.latestVersion) return; - setDismissedVersion(data.latestVersion); - toast.info(`Sofa v${data.latestVersion} is available`, { - description: `You're running v${data.currentVersion}.`, - duration: 15_000, - action: data.releaseUrl - ? { - label: "View release", - onClick: () => window.open(data.releaseUrl as string, "_blank"), - } - : undefined, - }); + setDismissedVersion(data.latestVersion); + toast.info(`Sofa v${data.latestVersion} is available`, { + description: `You're running v${data.currentVersion}.`, + duration: 15_000, + action: data.releaseUrl + ? { + label: "View release", + onClick: () => window.open(data.releaseUrl as string, "_blank"), + } + : undefined, }); - }, [dismissedVersion, setDismissedVersion]); + }, [data, dismissedVersion, setDismissedVersion]); return null; } diff --git a/hooks/use-discover.ts b/hooks/use-discover.ts new file mode 100644 index 0000000..0d720de --- /dev/null +++ b/hooks/use-discover.ts @@ -0,0 +1,31 @@ +import useSWR from "swr"; +import { fetcher } from "@/lib/swr/fetcher"; + +type TitleStatus = "watchlist" | "in_progress" | "completed"; + +interface TitleRowItem { + tmdbId: number; + type: "movie" | "tv"; + title: string; + posterPath: string | null; + releaseDate: string | null; + voteAverage: number; +} + +interface DiscoverResponse { + items: TitleRowItem[]; + userStatuses: Record; + episodeProgress: Record; +} + +export function useDiscover(mediaType: "movie" | "tv", genreId: number | null) { + const { data, isLoading } = useSWR( + genreId != null + ? `/api/discover?mediaType=${mediaType}&genreId=${genreId}` + : null, + fetcher, + { revalidateOnFocus: false, dedupingInterval: 2_000 }, + ); + + return { data, isLoading }; +} diff --git a/hooks/use-stats.ts b/hooks/use-stats.ts new file mode 100644 index 0000000..8f66f83 --- /dev/null +++ b/hooks/use-stats.ts @@ -0,0 +1,18 @@ +import useSWR from "swr"; +import type { HistoryBucket, TimePeriod } from "@/lib/services/discovery"; +import { fetcher } from "@/lib/swr/fetcher"; + +interface StatsResponse { + count: number; + history: HistoryBucket[]; +} + +export function useStats(type: "movies" | "episodes", period: TimePeriod) { + const { data } = useSWR( + `/api/stats?type=${type}&period=${period}`, + fetcher, + { revalidateOnFocus: false }, + ); + + return data; +} diff --git a/hooks/use-system-health.ts b/hooks/use-system-health.ts new file mode 100644 index 0000000..ee7d3c0 --- /dev/null +++ b/hooks/use-system-health.ts @@ -0,0 +1,25 @@ +import useSWR from "swr"; +import type { SystemHealthData } from "@/lib/services/system-health"; +import { fetcher } from "@/lib/swr/fetcher"; + +interface StatusResponse { + tmdbConfigured: boolean; + health: SystemHealthData; +} + +export function useSystemHealth(initialData: SystemHealthData) { + const { data, isValidating, mutate } = useSWR( + "/api/status", + fetcher, + { + revalidateOnFocus: false, + fallbackData: { tmdbConfigured: true, health: initialData }, + }, + ); + + return { + data: data?.health ?? initialData, + isRefreshing: isValidating, + refresh: () => mutate(), + }; +} diff --git a/lib/actions/explore.ts b/lib/actions/explore.ts deleted file mode 100644 index ee50dcf..0000000 --- a/lib/actions/explore.ts +++ /dev/null @@ -1,35 +0,0 @@ -"use server"; - -import { requireSession } from "@/lib/auth/session"; -import { discover } from "@/lib/tmdb/client"; -import { tmdbImageUrl } from "@/lib/tmdb/image"; - -export async function discoverByGenre( - mediaType: "movie" | "tv", - genreId: number, -) { - await requireSession(); - const results = await discover(mediaType, { - sort_by: "popularity.desc", - "vote_count.gte": "50", - with_genres: String(genreId), - }); - // Discover may return movie (title, release_date) or TV (name, first_air_date) - // fields depending on mediaType. The schema types them separately, so widen. - type DiscoverResult = NonNullable[number] & { - title?: string; - name?: string; - release_date?: string; - first_air_date?: string; - }; - return ((results.results ?? []) as DiscoverResult[]) - .filter((r) => r.poster_path) - .map((r) => ({ - tmdbId: r.id, - type: mediaType, - title: r.title ?? r.name ?? "", - posterPath: tmdbImageUrl(r.poster_path ?? null, "posters"), - releaseDate: r.release_date ?? r.first_air_date ?? null, - voteAverage: r.vote_average, - })); -} diff --git a/lib/actions/settings.ts b/lib/actions/settings.ts index c2066fb..9fb6fed 100644 --- a/lib/actions/settings.ts +++ b/lib/actions/settings.ts @@ -19,14 +19,6 @@ import { restoreFromBackup, } from "@/lib/services/backup"; import { getSetting, setSetting } from "@/lib/services/settings"; -import { - getSystemHealth, - type SystemHealthData, -} from "@/lib/services/system-health"; -import { - getCachedUpdateCheck, - type UpdateCheckResult, -} from "@/lib/services/update-check"; const providerSchema = z.enum(["plex", "jellyfin", "emby", "sonarr", "radarr"]); @@ -222,13 +214,6 @@ export async function setBackupScheduleAction( rescheduleBackup(); } -// --- System health actions --- - -export async function getSystemHealthAction(): Promise { - await requireAdmin(); - return getSystemHealth(); -} - // --- Job trigger action --- export async function triggerJobAction( @@ -256,17 +241,6 @@ export async function restoreBackupAction(formData: FormData): Promise { await restoreFromBackup(buffer); } -// --- Update check action --- - -export async function getUpdateCheckAction(): Promise { - try { - await requireAdmin(); - return getCachedUpdateCheck(); - } catch { - return null; - } -} - // --- Avatar actions --- const MAX_AVATAR_SIZE = 2 * 1024 * 1024; // 2MB diff --git a/lib/actions/setup.ts b/lib/actions/setup.ts deleted file mode 100644 index 15111c1..0000000 --- a/lib/actions/setup.ts +++ /dev/null @@ -1,7 +0,0 @@ -"use server"; - -import { isTmdbConfigured } from "@/lib/config"; - -export async function checkTmdbConfigured() { - return isTmdbConfigured(); -} diff --git a/lib/actions/watchlist.ts b/lib/actions/watchlist.ts index 2a06f36..1dffa7e 100644 --- a/lib/actions/watchlist.ts +++ b/lib/actions/watchlist.ts @@ -1,38 +1,11 @@ "use server"; import { and, eq } from "drizzle-orm"; -import { z } from "zod"; -import { getSession, requireSession } from "@/lib/auth/session"; +import { requireSession } from "@/lib/auth/session"; import { db } from "@/lib/db/client"; import { userTitleStatus } from "@/lib/db/schema"; -import { - getWatchCount, - getWatchHistory, - type HistoryBucket, - type TimePeriod, -} from "@/lib/services/discovery"; import { getOrFetchTitleByTmdbId } from "@/lib/services/metadata"; -import { - getEpisodeProgressByTmdbIds, - getUserStatusesByTmdbIds, - setTitleStatus, -} from "@/lib/services/tracking"; - -export async function fetchUserStatuses( - tmdbIds: { tmdbId: number; type: string }[], -): Promise> { - const session = await getSession(); - if (!session) return {}; - return getUserStatusesByTmdbIds(session.user.id, tmdbIds); -} - -export async function fetchEpisodeProgress( - tmdbIds: { tmdbId: number; type: string }[], -): Promise> { - const session = await getSession(); - if (!session) return {}; - return getEpisodeProgressByTmdbIds(session.user.id, tmdbIds); -} +import { setTitleStatus } from "@/lib/services/tracking"; export async function quickAddToWatchlist( tmdbId: number, @@ -62,19 +35,3 @@ export async function quickAddToWatchlist( setTitleStatus(userId, title.id, "watchlist"); return { success: true, titleId: title.id, alreadyAdded: false }; } - -const statsSchema = z.object({ - type: z.enum(["movies", "episodes"]), - period: z.enum(["today", "this_week", "this_month", "this_year"]), -}); - -export async function getStatsAction( - type: "movies" | "episodes", - period: TimePeriod, -): Promise<{ count: number; history: HistoryBucket[] }> { - const session = await requireSession(); - const parsed = statsSchema.parse({ type, period }); - const count = getWatchCount(session.user.id, parsed.type, parsed.period); - const history = getWatchHistory(session.user.id, parsed.type, parsed.period); - return { count, history }; -} diff --git a/proxy.ts b/proxy.ts index 9a520ed..95a111e 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,7 +1,7 @@ import { getSessionCookie } from "better-auth/cookies"; import { type NextRequest, NextResponse } from "next/server"; -const authRoutes = new Set(["/login", "/register", "/setup"]); +const authRoutes = new Set(["/login", "/register"]); export function proxy(request: NextRequest) { const { pathname } = request.nextUrl;