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