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:
jake committed 2026-03-07 16:00:19 -05:00
1 parent 8a45aa1c36
commit 7ed172d675
34 files changed
+530 -664

No files matched your search

+14 -8
View File
@@ -7,7 +7,8 @@ WORKDIR /app
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 ---
FROM base AS builder
@@ -32,21 +33,26 @@ WORKDIR /app
ARG APP_VERSION
ARG GIT_COMMIT_SHA
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
ENV NEXT_TELEMETRY_DISABLED=1
ENV HOSTNAME=0.0.0.0
ENV DATA_DIR=/data
ENV APP_VERSION=${APP_VERSION}
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 \
&& 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
EXPOSE 3000
+7 -1
View File
@@ -1,7 +1,13 @@
import { Suspense } from "react";
export default function AuthLayout({
children,
}: {
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 { SetupForm } from "./_components/setup-form";
export default async function SetupPage() {
export default function SetupPage() {
return <SetupContent />;
}
async function SetupContent() {
await connection();
if (isTmdbConfigured()) redirect("/");
return <SetupForm />;
@@ -1,5 +1,3 @@
"use client";
import { IconPlayerPlay } from "@tabler/icons-react";
import Image from "next/image";
import Link from "next/link";
@@ -1,5 +1,3 @@
"use client";
import {
Carousel,
CarouselContent,
@@ -1,5 +1,3 @@
"use client";
import type { ReactNode } from "react";
export function FeedSection({
@@ -1,7 +1,6 @@
"use client";
import { createStore, Provider, useAtom, useAtomValue } from "jotai";
import { useState } from "react";
import { useState, useTransition } from "react";
import { TitleCardSkeleton } from "@/components/skeletons";
import { TitleCard } from "@/components/title-card";
import { Button } from "@/components/ui/button";
@@ -10,15 +9,11 @@ import {
CarouselContent,
CarouselItem,
} from "@/components/ui/carousel";
import { discoverByGenre } from "@/lib/actions/explore";
import {
defaultItemsAtom,
genreEnrichmentsAtom,
genreResultsAtom,
initialEpisodeProgressAtom,
initialUserStatusesAtom,
mediaTypeAtom,
selectedGenreAtom,
} from "@/lib/atoms/filterable-row";
fetchEpisodeProgress,
fetchUserStatuses,
} from "@/lib/actions/watchlist";
interface Genre {
id: number;
@@ -34,13 +29,15 @@ interface TitleRowItem {
voteAverage: number;
}
type TitleStatus = "watchlist" | "in_progress" | "completed";
interface FilterableTitleRowProps {
heading: string;
icon: React.ReactNode;
mediaType: "movie" | "tv";
defaultItems: TitleRowItem[];
genres: Genre[];
userStatuses?: Record<string, "watchlist" | "in_progress" | "completed">;
userStatuses?: Record<string, TitleStatus>;
episodeProgress?: Record<string, { watched: number; total: number }>;
}
@@ -50,56 +47,52 @@ export function FilterableTitleRow({
mediaType,
defaultItems,
genres,
userStatuses,
episodeProgress,
userStatuses: initialStatuses = {},
episodeProgress: initialProgress = {},
}: FilterableTitleRowProps) {
const [store] = useState(() => {
const s = createStore();
s.set(mediaTypeAtom, mediaType);
s.set(defaultItemsAtom, defaultItems);
s.set(initialUserStatusesAtom, userStatuses ?? {});
s.set(initialEpisodeProgressAtom, episodeProgress ?? {});
return s;
});
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 [selectedGenre, setSelectedGenre] = useState<number | null>(null);
const [genreResults, setGenreResults] = useState<TitleRowItem[] | null>(null);
const [genreStatuses, setGenreStatuses] = useState<
Record<string, TitleStatus>
>({});
const [genreProgress, setGenreProgress] = useState<
Record<string, { watched: number; total: number }>
>({});
const [isPending, startTransition] = useTransition();
const items = selectedGenre === null ? defaultItems : (genreResults ?? []);
const userStatuses = selectedGenre === null ? initialStatuses : genreStatuses;
const episodeProgress =
selectedGenre === null
? initialProgress
: (genreEnrichments?.progress ?? initialProgress);
selectedGenre === null ? initialProgress : genreProgress;
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 (
@@ -134,7 +127,7 @@ function FilterableTitleRowInner({
</div>
{/* Loading skeleton */}
{loading && (
{isPending && (
<div className="-mx-4 flex gap-4 overflow-hidden px-4 sm:-mx-0 sm:px-0">
{Array.from({ length: 8 }).map((_, i) => (
<div
@@ -149,14 +142,14 @@ function FilterableTitleRowInner({
)}
{/* 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">
No titles found for this genre.
</p>
)}
{/* Carousel */}
{!loading && items.length > 0 && (
{!isPending && items.length > 0 && (
<div key={selectedGenre ?? "default"}>
<Carousel
opts={{
+27 -9
View File
@@ -37,21 +37,39 @@ function mapResults(
}));
}
export default async function ExplorePage() {
// Fetch session in parallel with TMDB calls
const [trending, popularMovies, popularTv, movieGenres, tvGenres, session] =
async function getExploreTmdbData() {
const [trending, popularMovies, popularTv, movieGenres, tvGenres] =
await Promise.all([
getTrending("all", "day"),
getPopular("movie"),
getPopular("tv"),
getGenres("movie"),
getGenres("tv"),
getSession(),
]);
const trendingItems = mapResults(trending.results, "movie");
const popularMovieItems = mapResults(popularMovies.results, "movie");
const popularTvItems = mapResults(popularTv.results, "tv");
return {
trending,
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
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" />}
mediaType="movie"
defaultItems={popularMovieItems.slice(0, 20)}
genres={movieGenres.genres}
genres={movieGenres}
userStatuses={userStatuses}
episodeProgress={episodeProgress}
/>
@@ -114,7 +132,7 @@ export default async function ExplorePage() {
}
mediaType="tv"
defaultItems={popularTvItems.slice(0, 20)}
genres={tvGenres.genres}
genres={tvGenres}
userStatuses={userStatuses}
episodeProgress={episodeProgress}
/>
+28 -20
View File
@@ -1,5 +1,5 @@
import { Provider as StoreProvider } from "jotai";
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { CommandPalette } from "@/components/command-palette";
import { MobileTabBar } from "@/components/mobile-tab-bar";
import { NavBar } from "@/components/nav-bar";
@@ -7,32 +7,40 @@ import { ProgressProvider } from "@/components/navigation-progress";
import { UpdateToast } from "@/components/update-toast";
import { getSession } from "@/lib/auth/session";
export default async function PagesLayout({
export default function PagesLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<Suspense>
<ProgressProvider>
<AuthenticatedShell>{children}</AuthenticatedShell>
</ProgressProvider>
</Suspense>
);
}
async function AuthenticatedShell({ children }: { children: React.ReactNode }) {
const session = await getSession();
if (!session) redirect("/login");
return (
<StoreProvider>
<ProgressProvider>
<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} />
{/* 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]" />
<main
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))]"
>
{children}
</main>
</div>
<MobileTabBar />
<CommandPalette />
{session.user.role === "admin" && <UpdateToast />}
</ProgressProvider>
</StoreProvider>
<>
<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} />
{/* 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]" />
<main
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))]"
>
{children}
</main>
</div>
<MobileTabBar />
<CommandPalette />
{session.user.role === "admin" && <UpdateToast />}
</>
);
}
@@ -1,5 +1,3 @@
"use client";
import { IconCalendar, IconMapPin } from "@tabler/icons-react";
import { format, parseISO } from "date-fns";
import Image from "next/image";
+15 -3
View File
@@ -1,5 +1,6 @@
import { eq } from "drizzle-orm";
import type { Metadata } from "next";
import { cacheLife, cacheTag } from "next/cache";
import { notFound } from "next/navigation";
import { getSession } from "@/lib/auth/session";
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({
params,
}: {
@@ -31,10 +43,10 @@ export default async function PersonDetailPage({
}) {
const { id } = await params;
const person = await getOrFetchPerson(id);
if (!person) notFound();
const data = await getCachedPersonData(id);
if (!data) notFound();
const filmography = getLocalFilmography(person.id);
const { person, filmography } = data;
const session = await getSession();
const userStatuses = session
? getUserStatusesByTitleIds(
@@ -2,10 +2,9 @@
import { IconCalendarWeek } from "@tabler/icons-react";
import { format, formatDistanceToNow } from "date-fns";
import { useAtomValue, useSetAtom } from "jotai";
import { useHydrateAtoms } from "jotai/utils";
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 { ButtonGroup } from "@/components/ui/button-group";
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
@@ -18,11 +17,10 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
backupScheduleAtom,
savingScheduleAtom,
togglingScheduleAtom,
useBackupScheduleActions,
} from "@/lib/atoms/backup-schedule";
setBackupScheduleAction,
setMaxBackupsAction,
setScheduledBackupAction,
} from "@/lib/actions/settings";
import type { BackupFrequency } from "@/lib/cron";
const FREQUENCY_OPTIONS: { value: BackupFrequency; label: string }[] = [
@@ -44,6 +42,14 @@ const DAYS_OF_WEEK = [
"Saturday",
] as const;
interface BackupScheduleState {
enabled: boolean;
maxRetention: number;
frequency: BackupFrequency;
time: string;
dow: number;
}
function getNextBackupDate(
frequency: BackupFrequency,
time: string,
@@ -118,48 +124,83 @@ export function BackupScheduleSection({
initialTime: string;
initialDow: number;
}) {
useHydrateAtoms([
[
backupScheduleAtom,
{
enabled: initialScheduledEnabled,
maxRetention: initialMaxRetention,
frequency: initialFrequency,
time: initialTime,
dow: initialDow,
},
],
]);
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 [schedule, setSchedule] = useState<BackupScheduleState>({
enabled: initialScheduledEnabled,
maxRetention: initialMaxRetention,
frequency: initialFrequency,
time: initialTime,
dow: initialDow,
});
const [savingSchedule, setSavingSchedule] = useState(false);
const [togglingSchedule, setTogglingSchedule] = useState(false);
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 (
<>
<CardContent>
@@ -13,6 +13,7 @@ import { formatDistanceToNow } from "date-fns";
import { AnimatePresence, motion } from "motion/react";
import type { ComponentType, ReactNode } from "react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -37,7 +38,11 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useConnectionActions } from "@/lib/atoms/integrations";
import {
deleteIntegration,
regenerateIntegrationToken,
saveIntegration,
} from "@/lib/actions/settings";
// ─── Types ──────────────────────────────────────────────────────────
@@ -78,9 +83,56 @@ export interface IntegrationConfig {
// ─── Component ──────────────────────────────────────────────────────
export function IntegrationCard({ config }: { config: IntegrationConfig }) {
const { connection, handleConnect, handleDelete, handleRegenerateToken } =
useConnectionActions(config.provider, config.label);
export function IntegrationCard({
config,
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 [copied, setCopied] = useState(false);
const [cardOpen, setCardOpen] = useState(false);
@@ -1,10 +1,7 @@
"use client";
import { IconWebhook } from "@tabler/icons-react";
import { useSetAtom } from "jotai";
import { useHydrateAtoms } from "jotai/utils";
import { useEffect } from "react";
import { connectionsAtom } from "@/lib/atoms/integrations";
import { useState } from "react";
import {
IntegrationCard,
type IntegrationConnection,
@@ -16,11 +13,7 @@ export function IntegrationsSection({
}: {
initialConnections: IntegrationConnection[];
}) {
useHydrateAtoms([[connectionsAtom, initialConnections]]);
const setConnections = useSetAtom(connectionsAtom);
useEffect(() => {
setConnections(initialConnections);
}, [initialConnections, setConnections]);
const [connections, setConnections] = useState(initialConnections);
return (
<div>
@@ -35,7 +28,14 @@ export function IntegrationsSection({
</div>
<div className="space-y-3">
{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>
@@ -9,8 +9,6 @@ import {
IconPlayerPlay,
IconRefresh,
} from "@tabler/icons-react";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { useHydrateAtoms } from "jotai/utils";
import { useState } from "react";
import { toast } from "sonner";
import { StatusDot } from "@/components/status-dot";
@@ -41,10 +39,6 @@ import {
getSystemHealthAction,
triggerJobAction,
} from "@/lib/actions/settings";
import {
systemHealthDataAtom,
systemHealthRefreshingAtom,
} from "@/lib/atoms/system-health";
import type { SystemHealthData } from "@/lib/services/system-health";
const JOB_LABELS: Record<string, string> = {
@@ -137,9 +131,14 @@ function LiveTimeAgo({
return <>{text}</>;
}
function useSystemHealthRefresh() {
const [isRefreshing, setRefreshing] = useAtom(systemHealthRefreshingAtom);
const setData = useSetAtom(systemHealthDataAtom);
/** Hydrates system health state and renders the 3 cards */
export function SystemHealthCards({
initialData,
}: {
initialData: SystemHealthData;
}) {
const [data, setData] = useState(initialData);
const [isRefreshing, setRefreshing] = useState(false);
async function refresh() {
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 (
<div className="space-y-3">
<SystemStatusCard />
<BackgroundJobsCard />
<StorageCard />
<SystemStatusCard
checkedAt={data.checkedAt}
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>
);
}
function RefreshButton() {
const { isRefreshing, refresh } = useSystemHealthRefresh();
function RefreshButton({
isRefreshing,
onRefresh,
}: {
isRefreshing: boolean;
onRefresh: () => void;
}) {
return (
<Tooltip>
<TooltipTrigger
@@ -186,7 +192,7 @@ function RefreshButton() {
variant="ghost"
size="icon"
aria-label="Refresh system health"
onClick={refresh}
onClick={onRefresh}
disabled={isRefreshing}
className="text-muted-foreground"
/>
@@ -199,9 +205,17 @@ function RefreshButton() {
);
}
function SystemStatusCard() {
const data = useAtomValue(systemHealthDataAtom);
function SystemStatusCard({
checkedAt,
database,
tmdb,
environment,
isRefreshing,
onRefresh,
}: Pick<SystemHealthData, "checkedAt" | "database" | "tmdb" | "environment"> & {
isRefreshing: boolean;
onRefresh: () => void;
}) {
return (
<Card className="border-l-2 border-l-primary/30">
<CardContent>
@@ -216,11 +230,11 @@ function SystemStatusCard() {
<div>
<CardTitle>Health status</CardTitle>
<CardDescription suppressHydrationWarning>
Checked <LiveTimeAgo date={data.checkedAt} />
Checked <LiveTimeAgo date={checkedAt} />
</CardDescription>
</div>
</div>
<RefreshButton />
<RefreshButton isRefreshing={isRefreshing} onRefresh={onRefresh} />
</div>
</CardContent>
@@ -231,9 +245,9 @@ function SystemStatusCard() {
Database
</span>
<span className="font-mono text-[11px] text-muted-foreground">
{formatBytes(data.database.dbSizeBytes)}
{data.database.walSizeBytes > 0 &&
` + ${formatBytes(data.database.walSizeBytes)} WAL`}
{formatBytes(database.dbSizeBytes)}
{database.walSizeBytes > 0 &&
` + ${formatBytes(database.walSizeBytes)} WAL`}
</span>
</div>
</CardContent>
@@ -244,22 +258,22 @@ function SystemStatusCard() {
<span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
TMDB API
</span>
{!data.tmdb.tokenConfigured ? (
{!tmdb.tokenConfigured ? (
<>
<StatusDot status="error" />
<span className="text-muted-foreground/50 text-xs">
Not configured
</span>
</>
) : data.tmdb.connected && data.tmdb.tokenValid ? (
) : tmdb.connected && tmdb.tokenValid ? (
<>
<StatusDot status="ok" />
<span className="text-muted-foreground text-xs">Connected</span>
<span className="font-mono text-[11px] text-muted-foreground/80">
{data.tmdb.responseTimeMs}ms
{tmdb.responseTimeMs}ms
</span>
</>
) : data.tmdb.connected && !data.tmdb.tokenValid ? (
) : tmdb.connected && !tmdb.tokenValid ? (
<>
<StatusDot status="error" />
<span className="text-destructive text-xs">Invalid token</span>
@@ -268,9 +282,9 @@ function SystemStatusCard() {
<>
<StatusDot status="error" />
<span className="text-destructive text-xs">Unreachable</span>
{data.tmdb.error && (
{tmdb.error && (
<span className="text-[11px] text-muted-foreground/50">
{data.tmdb.error}
{tmdb.error}
</span>
)}
</>
@@ -283,7 +297,7 @@ function SystemStatusCard() {
<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">
Environment
{data.environment.dataDirWritable ? (
{environment.dataDirWritable ? (
<IconCheck aria-hidden={true} className="size-3 text-green-500" />
) : (
<IconAlertTriangle
@@ -293,7 +307,7 @@ function SystemStatusCard() {
)}
</span>
<div className="space-y-1">
{data.environment.envVars
{environment.envVars
.filter((env) => env.value !== null)
.map((env) => (
<div
@@ -313,9 +327,14 @@ function SystemStatusCard() {
);
}
function BackgroundJobsCard() {
const data = useAtomValue(systemHealthDataAtom);
const { refresh } = useSystemHealthRefresh();
function BackgroundJobsCard({
jobs,
isRefreshing,
onRefresh,
}: Pick<SystemHealthData, "jobs"> & {
isRefreshing: boolean;
onRefresh: () => void;
}) {
const [triggeringJob, setTriggeringJob] = useState<string | null>(null);
const handleTrigger = async (jobName: string) => {
@@ -324,7 +343,7 @@ function BackgroundJobsCard() {
await triggerJobAction(jobName);
toast.success(`${JOB_LABELS[jobName] ?? jobName} triggered`);
// Refresh after a brief delay so the run shows up
setTimeout(refresh, 1500);
setTimeout(onRefresh, 1500);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to trigger job");
} 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.nextRunAt && !b.nextRunAt) return 0;
if (!a.nextRunAt) return 1;
if (!b.nextRunAt) return -1;
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(
(j) => j.lastStatus === "success",
).length;
@@ -362,7 +381,7 @@ function BackgroundJobsCard() {
</CardDescription>
</div>
</div>
<RefreshButton />
<RefreshButton isRefreshing={isRefreshing} onRefresh={onRefresh} />
</div>
</CardContent>
<CardContent className="border-border/30 border-t px-0 pt-0 pb-0">
@@ -535,9 +554,15 @@ function BackgroundJobsCard() {
);
}
function StorageCard() {
const data = useAtomValue(systemHealthDataAtom);
function StorageCard({
imageCache,
backups,
isRefreshing,
onRefresh,
}: Pick<SystemHealthData, "imageCache" | "backups"> & {
isRefreshing: boolean;
onRefresh: () => void;
}) {
return (
<Card className="border-l-2 border-l-primary/30">
<CardContent>
@@ -556,7 +581,7 @@ function StorageCard() {
</CardDescription>
</div>
</div>
<RefreshButton />
<RefreshButton isRefreshing={isRefreshing} onRefresh={onRefresh} />
</div>
</CardContent>
@@ -566,19 +591,19 @@ function StorageCard() {
<span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
Image cache
</span>
{data.imageCache.enabled ? (
{imageCache.enabled ? (
<span className="font-mono text-[11px] text-muted-foreground/50">
{formatBytes(data.imageCache.totalSizeBytes)}
{formatBytes(imageCache.totalSizeBytes)}
</span>
) : null}
</div>
{data.imageCache.enabled ? (
{imageCache.enabled ? (
<>
<p className="mt-1 text-muted-foreground text-xs">
{data.imageCache.imageCount.toLocaleString()} cached images
{imageCache.imageCount.toLocaleString()} cached images
</p>
<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}`)
.join(" · ")}
</p>
@@ -597,19 +622,19 @@ function StorageCard() {
<span className="font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider">
Backups
</span>
{data.backups.backupCount > 0 && (
{backups.backupCount > 0 && (
<span className="font-mono text-[11px] text-muted-foreground/50">
{formatBytes(data.backups.totalSizeBytes)}
{formatBytes(backups.totalSizeBytes)}
</span>
)}
</div>
{data.backups.backupCount > 0 ? (
{backups.backupCount > 0 ? (
<p
className="mt-1 text-muted-foreground text-xs"
suppressHydrationWarning
>
{data.backups.backupCount} backups · last{" "}
<LiveTimeAgo date={data.backups.lastBackupAt} fallback="unknown" />
{backups.backupCount} backups · last{" "}
<LiveTimeAgo date={backups.lastBackupAt} fallback="unknown" />
</p>
) : (
<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 { getRecommendationsForTitle } from "@/lib/services/discovery";
import { getUserStatusesByTitleIds } from "@/lib/services/tracking";
import type { RecommendedTitle } from "@/lib/types/title";
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 }) {
const recs = await getRecommendationsForTitle(titleId);
const recs = await getCachedRecommendations(titleId);
if (recs.length === 0) return null;
const recommendations: RecommendedTitle[] = recs.map((r) => ({
+4 -2
View File
@@ -1,7 +1,7 @@
import { eq } from "drizzle-orm";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { Suspense } from "react";
import { cache, Suspense } from "react";
import {
RecommendationsSkeleton,
SeasonsSkeleton,
@@ -23,6 +23,8 @@ import { TitleProvider } from "./_components/title-provider";
import { TitleRecommendations } from "./_components/title-recommendations";
import { TitleSeasons } from "./_components/title-seasons";
const getCachedOrFetchTitle = cache((id: string) => getOrFetchTitle(id));
export async function generateMetadata({
params,
}: {
@@ -55,7 +57,7 @@ export default async function TitleDetailPage({
// Fetch title + user info in parallel
const session = await getSession();
const [result, userInfo] = await Promise.all([
getOrFetchTitle(id),
getCachedOrFetchTitle(id),
session ? getUserTitleInfo(session.user.id, id) : null,
]);
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 { NextResponse } from "next/server";
import { connection, NextResponse } from "next/server";
import { db } from "@/lib/db/client";
import { createLogger } from "@/lib/logger";
const log = createLogger("health");
export async function GET() {
await connection();
try {
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 type { Metadata, Viewport } from "next";
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 "./globals.css";
import { Toaster } from "@/components/ui/sonner";
const dmSans = DM_Sans({
variable: "--font-dm-sans",
@@ -53,10 +54,12 @@ export default function RootLayout({
>
Skip to main content
</a>
<MotionConfig reducedMotion="user">
<TooltipProvider>{children}</TooltipProvider>
</MotionConfig>
<Toaster position="bottom-right" />
<StoreProvider>
<MotionConfig reducedMotion="user">
<TooltipProvider>{children}</TooltipProvider>
<Toaster position="bottom-right" />
</MotionConfig>
</StoreProvider>
</body>
</html>
);
+9 -3
View File
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
import { connection } from "next/server";
import { Suspense } from "react";
import { LandingPage } from "@/components/landing-page";
import { getSession } from "@/lib/auth/session";
import { isTmdbConfigured } from "@/lib/config";
@@ -26,9 +26,15 @@ const posterUrls = posterPaths
.map((p) => tmdbImageUrl(p, "w300"))
.filter(Boolean) as string[];
export default async function Home() {
await connection();
export default function Home() {
return (
<Suspense>
<HomeContent />
</Suspense>
);
}
async function HomeContent() {
const session = await getSession();
if (session?.user) redirect("/dashboard");
if (!isTmdbConfigured()) redirect("/setup");
+8 -14
View File
@@ -959,7 +959,7 @@
"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=="],
@@ -1349,7 +1349,7 @@
"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=="],
@@ -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=="],
"@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/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=="],
"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=="],
"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=="],
"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=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"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=="],
"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=="],
"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=="],
"router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
"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/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() {
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" />
<div className="flex items-center gap-3 p-3">
<div className="min-w-0 flex-1 space-y-2">
@@ -38,7 +38,13 @@ export function ContinueWatchingSkeleton() {
export function StatCardSkeleton() {
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() {
return (
<div className="space-y-10">
<div className="flex flex-col gap-6 sm:flex-row sm:gap-8">
<Skeleton className="size-40 shrink-0 rounded-2xl sm:size-56" />
<div className="flex-1 space-y-4">
<Skeleton className="h-10 w-2/3 sm:h-14" />
<Skeleton className="h-5 w-24 rounded-full" />
<Skeleton className="size-40 shrink-0 self-center rounded-2xl sm:size-56 sm:self-start" />
<div className="flex-1 space-y-3">
<Skeleton className="h-9 w-2/3 sm:h-12" />
<Skeleton className="h-5 w-24 rounded-md" />
<div className="flex gap-4">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-4 w-36" />
@@ -128,25 +113,24 @@ export function PersonDetailSkeleton() {
export function SeasonsSkeleton() {
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="space-y-3">
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded" />
<Skeleton className="h-6 w-24" />
<Skeleton className="h-7 w-28" />
</div>
<div className="flex gap-2">
<Skeleton className="h-8 w-20 rounded-full" />
<Skeleton className="h-8 w-20 rounded-full" />
<Skeleton className="h-8 w-20 rounded-full" />
</div>
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholders
<div key={i} className="flex gap-3 rounded-lg bg-card/50 p-3">
<Skeleton className="aspect-video w-32 shrink-0 rounded" />
<div className="flex-1 space-y-2 py-1">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-1/2" />
<div className="space-y-2">
{["s1", "s2", "s3"].map((id) => (
<div
key={id}
className="overflow-hidden rounded-xl border border-border/50 bg-card/50"
>
<div className="flex items-center justify-between p-4">
<Skeleton className="h-4 w-24" />
<div className="flex items-center gap-3">
<Skeleton className="hidden h-2 w-24 rounded-full sm:block" />
<Skeleton className="h-3 w-10" />
<Skeleton className="size-4" />
</div>
</div>
</div>
))}
@@ -158,7 +142,7 @@ export function SeasonsSkeleton() {
export function RecommendationsSkeleton() {
return (
<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">
<TitleCardSkeleton />
<TitleCardSkeleton />
@@ -174,15 +158,16 @@ export function RecommendationsSkeleton() {
export function TitleDetailSkeleton() {
return (
<div className="space-y-10">
<Skeleton className="-mt-6 mr-[calc(-50vw+50%)] ml-[calc(-50vw+50%)] h-80 rounded-none sm:h-[28rem]" />
<div className="flex flex-row gap-4 sm:gap-8">
<Skeleton className="h-[180px] w-[120px] shrink-0 rounded-xl sm:h-[330px] sm:w-[220px]" />
<Skeleton className="-mt-6 mr-[calc(-50vw+50%)] ml-[calc(-50vw+50%)] h-80 rounded-none md:h-[28rem]" />
<div className="flex flex-col gap-4 md:flex-row md:gap-8">
<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>
<Skeleton className="h-8 w-2/3 sm:h-12" />
<div className="mt-2 flex items-center gap-3">
<Skeleton className="h-5 w-14 rounded" />
<Skeleton className="h-7 w-2/3 md:h-12" />
<div className="mt-2 flex items-center gap-2">
<Skeleton className="h-5 w-6 rounded" />
<Skeleton className="h-4 w-10" />
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-16" />
</div>
</div>
@@ -191,10 +176,10 @@ export function TitleDetailSkeleton() {
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
</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-6 w-px" />
<div className="flex gap-1">
<Skeleton className="h-4 w-px" />
<div className="flex gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton
<Skeleton key={i} className="size-5 rounded" />
@@ -206,17 +191,3 @@ export function TitleDetailSkeleton() {
</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 { toast } from "sonner";
import { getUpdateCheckAction } from "@/lib/actions/settings";
import { updateToastShownAtom } from "@/lib/atoms/update-check";
import { updateToastDismissedVersionAtom } from "@/lib/atoms/update-check";
export function UpdateToast() {
const [shown, setShown] = useAtom(updateToastShownAtom);
const [dismissedVersion, setDismissedVersion] = useAtom(
updateToastDismissedVersionAtom,
);
useEffect(() => {
if (shown) return;
void getUpdateCheckAction().then((data) => {
if (!data?.updateAvailable) return;
if (dismissedVersion === data.latestVersion) return;
setShown(true);
setDismissedVersion(data.latestVersion);
toast.info(`Sofa v${data.latestVersion} is available`, {
description: `You're running v${data.currentVersion}.`,
duration: 15_000,
@@ -27,7 +28,7 @@ export function UpdateToast() {
: undefined,
});
});
}, [shown, setShown]);
}, [dismissedVersion, setDismissedVersion]);
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 helpOpenAtom = atom(false);
const RECENT_KEY = "cp:recent-searches";
const RECENT_KEY = "sofa:recent-searches";
export const MAX_RECENT = 5;
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";
export const updateToastShownAtom = atomWithStorage<boolean>(
"sofa:update-toast-shown",
false,
export const updateToastDismissedVersionAtom = atomWithStorage<string | null>(
"sofa:update-toast-dismissed-version",
null,
createJSONStorage(() => sessionStorage),
);
+9 -2
View File
@@ -1,4 +1,5 @@
import { eq, inArray, sql } from "drizzle-orm";
import { updateTag } from "next/cache";
import { db } from "@/lib/db/client";
import { persons, titleCast, titles } from "@/lib/db/schema";
import { createLogger } from "@/lib/logger";
@@ -87,6 +88,8 @@ export async function refreshCredits(titleId: string) {
log.debug(`Refreshing credits for "${title.title}" (${title.type})`);
try {
let personIds: Map<number, string>;
if (title.type === "movie") {
const credits = await getMovieCredits(title.tmdbId);
const castSlice = credits.cast.slice(0, 20);
@@ -117,7 +120,7 @@ export async function refreshCredits(titleId: string) {
popularity: c.popularity,
})),
];
const personIds = batchUpsertPersons(allPeople);
personIds = batchUpsertPersons(allPeople);
// Collect all titleCast rows (cast + crew) and batch insert
const now = new Date();
@@ -212,7 +215,7 @@ export async function refreshCredits(titleId: string) {
popularity: c.person.popularity,
})),
];
const personIds = batchUpsertPersons(allPeople);
personIds = batchUpsertPersons(allPeople);
// Collect all titleCast rows (cast + crew) and batch insert
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}"`);
if (imageCacheEnabled()) {
+3
View File
@@ -1,4 +1,5 @@
import { eq, inArray, sql } from "drizzle-orm";
import { updateTag } from "next/cache";
import { db } from "@/lib/db/client";
import {
availabilityOffers,
@@ -573,6 +574,8 @@ export async function refreshRecommendations(titleId: string) {
.run();
}
});
updateTag(`recs-${titleId}`);
}
/** Fetch seasons from the DB, building the Season[] structure. */
+4 -6
View File
@@ -1,18 +1,16 @@
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 = {
output: "standalone",
reactCompiler: true,
cacheComponents: true,
images: {
remotePatterns: [
{
protocol: "https",
hostname: imageHost,
hostname: process.env.TMDB_IMAGE_BASE_URL
? new URL(process.env.TMDB_IMAGE_BASE_URL).hostname
: "image.tmdb.org",
},
],
},