Replace Jotai atoms with local state, add Suspense for PPR compatibility

- Delete filterable-row, backup-schedule, integrations, and system-health
  atom files; replace with useState/useTransition + server action calls
- Replace /api/explore/discover route with discoverByGenre server action;
  refactor FilterableTitleRow to call it directly via useTransition
- Wrap PagesLayout and AuthLayout children in Suspense to fix dynamic
  rendering errors during PPR static generation; remove StoreProvider
- Sequence ExplorePage session fetch before TMDB calls to prevent
  build-time requests during static generation
- Drop unnecessary "use client" directives from dashboard and person
  components that have no client-side hooks or browser API usage
- Improve Dockerfile: add bun install layer cache mount, copy
  .next/cache from builder, reorder ENV declarations
This commit is contained in:
2026-03-07 16:00:19 -05:00
parent 8a45aa1c36
commit 7ed172d675
34 changed files with 530 additions and 664 deletions
+14 -8
View File
@@ -7,7 +7,8 @@ WORKDIR /app
COPY package.json bun.lock ./ COPY package.json bun.lock ./
RUN bun install --frozen-lockfile RUN --mount=type=cache,target=/root/.bun/install/cache \
bun install --no-save --frozen-lockfile
# --- Builder --- # --- Builder ---
FROM base AS builder FROM base AS builder
@@ -32,21 +33,26 @@ WORKDIR /app
ARG APP_VERSION ARG APP_VERSION
ARG GIT_COMMIT_SHA ARG GIT_COMMIT_SHA
ENV NODE_ENV=production ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
ENV PORT=3000 ENV PORT=3000
ENV NEXT_TELEMETRY_DISABLED=1 ENV HOSTNAME=0.0.0.0
ENV DATA_DIR=/data ENV DATA_DIR=/data
ENV APP_VERSION=${APP_VERSION} ENV APP_VERSION=${APP_VERSION}
ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA} ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA}
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=builder --chown=bun:bun /app/public ./public
RUN mkdir .next \
&& chown bun:bun .next
COPY --from=builder --chown=bun:bun /app/.next/standalone ./
COPY --from=builder --chown=bun:bun /app/.next/static ./.next/static
COPY --from=builder --chown=bun:bun /app/.next/cache ./.next/cache
COPY --from=builder --chown=bun:bun /app/drizzle ./drizzle
RUN mkdir -p /data \ RUN mkdir -p /data \
&& chown bun:bun /data && chown bun:bun /data
COPY --from=builder --chown=bun:bun /app/public ./public
COPY --from=builder --chown=bun:bun /app/.next/standalone ./
COPY --from=builder --chown=bun:bun /app/.next/static ./.next/static
COPY --from=builder --chown=bun:bun /app/drizzle ./drizzle
USER bun USER bun
EXPOSE 3000 EXPOSE 3000
+7 -1
View File
@@ -1,7 +1,13 @@
import { Suspense } from "react";
export default function AuthLayout({ export default function AuthLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return <main className="min-h-screen">{children}</main>; return (
<main className="min-h-screen">
<Suspense>{children}</Suspense>
</main>
);
} }
+5 -1
View File
@@ -3,7 +3,11 @@ import { connection } from "next/server";
import { isTmdbConfigured } from "@/lib/config"; import { isTmdbConfigured } from "@/lib/config";
import { SetupForm } from "./_components/setup-form"; import { SetupForm } from "./_components/setup-form";
export default async function SetupPage() { export default function SetupPage() {
return <SetupContent />;
}
async function SetupContent() {
await connection(); await connection();
if (isTmdbConfigured()) redirect("/"); if (isTmdbConfigured()) redirect("/");
return <SetupForm />; return <SetupForm />;
@@ -1,5 +1,3 @@
"use client";
import { IconPlayerPlay } from "@tabler/icons-react"; import { IconPlayerPlay } from "@tabler/icons-react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
@@ -1,5 +1,3 @@
"use client";
import { import {
Carousel, Carousel,
CarouselContent, CarouselContent,
@@ -1,5 +1,3 @@
"use client";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
export function FeedSection({ export function FeedSection({
@@ -1,7 +1,6 @@
"use client"; "use client";
import { createStore, Provider, useAtom, useAtomValue } from "jotai"; 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";
@@ -10,15 +9,11 @@ import {
CarouselContent, CarouselContent,
CarouselItem, CarouselItem,
} from "@/components/ui/carousel"; } from "@/components/ui/carousel";
import { discoverByGenre } from "@/lib/actions/explore";
import { import {
defaultItemsAtom, fetchEpisodeProgress,
genreEnrichmentsAtom, fetchUserStatuses,
genreResultsAtom, } from "@/lib/actions/watchlist";
initialEpisodeProgressAtom,
initialUserStatusesAtom,
mediaTypeAtom,
selectedGenreAtom,
} from "@/lib/atoms/filterable-row";
interface Genre { interface Genre {
id: number; id: number;
@@ -34,13 +29,15 @@ interface TitleRowItem {
voteAverage: number; voteAverage: number;
} }
type TitleStatus = "watchlist" | "in_progress" | "completed";
interface FilterableTitleRowProps { interface FilterableTitleRowProps {
heading: string; heading: string;
icon: React.ReactNode; icon: React.ReactNode;
mediaType: "movie" | "tv"; mediaType: "movie" | "tv";
defaultItems: TitleRowItem[]; defaultItems: TitleRowItem[];
genres: Genre[]; genres: Genre[];
userStatuses?: Record<string, "watchlist" | "in_progress" | "completed">; userStatuses?: Record<string, TitleStatus>;
episodeProgress?: Record<string, { watched: number; total: number }>; episodeProgress?: Record<string, { watched: number; total: number }>;
} }
@@ -50,56 +47,52 @@ export function FilterableTitleRow({
mediaType, mediaType,
defaultItems, defaultItems,
genres, genres,
userStatuses, userStatuses: initialStatuses = {},
episodeProgress, episodeProgress: initialProgress = {},
}: FilterableTitleRowProps) { }: FilterableTitleRowProps) {
const [store] = useState(() => { const [selectedGenre, setSelectedGenre] = useState<number | null>(null);
const s = createStore(); const [genreResults, setGenreResults] = useState<TitleRowItem[] | null>(null);
s.set(mediaTypeAtom, mediaType); const [genreStatuses, setGenreStatuses] = useState<
s.set(defaultItemsAtom, defaultItems); Record<string, TitleStatus>
s.set(initialUserStatusesAtom, userStatuses ?? {}); >({});
s.set(initialEpisodeProgressAtom, episodeProgress ?? {}); const [genreProgress, setGenreProgress] = useState<
return s; Record<string, { watched: number; total: number }>
}); >({});
const [isPending, startTransition] = useTransition();
return (
<Provider store={store}>
<FilterableTitleRowInner heading={heading} icon={icon} genres={genres} />
</Provider>
);
}
function FilterableTitleRowInner({
heading,
icon,
genres,
}: {
heading: string;
icon: React.ReactNode;
genres: Genre[];
}) {
const [selectedGenre, setSelectedGenre] = useAtom(selectedGenreAtom);
const defaults = useAtomValue(defaultItemsAtom);
const genreResults = useAtomValue(genreResultsAtom);
const initialStatuses = useAtomValue(initialUserStatusesAtom);
const initialProgress = useAtomValue(initialEpisodeProgressAtom);
const genreEnrichments = useAtomValue(genreEnrichmentsAtom);
const loading = selectedGenre !== null && genreResults === undefined;
const items = selectedGenre === null ? defaults : (genreResults ?? []);
const userStatuses =
selectedGenre === null
? initialStatuses
: (genreEnrichments?.statuses ?? initialStatuses);
const items = selectedGenre === null ? defaultItems : (genreResults ?? []);
const userStatuses = selectedGenre === null ? initialStatuses : genreStatuses;
const episodeProgress = const episodeProgress =
selectedGenre === null selectedGenre === null ? initialProgress : genreProgress;
? initialProgress
: (genreEnrichments?.progress ?? initialProgress);
function toggleGenre(genreId: number) { function toggleGenre(genreId: number) {
setSelectedGenre(selectedGenre === genreId ? null : genreId); 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({});
}
});
} }
return ( return (
@@ -134,7 +127,7 @@ function FilterableTitleRowInner({
</div> </div>
{/* Loading skeleton */} {/* Loading skeleton */}
{loading && ( {isPending && (
<div className="-mx-4 flex gap-4 overflow-hidden px-4 sm:-mx-0 sm:px-0"> <div className="-mx-4 flex gap-4 overflow-hidden px-4 sm:-mx-0 sm:px-0">
{Array.from({ length: 8 }).map((_, i) => ( {Array.from({ length: 8 }).map((_, i) => (
<div <div
@@ -149,14 +142,14 @@ function FilterableTitleRowInner({
)} )}
{/* Empty state */} {/* Empty state */}
{!loading && selectedGenre !== null && items.length === 0 && ( {!isPending && selectedGenre !== null && items.length === 0 && (
<p className="py-8 text-center text-muted-foreground text-sm"> <p className="py-8 text-center text-muted-foreground text-sm">
No titles found for this genre. No titles found for this genre.
</p> </p>
)} )}
{/* Carousel */} {/* Carousel */}
{!loading && items.length > 0 && ( {!isPending && items.length > 0 && (
<div key={selectedGenre ?? "default"}> <div key={selectedGenre ?? "default"}>
<Carousel <Carousel
opts={{ opts={{
+27 -9
View File
@@ -37,21 +37,39 @@ function mapResults(
})); }));
} }
export default async function ExplorePage() { async function getExploreTmdbData() {
// Fetch session in parallel with TMDB calls const [trending, popularMovies, popularTv, movieGenres, tvGenres] =
const [trending, popularMovies, popularTv, movieGenres, tvGenres, session] =
await Promise.all([ await Promise.all([
getTrending("all", "day"), getTrending("all", "day"),
getPopular("movie"), getPopular("movie"),
getPopular("tv"), getPopular("tv"),
getGenres("movie"), getGenres("movie"),
getGenres("tv"), getGenres("tv"),
getSession(),
]); ]);
const trendingItems = mapResults(trending.results, "movie"); return {
const popularMovieItems = mapResults(popularMovies.results, "movie"); trending,
const popularTvItems = mapResults(popularTv.results, "tv"); trendingItems: mapResults(trending.results, "movie"),
popularMovieItems: mapResults(popularMovies.results, "movie"),
popularTvItems: mapResults(popularTv.results, "tv"),
movieGenres: movieGenres.genres,
tvGenres: tvGenres.genres,
};
}
export default async function ExplorePage() {
// Await session first — its headers() call triggers the dynamic bailout during
// PPR static generation, preventing TMDB fetches from firing at build time.
const session = await getSession();
const {
trending,
trendingItems,
popularMovieItems,
popularTvItems,
movieGenres,
tvGenres,
} = await getExploreTmdbData();
// Fetch user statuses and episode progress for all visible TMDB IDs // Fetch user statuses and episode progress for all visible TMDB IDs
let userStatuses: Record<string, "watchlist" | "in_progress" | "completed"> = let userStatuses: Record<string, "watchlist" | "in_progress" | "completed"> =
@@ -102,7 +120,7 @@ export default async function ExplorePage() {
icon={<IconMovie aria-hidden={true} className="size-5 text-primary" />} icon={<IconMovie aria-hidden={true} className="size-5 text-primary" />}
mediaType="movie" mediaType="movie"
defaultItems={popularMovieItems.slice(0, 20)} defaultItems={popularMovieItems.slice(0, 20)}
genres={movieGenres.genres} genres={movieGenres}
userStatuses={userStatuses} userStatuses={userStatuses}
episodeProgress={episodeProgress} episodeProgress={episodeProgress}
/> />
@@ -114,7 +132,7 @@ export default async function ExplorePage() {
} }
mediaType="tv" mediaType="tv"
defaultItems={popularTvItems.slice(0, 20)} defaultItems={popularTvItems.slice(0, 20)}
genres={tvGenres.genres} genres={tvGenres}
userStatuses={userStatuses} userStatuses={userStatuses}
episodeProgress={episodeProgress} episodeProgress={episodeProgress}
/> />
+28 -20
View File
@@ -1,5 +1,5 @@
import { Provider as StoreProvider } from "jotai";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { Suspense } from "react";
import { CommandPalette } from "@/components/command-palette"; import { CommandPalette } from "@/components/command-palette";
import { MobileTabBar } from "@/components/mobile-tab-bar"; import { MobileTabBar } from "@/components/mobile-tab-bar";
import { NavBar } from "@/components/nav-bar"; import { NavBar } from "@/components/nav-bar";
@@ -7,32 +7,40 @@ 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";
export default async function PagesLayout({ export default function PagesLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return (
<Suspense>
<ProgressProvider>
<AuthenticatedShell>{children}</AuthenticatedShell>
</ProgressProvider>
</Suspense>
);
}
async function AuthenticatedShell({ children }: { children: React.ReactNode }) {
const session = await getSession(); const session = await getSession();
if (!session) redirect("/login"); if (!session) redirect("/login");
return ( return (
<StoreProvider> <>
<ProgressProvider> <div className="relative z-0 min-h-screen pb-[calc(3.5rem+env(safe-area-inset-bottom))] sm:pb-0">
<div className="relative z-0 min-h-screen pb-[calc(3.5rem+env(safe-area-inset-bottom))] sm:pb-0"> <NavBar userName={session.user.name} />
<NavBar userName={session.user.name} /> {/* Ambient glow — smaller on mobile to add warmth without overwhelming */}
{/* Ambient glow — smaller on mobile to add warmth without overwhelming */} <div className="pointer-events-none fixed top-1/4 left-1/2 h-[300px] w-[300px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/3 blur-[120px] sm:h-[600px] sm:w-[800px] sm:blur-[200px]" />
<div className="pointer-events-none fixed top-1/4 left-1/2 h-[300px] w-[300px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/3 blur-[120px] sm:h-[600px] sm:w-[800px] sm:blur-[200px]" /> <main
<main id="main-content"
id="main-content" className="relative mx-auto max-w-6xl py-6 pr-[max(1rem,env(safe-area-inset-right))] pl-[max(1rem,env(safe-area-inset-left))] sm:pr-[max(1.5rem,env(safe-area-inset-right))] sm:pl-[max(1.5rem,env(safe-area-inset-left))]"
className="relative mx-auto max-w-6xl py-6 pr-[max(1rem,env(safe-area-inset-right))] pl-[max(1rem,env(safe-area-inset-left))] sm:pr-[max(1.5rem,env(safe-area-inset-right))] sm:pl-[max(1.5rem,env(safe-area-inset-left))]" >
> {children}
{children} </main>
</main> </div>
</div> <MobileTabBar />
<MobileTabBar /> <CommandPalette />
<CommandPalette /> {session.user.role === "admin" && <UpdateToast />}
{session.user.role === "admin" && <UpdateToast />} </>
</ProgressProvider>
</StoreProvider>
); );
} }
@@ -1,5 +1,3 @@
"use client";
import { IconCalendar, IconMapPin } from "@tabler/icons-react"; import { IconCalendar, IconMapPin } from "@tabler/icons-react";
import { format, parseISO } from "date-fns"; import { format, parseISO } from "date-fns";
import Image from "next/image"; import Image from "next/image";
+15 -3
View File
@@ -1,5 +1,6 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { cacheLife, cacheTag } from "next/cache";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getSession } from "@/lib/auth/session"; import { getSession } from "@/lib/auth/session";
import { db } from "@/lib/db/client"; import { db } from "@/lib/db/client";
@@ -24,6 +25,17 @@ export async function generateMetadata({
}; };
} }
async function getCachedPersonData(id: string) {
"use cache";
cacheLife("hours");
cacheTag(`person-${id}`);
const person = await getOrFetchPerson(id);
if (!person) return null;
const filmography = getLocalFilmography(person.id);
return { person, filmography };
}
export default async function PersonDetailPage({ export default async function PersonDetailPage({
params, params,
}: { }: {
@@ -31,10 +43,10 @@ export default async function PersonDetailPage({
}) { }) {
const { id } = await params; const { id } = await params;
const person = await getOrFetchPerson(id); const data = await getCachedPersonData(id);
if (!person) notFound(); if (!data) notFound();
const filmography = getLocalFilmography(person.id); const { person, filmography } = data;
const session = await getSession(); const session = await getSession();
const userStatuses = session const userStatuses = session
? getUserStatusesByTitleIds( ? getUserStatusesByTitleIds(
@@ -2,10 +2,9 @@
import { IconCalendarWeek } from "@tabler/icons-react"; import { IconCalendarWeek } from "@tabler/icons-react";
import { format, formatDistanceToNow } from "date-fns"; import { format, formatDistanceToNow } from "date-fns";
import { useAtomValue, useSetAtom } from "jotai";
import { useHydrateAtoms } from "jotai/utils";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useEffect } from "react"; import { useCallback, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group"; import { ButtonGroup } from "@/components/ui/button-group";
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card"; import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
@@ -18,11 +17,10 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { import {
backupScheduleAtom, setBackupScheduleAction,
savingScheduleAtom, setMaxBackupsAction,
togglingScheduleAtom, setScheduledBackupAction,
useBackupScheduleActions, } from "@/lib/actions/settings";
} from "@/lib/atoms/backup-schedule";
import type { BackupFrequency } from "@/lib/cron"; import type { BackupFrequency } from "@/lib/cron";
const FREQUENCY_OPTIONS: { value: BackupFrequency; label: string }[] = [ const FREQUENCY_OPTIONS: { value: BackupFrequency; label: string }[] = [
@@ -44,6 +42,14 @@ const DAYS_OF_WEEK = [
"Saturday", "Saturday",
] as const; ] as const;
interface BackupScheduleState {
enabled: boolean;
maxRetention: number;
frequency: BackupFrequency;
time: string;
dow: number;
}
function getNextBackupDate( function getNextBackupDate(
frequency: BackupFrequency, frequency: BackupFrequency,
time: string, time: string,
@@ -118,48 +124,83 @@ export function BackupScheduleSection({
initialTime: string; initialTime: string;
initialDow: number; initialDow: number;
}) { }) {
useHydrateAtoms([ const [schedule, setSchedule] = useState<BackupScheduleState>({
[ enabled: initialScheduledEnabled,
backupScheduleAtom, maxRetention: initialMaxRetention,
{ frequency: initialFrequency,
enabled: initialScheduledEnabled, time: initialTime,
maxRetention: initialMaxRetention, dow: initialDow,
frequency: initialFrequency, });
time: initialTime, const [savingSchedule, setSavingSchedule] = useState(false);
dow: initialDow, const [togglingSchedule, setTogglingSchedule] = useState(false);
},
],
]);
const setSchedule = useSetAtom(backupScheduleAtom);
useEffect(() => {
setSchedule({
enabled: initialScheduledEnabled,
maxRetention: initialMaxRetention,
frequency: initialFrequency,
time: initialTime,
dow: initialDow,
});
}, [
setSchedule,
initialScheduledEnabled,
initialMaxRetention,
initialFrequency,
initialTime,
initialDow,
]);
return <BackupScheduleInner />;
}
function BackupScheduleInner() {
const schedule = useAtomValue(backupScheduleAtom);
const savingSchedule = useAtomValue(savingScheduleAtom);
const togglingSchedule = useAtomValue(togglingScheduleAtom);
const { toggleScheduled, changeMaxRetention, changeSchedule } =
useBackupScheduleActions();
const { enabled, maxRetention, frequency, time, dow } = schedule; const { enabled, maxRetention, frequency, time, dow } = schedule;
const toggleScheduled = useCallback(
async (checked: boolean) => {
const previous = schedule.enabled;
setSchedule((prev) => ({ ...prev, enabled: checked }));
setTogglingSchedule(true);
try {
await setScheduledBackupAction(checked);
toast.success(
checked ? "Scheduled backups enabled" : "Scheduled backups disabled",
);
} catch {
setSchedule((prev) => ({ ...prev, enabled: previous }));
toast.error("Failed to update scheduled backup setting");
} finally {
setTogglingSchedule(false);
}
},
[schedule.enabled],
);
const changeMaxRetention = useCallback(
async (value: number) => {
const previous = schedule.maxRetention;
setSchedule((prev) => ({ ...prev, maxRetention: value }));
try {
await setMaxBackupsAction(value);
} catch {
setSchedule((prev) => ({ ...prev, maxRetention: previous }));
toast.error("Failed to update retention setting");
}
},
[schedule.maxRetention],
);
const changeSchedule = useCallback(
async (
newFrequency: BackupFrequency,
newTime: string,
newDow = schedule.dow,
) => {
const prev = {
frequency: schedule.frequency,
time: schedule.time,
dow: schedule.dow,
};
setSchedule((s) => ({
...s,
frequency: newFrequency,
time: newTime,
dow: newDow,
}));
setSavingSchedule(true);
try {
await setBackupScheduleAction(newFrequency, newTime, newDow);
toast.success("Schedule updated");
} catch {
setSchedule((s) => ({ ...s, ...prev }));
toast.error("Failed to update schedule");
} finally {
setSavingSchedule(false);
}
},
[schedule.frequency, schedule.time, schedule.dow],
);
return ( return (
<> <>
<CardContent> <CardContent>
@@ -13,6 +13,7 @@ import { formatDistanceToNow } from "date-fns";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import type { ComponentType, ReactNode } from "react"; import type { ComponentType, ReactNode } from "react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -37,7 +38,11 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { useConnectionActions } from "@/lib/atoms/integrations"; import {
deleteIntegration,
regenerateIntegrationToken,
saveIntegration,
} from "@/lib/actions/settings";
// ─── Types ────────────────────────────────────────────────────────── // ─── Types ──────────────────────────────────────────────────────────
@@ -78,9 +83,56 @@ export interface IntegrationConfig {
// ─── Component ────────────────────────────────────────────────────── // ─── Component ──────────────────────────────────────────────────────
export function IntegrationCard({ config }: { config: IntegrationConfig }) { export function IntegrationCard({
const { connection, handleConnect, handleDelete, handleRegenerateToken } = config,
useConnectionActions(config.provider, config.label); connection,
setConnections,
}: {
config: IntegrationConfig;
connection: IntegrationConnection | null;
setConnections: React.Dispatch<React.SetStateAction<IntegrationConnection[]>>;
}) {
const { provider, label } = config;
async function handleConnect() {
try {
const result = await saveIntegration(provider);
setConnections((prev) => [...prev, { ...result, recentEvents: [] }]);
toast.success(`${label} connected`);
} catch {
toast.error(`Failed to connect ${label}`);
}
}
async function handleDelete() {
let previous: IntegrationConnection[] = [];
setConnections((prev) => {
previous = prev;
return prev.filter((c) => c.provider !== provider);
});
try {
await deleteIntegration(provider);
toast.success(`${label} disconnected`);
} catch {
setConnections(previous);
toast.error(`Failed to disconnect ${label}`);
}
}
async function handleRegenerateToken() {
try {
const result = await regenerateIntegrationToken(provider);
setConnections((prev) =>
prev.map((c) =>
c.provider === provider ? { ...c, token: result.token } : c,
),
);
toast.success(`${label} URL regenerated`);
} catch {
toast.error(`Failed to regenerate ${label} URL`);
}
}
const [connecting, setConnecting] = useState(false); const [connecting, setConnecting] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [cardOpen, setCardOpen] = useState(false); const [cardOpen, setCardOpen] = useState(false);
@@ -1,10 +1,7 @@
"use client"; "use client";
import { IconWebhook } from "@tabler/icons-react"; import { IconWebhook } from "@tabler/icons-react";
import { useSetAtom } from "jotai"; import { useState } from "react";
import { useHydrateAtoms } from "jotai/utils";
import { useEffect } from "react";
import { connectionsAtom } from "@/lib/atoms/integrations";
import { import {
IntegrationCard, IntegrationCard,
type IntegrationConnection, type IntegrationConnection,
@@ -16,11 +13,7 @@ export function IntegrationsSection({
}: { }: {
initialConnections: IntegrationConnection[]; initialConnections: IntegrationConnection[];
}) { }) {
useHydrateAtoms([[connectionsAtom, initialConnections]]); const [connections, setConnections] = useState(initialConnections);
const setConnections = useSetAtom(connectionsAtom);
useEffect(() => {
setConnections(initialConnections);
}, [initialConnections, setConnections]);
return ( return (
<div> <div>
@@ -35,7 +28,14 @@ export function IntegrationsSection({
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{INTEGRATION_CONFIGS.map((config) => ( {INTEGRATION_CONFIGS.map((config) => (
<IntegrationCard key={config.provider} config={config} /> <IntegrationCard
key={config.provider}
config={config}
connection={
connections.find((c) => c.provider === config.provider) ?? null
}
setConnections={setConnections}
/>
))} ))}
</div> </div>
</div> </div>
@@ -9,8 +9,6 @@ import {
IconPlayerPlay, IconPlayerPlay,
IconRefresh, IconRefresh,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { useHydrateAtoms } from "jotai/utils";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { StatusDot } from "@/components/status-dot"; import { StatusDot } from "@/components/status-dot";
@@ -41,10 +39,6 @@ import {
getSystemHealthAction, getSystemHealthAction,
triggerJobAction, triggerJobAction,
} from "@/lib/actions/settings"; } from "@/lib/actions/settings";
import {
systemHealthDataAtom,
systemHealthRefreshingAtom,
} from "@/lib/atoms/system-health";
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,9 +131,14 @@ function LiveTimeAgo({
return <>{text}</>; return <>{text}</>;
} }
function useSystemHealthRefresh() { /** Hydrates system health state and renders the 3 cards */
const [isRefreshing, setRefreshing] = useAtom(systemHealthRefreshingAtom); export function SystemHealthCards({
const setData = useSetAtom(systemHealthDataAtom); initialData,
}: {
initialData: SystemHealthData;
}) {
const [data, setData] = useState(initialData);
const [isRefreshing, setRefreshing] = useState(false);
async function refresh() { async function refresh() {
setRefreshing(true); setRefreshing(true);
@@ -153,31 +152,38 @@ function useSystemHealthRefresh() {
} }
} }
return { isRefreshing, refresh };
}
/** Hydrates the system health atom and renders the 3 cards */
export function SystemHealthCards({
initialData,
}: {
initialData: SystemHealthData;
}) {
useHydrateAtoms([
[systemHealthDataAtom, initialData],
[systemHealthRefreshingAtom, false],
]);
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<SystemStatusCard /> <SystemStatusCard
<BackgroundJobsCard /> checkedAt={data.checkedAt}
<StorageCard /> database={data.database}
tmdb={data.tmdb}
environment={data.environment}
isRefreshing={isRefreshing}
onRefresh={refresh}
/>
<BackgroundJobsCard
jobs={data.jobs}
isRefreshing={isRefreshing}
onRefresh={refresh}
/>
<StorageCard
imageCache={data.imageCache}
backups={data.backups}
isRefreshing={isRefreshing}
onRefresh={refresh}
/>
</div> </div>
); );
} }
function RefreshButton() { function RefreshButton({
const { isRefreshing, refresh } = useSystemHealthRefresh(); isRefreshing,
onRefresh,
}: {
isRefreshing: boolean;
onRefresh: () => void;
}) {
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
@@ -186,7 +192,7 @@ function RefreshButton() {
variant="ghost" variant="ghost"
size="icon" size="icon"
aria-label="Refresh system health" aria-label="Refresh system health"
onClick={refresh} onClick={onRefresh}
disabled={isRefreshing} disabled={isRefreshing}
className="text-muted-foreground" className="text-muted-foreground"
/> />
@@ -199,9 +205,17 @@ function RefreshButton() {
); );
} }
function SystemStatusCard() { function SystemStatusCard({
const data = useAtomValue(systemHealthDataAtom); checkedAt,
database,
tmdb,
environment,
isRefreshing,
onRefresh,
}: Pick<SystemHealthData, "checkedAt" | "database" | "tmdb" | "environment"> & {
isRefreshing: boolean;
onRefresh: () => void;
}) {
return ( return (
<Card className="border-l-2 border-l-primary/30"> <Card className="border-l-2 border-l-primary/30">
<CardContent> <CardContent>
@@ -216,11 +230,11 @@ function SystemStatusCard() {
<div> <div>
<CardTitle>Health status</CardTitle> <CardTitle>Health status</CardTitle>
<CardDescription suppressHydrationWarning> <CardDescription suppressHydrationWarning>
Checked <LiveTimeAgo date={data.checkedAt} /> Checked <LiveTimeAgo date={checkedAt} />
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
<RefreshButton /> <RefreshButton isRefreshing={isRefreshing} onRefresh={onRefresh} />
</div> </div>
</CardContent> </CardContent>
@@ -231,9 +245,9 @@ function SystemStatusCard() {
Database Database
</span> </span>
<span className="font-mono text-[11px] text-muted-foreground"> <span className="font-mono text-[11px] text-muted-foreground">
{formatBytes(data.database.dbSizeBytes)} {formatBytes(database.dbSizeBytes)}
{data.database.walSizeBytes > 0 && {database.walSizeBytes > 0 &&
` + ${formatBytes(data.database.walSizeBytes)} WAL`} ` + ${formatBytes(database.walSizeBytes)} WAL`}
</span> </span>
</div> </div>
</CardContent> </CardContent>
@@ -244,22 +258,22 @@ function SystemStatusCard() {
<span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider"> <span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
TMDB API TMDB API
</span> </span>
{!data.tmdb.tokenConfigured ? ( {!tmdb.tokenConfigured ? (
<> <>
<StatusDot status="error" /> <StatusDot status="error" />
<span className="text-muted-foreground/50 text-xs"> <span className="text-muted-foreground/50 text-xs">
Not configured Not configured
</span> </span>
</> </>
) : data.tmdb.connected && data.tmdb.tokenValid ? ( ) : tmdb.connected && tmdb.tokenValid ? (
<> <>
<StatusDot status="ok" /> <StatusDot status="ok" />
<span className="text-muted-foreground text-xs">Connected</span> <span className="text-muted-foreground text-xs">Connected</span>
<span className="font-mono text-[11px] text-muted-foreground/80"> <span className="font-mono text-[11px] text-muted-foreground/80">
{data.tmdb.responseTimeMs}ms {tmdb.responseTimeMs}ms
</span> </span>
</> </>
) : data.tmdb.connected && !data.tmdb.tokenValid ? ( ) : tmdb.connected && !tmdb.tokenValid ? (
<> <>
<StatusDot status="error" /> <StatusDot status="error" />
<span className="text-destructive text-xs">Invalid token</span> <span className="text-destructive text-xs">Invalid token</span>
@@ -268,9 +282,9 @@ function SystemStatusCard() {
<> <>
<StatusDot status="error" /> <StatusDot status="error" />
<span className="text-destructive text-xs">Unreachable</span> <span className="text-destructive text-xs">Unreachable</span>
{data.tmdb.error && ( {tmdb.error && (
<span className="text-[11px] text-muted-foreground/50"> <span className="text-[11px] text-muted-foreground/50">
{data.tmdb.error} {tmdb.error}
</span> </span>
)} )}
</> </>
@@ -283,7 +297,7 @@ function SystemStatusCard() {
<div className="space-y-2"> <div className="space-y-2">
<span className="inline-flex items-center gap-1.5 font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider"> <span className="inline-flex items-center gap-1.5 font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
Environment Environment
{data.environment.dataDirWritable ? ( {environment.dataDirWritable ? (
<IconCheck aria-hidden={true} className="size-3 text-green-500" /> <IconCheck aria-hidden={true} className="size-3 text-green-500" />
) : ( ) : (
<IconAlertTriangle <IconAlertTriangle
@@ -293,7 +307,7 @@ function SystemStatusCard() {
)} )}
</span> </span>
<div className="space-y-1"> <div className="space-y-1">
{data.environment.envVars {environment.envVars
.filter((env) => env.value !== null) .filter((env) => env.value !== null)
.map((env) => ( .map((env) => (
<div <div
@@ -313,9 +327,14 @@ function SystemStatusCard() {
); );
} }
function BackgroundJobsCard() { function BackgroundJobsCard({
const data = useAtomValue(systemHealthDataAtom); jobs,
const { refresh } = useSystemHealthRefresh(); isRefreshing,
onRefresh,
}: Pick<SystemHealthData, "jobs"> & {
isRefreshing: boolean;
onRefresh: () => void;
}) {
const [triggeringJob, setTriggeringJob] = useState<string | null>(null); const [triggeringJob, setTriggeringJob] = useState<string | null>(null);
const handleTrigger = async (jobName: string) => { const handleTrigger = async (jobName: string) => {
@@ -324,7 +343,7 @@ function BackgroundJobsCard() {
await triggerJobAction(jobName); await triggerJobAction(jobName);
toast.success(`${JOB_LABELS[jobName] ?? jobName} triggered`); toast.success(`${JOB_LABELS[jobName] ?? jobName} triggered`);
// Refresh after a brief delay so the run shows up // Refresh after a brief delay so the run shows up
setTimeout(refresh, 1500); setTimeout(onRefresh, 1500);
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to trigger job"); toast.error(err instanceof Error ? err.message : "Failed to trigger job");
} finally { } finally {
@@ -332,14 +351,14 @@ function BackgroundJobsCard() {
} }
}; };
const sortedJobs = [...data.jobs].sort((a, b) => { const sortedJobs = [...jobs].sort((a, b) => {
if (a.disabled !== b.disabled) return a.disabled ? 1 : -1; if (a.disabled !== b.disabled) return a.disabled ? 1 : -1;
if (!a.nextRunAt && !b.nextRunAt) return 0; if (!a.nextRunAt && !b.nextRunAt) return 0;
if (!a.nextRunAt) return 1; if (!a.nextRunAt) return 1;
if (!b.nextRunAt) return -1; if (!b.nextRunAt) return -1;
return new Date(a.nextRunAt).getTime() - new Date(b.nextRunAt).getTime(); return new Date(a.nextRunAt).getTime() - new Date(b.nextRunAt).getTime();
}); });
const activeJobs = data.jobs.filter((j) => !j.disabled); const activeJobs = jobs.filter((j) => !j.disabled);
const healthyCount = activeJobs.filter( const healthyCount = activeJobs.filter(
(j) => j.lastStatus === "success", (j) => j.lastStatus === "success",
).length; ).length;
@@ -362,7 +381,7 @@ function BackgroundJobsCard() {
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
<RefreshButton /> <RefreshButton isRefreshing={isRefreshing} onRefresh={onRefresh} />
</div> </div>
</CardContent> </CardContent>
<CardContent className="border-border/30 border-t px-0 pt-0 pb-0"> <CardContent className="border-border/30 border-t px-0 pt-0 pb-0">
@@ -535,9 +554,15 @@ function BackgroundJobsCard() {
); );
} }
function StorageCard() { function StorageCard({
const data = useAtomValue(systemHealthDataAtom); imageCache,
backups,
isRefreshing,
onRefresh,
}: Pick<SystemHealthData, "imageCache" | "backups"> & {
isRefreshing: boolean;
onRefresh: () => void;
}) {
return ( return (
<Card className="border-l-2 border-l-primary/30"> <Card className="border-l-2 border-l-primary/30">
<CardContent> <CardContent>
@@ -556,7 +581,7 @@ function StorageCard() {
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
<RefreshButton /> <RefreshButton isRefreshing={isRefreshing} onRefresh={onRefresh} />
</div> </div>
</CardContent> </CardContent>
@@ -566,19 +591,19 @@ function StorageCard() {
<span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider"> <span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
Image cache Image cache
</span> </span>
{data.imageCache.enabled ? ( {imageCache.enabled ? (
<span className="font-mono text-[11px] text-muted-foreground/50"> <span className="font-mono text-[11px] text-muted-foreground/50">
{formatBytes(data.imageCache.totalSizeBytes)} {formatBytes(imageCache.totalSizeBytes)}
</span> </span>
) : null} ) : null}
</div> </div>
{data.imageCache.enabled ? ( {imageCache.enabled ? (
<> <>
<p className="mt-1 text-muted-foreground text-xs"> <p className="mt-1 text-muted-foreground text-xs">
{data.imageCache.imageCount.toLocaleString()} cached images {imageCache.imageCount.toLocaleString()} cached images
</p> </p>
<p className="mt-0.5 text-[10px] text-muted-foreground/50 leading-relaxed"> <p className="mt-0.5 text-[10px] text-muted-foreground/50 leading-relaxed">
{Object.entries(data.imageCache.categories) {Object.entries(imageCache.categories)
.map(([name, cat]) => `${name} ${cat.count}`) .map(([name, cat]) => `${name} ${cat.count}`)
.join(" · ")} .join(" · ")}
</p> </p>
@@ -597,19 +622,19 @@ function StorageCard() {
<span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider"> <span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
Backups Backups
</span> </span>
{data.backups.backupCount > 0 && ( {backups.backupCount > 0 && (
<span className="font-mono text-[11px] text-muted-foreground/50"> <span className="font-mono text-[11px] text-muted-foreground/50">
{formatBytes(data.backups.totalSizeBytes)} {formatBytes(backups.totalSizeBytes)}
</span> </span>
)} )}
</div> </div>
{data.backups.backupCount > 0 ? ( {backups.backupCount > 0 ? (
<p <p
className="mt-1 text-muted-foreground text-xs" className="mt-1 text-muted-foreground text-xs"
suppressHydrationWarning suppressHydrationWarning
> >
{data.backups.backupCount} backups · last{" "} {backups.backupCount} backups · last{" "}
<LiveTimeAgo date={data.backups.lastBackupAt} fallback="unknown" /> <LiveTimeAgo date={backups.lastBackupAt} fallback="unknown" />
</p> </p>
) : ( ) : (
<p className="mt-1 flex items-center gap-1.5 text-muted-foreground/50 text-xs"> <p className="mt-1 flex items-center gap-1.5 text-muted-foreground/50 text-xs">
@@ -1,11 +1,20 @@
import { cacheLife, cacheTag } from "next/cache";
import { getSession } from "@/lib/auth/session"; import { getSession } from "@/lib/auth/session";
import { getRecommendationsForTitle } from "@/lib/services/discovery"; import { getRecommendationsForTitle } from "@/lib/services/discovery";
import { getUserStatusesByTitleIds } from "@/lib/services/tracking"; import { getUserStatusesByTitleIds } from "@/lib/services/tracking";
import type { RecommendedTitle } from "@/lib/types/title"; import type { RecommendedTitle } from "@/lib/types/title";
import { RecommendationsGrid } from "./recommendations-grid"; import { RecommendationsGrid } from "./recommendations-grid";
async function getCachedRecommendations(titleId: string) {
"use cache";
cacheLife("hours");
cacheTag(`recs-${titleId}`);
return getRecommendationsForTitle(titleId);
}
export async function TitleRecommendations({ titleId }: { titleId: string }) { export async function TitleRecommendations({ titleId }: { titleId: string }) {
const recs = await getRecommendationsForTitle(titleId); const recs = await getCachedRecommendations(titleId);
if (recs.length === 0) return null; if (recs.length === 0) return null;
const recommendations: RecommendedTitle[] = recs.map((r) => ({ const recommendations: RecommendedTitle[] = recs.map((r) => ({
+4 -2
View File
@@ -1,7 +1,7 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Suspense } from "react"; import { cache, Suspense } from "react";
import { import {
RecommendationsSkeleton, RecommendationsSkeleton,
SeasonsSkeleton, SeasonsSkeleton,
@@ -23,6 +23,8 @@ import { TitleProvider } from "./_components/title-provider";
import { TitleRecommendations } from "./_components/title-recommendations"; import { TitleRecommendations } from "./_components/title-recommendations";
import { TitleSeasons } from "./_components/title-seasons"; import { TitleSeasons } from "./_components/title-seasons";
const getCachedOrFetchTitle = cache((id: string) => getOrFetchTitle(id));
export async function generateMetadata({ export async function generateMetadata({
params, params,
}: { }: {
@@ -55,7 +57,7 @@ export default async function TitleDetailPage({
// Fetch title + user info in parallel // Fetch title + user info in parallel
const session = await getSession(); const session = await getSession();
const [result, userInfo] = await Promise.all([ const [result, userInfo] = await Promise.all([
getOrFetchTitle(id), getCachedOrFetchTitle(id),
session ? getUserTitleInfo(session.user.id, id) : null, session ? getUserTitleInfo(session.user.id, id) : null,
]); ]);
if (!result) notFound(); if (!result) notFound();
-82
View File
@@ -1,82 +0,0 @@
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getSession } from "@/lib/auth/session";
import { isTmdbConfigured } from "@/lib/config";
import { discover } from "@/lib/tmdb/client";
import { tmdbImageUrl } from "@/lib/tmdb/image";
const querySchema = z.object({
type: z.enum(["movie", "tv"]).default("movie"),
sort_by: z
.string()
.regex(/^[a-z_]+\.(asc|desc)$/)
.default("popularity.desc"),
genre: z
.string()
.regex(/^\d+(,\d+)*$/)
.optional(),
page: z.coerce.number().int().min(1).max(500).default(1),
});
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. Visit /setup for instructions.",
code: "TMDB_NOT_CONFIGURED",
},
{ status: 503 },
);
}
const raw = Object.fromEntries(req.nextUrl.searchParams);
const result = querySchema.safeParse(raw);
if (!result.success) {
return NextResponse.json(
{ error: result.error.issues[0].message },
{ status: 400 },
);
}
const { type, sort_by, genre, page } = result.data;
const params: Record<string, string> = {
sort_by,
"vote_count.gte": "50",
};
if (genre) {
params.with_genres = genre;
}
let results: Awaited<ReturnType<typeof discover>>;
try {
results = await discover(type, params, page);
} catch {
return NextResponse.json(
{ error: "Failed to fetch discover results" },
{ status: 502 },
);
}
const filtered = results.results.filter((r) => r.poster_path);
return NextResponse.json({
results: filtered.map((r) => ({
tmdbId: r.id,
type,
title: r.title ?? r.name,
overview: r.overview,
releaseDate: r.release_date ?? r.first_air_date,
posterPath: tmdbImageUrl(r.poster_path, "w500"),
popularity: r.popularity,
voteAverage: r.vote_average,
})),
page: results.page,
totalPages: results.total_pages,
});
}
+3 -1
View File
@@ -1,11 +1,13 @@
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { NextResponse } from "next/server"; import { connection, NextResponse } from "next/server";
import { db } from "@/lib/db/client"; import { db } from "@/lib/db/client";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";
const log = createLogger("health"); const log = createLogger("health");
export async function GET() { export async function GET() {
await connection();
try { try {
db.run(sql`SELECT 1`); db.run(sql`SELECT 1`);
+8 -5
View File
@@ -1,10 +1,11 @@
import { Provider as StoreProvider } from "jotai";
import { MotionConfig } from "motion/react"; import { MotionConfig } from "motion/react";
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { DM_Sans, DM_Serif_Display, Geist_Mono } from "next/font/google"; import { DM_Sans, DM_Serif_Display, Geist_Mono } from "next/font/google";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import "./globals.css"; import "./globals.css";
import { Toaster } from "@/components/ui/sonner";
const dmSans = DM_Sans({ const dmSans = DM_Sans({
variable: "--font-dm-sans", variable: "--font-dm-sans",
@@ -53,10 +54,12 @@ export default function RootLayout({
> >
Skip to main content Skip to main content
</a> </a>
<MotionConfig reducedMotion="user"> <StoreProvider>
<TooltipProvider>{children}</TooltipProvider> <MotionConfig reducedMotion="user">
</MotionConfig> <TooltipProvider>{children}</TooltipProvider>
<Toaster position="bottom-right" /> <Toaster position="bottom-right" />
</MotionConfig>
</StoreProvider>
</body> </body>
</html> </html>
); );
+9 -3
View File
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { connection } from "next/server"; import { Suspense } from "react";
import { LandingPage } from "@/components/landing-page"; import { LandingPage } from "@/components/landing-page";
import { getSession } from "@/lib/auth/session"; import { getSession } from "@/lib/auth/session";
import { isTmdbConfigured } from "@/lib/config"; import { isTmdbConfigured } from "@/lib/config";
@@ -26,9 +26,15 @@ const posterUrls = posterPaths
.map((p) => tmdbImageUrl(p, "w300")) .map((p) => tmdbImageUrl(p, "w300"))
.filter(Boolean) as string[]; .filter(Boolean) as string[];
export default async function Home() { export default function Home() {
await connection(); return (
<Suspense>
<HomeContent />
</Suspense>
);
}
async function HomeContent() {
const session = await getSession(); const session = await getSession();
if (session?.user) redirect("/dashboard"); if (session?.user) redirect("/dashboard");
if (!isTmdbConfigured()) redirect("/setup"); if (!isTmdbConfigured()) redirect("/setup");
+8 -14
View File
@@ -959,7 +959,7 @@
"human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
@@ -1349,7 +1349,7 @@
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
@@ -1543,12 +1543,6 @@
"@azure/identity/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], "@azure/identity/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
@@ -1593,8 +1587,6 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"body-parser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -1615,14 +1607,14 @@
"isomorphic-fetch/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], "isomorphic-fetch/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"jsonwebtoken/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"mssql/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "mssql/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"mysql2/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"node-vibrant/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], "node-vibrant/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
@@ -1637,14 +1629,16 @@
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"raw-body/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], "router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
"shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"tedious/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
+39 -68
View File
@@ -23,7 +23,7 @@ export function TitleCardSkeleton() {
export function ContinueWatchingSkeleton() { export function ContinueWatchingSkeleton() {
return ( return (
<div className="w-[calc(100vw-3rem)] shrink-0 overflow-hidden rounded-xl bg-card/50 ring-1 ring-white/[0.06] sm:w-72"> <div className="w-64 shrink-0 overflow-hidden rounded-xl bg-card/50 ring-1 ring-white/[0.06] sm:w-72">
<Skeleton className="aspect-video w-full rounded-none" /> <Skeleton className="aspect-video w-full rounded-none" />
<div className="flex items-center gap-3 p-3"> <div className="flex items-center gap-3 p-3">
<div className="min-w-0 flex-1 space-y-2"> <div className="min-w-0 flex-1 space-y-2">
@@ -38,7 +38,13 @@ export function ContinueWatchingSkeleton() {
export function StatCardSkeleton() { export function StatCardSkeleton() {
return ( return (
<Skeleton className="h-[88px] w-full rounded-xl border border-border/30" /> <div className="overflow-hidden rounded-xl border border-border/30 bg-card/50 p-4">
<div className="flex items-center gap-2">
<Skeleton className="h-6 w-6 rounded-md" />
<Skeleton className="h-3 w-16" />
</div>
<Skeleton className="mt-2 h-7 w-12" />
</div>
); );
} }
@@ -82,35 +88,14 @@ export function TitleGridSectionSkeleton() {
); );
} }
export function CastSkeleton() {
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded" />
<Skeleton className="h-6 w-20" />
</div>
<div className="flex gap-4 overflow-hidden">
{Array.from({ length: 8 }).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholders
<div key={i} className="flex shrink-0 flex-col items-center gap-2">
<Skeleton className="size-20 rounded-full sm:size-24" />
<Skeleton className="h-3 w-16" />
<Skeleton className="h-2.5 w-12" />
</div>
))}
</div>
</div>
);
}
export function PersonDetailSkeleton() { export function PersonDetailSkeleton() {
return ( return (
<div className="space-y-10"> <div className="space-y-10">
<div className="flex flex-col gap-6 sm:flex-row sm:gap-8"> <div className="flex flex-col gap-6 sm:flex-row sm:gap-8">
<Skeleton className="size-40 shrink-0 rounded-2xl sm:size-56" /> <Skeleton className="size-40 shrink-0 self-center rounded-2xl sm:size-56 sm:self-start" />
<div className="flex-1 space-y-4"> <div className="flex-1 space-y-3">
<Skeleton className="h-10 w-2/3 sm:h-14" /> <Skeleton className="h-9 w-2/3 sm:h-12" />
<Skeleton className="h-5 w-24 rounded-full" /> <Skeleton className="h-5 w-24 rounded-md" />
<div className="flex gap-4"> <div className="flex gap-4">
<Skeleton className="h-4 w-28" /> <Skeleton className="h-4 w-28" />
<Skeleton className="h-4 w-36" /> <Skeleton className="h-4 w-36" />
@@ -128,25 +113,24 @@ export function PersonDetailSkeleton() {
export function SeasonsSkeleton() { export function SeasonsSkeleton() {
return ( return (
<div className="space-y-4"> <div className="space-y-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
<Skeleton className="size-5 rounded" /> <Skeleton className="size-5 rounded" />
<Skeleton className="h-6 w-24" /> <Skeleton className="h-7 w-28" />
</div> </div>
<div className="flex gap-2"> <div className="space-y-2">
<Skeleton className="h-8 w-20 rounded-full" /> {["s1", "s2", "s3"].map((id) => (
<Skeleton className="h-8 w-20 rounded-full" /> <div
<Skeleton className="h-8 w-20 rounded-full" /> key={id}
</div> className="overflow-hidden rounded-xl border border-border/50 bg-card/50"
<div className="space-y-3"> >
{Array.from({ length: 4 }).map((_, i) => ( <div className="flex items-center justify-between p-4">
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholders <Skeleton className="h-4 w-24" />
<div key={i} className="flex gap-3 rounded-lg bg-card/50 p-3"> <div className="flex items-center gap-3">
<Skeleton className="aspect-video w-32 shrink-0 rounded" /> <Skeleton className="hidden h-2 w-24 rounded-full sm:block" />
<div className="flex-1 space-y-2 py-1"> <Skeleton className="h-3 w-10" />
<Skeleton className="h-4 w-2/3" /> <Skeleton className="size-4" />
<Skeleton className="h-3 w-full" /> </div>
<Skeleton className="h-3 w-1/2" />
</div> </div>
</div> </div>
))} ))}
@@ -158,7 +142,7 @@ export function SeasonsSkeleton() {
export function RecommendationsSkeleton() { export function RecommendationsSkeleton() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Skeleton className="h-8 w-48" /> <SectionHeading width="w-36" />
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
<TitleCardSkeleton /> <TitleCardSkeleton />
<TitleCardSkeleton /> <TitleCardSkeleton />
@@ -174,15 +158,16 @@ export function RecommendationsSkeleton() {
export function TitleDetailSkeleton() { export function TitleDetailSkeleton() {
return ( return (
<div className="space-y-10"> <div className="space-y-10">
<Skeleton className="-mt-6 mr-[calc(-50vw+50%)] ml-[calc(-50vw+50%)] h-80 rounded-none sm:h-[28rem]" /> <Skeleton className="-mt-6 mr-[calc(-50vw+50%)] ml-[calc(-50vw+50%)] h-80 rounded-none md:h-[28rem]" />
<div className="flex flex-row gap-4 sm:gap-8"> <div className="flex flex-col gap-4 md:flex-row md:gap-8">
<Skeleton className="h-[180px] w-[120px] shrink-0 rounded-xl sm:h-[330px] sm:w-[220px]" /> <Skeleton className="aspect-[2/3] w-[140px] shrink-0 self-center rounded-xl md:w-[220px] md:self-start" />
<div className="flex-1 space-y-5"> <div className="flex-1 space-y-5">
<div> <div>
<Skeleton className="h-8 w-2/3 sm:h-12" /> <Skeleton className="h-7 w-2/3 md:h-12" />
<div className="mt-2 flex items-center gap-3"> <div className="mt-2 flex items-center gap-2">
<Skeleton className="h-5 w-14 rounded" /> <Skeleton className="h-5 w-6 rounded" />
<Skeleton className="h-4 w-10" /> <Skeleton className="h-4 w-10" />
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-16" /> <Skeleton className="h-4 w-16" />
</div> </div>
</div> </div>
@@ -191,10 +176,10 @@ export function TitleDetailSkeleton() {
<Skeleton className="h-4 w-5/6" /> <Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" /> <Skeleton className="h-4 w-4/6" />
</div> </div>
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<Skeleton className="h-9 w-28 rounded-lg" /> <Skeleton className="h-9 w-28 rounded-lg" />
<Skeleton className="h-6 w-px" /> <Skeleton className="h-4 w-px" />
<div className="flex gap-1"> <div className="flex gap-0.5">
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton // biome-ignore lint/suspicious/noArrayIndexKey: static skeleton
<Skeleton key={i} className="size-5 rounded" /> <Skeleton key={i} className="size-5 rounded" />
@@ -206,17 +191,3 @@ export function TitleDetailSkeleton() {
</div> </div>
); );
} }
export function DashboardSkeleton() {
return (
<div className="space-y-10">
<div>
<Skeleton className="h-9 w-64" />
<Skeleton className="mt-1 h-4 w-48" />
</div>
<StatsSectionSkeleton />
<ContinueWatchingSectionSkeleton />
<TitleGridSectionSkeleton />
</div>
);
}
+7 -6
View File
@@ -4,18 +4,19 @@ 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 { getUpdateCheckAction } from "@/lib/actions/settings";
import { updateToastShownAtom } from "@/lib/atoms/update-check"; import { updateToastDismissedVersionAtom } from "@/lib/atoms/update-check";
export function UpdateToast() { export function UpdateToast() {
const [shown, setShown] = useAtom(updateToastShownAtom); const [dismissedVersion, setDismissedVersion] = useAtom(
updateToastDismissedVersionAtom,
);
useEffect(() => { useEffect(() => {
if (shown) return;
void getUpdateCheckAction().then((data) => { void getUpdateCheckAction().then((data) => {
if (!data?.updateAvailable) return; if (!data?.updateAvailable) return;
if (dismissedVersion === data.latestVersion) return;
setShown(true); setDismissedVersion(data.latestVersion);
toast.info(`Sofa v${data.latestVersion} is available`, { toast.info(`Sofa v${data.latestVersion} is available`, {
description: `You're running v${data.currentVersion}.`, description: `You're running v${data.currentVersion}.`,
duration: 15_000, duration: 15_000,
@@ -27,7 +28,7 @@ export function UpdateToast() {
: undefined, : undefined,
}); });
}); });
}, [shown, setShown]); }, [dismissedVersion, setDismissedVersion]);
return null; return null;
} }
+29
View File
@@ -0,0 +1,29 @@
"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),
});
return results.results
.filter((r) => r.poster_path)
.map((r) => ({
tmdbId: r.id,
type: mediaType,
title: (r.title ?? r.name) as string,
posterPath: tmdbImageUrl(r.poster_path, "w500"),
releaseDate: (r.release_date ?? r.first_air_date ?? null) as
| string
| null,
voteAverage: r.vote_average,
}));
}
-107
View File
@@ -1,107 +0,0 @@
import { atom, useAtom } from "jotai";
import { useCallback } from "react";
import { toast } from "sonner";
import {
setBackupScheduleAction,
setMaxBackupsAction,
setScheduledBackupAction,
} from "@/lib/actions/settings";
import type { BackupFrequency } from "@/lib/cron";
export interface BackupScheduleState {
enabled: boolean;
maxRetention: number;
frequency: BackupFrequency;
time: string;
dow: number;
}
export const backupScheduleAtom = atom<BackupScheduleState>({
enabled: false,
maxRetention: 7,
frequency: "1d",
time: "03:00",
dow: 0,
});
export const savingScheduleAtom = atom(false);
export const togglingScheduleAtom = atom(false);
export function useBackupScheduleActions() {
const [schedule, setSchedule] = useAtom(backupScheduleAtom);
const [, setSavingSchedule] = useAtom(savingScheduleAtom);
const [, setTogglingSchedule] = useAtom(togglingScheduleAtom);
const toggleScheduled = useCallback(
async (checked: boolean) => {
const previous = schedule.enabled;
setSchedule((prev) => ({ ...prev, enabled: checked }));
setTogglingSchedule(true);
try {
await setScheduledBackupAction(checked);
toast.success(
checked ? "Scheduled backups enabled" : "Scheduled backups disabled",
);
} catch {
setSchedule((prev) => ({ ...prev, enabled: previous }));
toast.error("Failed to update scheduled backup setting");
} finally {
setTogglingSchedule(false);
}
},
[schedule.enabled, setSchedule, setTogglingSchedule],
);
const changeMaxRetention = useCallback(
async (value: number) => {
const previous = schedule.maxRetention;
setSchedule((prev) => ({ ...prev, maxRetention: value }));
try {
await setMaxBackupsAction(value);
} catch {
setSchedule((prev) => ({ ...prev, maxRetention: previous }));
toast.error("Failed to update retention setting");
}
},
[schedule.maxRetention, setSchedule],
);
const changeSchedule = useCallback(
async (
newFrequency: BackupFrequency,
newTime: string,
newDow = schedule.dow,
) => {
const prev = {
frequency: schedule.frequency,
time: schedule.time,
dow: schedule.dow,
};
setSchedule((s) => ({
...s,
frequency: newFrequency,
time: newTime,
dow: newDow,
}));
setSavingSchedule(true);
try {
await setBackupScheduleAction(newFrequency, newTime, newDow);
toast.success("Schedule updated");
} catch {
setSchedule((s) => ({ ...s, ...prev }));
toast.error("Failed to update schedule");
} finally {
setSavingSchedule(false);
}
},
[
schedule.frequency,
schedule.time,
schedule.dow,
setSchedule,
setSavingSchedule,
],
);
return { toggleScheduled, changeMaxRetention, changeSchedule };
}
+1 -1
View File
@@ -4,6 +4,6 @@ import { atomWithStorage } from "jotai/utils";
export const commandPaletteOpenAtom = atom(false); export const commandPaletteOpenAtom = atom(false);
export const helpOpenAtom = atom(false); export const helpOpenAtom = atom(false);
const RECENT_KEY = "cp:recent-searches"; const RECENT_KEY = "sofa:recent-searches";
export const MAX_RECENT = 5; export const MAX_RECENT = 5;
export const recentSearchesAtom = atomWithStorage<string[]>(RECENT_KEY, []); export const recentSearchesAtom = atomWithStorage<string[]>(RECENT_KEY, []);
-61
View File
@@ -1,61 +0,0 @@
import { atom } from "jotai";
import { unwrap } from "jotai/utils";
import {
fetchEpisodeProgress,
fetchUserStatuses,
} from "@/lib/actions/watchlist";
type TitleStatus = "watchlist" | "in_progress" | "completed";
interface TitleRowItem {
tmdbId: number;
type: "movie" | "tv";
title: string;
posterPath: string | null;
releaseDate: string | null;
voteAverage: number;
}
export const selectedGenreAtom = atom<number | null>(null);
export const mediaTypeAtom = atom<"movie" | "tv">("movie");
export const defaultItemsAtom = atom<TitleRowItem[]>([]);
export const initialUserStatusesAtom = atom<Record<string, TitleStatus>>({});
export const initialEpisodeProgressAtom = atom<
Record<string, { watched: number; total: number }>
>({});
const genreResultsAsyncAtom = atom(async (get) => {
const genre = get(selectedGenreAtom);
const mediaType = get(mediaTypeAtom);
if (genre === null) return null;
const res = await fetch(
`/api/explore/discover?type=${mediaType}&genre=${genre}&sort_by=popularity.desc`,
);
const data = await res.json();
return (data.results ?? []) as TitleRowItem[];
});
export const genreResultsAtom = unwrap(genreResultsAsyncAtom);
interface GenreEnrichments {
statuses: Record<string, TitleStatus>;
progress: Record<string, { watched: number; total: number }>;
}
const genreEnrichmentsAsyncAtom = atom(
async (get): Promise<GenreEnrichments | null> => {
const genre = get(selectedGenreAtom);
if (genre === null) return null;
const genreResults = await get(genreResultsAsyncAtom);
if (!genreResults || genreResults.length === 0)
return { statuses: {}, progress: {} };
const items = genreResults.map((r) => ({ tmdbId: r.tmdbId, type: r.type }));
const [statuses, progress] = await Promise.all([
fetchUserStatuses(items),
fetchEpisodeProgress(items),
]);
return { statuses, progress };
},
);
export const genreEnrichmentsAtom = unwrap(genreEnrichmentsAsyncAtom);
-59
View File
@@ -1,59 +0,0 @@
import { atom, useAtom } from "jotai";
import { useCallback } from "react";
import { toast } from "sonner";
import type { IntegrationConnection } from "@/app/(pages)/settings/_components/integration-card";
import {
deleteIntegration,
regenerateIntegrationToken,
saveIntegration,
} from "@/lib/actions/settings";
export const connectionsAtom = atom<IntegrationConnection[]>([]);
export function useConnectionActions(provider: string, label: string) {
const [connections, setConnections] = useAtom(connectionsAtom);
const connection = connections.find((c) => c.provider === provider) ?? null;
const handleConnect = useCallback(async () => {
try {
const result = await saveIntegration(provider);
setConnections((prev) => [...prev, { ...result, recentEvents: [] }]);
toast.success(`${label} connected`);
} catch {
toast.error(`Failed to connect ${label}`);
}
}, [provider, label, setConnections]);
const handleDelete = useCallback(async () => {
const previous = connections;
setConnections((prev) => prev.filter((c) => c.provider !== provider));
try {
await deleteIntegration(provider);
toast.success(`${label} disconnected`);
} catch {
setConnections(previous);
toast.error(`Failed to disconnect ${label}`);
}
}, [provider, label, connections, setConnections]);
const handleRegenerateToken = useCallback(async () => {
try {
const result = await regenerateIntegrationToken(provider);
setConnections((prev) =>
prev.map((c) =>
c.provider === provider ? { ...c, token: result.token } : c,
),
);
toast.success(`${label} URL regenerated`);
} catch {
toast.error(`Failed to regenerate ${label} URL`);
}
}, [provider, label, setConnections]);
return {
connection,
handleConnect,
handleDelete,
handleRegenerateToken,
};
}
-7
View File
@@ -1,7 +0,0 @@
import { atom } from "jotai";
import type { SystemHealthData } from "@/lib/services/system-health";
export const systemHealthDataAtom = atom<SystemHealthData>(
undefined as unknown as SystemHealthData,
);
export const systemHealthRefreshingAtom = atom(false);
+3 -3
View File
@@ -1,7 +1,7 @@
import { atomWithStorage, createJSONStorage } from "jotai/utils"; import { atomWithStorage, createJSONStorage } from "jotai/utils";
export const updateToastShownAtom = atomWithStorage<boolean>( export const updateToastDismissedVersionAtom = atomWithStorage<string | null>(
"sofa:update-toast-shown", "sofa:update-toast-dismissed-version",
false, null,
createJSONStorage(() => sessionStorage), createJSONStorage(() => sessionStorage),
); );
+9 -2
View File
@@ -1,4 +1,5 @@
import { eq, inArray, sql } from "drizzle-orm"; import { eq, inArray, sql } from "drizzle-orm";
import { updateTag } from "next/cache";
import { db } from "@/lib/db/client"; import { db } from "@/lib/db/client";
import { persons, titleCast, titles } from "@/lib/db/schema"; import { persons, titleCast, titles } from "@/lib/db/schema";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";
@@ -87,6 +88,8 @@ export async function refreshCredits(titleId: string) {
log.debug(`Refreshing credits for "${title.title}" (${title.type})`); log.debug(`Refreshing credits for "${title.title}" (${title.type})`);
try { try {
let personIds: Map<number, string>;
if (title.type === "movie") { if (title.type === "movie") {
const credits = await getMovieCredits(title.tmdbId); const credits = await getMovieCredits(title.tmdbId);
const castSlice = credits.cast.slice(0, 20); const castSlice = credits.cast.slice(0, 20);
@@ -117,7 +120,7 @@ export async function refreshCredits(titleId: string) {
popularity: c.popularity, popularity: c.popularity,
})), })),
]; ];
const personIds = batchUpsertPersons(allPeople); personIds = batchUpsertPersons(allPeople);
// Collect all titleCast rows (cast + crew) and batch insert // Collect all titleCast rows (cast + crew) and batch insert
const now = new Date(); const now = new Date();
@@ -212,7 +215,7 @@ export async function refreshCredits(titleId: string) {
popularity: c.person.popularity, popularity: c.person.popularity,
})), })),
]; ];
const personIds = batchUpsertPersons(allPeople); personIds = batchUpsertPersons(allPeople);
// Collect all titleCast rows (cast + crew) and batch insert // Collect all titleCast rows (cast + crew) and batch insert
const now = new Date(); const now = new Date();
@@ -270,6 +273,10 @@ export async function refreshCredits(titleId: string) {
} }
} }
for (const personId of personIds.values()) {
updateTag(`person-${personId}`);
}
log.debug(`Credits refreshed for "${title.title}"`); log.debug(`Credits refreshed for "${title.title}"`);
if (imageCacheEnabled()) { if (imageCacheEnabled()) {
+3
View File
@@ -1,4 +1,5 @@
import { eq, inArray, sql } from "drizzle-orm"; import { eq, inArray, sql } from "drizzle-orm";
import { updateTag } from "next/cache";
import { db } from "@/lib/db/client"; import { db } from "@/lib/db/client";
import { import {
availabilityOffers, availabilityOffers,
@@ -573,6 +574,8 @@ export async function refreshRecommendations(titleId: string) {
.run(); .run();
} }
}); });
updateTag(`recs-${titleId}`);
} }
/** Fetch seasons from the DB, building the Season[] structure. */ /** Fetch seasons from the DB, building the Season[] structure. */
+4 -6
View File
@@ -1,18 +1,16 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const imageBaseUrl = process.env.TMDB_IMAGE_BASE_URL || "";
const imageHost = imageBaseUrl
? new URL(imageBaseUrl).hostname
: "image.tmdb.org";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
reactCompiler: true, reactCompiler: true,
cacheComponents: true,
images: { images: {
remotePatterns: [ remotePatterns: [
{ {
protocol: "https", protocol: "https",
hostname: imageHost, hostname: process.env.TMDB_IMAGE_BASE_URL
? new URL(process.env.TMDB_IMAGE_BASE_URL).hostname
: "image.tmdb.org",
}, },
], ],
}, },