mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
Replace server actions with API routes and add SWR data-fetching hooks
- Convert discover, stats, status, and system-health server actions to proper API route handlers under `app/api/`; delete `lib/actions/explore.ts`, `lib/actions/settings.ts`, and `lib/actions/setup.ts` - Add `use-discover`, `use-stats`, and `use-system-health` SWR hooks that call the new routes; update `command-palette`, `title-card`, `update-toast`, and `stats-display` to consume them - Lift auth centering wrapper from individual login/register pages into `(auth)/layout.tsx`; switch both pages from `auth.api.getSession` to the cached `getSession()` helper - Relocate setup wizard from `app/(auth)/setup/` to `app/setup/` (outside auth group) with dedicated `copy-button` and `refresh-button` client components - Move `not-found.tsx` and `error.tsx` to app root so they apply globally instead of only within the pages route group
This commit is contained in:
@@ -7,7 +7,9 @@ export default function AuthLayout({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen">
|
<main className="min-h-screen">
|
||||||
|
<div className="flex min-h-[80vh] items-center justify-center px-4">
|
||||||
<Suspense>{children}</Suspense>
|
<Suspense>{children}</Suspense>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { headers } from "next/headers";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AuthForm } from "@/components/auth-form";
|
import { AuthForm } from "@/components/auth-form";
|
||||||
import { auth } from "@/lib/auth/server";
|
import { getSession } from "@/lib/auth/session";
|
||||||
import {
|
import {
|
||||||
getOidcProviderName,
|
getOidcProviderName,
|
||||||
isOidcConfigured,
|
isOidcConfigured,
|
||||||
@@ -10,7 +9,7 @@ import {
|
|||||||
import { getUserCount, isRegistrationOpen } from "@/lib/services/settings";
|
import { getUserCount, isRegistrationOpen } from "@/lib/services/settings";
|
||||||
|
|
||||||
export default async function LoginPage() {
|
export default async function LoginPage() {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await getSession();
|
||||||
if (session) redirect("/dashboard");
|
if (session) redirect("/dashboard");
|
||||||
|
|
||||||
if (getUserCount() === 0) {
|
if (getUserCount() === 0) {
|
||||||
@@ -20,7 +19,6 @@ export default async function LoginPage() {
|
|||||||
const oidcEnabled = isOidcConfigured();
|
const oidcEnabled = isOidcConfigured();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[80vh] items-center justify-center px-4">
|
|
||||||
<AuthForm
|
<AuthForm
|
||||||
mode="login"
|
mode="login"
|
||||||
authConfig={{
|
authConfig={{
|
||||||
@@ -30,6 +28,5 @@ export default async function LoginPage() {
|
|||||||
registrationOpen: isRegistrationOpen(),
|
registrationOpen: isRegistrationOpen(),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { IconLock } from "@tabler/icons-react";
|
import { IconLock } from "@tabler/icons-react";
|
||||||
import { headers } from "next/headers";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AuthForm } from "@/components/auth-form";
|
import { AuthForm } from "@/components/auth-form";
|
||||||
import { auth } from "@/lib/auth/server";
|
import { getSession } from "@/lib/auth/session";
|
||||||
import {
|
import {
|
||||||
getOidcProviderName,
|
getOidcProviderName,
|
||||||
isOidcConfigured,
|
isOidcConfigured,
|
||||||
@@ -12,12 +11,11 @@ import {
|
|||||||
import { isRegistrationOpen } from "@/lib/services/settings";
|
import { isRegistrationOpen } from "@/lib/services/settings";
|
||||||
|
|
||||||
export default async function RegisterPage() {
|
export default async function RegisterPage() {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await getSession();
|
||||||
if (session) redirect("/dashboard");
|
if (session) redirect("/dashboard");
|
||||||
|
|
||||||
if (!isRegistrationOpen()) {
|
if (!isRegistrationOpen()) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[80vh] items-center justify-center px-4">
|
|
||||||
<div className="relative mx-auto w-full max-w-sm">
|
<div className="relative mx-auto w-full max-w-sm">
|
||||||
<div className="absolute -inset-4 rounded-2xl bg-primary/3 blur-2xl" />
|
<div className="absolute -inset-4 rounded-2xl bg-primary/3 blur-2xl" />
|
||||||
<div className="relative space-y-6 rounded-xl border border-border/50 bg-card/80 p-8 text-center backdrop-blur-sm">
|
<div className="relative space-y-6 rounded-xl border border-border/50 bg-card/80 p-8 text-center backdrop-blur-sm">
|
||||||
@@ -41,14 +39,12 @@ export default async function RegisterPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const oidcEnabled = isOidcConfigured();
|
const oidcEnabled = isOidcConfigured();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[80vh] items-center justify-center px-4">
|
|
||||||
<AuthForm
|
<AuthForm
|
||||||
mode="register"
|
mode="register"
|
||||||
authConfig={{
|
authConfig={{
|
||||||
@@ -57,6 +53,5 @@ export default async function RegisterPage() {
|
|||||||
passwordLoginDisabled: isPasswordLoginDisabled(),
|
passwordLoginDisabled: isPasswordLoginDisabled(),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{" "}
|
|
||||||
<a
|
|
||||||
href="https://www.themoviedb.org/signup"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-1 font-medium text-primary underline decoration-primary/30 underline-offset-2 transition-colors hover:decoration-primary"
|
|
||||||
>
|
|
||||||
themoviedb.org
|
|
||||||
<IconExternalLink aria-hidden={true} className="size-3.5" />
|
|
||||||
</a>{" "}
|
|
||||||
and sign up for a free account.
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
number: "2",
|
|
||||||
title: "Request an API key",
|
|
||||||
description: (
|
|
||||||
<>
|
|
||||||
Go to{" "}
|
|
||||||
<a
|
|
||||||
href="https://www.themoviedb.org/settings/api"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-1 font-medium text-primary underline decoration-primary/30 underline-offset-2 transition-colors hover:decoration-primary"
|
|
||||||
>
|
|
||||||
Settings → API
|
|
||||||
<IconExternalLink aria-hidden={true} className="size-3.5" />
|
|
||||||
</a>{" "}
|
|
||||||
and request an API key. Choose “Developer” when asked. You
|
|
||||||
need the{" "}
|
|
||||||
<span className="font-mono text-primary text-xs">
|
|
||||||
API Read Access Token
|
|
||||||
</span>{" "}
|
|
||||||
(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<number | null>(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 (
|
|
||||||
<motion.div
|
|
||||||
className="mx-auto max-w-2xl space-y-10"
|
|
||||||
initial="hidden"
|
|
||||||
animate="visible"
|
|
||||||
variants={{
|
|
||||||
hidden: {},
|
|
||||||
visible: { transition: { staggerChildren: 0.12 } },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<motion.div variants={sectionVariants} className="space-y-3">
|
|
||||||
<div className="inline-flex items-center gap-2 rounded-full border border-primary/20 bg-primary/5 px-3 py-1 font-medium text-primary text-xs">
|
|
||||||
<IconKey aria-hidden={true} className="size-3.5" />
|
|
||||||
Setup required
|
|
||||||
</div>
|
|
||||||
<h1 className="text-balance font-display text-3xl tracking-tight sm:text-4xl">
|
|
||||||
Connect to TMDB
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-lg text-muted-foreground leading-relaxed">
|
|
||||||
Sofa uses{" "}
|
|
||||||
<a
|
|
||||||
href="https://www.themoviedb.org/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="font-medium text-foreground underline decoration-border underline-offset-2 transition-colors hover:decoration-primary"
|
|
||||||
>
|
|
||||||
The Movie Database
|
|
||||||
</a>{" "}
|
|
||||||
for movie & TV metadata, posters, and streaming availability.
|
|
||||||
You'll need a free API key to get started.
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Steps */}
|
|
||||||
<motion.div variants={sectionVariants} className="space-y-6">
|
|
||||||
{steps.map((step, i) => (
|
|
||||||
<div key={step.number} className="flex gap-4">
|
|
||||||
<div className="flex shrink-0 items-start pt-0.5">
|
|
||||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-primary/10 font-mono font-semibold text-primary text-sm">
|
|
||||||
{step.number}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h2 className="font-medium">{step.title}</h2>
|
|
||||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
|
||||||
{step.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Show env snippets for step 3 */}
|
|
||||||
{i === 2 && (
|
|
||||||
<div className="mt-4 space-y-3">
|
|
||||||
{envSnippets.map((snippet, idx) => (
|
|
||||||
<div
|
|
||||||
key={snippet.label}
|
|
||||||
className="group relative overflow-hidden rounded-lg border border-border/50 bg-card/60"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between border-border/30 border-b px-3 py-1.5">
|
|
||||||
<span className="font-medium text-[11px] text-muted-foreground uppercase tracking-wider">
|
|
||||||
{snippet.label}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="xs"
|
|
||||||
onClick={() => copySnippet(idx, snippet.code)}
|
|
||||||
className="text-[11px] text-muted-foreground"
|
|
||||||
>
|
|
||||||
{copiedIdx === idx ? (
|
|
||||||
<>
|
|
||||||
<IconCheck
|
|
||||||
aria-hidden={true}
|
|
||||||
className="size-3 text-green-400"
|
|
||||||
/>
|
|
||||||
Copied
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<IconCopy aria-hidden={true} className="size-3" />
|
|
||||||
Copy
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<pre className="overflow-x-auto p-3 font-mono text-foreground/80 text-sm">
|
|
||||||
{snippet.code}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Status check */}
|
|
||||||
<motion.div variants={sectionVariants}>
|
|
||||||
<div className="rounded-xl border border-border/50 bg-card/40 p-5">
|
|
||||||
{configured ? (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500/10">
|
|
||||||
<IconCheck
|
|
||||||
aria-hidden={true}
|
|
||||||
className="size-5 text-green-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-green-400">
|
|
||||||
TMDB API key detected
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Redirecting you to Sofa…
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<p className="font-medium text-sm">
|
|
||||||
After setting the key and restarting:
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
Click the button to verify your configuration
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<form action={checkAction}>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={isPending}
|
|
||||||
size="lg"
|
|
||||||
className="h-9 rounded-lg px-4 text-sm hover:shadow-md hover:shadow-primary/20"
|
|
||||||
>
|
|
||||||
{isPending ? (
|
|
||||||
<>
|
|
||||||
<Spinner className="size-3" />
|
|
||||||
Checking…
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Check configuration"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 <SetupContent />;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function SetupContent() {
|
|
||||||
await connection();
|
|
||||||
if (isTmdbConfigured()) redirect("/");
|
|
||||||
return <SetupForm />;
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
IconMovie,
|
IconMovie,
|
||||||
IconPlayerPlay,
|
IconPlayerPlay,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { getStatsAction } from "@/lib/actions/watchlist";
|
import { useStats } from "@/hooks/use-stats";
|
||||||
import type {
|
import type {
|
||||||
DashboardStats,
|
DashboardStats,
|
||||||
HistoryBucket,
|
HistoryBucket,
|
||||||
@@ -123,22 +123,9 @@ function PeriodSelector({
|
|||||||
export function StatsDisplay({ stats }: { stats: DashboardStats }) {
|
export function StatsDisplay({ stats }: { stats: DashboardStats }) {
|
||||||
const [moviePeriod, setMoviePeriod] = useState<TimePeriod>("this_month");
|
const [moviePeriod, setMoviePeriod] = useState<TimePeriod>("this_month");
|
||||||
const [episodePeriod, setEpisodePeriod] = useState<TimePeriod>("this_week");
|
const [episodePeriod, setEpisodePeriod] = useState<TimePeriod>("this_week");
|
||||||
const [movieStats, setMovieStats] = useState<{
|
|
||||||
count: number;
|
|
||||||
history: HistoryBucket[];
|
|
||||||
} | null>(null);
|
|
||||||
const [episodeStats, setEpisodeStats] = useState<{
|
|
||||||
count: number;
|
|
||||||
history: HistoryBucket[];
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const movieStats = useStats("movies", moviePeriod);
|
||||||
void getStatsAction("movies", moviePeriod).then(setMovieStats);
|
const episodeStats = useStats("episodes", episodePeriod);
|
||||||
}, [moviePeriod]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void getStatsAction("episodes", episodePeriod).then(setEpisodeStats);
|
|
||||||
}, [episodePeriod]);
|
|
||||||
|
|
||||||
const movieCount = movieStats?.count ?? stats.moviesThisMonth;
|
const movieCount = movieStats?.count ?? stats.moviesThisMonth;
|
||||||
const movieHistory = movieStats?.history;
|
const movieHistory = movieStats?.history;
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useState } from "react";
|
||||||
import { TitleCardSkeleton } from "@/components/skeletons";
|
import { TitleCardSkeleton } from "@/components/skeletons";
|
||||||
import { TitleCard } from "@/components/title-card";
|
import { TitleCard } from "@/components/title-card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { discoverByGenre } from "@/lib/actions/explore";
|
import { useDiscover } from "@/hooks/use-discover";
|
||||||
import {
|
|
||||||
fetchEpisodeProgress,
|
|
||||||
fetchUserStatuses,
|
|
||||||
} from "@/lib/actions/watchlist";
|
|
||||||
|
|
||||||
interface Genre {
|
interface Genre {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -47,48 +43,24 @@ export function FilterableTitleRow({
|
|||||||
episodeProgress: initialProgress = {},
|
episodeProgress: initialProgress = {},
|
||||||
}: FilterableTitleRowProps) {
|
}: FilterableTitleRowProps) {
|
||||||
const [selectedGenre, setSelectedGenre] = useState<number | null>(null);
|
const [selectedGenre, setSelectedGenre] = useState<number | null>(null);
|
||||||
const [genreResults, setGenreResults] = useState<TitleRowItem[] | null>(null);
|
const { data: discoverData, isLoading: isPending } = useDiscover(
|
||||||
const [genreStatuses, setGenreStatuses] = useState<
|
mediaType,
|
||||||
Record<string, TitleStatus>
|
selectedGenre,
|
||||||
>({});
|
);
|
||||||
const [genreProgress, setGenreProgress] = useState<
|
|
||||||
Record<string, { watched: number; total: number }>
|
|
||||||
>({});
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
|
|
||||||
const items = selectedGenre === null ? defaultItems : (genreResults ?? []);
|
const items =
|
||||||
const userStatuses = selectedGenre === null ? initialStatuses : genreStatuses;
|
selectedGenre === null ? defaultItems : (discoverData?.items ?? []);
|
||||||
|
const userStatuses =
|
||||||
|
selectedGenre === null
|
||||||
|
? initialStatuses
|
||||||
|
: (discoverData?.userStatuses ?? {});
|
||||||
const episodeProgress =
|
const episodeProgress =
|
||||||
selectedGenre === null ? initialProgress : genreProgress;
|
selectedGenre === null
|
||||||
|
? initialProgress
|
||||||
|
: (discoverData?.episodeProgress ?? {});
|
||||||
|
|
||||||
function toggleGenre(genreId: number) {
|
function toggleGenre(genreId: number) {
|
||||||
if (selectedGenre === genreId) {
|
setSelectedGenre(genreId === selectedGenre ? null : 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({});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTransition } from "react";
|
import { useTransition } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { useProgress } from "@/components/navigation-progress";
|
import { useProgress } from "@/components/navigation-progress";
|
||||||
import { resolveTitle } from "@/lib/actions/titles";
|
import { resolveTitle } from "@/lib/actions/titles";
|
||||||
|
|
||||||
@@ -37,8 +38,14 @@ export function HeroBanner({
|
|||||||
if (isPending) return;
|
if (isPending) return;
|
||||||
progress.start();
|
progress.start();
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
const id = await resolveTitle(tmdbId, type);
|
const id = await resolveTitle(tmdbId, type);
|
||||||
if (id) router.push(`/titles/${id}`);
|
if (id) router.push(`/titles/${id}`);
|
||||||
|
else progress.done();
|
||||||
|
} catch {
|
||||||
|
progress.done();
|
||||||
|
toast.error("Failed to load title");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { MobileTabBar, NavBar } from "@/components/nav-bar";
|
|||||||
import { ProgressProvider } from "@/components/navigation-progress";
|
import { ProgressProvider } from "@/components/navigation-progress";
|
||||||
import { UpdateToast } from "@/components/update-toast";
|
import { UpdateToast } from "@/components/update-toast";
|
||||||
import { getSession } from "@/lib/auth/session";
|
import { getSession } from "@/lib/auth/session";
|
||||||
|
import { getCachedUpdateCheck } from "@/lib/services/update-check";
|
||||||
|
|
||||||
export default function PagesLayout({
|
export default function PagesLayout({
|
||||||
children,
|
children,
|
||||||
@@ -44,7 +45,9 @@ async function AuthenticatedShell({ children }: { children: React.ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
<MobileTabBar />
|
<MobileTabBar />
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
{session.user.role === "admin" && <UpdateToast />}
|
{session.user.role === "admin" && (
|
||||||
|
<UpdateToast data={getCachedUpdateCheck()} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export default function PagesNotFound() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center gap-6 py-24 text-center">
|
|
||||||
{/* Ghosted 404 */}
|
|
||||||
<h1 className="animate-stagger-item font-display text-[6rem] text-foreground/[0.06] leading-[0.85] tracking-tight sm:text-[8rem]">
|
|
||||||
404
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="-mt-4 animate-stagger-item space-y-2"
|
|
||||||
style={{ "--stagger-index": 1 } as React.CSSProperties}
|
|
||||||
>
|
|
||||||
<h2 className="font-display text-2xl tracking-tight sm:text-3xl">
|
|
||||||
Page not found
|
|
||||||
</h2>
|
|
||||||
<p className="mx-auto max-w-sm text-muted-foreground text-sm leading-relaxed">
|
|
||||||
This page doesn't exist or may have been moved. Try searching for
|
|
||||||
what you're looking for instead.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="flex animate-stagger-item items-center gap-3"
|
|
||||||
style={{ "--stagger-index": 2 } as React.CSSProperties}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href="/dashboard"
|
|
||||||
className="group relative inline-flex h-10 items-center justify-center overflow-hidden rounded-lg bg-primary px-5 font-medium text-primary-foreground text-sm transition-shadow hover:shadow-lg hover:shadow-primary/20"
|
|
||||||
>
|
|
||||||
<span className="relative z-10">Dashboard</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="/explore"
|
|
||||||
className="inline-flex h-10 items-center justify-center rounded-lg border border-border px-5 font-medium text-sm transition-colors hover:border-primary/40 hover:bg-primary/5"
|
|
||||||
>
|
|
||||||
Explore
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -34,11 +34,9 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { useSystemHealth } from "@/hooks/use-system-health";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||||
import {
|
import { triggerJobAction } from "@/lib/actions/settings";
|
||||||
getSystemHealthAction,
|
|
||||||
triggerJobAction,
|
|
||||||
} from "@/lib/actions/settings";
|
|
||||||
import type { SystemHealthData } from "@/lib/services/system-health";
|
import type { SystemHealthData } from "@/lib/services/system-health";
|
||||||
|
|
||||||
const JOB_LABELS: Record<string, string> = {
|
const JOB_LABELS: Record<string, string> = {
|
||||||
@@ -137,20 +135,7 @@ export function SystemHealthCards({
|
|||||||
}: {
|
}: {
|
||||||
initialData: SystemHealthData;
|
initialData: SystemHealthData;
|
||||||
}) {
|
}) {
|
||||||
const [data, setData] = useState(initialData);
|
const { data, isRefreshing, refresh } = useSystemHealth(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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getSession } from "@/lib/auth/session";
|
||||||
import { AVATAR_DIR } from "@/lib/constants";
|
import { AVATAR_DIR } from "@/lib/constants";
|
||||||
|
|
||||||
const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
|
const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
|
||||||
@@ -8,6 +9,11 @@ export async function GET(
|
|||||||
_req: NextRequest,
|
_req: NextRequest,
|
||||||
{ params }: { params: Promise<{ userId: string }> },
|
{ params }: { params: Promise<{ userId: string }> },
|
||||||
) {
|
) {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const { userId } = await params;
|
const { userId } = await params;
|
||||||
|
|
||||||
// Sanitize userId to prevent path traversal
|
// Sanitize userId to prevent path traversal
|
||||||
|
|||||||
@@ -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<typeof results.results>[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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
+6
-23
@@ -7,39 +7,22 @@ export default function NotFound() {
|
|||||||
{/* Warm projector glow */}
|
{/* Warm projector glow */}
|
||||||
<div className="pointer-events-none absolute top-[40%] left-1/2 h-[500px] w-[500px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/4 blur-[160px]" />
|
<div className="pointer-events-none absolute top-[40%] left-1/2 h-[500px] w-[500px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/4 blur-[160px]" />
|
||||||
|
|
||||||
{/* Subtle vertical light beam */}
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute top-0 left-1/2 h-[60vh] w-px -translate-x-1/2"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"linear-gradient(to bottom, oklch(0.8 0.14 65 / 0.12), transparent)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative z-10 flex flex-col items-center text-center">
|
<div className="relative z-10 flex flex-col items-center text-center">
|
||||||
{/* Frame counter label */}
|
|
||||||
<p
|
|
||||||
className="animate-stagger-item font-mono text-[10px] text-muted-foreground/40 uppercase tracking-[0.5em]"
|
|
||||||
style={{ "--stagger-index": 0 } as React.CSSProperties}
|
|
||||||
>
|
|
||||||
Scene not found
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Large ghosted 404 */}
|
{/* Large ghosted 404 */}
|
||||||
<h1
|
<h1
|
||||||
className="animate-stagger-item font-display text-[8rem] text-foreground/[0.06] leading-[0.85] tracking-tight sm:text-[11rem] md:text-[14rem]"
|
className="animate-stagger-item font-display text-[8rem] text-foreground/[0.06] leading-[0.85] tracking-tight sm:text-[11rem] md:text-[14rem]"
|
||||||
style={{ "--stagger-index": 1 } as React.CSSProperties}
|
style={{ "--stagger-index": 0 } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
404
|
404
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Message */}
|
{/* Message */}
|
||||||
<div
|
<div
|
||||||
className="-mt-2 animate-stagger-item space-y-3 sm:-mt-4"
|
className="animate-stagger-item space-y-3"
|
||||||
style={{ "--stagger-index": 2 } as React.CSSProperties}
|
style={{ "--stagger-index": 1 } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<h2 className="font-display text-2xl tracking-tight sm:text-3xl">
|
<h2 className="font-display text-2xl tracking-tight sm:text-3xl">
|
||||||
Lost in the credits
|
Scene not found
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mx-auto max-w-sm text-muted-foreground text-sm leading-relaxed">
|
<p className="mx-auto max-w-sm text-muted-foreground text-sm leading-relaxed">
|
||||||
This page was left on the cutting room floor. It may have been
|
This page was left on the cutting room floor. It may have been
|
||||||
@@ -50,7 +33,7 @@ export default function NotFound() {
|
|||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div
|
<div
|
||||||
className="mt-8 flex animate-stagger-item items-center gap-3"
|
className="mt-8 flex animate-stagger-item items-center gap-3"
|
||||||
style={{ "--stagger-index": 3 } as React.CSSProperties}
|
style={{ "--stagger-index": 2 } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
@@ -64,7 +47,7 @@ export default function NotFound() {
|
|||||||
{/* Logo watermark */}
|
{/* Logo watermark */}
|
||||||
<div
|
<div
|
||||||
className="mt-16 animate-stagger-item text-muted-foreground/20"
|
className="mt-16 animate-stagger-item text-muted-foreground/20"
|
||||||
style={{ "--stagger-index": 4 } as React.CSSProperties}
|
style={{ "--stagger-index": 3 } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<SofaLogo className="size-8" />
|
<SofaLogo className="size-8" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="text-[11px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<IconCheck aria-hidden={true} className="size-3 text-green-400" />
|
||||||
|
Copied
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<IconCopy aria-hidden={true} className="size-3" />
|
||||||
|
Copy
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="h-9 rounded-lg px-4 text-sm hover:shadow-md hover:shadow-primary/20"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
>
|
||||||
|
{isRefreshing ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<IconRefresh aria-hidden={true} className="size-3.5" />
|
||||||
|
)}
|
||||||
|
{isRefreshing ? "Checking…" : "Check configuration"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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{" "}
|
||||||
|
<a
|
||||||
|
href="https://www.themoviedb.org/signup"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 font-medium text-primary underline decoration-primary/30 underline-offset-2 transition-colors hover:decoration-primary"
|
||||||
|
>
|
||||||
|
themoviedb.org
|
||||||
|
<IconExternalLink
|
||||||
|
aria-hidden={true}
|
||||||
|
className="size-3.5 translate-y-[-1px] text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</a>{" "}
|
||||||
|
and sign up for a free account.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: "2",
|
||||||
|
title: "Request an API key",
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
Go to{" "}
|
||||||
|
<a
|
||||||
|
href="https://www.themoviedb.org/settings/api"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 font-medium text-primary underline decoration-primary/30 underline-offset-2 transition-colors hover:decoration-primary"
|
||||||
|
>
|
||||||
|
Settings → API
|
||||||
|
<IconExternalLink
|
||||||
|
aria-hidden={true}
|
||||||
|
className="size-3.5 translate-y-[-1px] text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</a>{" "}
|
||||||
|
and request an API key. Choose “Developer” when asked. You
|
||||||
|
need the{" "}
|
||||||
|
<span className="font-mono text-primary text-xs">
|
||||||
|
API Read Access Token
|
||||||
|
</span>{" "}
|
||||||
|
(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 (
|
||||||
|
<Suspense>
|
||||||
|
<SetupContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function SetupContent() {
|
||||||
|
await connection();
|
||||||
|
if (isTmdbConfigured()) redirect("/");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto my-10 max-w-2xl space-y-10">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-primary/20 bg-primary/5 px-3 py-1 font-medium text-primary text-xs">
|
||||||
|
<IconKey aria-hidden={true} className="size-3.5" />
|
||||||
|
Setup required
|
||||||
|
</div>
|
||||||
|
<h1 className="text-balance font-display text-3xl tracking-tight sm:text-4xl">
|
||||||
|
Connect to TMDB
|
||||||
|
</h1>
|
||||||
|
<p className="max-w-lg text-muted-foreground leading-relaxed">
|
||||||
|
Sofa uses{" "}
|
||||||
|
<a
|
||||||
|
href="https://www.themoviedb.org/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 font-medium text-primary underline decoration-primary/30 underline-offset-2 transition-colors hover:decoration-primary"
|
||||||
|
>
|
||||||
|
The Movie Database
|
||||||
|
<IconExternalLink
|
||||||
|
aria-hidden={true}
|
||||||
|
className="size-3.5 translate-y-[-1px] text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</a>{" "}
|
||||||
|
for movie & TV metadata, posters, and streaming availability.
|
||||||
|
You'll need a free API key to get started.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Steps */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{steps.map((step, i) => (
|
||||||
|
<div key={step.number} className="flex gap-4">
|
||||||
|
<div className="flex shrink-0 items-start pt-0.5">
|
||||||
|
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-primary/10 font-mono font-semibold text-primary text-sm">
|
||||||
|
{step.number}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 overflow-x-auto">
|
||||||
|
<h2 className="font-medium">{step.title}</h2>
|
||||||
|
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||||
|
{step.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Show env snippets for step 3 */}
|
||||||
|
{i === 2 && (
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{envSnippets.map((snippet) => (
|
||||||
|
<div
|
||||||
|
key={snippet.label}
|
||||||
|
className="group relative rounded-lg border border-border/50 bg-card/60"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-border/30 border-b px-3 py-1.5">
|
||||||
|
<span className="font-medium text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
{snippet.label}
|
||||||
|
</span>
|
||||||
|
<CopyButton code={snippet.code} />
|
||||||
|
</div>
|
||||||
|
<pre className="overflow-x-auto p-3 font-mono text-foreground/80 text-sm">
|
||||||
|
{snippet.code}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status check */}
|
||||||
|
<div className="rounded-xl border border-border/50 bg-card/40 p-5">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="font-medium text-sm">
|
||||||
|
After setting the key and restarting:
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Click the button to verify your configuration
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<RefreshButton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-col items-center gap-2">
|
||||||
|
<a
|
||||||
|
href="https://www.themoviedb.org/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="transition-opacity hover:opacity-70"
|
||||||
|
>
|
||||||
|
<TmdbLogo className="h-3" />
|
||||||
|
</a>
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||||
|
This product uses the TMDB API but is not endorsed or certified by
|
||||||
|
TMDB.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -70,7 +70,6 @@ export function AuthForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
router.push("/dashboard");
|
router.push("/dashboard");
|
||||||
router.refresh();
|
|
||||||
} catch {
|
} catch {
|
||||||
setError("Something went wrong");
|
setError("Something went wrong");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -88,6 +87,7 @@ export function AuthForm({
|
|||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to start SSO login");
|
setError("Failed to start SSO login");
|
||||||
|
} finally {
|
||||||
setOidcLoading(false);
|
setOidcLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useAtom } from "jotai";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { useProgress } from "@/components/navigation-progress";
|
import { useProgress } from "@/components/navigation-progress";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
@@ -136,12 +137,24 @@ export function CommandPalette() {
|
|||||||
setCommandPaletteOpen(false);
|
setCommandPaletteOpen(false);
|
||||||
progress.start();
|
progress.start();
|
||||||
if (result.type === "person") {
|
if (result.type === "person") {
|
||||||
void resolvePerson(result.tmdbId).then((id) => {
|
void resolvePerson(result.tmdbId)
|
||||||
|
.then((id) => {
|
||||||
if (id) router.push(`/people/${id}`);
|
if (id) router.push(`/people/${id}`);
|
||||||
|
else progress.done();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
progress.done();
|
||||||
|
toast.error("Failed to load person");
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
void resolveTitle(result.tmdbId, result.type).then((id) => {
|
void resolveTitle(result.tmdbId, result.type)
|
||||||
|
.then((id) => {
|
||||||
if (id) router.push(`/titles/${id}`);
|
if (id) router.push(`/titles/${id}`);
|
||||||
|
else progress.done();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
progress.done();
|
||||||
|
toast.error("Failed to load title");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { useProgress } from "@/components/navigation-progress";
|
import { useProgress } from "@/components/navigation-progress";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -324,11 +325,17 @@ export function TitleCard({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
progress.start();
|
progress.start();
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
const resolvedId = await resolveTitle(
|
const resolvedId = await resolveTitle(
|
||||||
tmdbId,
|
tmdbId,
|
||||||
type as "movie" | "tv",
|
type as "movie" | "tv",
|
||||||
);
|
);
|
||||||
if (resolvedId) router.push(`/titles/${resolvedId}`);
|
if (resolvedId) router.push(`/titles/${resolvedId}`);
|
||||||
|
else progress.done();
|
||||||
|
} catch {
|
||||||
|
progress.done();
|
||||||
|
toast.error("Failed to load title");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,16 +3,15 @@
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getUpdateCheckAction } from "@/lib/actions/settings";
|
|
||||||
import { updateToastDismissedVersionAtom } from "@/lib/atoms/update-check";
|
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(
|
const [dismissedVersion, setDismissedVersion] = useAtom(
|
||||||
updateToastDismissedVersionAtom,
|
updateToastDismissedVersionAtom,
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void getUpdateCheckAction().then((data) => {
|
|
||||||
if (!data?.updateAvailable) return;
|
if (!data?.updateAvailable) return;
|
||||||
if (dismissedVersion === data.latestVersion) return;
|
if (dismissedVersion === data.latestVersion) return;
|
||||||
|
|
||||||
@@ -27,8 +26,7 @@ export function UpdateToast() {
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
});
|
}, [data, dismissedVersion, setDismissedVersion]);
|
||||||
}, [dismissedVersion, setDismissedVersion]);
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, TitleStatus>;
|
||||||
|
episodeProgress: Record<string, { watched: number; total: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDiscover(mediaType: "movie" | "tv", genreId: number | null) {
|
||||||
|
const { data, isLoading } = useSWR<DiscoverResponse>(
|
||||||
|
genreId != null
|
||||||
|
? `/api/discover?mediaType=${mediaType}&genreId=${genreId}`
|
||||||
|
: null,
|
||||||
|
fetcher,
|
||||||
|
{ revalidateOnFocus: false, dedupingInterval: 2_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
return { data, isLoading };
|
||||||
|
}
|
||||||
@@ -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<StatsResponse>(
|
||||||
|
`/api/stats?type=${type}&period=${period}`,
|
||||||
|
fetcher,
|
||||||
|
{ revalidateOnFocus: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -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<StatusResponse>(
|
||||||
|
"/api/status",
|
||||||
|
fetcher,
|
||||||
|
{
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
fallbackData: { tmdbConfigured: true, health: initialData },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: data?.health ?? initialData,
|
||||||
|
isRefreshing: isValidating,
|
||||||
|
refresh: () => mutate(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<typeof results.results>[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,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
@@ -19,14 +19,6 @@ import {
|
|||||||
restoreFromBackup,
|
restoreFromBackup,
|
||||||
} from "@/lib/services/backup";
|
} from "@/lib/services/backup";
|
||||||
import { getSetting, setSetting } from "@/lib/services/settings";
|
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"]);
|
const providerSchema = z.enum(["plex", "jellyfin", "emby", "sonarr", "radarr"]);
|
||||||
|
|
||||||
@@ -222,13 +214,6 @@ export async function setBackupScheduleAction(
|
|||||||
rescheduleBackup();
|
rescheduleBackup();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- System health actions ---
|
|
||||||
|
|
||||||
export async function getSystemHealthAction(): Promise<SystemHealthData> {
|
|
||||||
await requireAdmin();
|
|
||||||
return getSystemHealth();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Job trigger action ---
|
// --- Job trigger action ---
|
||||||
|
|
||||||
export async function triggerJobAction(
|
export async function triggerJobAction(
|
||||||
@@ -256,17 +241,6 @@ export async function restoreBackupAction(formData: FormData): Promise<void> {
|
|||||||
await restoreFromBackup(buffer);
|
await restoreFromBackup(buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Update check action ---
|
|
||||||
|
|
||||||
export async function getUpdateCheckAction(): Promise<UpdateCheckResult | null> {
|
|
||||||
try {
|
|
||||||
await requireAdmin();
|
|
||||||
return getCachedUpdateCheck();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Avatar actions ---
|
// --- Avatar actions ---
|
||||||
|
|
||||||
const MAX_AVATAR_SIZE = 2 * 1024 * 1024; // 2MB
|
const MAX_AVATAR_SIZE = 2 * 1024 * 1024; // 2MB
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { isTmdbConfigured } from "@/lib/config";
|
|
||||||
|
|
||||||
export async function checkTmdbConfigured() {
|
|
||||||
return isTmdbConfigured();
|
|
||||||
}
|
|
||||||
@@ -1,38 +1,11 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { requireSession } from "@/lib/auth/session";
|
||||||
import { getSession, requireSession } from "@/lib/auth/session";
|
|
||||||
import { db } from "@/lib/db/client";
|
import { db } from "@/lib/db/client";
|
||||||
import { userTitleStatus } from "@/lib/db/schema";
|
import { userTitleStatus } from "@/lib/db/schema";
|
||||||
import {
|
|
||||||
getWatchCount,
|
|
||||||
getWatchHistory,
|
|
||||||
type HistoryBucket,
|
|
||||||
type TimePeriod,
|
|
||||||
} from "@/lib/services/discovery";
|
|
||||||
import { getOrFetchTitleByTmdbId } from "@/lib/services/metadata";
|
import { getOrFetchTitleByTmdbId } from "@/lib/services/metadata";
|
||||||
import {
|
import { setTitleStatus } from "@/lib/services/tracking";
|
||||||
getEpisodeProgressByTmdbIds,
|
|
||||||
getUserStatusesByTmdbIds,
|
|
||||||
setTitleStatus,
|
|
||||||
} from "@/lib/services/tracking";
|
|
||||||
|
|
||||||
export async function fetchUserStatuses(
|
|
||||||
tmdbIds: { tmdbId: number; type: string }[],
|
|
||||||
): Promise<Record<string, "watchlist" | "in_progress" | "completed">> {
|
|
||||||
const session = await getSession();
|
|
||||||
if (!session) return {};
|
|
||||||
return getUserStatusesByTmdbIds(session.user.id, tmdbIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchEpisodeProgress(
|
|
||||||
tmdbIds: { tmdbId: number; type: string }[],
|
|
||||||
): Promise<Record<string, { watched: number; total: number }>> {
|
|
||||||
const session = await getSession();
|
|
||||||
if (!session) return {};
|
|
||||||
return getEpisodeProgressByTmdbIds(session.user.id, tmdbIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function quickAddToWatchlist(
|
export async function quickAddToWatchlist(
|
||||||
tmdbId: number,
|
tmdbId: number,
|
||||||
@@ -62,19 +35,3 @@ export async function quickAddToWatchlist(
|
|||||||
setTitleStatus(userId, title.id, "watchlist");
|
setTitleStatus(userId, title.id, "watchlist");
|
||||||
return { success: true, titleId: title.id, alreadyAdded: false };
|
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 };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getSessionCookie } from "better-auth/cookies";
|
import { getSessionCookie } from "better-auth/cookies";
|
||||||
import { type NextRequest, NextResponse } from "next/server";
|
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) {
|
export function proxy(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
|||||||
Reference in New Issue
Block a user