mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-16 07:25:44 -04:00
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:
34 files changed
+530
-664
No files matched your search
+14
-8
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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={{
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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, []);
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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),
|
||||
);
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user