mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
Migrate from server actions & REST APIs to oRPC for type-safe RPC (#5)
* Replace server actions with API routes and migrate SWR to TanStack Query - Add 33 new REST API routes covering titles, episodes, seasons, people, dashboard, explore, integrations, admin, backups, and account operations - Replace SWR with @tanstack/react-query: add QueryProvider, api-client helper, query-client singleton, and TanStack Query hooks for titles - Migrate all 26 server actions to fetch calls against the new API routes in 10 client components (use-title-actions, title-card, command-palette, hero-banner, integration-card, account/backup/registration/update-check settings sections) - Delete lib/actions/ directory (titles.ts, watchlist.ts, people.ts, settings.ts) and lib/swr/fetcher.ts https://claude.ai/code/session_01L6gF2sqqe6gw5bemZUnRBb * Convert pages to TanStack Query client-side data fetching Migrate dashboard, explore, settings, and people pages from server component data fetching to client-side TanStack Query hooks. Create query hook files for each domain (dashboard, explore, people, admin, integrations). Add GET routes for admin/registration and admin/update-check. Update CLAUDE.md architecture docs to reflect TanStack Query patterns. https://claude.ai/code/session_01L6gF2sqqe6gw5bemZUnRBb * Migrate API layer from REST routes to oRPC with TanStack Query Replace ~35 hand-written REST API routes and manual TanStack Query hooks with a contract-first oRPC setup that provides end-to-end type safety from contract → server procedures → client → TanStack Query. - Add oRPC foundation: contract, context, auth/admin middleware, RPCHandler, client (browser + SSR via globalThis), TanStack Query utils - Implement ~30 procedures across 14 files (titles, episodes, seasons, people, dashboard, explore, search, discover, stats, status, integrations, admin, account, watchlist) - Migrate all client components from api()/lib/queries/ to orpc.*.queryOptions() and client.*.method() calls - Delete all replaced REST API routes, lib/queries/, lib/api-client.ts, and old hooks (use-discover, use-search, use-stats, use-system-health) - Wire up SSR optimization via instrumentation.ts - Keep non-RPC routes: auth, images, avatars, backup restore/download, webhooks, lists, health https://claude.ai/code/session_01L6gF2sqqe6gw5bemZUnRBb * Add strict Zod output schemas to all oRPC contract procedures Replaces the permissive z.any() output schemas with precise Zod schemas for every procedure that returns data. This makes the contract fully self-documenting and enables runtime output validation. - Define ~30 output schemas in schemas.ts covering titles, people, dashboard, explore, search, discover, stats, integrations, admin, etc. - Wire all output schemas into contract.ts - Fix procedure return type mismatches (nullable fields, enum narrowing) - Align consumer types (SearchResult, IntegrationConnection) with contract https://claude.ai/code/session_01L6gF2sqqe6gw5bemZUnRBb * Delete lib/types.ts — infer all types from Zod schemas The oRPC Zod schemas are now the single source of truth for domain types. All 17 consumer files (services, components, atoms, utils) now import inferred types (Episode, Season, ResolvedTitle, CastMember, etc.) from lib/orpc/schemas instead of hand-written interfaces in lib/types.ts. https://claude.ai/code/session_01L6gF2sqqe6gw5bemZUnRBb * Migrate all mutations to TanStack Query useMutation Replace raw oRPC client calls with useMutation/mutateAsync via orpc.*.mutationOptions() for consistent mutation tracking across all components: title-card, command-palette, account, system-health, integrations, backups, backup-schedule, and title-actions. https://claude.ai/code/session_01L6gF2sqqe6gw5bemZUnRBb * Fix bugs in settings/explore and bump dependencies - Deduplicate backup list on create (filter by filename before prepend) - Add isError state to backup-schedule section with fallback UI - Add error check for avatar DELETE response before clearing state - Strengthen backup restore file guard with `instanceof File` - Change explore trending default type from "movie" to "all" - Pin all oRPC and TanStack Query packages to exact versions - Bump jotai 2.18.0→2.18.1, motion 12.35.1→12.35.2, shadcn 4.0.0→4.0.2, @types/node 25.3.5→25.4.0, semver 6.3.1→7.7.4 * Migrate title recommendations to client-side TanStack Query - Convert TitleRecommendations from server component with `"use cache"` to a client component using `orpc.titles.recommendations.queryOptions()` with an inline skeleton loading state - Delete recommendations-grid.tsx — merged into title-recommendations.tsx - Remove RecommendationsSkeleton from skeletons.tsx (now inline) - Drop Suspense wrapper in title detail page (client component handles its own loading state) - Include userStatuses in recommendations procedure output so the component no longer needs a separate session/tracking lookup - Remove `revalidate` option and all `updateTag`/`cacheTag`/`cacheLife` calls from refreshCredits and refreshRecommendations — no longer needed without "use cache" server components - Remove `cacheComponents: true` from next.config.ts * Replace Jotai title atoms with React Context + TanStack Query cache - Add title-context.tsx with TitleContext and useTitleContext / useTitleUserInfo hooks; useTitleUserInfo reads from orpc.titles.userInfo cache instead of atoms - Rewrite TitleProvider to seed the userInfo cache via queryClient.setQueryData on mount (unconditional overwrite prevents stale data on revisit / account switch) and provide titleId, titleType, titleName, seasons, and watchingEp via context - Rewrite use-title-actions to read/write userInfo via queryClient.getQueryData / setQueryData helpers instead of useStore + individual atoms; optimistic updates and rollbacks now operate on a single UserInfo object in the query cache - Migrate title-actions, title-keyboard-shortcuts, and title-seasons off useAtomValue / useSetAtom to the new context hooks - Delete per-title atoms from lib/atoms/title.ts * Migrate avatar and backup restore routes to oRPC procedures - Add `account.uploadAvatar` and `account.removeAvatar` procedures (FormData file input, auth middleware) replacing `PUT`/`DELETE` `/api/account/avatar` route - Add `admin.backups.restore` procedure (File input, admin middleware) replacing `POST /api/admin/backups/restore` route - Delete both REST route files - Migrate account-section and backup-restore-section off `useTransition` + raw `fetch` to `useMutation` via `orpc.*.mutationOptions()` - Rename `lib/utils/title-theme.ts` → `lib/theme.ts` and `getTitleThemeStyle` → `getThemeCssProperties`; update title detail page import accordingly * Add OpenAPI v1 REST API endpoint via @orpc/openapi - Add `@orpc/openapi` and `@orpc/json-schema` dependencies - Add `lib/orpc/openapi-handler.ts` with OpenAPIHandler instance - Add `app/api/v1/[[...rest]]/route.ts` catch-all serving GET/POST/PUT/DELETE at `/api/v1` with request-headers context - Annotate contract procedures with OpenAPI metadata (tags, summaries, descriptions, and successStatus codes) across all ~30 procedures * Fix 8 bugs: memory safety, data races, and correctness issues - Move queryClient.setQueryData from useState initializer to useEffect to avoid mutating the query cache during React render phase - Add SQL LIMIT to per-integration event queries instead of loading all events and trimming in memory - Lower backup restore upload limit from 500 MB to 100 MB and stream upload to disk instead of buffering entirely in memory - Reorder avatar upload to write new file before deleting old one so a failed upload doesn't remove the current avatar - Make optimistic rollback field-specific so a failed rating update doesn't erase concurrent status or episode-watch changes - Gate titles.userInfo query behind session state to prevent failing RPC calls for anonymous users - Derive OpenAPI session cookie name from BETTER_AUTH_URL so HTTPS deployments use the correct __Secure- prefixed cookie Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add explicit z.void() output schemas to all 17 void procedures Ensures the OpenAPI spec correctly generates 204 No Content responses for mutation endpoints that don't return data. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -48,7 +48,8 @@ bun run test
|
||||
- **Auth**: Better Auth with Drizzle adapter — email/password + optional OIDC/SSO via `genericOAuth` plugin
|
||||
- **Styling**: Tailwind CSS v4, shadcn components, dark cinema theme with warm primary accents
|
||||
- **Fonts**: DM Serif Display (display), DM Sans (body), Geist Mono (mono)
|
||||
- **State**: Jotai (client), SWR (data fetching)
|
||||
- **API**: oRPC (contract-first, type-safe RPC) with `@orpc/tanstack-query` for TanStack Query integration
|
||||
- **State**: Jotai (client), TanStack Query (data fetching & mutations via oRPC)
|
||||
- **Linting**: Biome (2-space indent, organized imports, React/Next.js recommended rules)
|
||||
- **External API**: TMDB (The Movie Database) with Bearer token auth
|
||||
|
||||
@@ -67,10 +68,24 @@ bun run test
|
||||
- `lib/logger.ts` — Structured logger with `LOG_LEVEL` support
|
||||
- `lib/types/` — Shared TypeScript types (e.g. `title.ts`)
|
||||
- `lib/cron.ts` — Background job scheduler (croner, `globalThis` singleton)
|
||||
- `app/api/` — Next.js route handlers
|
||||
- `lib/query-client.ts` — Singleton `QueryClient` with default options (30s stale time)
|
||||
- `lib/orpc/` — oRPC API layer (contract-first, type-safe RPC):
|
||||
- `contract.ts` — Full API contract definition (~30 procedures)
|
||||
- `schemas.ts` — Shared Zod input schemas used by contract
|
||||
- `context.ts` — Context type & `os` implementer
|
||||
- `middleware.ts` — Auth (`authed`) and admin (`admin`) middleware
|
||||
- `router.ts` — Assembled router implementing the contract
|
||||
- `handler.ts` — RPCHandler instance with error logging
|
||||
- `client.ts` — Client-side oRPC client (RPCLink)
|
||||
- `client.server.ts` — Server-side oRPC client (zero HTTP overhead for SSR)
|
||||
- `tanstack.ts` — `orpc` utils for TanStack Query (`orpc.*.queryOptions()`)
|
||||
- `procedures/` — Procedure implementations by domain (titles, episodes, seasons, people, dashboard, explore, search, discover, stats, status, integrations, admin, account, watchlist)
|
||||
- `app/rpc/[[...rest]]/route.ts` — Next.js catch-all route handler for oRPC
|
||||
- `app/api/` — Non-RPC routes (auth, images, avatars, backups, webhooks, lists, health)
|
||||
- `app/(pages)/` — Authenticated pages (dashboard, explore, titles/[id], people/[id], settings)
|
||||
- `app/(auth)/` — Auth pages (login, register, setup)
|
||||
- `components/` — App components + `components/ui/` for shadcn primitives
|
||||
- `components/query-provider.tsx` — `QueryClientProvider` wrapper used in root layout
|
||||
|
||||
### Auth pattern
|
||||
|
||||
@@ -81,17 +96,62 @@ import { getSession } from "@/lib/auth/session";
|
||||
const session = await getSession();
|
||||
```
|
||||
|
||||
Route handlers call Better Auth directly:
|
||||
oRPC procedures use auth middleware that calls Better Auth:
|
||||
|
||||
```typescript
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session)
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
// use session.user.id
|
||||
// lib/orpc/middleware.ts — applied to procedures
|
||||
export const authed = base.middleware(async ({ context, next }) => {
|
||||
const session = await auth.api.getSession({ headers: context.headers });
|
||||
if (!session) throw new ORPCError("UNAUTHORIZED");
|
||||
return next({ context: { user: session.user, session: session.session } });
|
||||
});
|
||||
```
|
||||
|
||||
### oRPC API layer
|
||||
|
||||
All API procedures use oRPC with a contract-first approach. The contract defines the API shape in `lib/orpc/contract.ts`, procedures implement it in `lib/orpc/procedures/`, and the client consumes it with full type safety.
|
||||
|
||||
**Pattern — query in component:**
|
||||
```typescript
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
|
||||
const { data } = useQuery(orpc.dashboard.stats.queryOptions());
|
||||
```
|
||||
|
||||
**Pattern — mutation:**
|
||||
```typescript
|
||||
import { client } from "@/lib/orpc/client";
|
||||
|
||||
await client.titles.updateStatus({ id: titleId, status: "in_progress" });
|
||||
```
|
||||
|
||||
**Pattern — procedure definition:**
|
||||
```typescript
|
||||
// lib/orpc/procedures/dashboard.ts
|
||||
export const stats = os.dashboard.stats.func(async (input, context, meta) => {
|
||||
const { user } = context;
|
||||
return getUserStats(user.id);
|
||||
}, authed);
|
||||
```
|
||||
|
||||
**Page patterns:**
|
||||
- **Thin server shell** (titles, people): Server component fetches initial data via service layer, passes as `initialData` prop to client component that uses TanStack Query via `orpc` for refetching/mutations.
|
||||
- **Full client-side** (dashboard, explore, settings): Server component handles auth only; client components fetch all data via `orpc.*.queryOptions()` with skeleton loading states.
|
||||
|
||||
### Non-RPC routes
|
||||
|
||||
Only file-serving, auth, and webhook routes remain as traditional Next.js API routes:
|
||||
- `/api/auth/[...all]` — Better Auth catch-all
|
||||
- `/api/images/[...path]` — Image proxy/cache serving
|
||||
- `/api/avatars/[userId]` — Avatar file serving
|
||||
- `/api/account/avatar` — Avatar upload (FormData)
|
||||
- `/api/admin/backups/restore` — Backup restore (FormData upload)
|
||||
- `/api/backup/[filename]` — Backup file download
|
||||
- `/api/webhooks/[token]` — Plex/Jellyfin/Emby webhooks
|
||||
- `/api/lists/[token]` — External list feeds
|
||||
- `/api/health` — Simple health check (no auth)
|
||||
|
||||
### Database schema
|
||||
|
||||
All app tables use UUIDv7 text primary keys generated via `Bun.randomUUIDv7()`. Better Auth tables use their own ID format. Key relationships:
|
||||
|
||||
@@ -1,9 +1,42 @@
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
ContinueWatchingCard,
|
||||
type ContinueWatchingItemProps,
|
||||
} from "./continue-watching-card";
|
||||
|
||||
function ContinueWatchingSkeleton() {
|
||||
return (
|
||||
<div className="w-64 shrink-0 overflow-hidden rounded-xl bg-card/50 ring-1 ring-white/[0.06] sm:w-72">
|
||||
<Skeleton className="aspect-video w-full rounded-none" />
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-8 shrink-0 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContinueWatchingSectionSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded" />
|
||||
<Skeleton className="h-6 w-40" />
|
||||
</div>
|
||||
<div className="-mx-4 flex gap-4 overflow-hidden px-4 sm:-mx-0 sm:px-0">
|
||||
<ContinueWatchingSkeleton />
|
||||
<ContinueWatchingSkeleton />
|
||||
<ContinueWatchingSkeleton />
|
||||
<ContinueWatchingSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContinueWatchingList({
|
||||
items,
|
||||
}: {
|
||||
|
||||
@@ -1,31 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { IconPlayerPlay } from "@tabler/icons-react";
|
||||
import { getContinueWatchingFeed } from "@/lib/services/discovery";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
import type { ContinueWatchingItemProps } from "./continue-watching-card";
|
||||
import { ContinueWatchingList } from "./continue-watching-list";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
import {
|
||||
ContinueWatchingList,
|
||||
ContinueWatchingSectionSkeleton,
|
||||
} from "./continue-watching-list";
|
||||
import { FeedSection } from "./feed-section";
|
||||
|
||||
export async function ContinueWatchingSection({ userId }: { userId: string }) {
|
||||
const feed = await getContinueWatchingFeed(userId);
|
||||
if (feed.length === 0) return null;
|
||||
export function ContinueWatchingSection() {
|
||||
const { data, isPending } = useQuery(
|
||||
orpc.dashboard.continueWatching.queryOptions(),
|
||||
);
|
||||
|
||||
const items: ContinueWatchingItemProps[] = feed.map((item) => ({
|
||||
title: {
|
||||
id: item.title.id,
|
||||
title: item.title.title,
|
||||
backdropPath: tmdbImageUrl(item.title.backdropPath, "backdrops"),
|
||||
},
|
||||
nextEpisode: item.nextEpisode
|
||||
? {
|
||||
seasonNumber: item.nextEpisode.seasonNumber,
|
||||
episodeNumber: item.nextEpisode.episodeNumber,
|
||||
name: item.nextEpisode.name,
|
||||
stillPath: tmdbImageUrl(item.nextEpisode.stillPath, "stills"),
|
||||
}
|
||||
: null,
|
||||
totalEpisodes: item.totalEpisodes,
|
||||
watchedEpisodes: item.watchedEpisodes,
|
||||
}));
|
||||
if (isPending) return <ContinueWatchingSectionSkeleton />;
|
||||
|
||||
const items = data?.items ?? [];
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<FeedSection
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { IconBooks } from "@tabler/icons-react";
|
||||
import { getNewAvailableFeed } from "@/lib/services/discovery";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
import { FeedSection } from "./feed-section";
|
||||
import { TitleGrid } from "./title-grid";
|
||||
import { TitleGrid, TitleGridSectionSkeleton } from "./title-grid";
|
||||
|
||||
export async function LibrarySection({ userId }: { userId: string }) {
|
||||
const feed = await getNewAvailableFeed(userId);
|
||||
if (feed.length === 0) return null;
|
||||
export function LibrarySection() {
|
||||
const { data, isPending } = useQuery(orpc.dashboard.library.queryOptions());
|
||||
|
||||
const items = feed.slice(0, 10).map((t) => ({
|
||||
id: t.titleId,
|
||||
tmdbId: t.tmdbId,
|
||||
type: t.type,
|
||||
title: t.title,
|
||||
posterPath: tmdbImageUrl(t.posterPath, "posters"),
|
||||
releaseDate: t.releaseDate ?? t.firstAirDate,
|
||||
voteAverage: t.voteAverage,
|
||||
userStatus: t.userStatus as "watchlist" | "in_progress" | "completed",
|
||||
}));
|
||||
if (isPending) return <TitleGridSectionSkeleton />;
|
||||
|
||||
const items = data?.items ?? [];
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<FeedSection
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { IconThumbUp } from "@tabler/icons-react";
|
||||
import { getRecommendationsFeed } from "@/lib/services/discovery";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
import { FeedSection } from "./feed-section";
|
||||
import { TitleGrid } from "./title-grid";
|
||||
import { TitleGrid, TitleGridSectionSkeleton } from "./title-grid";
|
||||
|
||||
export async function RecommendationsSection({ userId }: { userId: string }) {
|
||||
const feed = await getRecommendationsFeed(userId);
|
||||
if (feed.length === 0) return null;
|
||||
export function RecommendationsSection() {
|
||||
const { data, isPending } = useQuery(
|
||||
orpc.dashboard.recommendations.queryOptions(),
|
||||
);
|
||||
|
||||
const items = feed
|
||||
.filter((item) => !!item)
|
||||
.slice(0, 10)
|
||||
.map((t) => ({
|
||||
id: t.id,
|
||||
tmdbId: t.tmdbId,
|
||||
type: t.type,
|
||||
title: t.title,
|
||||
posterPath: tmdbImageUrl(t.posterPath, "posters"),
|
||||
releaseDate: t.releaseDate ?? t.firstAirDate,
|
||||
voteAverage: t.voteAverage,
|
||||
}));
|
||||
if (isPending) return <TitleGridSectionSkeleton />;
|
||||
|
||||
const items = data?.items ?? [];
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<FeedSection
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
IconMovie,
|
||||
IconPlayerPlay,
|
||||
} from "@tabler/icons-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Select,
|
||||
@@ -14,7 +15,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useStats } from "@/hooks/use-stats";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
import type {
|
||||
DashboardStats,
|
||||
HistoryBucket,
|
||||
@@ -22,6 +24,29 @@ import type {
|
||||
} from "@/lib/services/discovery";
|
||||
import { Sparkline } from "./sparkline";
|
||||
|
||||
function StatCardSkeleton() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsSectionSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<StatCardSkeleton />
|
||||
<StatCardSkeleton />
|
||||
<StatCardSkeleton />
|
||||
<StatCardSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const periodLabels: Record<TimePeriod, string> = {
|
||||
today: "Today",
|
||||
this_week: "This Week",
|
||||
@@ -124,8 +149,14 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
|
||||
const [moviePeriod, setMoviePeriod] = useState<TimePeriod>("this_month");
|
||||
const [episodePeriod, setEpisodePeriod] = useState<TimePeriod>("this_week");
|
||||
|
||||
const movieStats = useStats("movies", moviePeriod);
|
||||
const episodeStats = useStats("episodes", episodePeriod);
|
||||
const { data: movieStats } = useQuery(
|
||||
orpc.stats.queryOptions({ input: { type: "movies", period: moviePeriod } }),
|
||||
);
|
||||
const { data: episodeStats } = useQuery(
|
||||
orpc.stats.queryOptions({
|
||||
input: { type: "episodes", period: episodePeriod },
|
||||
}),
|
||||
);
|
||||
|
||||
const movieCount = movieStats?.count ?? stats.moviesThisMonth;
|
||||
const movieHistory = movieStats?.history;
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { IconDeviceTv } from "@tabler/icons-react";
|
||||
import Link from "next/link";
|
||||
import { getUserStats } from "@/lib/services/discovery";
|
||||
import { StatsDisplay } from "./stats-display";
|
||||
"use client";
|
||||
|
||||
export async function StatsSection({ userId }: { userId: string }) {
|
||||
const stats = await getUserStats(userId);
|
||||
import { IconDeviceTv } from "@tabler/icons-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
import { StatsDisplay, StatsSectionSkeleton } from "./stats-display";
|
||||
|
||||
export function StatsSection() {
|
||||
const { data: stats, isPending } = useQuery(
|
||||
orpc.dashboard.stats.queryOptions(),
|
||||
);
|
||||
|
||||
if (isPending) return <StatsSectionSkeleton />;
|
||||
if (!stats) return null;
|
||||
|
||||
const isEmpty =
|
||||
stats.moviesThisMonth === 0 &&
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { TitleCard } from "@/components/title-card";
|
||||
import { TitleCard, TitleCardSkeleton } from "@/components/title-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
interface TitleGridItem {
|
||||
id: string;
|
||||
@@ -13,6 +14,24 @@ interface TitleGridItem {
|
||||
userStatus?: "watchlist" | "in_progress" | "completed" | null;
|
||||
}
|
||||
|
||||
export function TitleGridSectionSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded" />
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
<TitleCardSkeleton />
|
||||
<TitleCardSkeleton />
|
||||
<TitleCardSkeleton />
|
||||
<TitleCardSkeleton />
|
||||
<TitleCardSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TitleGrid({ items }: { items: TitleGridItem[] }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import { Suspense } from "react";
|
||||
import {
|
||||
ContinueWatchingSectionSkeleton,
|
||||
StatsSectionSkeleton,
|
||||
TitleGridSectionSkeleton,
|
||||
} from "@/components/skeletons";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { ContinueWatchingSection } from "./_components/continue-watching-section";
|
||||
import { LibrarySection } from "./_components/library-section";
|
||||
@@ -18,22 +12,10 @@ export default async function DashboardPage() {
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<WelcomeHeader name={session.user.name} />
|
||||
|
||||
<Suspense fallback={<StatsSectionSkeleton />}>
|
||||
<StatsSection userId={session.user.id} />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<ContinueWatchingSectionSkeleton />}>
|
||||
<ContinueWatchingSection userId={session.user.id} />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<TitleGridSectionSkeleton />}>
|
||||
<LibrarySection userId={session.user.id} />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<TitleGridSectionSkeleton />}>
|
||||
<RecommendationsSection userId={session.user.id} />
|
||||
</Suspense>
|
||||
<StatsSection />
|
||||
<ContinueWatchingSection />
|
||||
<LibrarySection />
|
||||
<RecommendationsSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
import { FilterableTitleRow } from "./filterable-title-row";
|
||||
import { HeroBanner } from "./hero-banner";
|
||||
import { TitleRow } from "./title-row";
|
||||
|
||||
function ExploreSkeletons() {
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<Skeleton className="-mt-6 mr-[calc(-50vw+50%)] ml-[calc(-50vw+50%)] h-[320px] rounded-none" />
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded" />
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</div>
|
||||
<div className="flex gap-4 overflow-hidden">
|
||||
{Array.from({ length: 8 }).map((_, j) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton
|
||||
key={j}
|
||||
className="w-[140px] shrink-0 sm:w-[160px]"
|
||||
>
|
||||
<Skeleton className="aspect-[2/3] w-full rounded-xl" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExploreClient() {
|
||||
const { data: trending, isPending: trendingPending } = useQuery(
|
||||
orpc.explore.trending.queryOptions({ input: { type: "all" } }),
|
||||
);
|
||||
const { data: popularMovies, isPending: moviesPending } = useQuery(
|
||||
orpc.explore.popular.queryOptions({ input: { type: "movie" } }),
|
||||
);
|
||||
const { data: popularTv, isPending: tvPending } = useQuery(
|
||||
orpc.explore.popular.queryOptions({ input: { type: "tv" } }),
|
||||
);
|
||||
const { data: movieGenreData } = useQuery(
|
||||
orpc.explore.genres.queryOptions({ input: { type: "movie" } }),
|
||||
);
|
||||
const { data: tvGenreData } = useQuery(
|
||||
orpc.explore.genres.queryOptions({ input: { type: "tv" } }),
|
||||
);
|
||||
|
||||
const isPending = trendingPending || moviesPending || tvPending;
|
||||
|
||||
if (isPending) return <ExploreSkeletons />;
|
||||
|
||||
// Merge user statuses and episode progress from all responses
|
||||
const userStatuses = {
|
||||
...trending?.userStatuses,
|
||||
...popularMovies?.userStatuses,
|
||||
...popularTv?.userStatuses,
|
||||
};
|
||||
const episodeProgress = {
|
||||
...trending?.episodeProgress,
|
||||
...popularMovies?.episodeProgress,
|
||||
...popularTv?.episodeProgress,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{trending?.hero && (
|
||||
<HeroBanner
|
||||
tmdbId={trending.hero.tmdbId}
|
||||
type={trending.hero.type}
|
||||
title={trending.hero.title}
|
||||
overview={trending.hero.overview}
|
||||
backdropPath={trending.hero.backdropPath}
|
||||
voteAverage={trending.hero.voteAverage}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TitleRow
|
||||
heading="Trending Today"
|
||||
icon={<IconFlame aria-hidden={true} className="size-5 text-primary" />}
|
||||
items={(trending?.items ?? []).slice(0, 20)}
|
||||
userStatuses={userStatuses}
|
||||
episodeProgress={episodeProgress}
|
||||
/>
|
||||
|
||||
<FilterableTitleRow
|
||||
heading="Popular Movies"
|
||||
icon={<IconMovie aria-hidden={true} className="size-5 text-primary" />}
|
||||
mediaType="movie"
|
||||
defaultItems={(popularMovies?.items ?? []).slice(0, 20)}
|
||||
genres={movieGenreData?.genres ?? []}
|
||||
userStatuses={userStatuses}
|
||||
episodeProgress={episodeProgress}
|
||||
/>
|
||||
|
||||
<FilterableTitleRow
|
||||
heading="Popular TV Shows"
|
||||
icon={
|
||||
<IconDeviceTv aria-hidden={true} className="size-5 text-primary" />
|
||||
}
|
||||
mediaType="tv"
|
||||
defaultItems={(popularTv?.items ?? []).slice(0, 20)}
|
||||
genres={tvGenreData?.genres ?? []}
|
||||
userStatuses={userStatuses}
|
||||
episodeProgress={episodeProgress}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { skipToken, useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { TitleCardSkeleton } from "@/components/skeletons";
|
||||
import { TitleCard } from "@/components/title-card";
|
||||
import { TitleCard, TitleCardSkeleton } from "@/components/title-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useDiscover } from "@/hooks/use-discover";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
|
||||
interface Genre {
|
||||
id: number;
|
||||
@@ -43,9 +43,13 @@ export function FilterableTitleRow({
|
||||
episodeProgress: initialProgress = {},
|
||||
}: FilterableTitleRowProps) {
|
||||
const [selectedGenre, setSelectedGenre] = useState<number | null>(null);
|
||||
const { data: discoverData, isLoading: isPending } = useDiscover(
|
||||
mediaType,
|
||||
selectedGenre,
|
||||
const { data: discoverData, isLoading: isPending } = useQuery(
|
||||
orpc.discover.queryOptions({
|
||||
input:
|
||||
selectedGenre != null
|
||||
? { mediaType, genreId: selectedGenre }
|
||||
: skipToken,
|
||||
}),
|
||||
);
|
||||
|
||||
const items =
|
||||
@@ -124,7 +128,7 @@ export function FilterableTitleRow({
|
||||
className="-mx-6 sm:-mx-2"
|
||||
>
|
||||
<div className="flex gap-4 px-6 py-2 sm:px-2">
|
||||
{items.slice(0, 20).map((item, i) => (
|
||||
{items.slice(0, 20).map((item: TitleRowItem, i: number) => (
|
||||
<div
|
||||
key={`${item.type}-${item.tmdbId}`}
|
||||
className="w-[140px] shrink-0 sm:w-[160px]"
|
||||
|
||||
@@ -6,12 +6,12 @@ import {
|
||||
IconPlus,
|
||||
IconStar,
|
||||
} from "@tabler/icons-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useProgress } from "@/components/navigation-progress";
|
||||
import { resolveTitle } from "@/lib/actions/titles";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
|
||||
interface HeroBannerProps {
|
||||
tmdbId: number;
|
||||
@@ -32,21 +32,23 @@ export function HeroBanner({
|
||||
}: HeroBannerProps) {
|
||||
const router = useRouter();
|
||||
const progress = useProgress();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function handleNavigate() {
|
||||
if (isPending) return;
|
||||
progress.start();
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const id = await resolveTitle(tmdbId, type);
|
||||
const resolveMutation = useMutation(
|
||||
orpc.titles.resolve.mutationOptions({
|
||||
onSuccess: ({ id }) => {
|
||||
if (id) router.push(`/titles/${id}`);
|
||||
else progress.done();
|
||||
} catch {
|
||||
},
|
||||
onError: () => {
|
||||
progress.done();
|
||||
toast.error("Failed to load title");
|
||||
}
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
function handleNavigate() {
|
||||
if (resolveMutation.isPending) return;
|
||||
progress.start();
|
||||
resolveMutation.mutate({ tmdbId, type });
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -107,7 +109,7 @@ export function HeroBanner({
|
||||
type="button"
|
||||
className="group/title cursor-pointer text-left"
|
||||
onClick={handleNavigate}
|
||||
disabled={isPending}
|
||||
disabled={resolveMutation.isPending}
|
||||
>
|
||||
<h2 className="text-balance font-display text-3xl tracking-tight transition-colors group-hover/title:text-primary sm:text-4xl">
|
||||
{title}
|
||||
@@ -119,7 +121,7 @@ export function HeroBanner({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNavigate}
|
||||
disabled={isPending}
|
||||
disabled={resolveMutation.isPending}
|
||||
className="mt-4 inline-flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-primary px-4 font-medium text-primary-foreground text-sm transition-shadow hover:shadow-md hover:shadow-primary/20 disabled:opacity-70"
|
||||
>
|
||||
<IconPlus aria-hidden={true} className="size-4" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TitleCardSkeleton } from "@/components/skeletons";
|
||||
import { TitleCardSkeleton } from "@/components/title-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
function TitleRowSkeleton({ withGenreChips }: { withGenreChips?: boolean }) {
|
||||
|
||||
@@ -1,154 +1,5 @@
|
||||
import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import {
|
||||
getEpisodeProgressByTmdbIds,
|
||||
getUserStatusesByTmdbIds,
|
||||
} from "@/lib/services/tracking";
|
||||
import { getGenres, getPopular, getTrending } from "@/lib/tmdb/client";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
import { FilterableTitleRow } from "./_components/filterable-title-row";
|
||||
import { HeroBanner } from "./_components/hero-banner";
|
||||
import { TitleRow } from "./_components/title-row";
|
||||
import { ExploreClient } from "./_components/explore-client";
|
||||
|
||||
function mapResults(
|
||||
results: {
|
||||
id: number;
|
||||
media_type?: string;
|
||||
title?: string;
|
||||
name?: string;
|
||||
poster_path?: string | null;
|
||||
release_date?: string;
|
||||
first_air_date?: string;
|
||||
vote_average: number;
|
||||
}[],
|
||||
fallbackType: "movie" | "tv",
|
||||
) {
|
||||
return results
|
||||
.filter((r) => r.poster_path)
|
||||
.map((r) => ({
|
||||
tmdbId: r.id,
|
||||
type: (r.media_type === "movie" || r.media_type === "tv"
|
||||
? r.media_type
|
||||
: fallbackType) as "movie" | "tv",
|
||||
title: r.title ?? r.name ?? "",
|
||||
posterPath: tmdbImageUrl(r.poster_path ?? null, "posters"),
|
||||
releaseDate: r.release_date ?? r.first_air_date ?? null,
|
||||
voteAverage: r.vote_average,
|
||||
}));
|
||||
}
|
||||
|
||||
async function getExploreTmdbData() {
|
||||
const [trending, popularMovies, popularTv, movieGenres, tvGenres] =
|
||||
await Promise.all([
|
||||
getTrending("all", "day"),
|
||||
getPopular("movie"),
|
||||
getPopular("tv"),
|
||||
getGenres("movie"),
|
||||
getGenres("tv"),
|
||||
]);
|
||||
|
||||
return {
|
||||
trending,
|
||||
trendingItems: mapResults(trending.results ?? [], "movie"),
|
||||
popularMovieItems: mapResults(popularMovies.results ?? [], "movie"),
|
||||
popularTvItems: mapResults(popularTv.results ?? [], "tv"),
|
||||
movieGenres: (movieGenres.genres ?? []).map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name ?? "",
|
||||
})),
|
||||
tvGenres: (tvGenres.genres ?? []).map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name ?? "",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ExplorePage() {
|
||||
// Await session first — its headers() call triggers the dynamic bailout during
|
||||
// PPR static generation, preventing TMDB fetches from firing at build time.
|
||||
const session = await getSession();
|
||||
|
||||
const {
|
||||
trending,
|
||||
trendingItems,
|
||||
popularMovieItems,
|
||||
popularTvItems,
|
||||
movieGenres,
|
||||
tvGenres,
|
||||
} = await getExploreTmdbData();
|
||||
|
||||
// Fetch user statuses and episode progress for all visible TMDB IDs
|
||||
let userStatuses: Record<string, "watchlist" | "in_progress" | "completed"> =
|
||||
{};
|
||||
let episodeProgress: Record<string, { watched: number; total: number }> = {};
|
||||
if (session) {
|
||||
const allItems = [
|
||||
...trendingItems,
|
||||
...popularMovieItems,
|
||||
...popularTvItems,
|
||||
];
|
||||
const tmdbLookups = allItems.map((i) => ({
|
||||
tmdbId: i.tmdbId,
|
||||
type: i.type,
|
||||
}));
|
||||
userStatuses = getUserStatusesByTmdbIds(session.user.id, tmdbLookups);
|
||||
episodeProgress = getEpisodeProgressByTmdbIds(session.user.id, tmdbLookups);
|
||||
}
|
||||
|
||||
const heroTitle = (trending.results ?? []).find(
|
||||
(r) =>
|
||||
r.backdrop_path && (r.media_type === "movie" || r.media_type === "tv"),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{heroTitle && (
|
||||
<HeroBanner
|
||||
tmdbId={heroTitle.id}
|
||||
type={heroTitle.media_type as "movie" | "tv"}
|
||||
title={
|
||||
("title" in heroTitle ? heroTitle.title : undefined) ??
|
||||
("name" in heroTitle ? heroTitle.name : undefined) ??
|
||||
""
|
||||
}
|
||||
overview={heroTitle.overview ?? ""}
|
||||
backdropPath={tmdbImageUrl(
|
||||
heroTitle.backdrop_path ?? null,
|
||||
"backdrops",
|
||||
)}
|
||||
voteAverage={heroTitle.vote_average}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TitleRow
|
||||
heading="Trending Today"
|
||||
icon={<IconFlame aria-hidden={true} className="size-5 text-primary" />}
|
||||
items={trendingItems.slice(0, 20)}
|
||||
userStatuses={userStatuses}
|
||||
episodeProgress={episodeProgress}
|
||||
/>
|
||||
|
||||
<FilterableTitleRow
|
||||
heading="Popular Movies"
|
||||
icon={<IconMovie aria-hidden={true} className="size-5 text-primary" />}
|
||||
mediaType="movie"
|
||||
defaultItems={popularMovieItems.slice(0, 20)}
|
||||
genres={movieGenres}
|
||||
userStatuses={userStatuses}
|
||||
episodeProgress={episodeProgress}
|
||||
/>
|
||||
|
||||
<FilterableTitleRow
|
||||
heading="Popular TV Shows"
|
||||
icon={
|
||||
<IconDeviceTv aria-hidden={true} className="size-5 text-primary" />
|
||||
}
|
||||
mediaType="tv"
|
||||
defaultItems={popularTvItems.slice(0, 20)}
|
||||
genres={tvGenres}
|
||||
userStatuses={userStatuses}
|
||||
episodeProgress={episodeProgress}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
export default function ExplorePage() {
|
||||
return <ExploreClient />;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Suspense } from "react";
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { MobileTabBar, NavBar } from "@/components/nav-bar";
|
||||
import { ProgressProvider } from "@/components/navigation-progress";
|
||||
import { QueryProvider } from "@/components/query-provider";
|
||||
import { UpdateToast } from "@/components/update-toast";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { getCachedUpdateCheck } from "@/lib/services/update-check";
|
||||
@@ -14,9 +15,11 @@ export default function PagesLayout({
|
||||
}) {
|
||||
return (
|
||||
<Suspense>
|
||||
<ProgressProvider>
|
||||
<AuthenticatedShell>{children}</AuthenticatedShell>
|
||||
</ProgressProvider>
|
||||
<QueryProvider>
|
||||
<ProgressProvider>
|
||||
<AuthenticatedShell>{children}</AuthenticatedShell>
|
||||
</ProgressProvider>
|
||||
</QueryProvider>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { PersonCredit } from "@/lib/types";
|
||||
import type { PersonCredit } from "@/lib/orpc/schemas";
|
||||
|
||||
type Filter = "all" | "movie" | "tv";
|
||||
type Sort = "newest" | "rating";
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { PersonCredit, ResolvedPerson } from "@/lib/orpc/schemas";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
import { FilmographyGrid } from "./filmography-grid";
|
||||
import { PersonHero } from "./person-hero";
|
||||
|
||||
export function PersonDetailSkeleton() {
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<div className="flex flex-col gap-6 sm:flex-row sm:gap-8">
|
||||
<Skeleton className="size-40 shrink-0 self-center rounded-2xl sm:size-56 sm:self-start" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<Skeleton className="h-9 w-2/3 sm:h-12" />
|
||||
<Skeleton className="h-5 w-24 rounded-md" />
|
||||
<div className="flex gap-4">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-4 w-36" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-4/6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PersonDetailResponse {
|
||||
person: ResolvedPerson;
|
||||
filmography: PersonCredit[];
|
||||
userStatuses: Record<string, "watchlist" | "in_progress" | "completed">;
|
||||
}
|
||||
|
||||
export function PersonDetailClient({
|
||||
id,
|
||||
initialData,
|
||||
}: {
|
||||
id: string;
|
||||
initialData?: PersonDetailResponse;
|
||||
}) {
|
||||
const { data, isPending } = useQuery({
|
||||
...orpc.people.detail.queryOptions({ input: { id } }),
|
||||
initialData,
|
||||
});
|
||||
|
||||
if (isPending) return <PersonDetailSkeleton />;
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<PersonHero person={data.person} />
|
||||
<FilmographyGrid
|
||||
credits={data.filmography}
|
||||
userStatuses={data.userStatuses}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { format, parseISO } from "date-fns";
|
||||
import Image from "next/image";
|
||||
import { ExpandableText } from "@/components/expandable-text";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { ResolvedPerson } from "@/lib/types";
|
||||
import type { ResolvedPerson } from "@/lib/orpc/schemas";
|
||||
|
||||
interface PersonHeroProps {
|
||||
person: ResolvedPerson;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PersonDetailSkeleton } from "@/components/skeletons";
|
||||
import { PersonDetailSkeleton } from "./_components/person-detail-client";
|
||||
|
||||
export default function PersonLoading() {
|
||||
return <PersonDetailSkeleton />;
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { cacheLife, cacheTag } from "next/cache";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { persons } from "@/lib/db/schema";
|
||||
import { getLocalFilmography, getOrFetchPerson } from "@/lib/services/person";
|
||||
import { getUserStatusesByTitleIds } from "@/lib/services/tracking";
|
||||
import { FilmographyGrid } from "./_components/filmography-grid";
|
||||
import { PersonHero } from "./_components/person-hero";
|
||||
import { PersonDetailClient } from "./_components/person-detail-client";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
@@ -25,17 +23,6 @@ export async function generateMetadata({
|
||||
};
|
||||
}
|
||||
|
||||
async function getCachedPersonData(id: string) {
|
||||
"use cache";
|
||||
cacheLife("hours");
|
||||
cacheTag(`person-${id}`);
|
||||
|
||||
const person = await getOrFetchPerson(id);
|
||||
if (!person) return null;
|
||||
const filmography = getLocalFilmography(person.id);
|
||||
return { person, filmography };
|
||||
}
|
||||
|
||||
export default async function PersonDetailPage({
|
||||
params,
|
||||
}: {
|
||||
@@ -43,10 +30,10 @@ export default async function PersonDetailPage({
|
||||
}) {
|
||||
const { id } = await params;
|
||||
|
||||
const data = await getCachedPersonData(id);
|
||||
if (!data) notFound();
|
||||
const person = await getOrFetchPerson(id);
|
||||
if (!person) notFound();
|
||||
|
||||
const { person, filmography } = data;
|
||||
const filmography = getLocalFilmography(person.id);
|
||||
const session = await getSession();
|
||||
const userStatuses = session
|
||||
? getUserStatusesByTitleIds(
|
||||
@@ -56,9 +43,9 @@ export default async function PersonDetailPage({
|
||||
: {};
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<PersonHero person={person} />
|
||||
<FilmographyGrid credits={filmography} userStatuses={userStatuses} />
|
||||
</div>
|
||||
<PersonDetailClient
|
||||
id={id}
|
||||
initialData={{ person, filmography, userStatuses }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
IconUser,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -28,12 +29,8 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
removeAvatarAction,
|
||||
updateNameAction,
|
||||
uploadAvatarAction,
|
||||
} from "@/lib/actions/settings";
|
||||
import { signOut } from "@/lib/auth/client";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
|
||||
export function AccountSection({
|
||||
user,
|
||||
@@ -47,7 +44,6 @@ export function AccountSection({
|
||||
};
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [avatarUrl, setAvatarUrl] = useState(user.image);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -56,8 +52,23 @@ export function AccountSection({
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [displayName, setDisplayName] = useState(user.name);
|
||||
const [editValue, setEditValue] = useState(user.name);
|
||||
const [isNamePending, startNameTransition] = useTransition();
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
const updateNameMutation = useMutation(
|
||||
orpc.account.updateName.mutationOptions({
|
||||
onSuccess: () => {
|
||||
const trimmed = editValue.trim();
|
||||
setDisplayName(trimmed);
|
||||
setIsEditingName(false);
|
||||
toast.success("Name updated");
|
||||
router.refresh();
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = err instanceof Error ? err.message : "Update failed";
|
||||
toast.error(message);
|
||||
},
|
||||
}),
|
||||
);
|
||||
const isNamePending = updateNameMutation.isPending;
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditingName) {
|
||||
@@ -72,39 +83,46 @@ export function AccountSection({
|
||||
});
|
||||
const initial = displayName?.charAt(0).toUpperCase() ?? "?";
|
||||
|
||||
const uploadAvatarMutation = useMutation(
|
||||
orpc.account.uploadAvatar.mutationOptions({
|
||||
onSuccess: (data) => {
|
||||
setAvatarUrl(data.imageUrl);
|
||||
toast.success("Profile picture updated");
|
||||
router.refresh();
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = err instanceof Error ? err.message : "Upload failed";
|
||||
toast.error(message);
|
||||
},
|
||||
onSettled: () => {
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await uploadAvatarAction(formData);
|
||||
setAvatarUrl(result.imageUrl);
|
||||
toast.success("Profile picture updated");
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Upload failed";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
});
|
||||
uploadAvatarMutation.mutate(file);
|
||||
}
|
||||
|
||||
function handleRemoveAvatar() {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await removeAvatarAction();
|
||||
const removeAvatarMutation = useMutation(
|
||||
orpc.account.removeAvatar.mutationOptions({
|
||||
onSuccess: () => {
|
||||
setAvatarUrl(undefined);
|
||||
toast.success("Profile picture removed");
|
||||
router.refresh();
|
||||
} catch {
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to remove profile picture");
|
||||
}
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
const isAvatarPending =
|
||||
uploadAvatarMutation.isPending || removeAvatarMutation.isPending;
|
||||
|
||||
function handleRemoveAvatar() {
|
||||
removeAvatarMutation.mutate();
|
||||
}
|
||||
|
||||
function handleNameSave() {
|
||||
@@ -115,18 +133,7 @@ export function AccountSection({
|
||||
return;
|
||||
}
|
||||
|
||||
startNameTransition(async () => {
|
||||
try {
|
||||
await updateNameAction(trimmed);
|
||||
setDisplayName(trimmed);
|
||||
setIsEditingName(false);
|
||||
toast.success("Name updated");
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Update failed";
|
||||
toast.error(message);
|
||||
}
|
||||
});
|
||||
updateNameMutation.mutate({ name: trimmed });
|
||||
}
|
||||
|
||||
function handleNameCancel() {
|
||||
@@ -166,7 +173,7 @@ export function AccountSection({
|
||||
}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
disabled={isPending}
|
||||
disabled={isAvatarPending}
|
||||
/>
|
||||
}
|
||||
className="relative shrink-0 cursor-pointer rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
@@ -176,7 +183,7 @@ export function AccountSection({
|
||||
>
|
||||
<Avatar className="size-12 overflow-hidden">
|
||||
<AvatarImage
|
||||
src={isPending ? undefined : avatarUrl}
|
||||
src={isAvatarPending ? undefined : avatarUrl}
|
||||
alt={displayName}
|
||||
/>
|
||||
<AvatarFallback className="bg-primary/10 font-display text-lg text-primary">
|
||||
@@ -185,19 +192,19 @@ export function AccountSection({
|
||||
</Avatar>
|
||||
|
||||
<AnimatePresence>
|
||||
{(isHovered || isPending) && (
|
||||
{(isHovered || isAvatarPending) && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className={`absolute inset-0 flex items-center justify-center rounded-full text-foreground/70 backdrop-blur-sm ${
|
||||
avatarUrl && !isPending
|
||||
avatarUrl && !isAvatarPending
|
||||
? "bg-destructive/40"
|
||||
: "bg-black/50"
|
||||
}`}
|
||||
>
|
||||
{isPending ? (
|
||||
{isAvatarPending ? (
|
||||
<Spinner className="size-4.5" />
|
||||
) : avatarUrl ? (
|
||||
<IconTrash className="size-4.5" />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { IconCloudUpload } from "@tabler/icons-react";
|
||||
import { useRef, useState, useTransition } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -16,29 +17,31 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { restoreBackupAction } from "@/lib/actions/settings";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
|
||||
export function BackupRestoreSection() {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [restoreDialogOpen, setRestoreDialogOpen] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function handleRestore(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await restoreBackupAction(formData);
|
||||
const restoreMutation = useMutation(
|
||||
orpc.admin.backups.restore.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast.success("Database restored. Reloading...");
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
} catch (err) {
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = err instanceof Error ? err.message : "Restore failed";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
},
|
||||
onSettled: () => {
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
function handleRestore(file: File) {
|
||||
restoreMutation.mutate(file);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -110,10 +113,14 @@ export function BackupRestoreSection() {
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isPending}
|
||||
disabled={restoreMutation.isPending}
|
||||
>
|
||||
{isPending ? <Spinner /> : <IconCloudUpload aria-hidden={true} />}
|
||||
{isPending ? "Restoring…" : "Upload"}
|
||||
{restoreMutation.isPending ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<IconCloudUpload aria-hidden={true} />
|
||||
)}
|
||||
{restoreMutation.isPending ? "Restoring…" : "Upload"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { IconCalendarWeek } from "@tabler/icons-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCallback, useState } from "react";
|
||||
@@ -15,13 +16,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
setBackupScheduleAction,
|
||||
setMaxBackupsAction,
|
||||
setScheduledBackupAction,
|
||||
} from "@/lib/actions/settings";
|
||||
import type { BackupFrequency } from "@/lib/cron";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
|
||||
const FREQUENCY_OPTIONS: { value: BackupFrequency; label: string }[] = [
|
||||
{ value: "6h", label: "6h" },
|
||||
@@ -111,96 +109,113 @@ function formatNextBackup(
|
||||
return `Next backup ${formatDistanceToNow(next, { addSuffix: true })}`;
|
||||
}
|
||||
|
||||
export function BackupScheduleSection({
|
||||
initialScheduledEnabled,
|
||||
initialMaxRetention,
|
||||
initialFrequency,
|
||||
initialTime,
|
||||
initialDow,
|
||||
}: {
|
||||
initialScheduledEnabled: boolean;
|
||||
initialMaxRetention: number;
|
||||
initialFrequency: BackupFrequency;
|
||||
initialTime: string;
|
||||
initialDow: number;
|
||||
}) {
|
||||
const [schedule, setSchedule] = useState<BackupScheduleState>({
|
||||
enabled: initialScheduledEnabled,
|
||||
maxRetention: initialMaxRetention,
|
||||
frequency: initialFrequency,
|
||||
time: initialTime,
|
||||
dow: initialDow,
|
||||
});
|
||||
const [savingSchedule, setSavingSchedule] = useState(false);
|
||||
const [togglingSchedule, setTogglingSchedule] = useState(false);
|
||||
export function BackupScheduleSection() {
|
||||
const {
|
||||
data: scheduleData,
|
||||
isPending,
|
||||
isError,
|
||||
} = useQuery(orpc.admin.backups.schedule.queryOptions());
|
||||
|
||||
const { enabled, maxRetention, frequency, time, dow } = schedule;
|
||||
const [schedule, setSchedule] = useState<BackupScheduleState | null>(null);
|
||||
|
||||
// Use local state if user has modified, else use query data
|
||||
const current: BackupScheduleState = schedule ?? {
|
||||
enabled: scheduleData?.enabled ?? false,
|
||||
maxRetention: scheduleData?.maxRetention ?? 7,
|
||||
frequency: (scheduleData?.frequency as BackupFrequency) ?? "1d",
|
||||
time: scheduleData?.time ?? "02:00",
|
||||
dow: scheduleData?.dayOfWeek ?? 0,
|
||||
};
|
||||
|
||||
const { enabled, maxRetention, frequency, time, dow } = current;
|
||||
|
||||
const updateScheduleMutation = useMutation(
|
||||
orpc.admin.backups.updateSchedule.mutationOptions({
|
||||
onMutate: (input) => {
|
||||
const previous = { ...current };
|
||||
const patch: Partial<BackupScheduleState> = {};
|
||||
if (input.enabled !== undefined) patch.enabled = input.enabled;
|
||||
if (input.maxRetention !== undefined)
|
||||
patch.maxRetention = input.maxRetention;
|
||||
if (input.frequency !== undefined)
|
||||
patch.frequency = input.frequency as BackupFrequency;
|
||||
if (input.time !== undefined) patch.time = input.time;
|
||||
if (input.dayOfWeek !== undefined) patch.dow = input.dayOfWeek;
|
||||
setSchedule({ ...current, ...patch });
|
||||
return { previous };
|
||||
},
|
||||
onError: (_, input, ctx) => {
|
||||
if (ctx?.previous) setSchedule(ctx.previous);
|
||||
if (input.enabled !== undefined) {
|
||||
toast.error("Failed to update scheduled backup setting");
|
||||
} else if (input.maxRetention !== undefined) {
|
||||
toast.error("Failed to update retention setting");
|
||||
} else {
|
||||
toast.error("Failed to update schedule");
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const togglingSchedule =
|
||||
updateScheduleMutation.isPending &&
|
||||
updateScheduleMutation.variables?.enabled !== undefined;
|
||||
const savingSchedule =
|
||||
updateScheduleMutation.isPending &&
|
||||
updateScheduleMutation.variables?.frequency !== undefined;
|
||||
|
||||
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);
|
||||
}
|
||||
(checked: boolean) => {
|
||||
updateScheduleMutation.mutate(
|
||||
{ enabled: checked },
|
||||
{
|
||||
onSuccess: () =>
|
||||
toast.success(
|
||||
checked
|
||||
? "Scheduled backups enabled"
|
||||
: "Scheduled backups disabled",
|
||||
),
|
||||
},
|
||||
);
|
||||
},
|
||||
[schedule.enabled],
|
||||
[updateScheduleMutation],
|
||||
);
|
||||
|
||||
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");
|
||||
}
|
||||
(value: number) => {
|
||||
updateScheduleMutation.mutate({ maxRetention: value });
|
||||
},
|
||||
[schedule.maxRetention],
|
||||
[updateScheduleMutation],
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
(newFrequency: BackupFrequency, newTime: string, newDow = current.dow) => {
|
||||
updateScheduleMutation.mutate(
|
||||
{
|
||||
frequency: newFrequency,
|
||||
time: newTime,
|
||||
dayOfWeek: newDow,
|
||||
},
|
||||
{ onSuccess: () => toast.success("Schedule updated") },
|
||||
);
|
||||
},
|
||||
[schedule.frequency, schedule.time, schedule.dow],
|
||||
[updateScheduleMutation, current.dow],
|
||||
);
|
||||
|
||||
if (isPending) {
|
||||
return <Skeleton className="h-20 w-full rounded-xl" />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Failed to load backup schedule settings.
|
||||
</p>
|
||||
</CardContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardContent>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IconShieldCheck,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
@@ -32,7 +33,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { createBackupAction, deleteBackupAction } from "@/lib/actions/settings";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
import type { BackupInfo } from "@/lib/services/backup";
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
@@ -45,52 +46,59 @@ function formatBackupDate(dateStr: string): string {
|
||||
return format(new Date(dateStr), "MMM d, h:mm a");
|
||||
}
|
||||
|
||||
export function BackupSection({
|
||||
initialBackups,
|
||||
}: {
|
||||
initialBackups: BackupInfo[];
|
||||
}) {
|
||||
const [backups, setBackups] = useState<BackupInfo[]>(initialBackups);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
export function BackupSection() {
|
||||
const { data } = useQuery(orpc.admin.backups.list.queryOptions());
|
||||
const [backups, setBackups] = useState<BackupInfo[] | null>(null);
|
||||
|
||||
async function handleCreateBackup() {
|
||||
setCreating(true);
|
||||
try {
|
||||
const backup = await createBackupAction();
|
||||
setBackups((prev) => [backup, ...prev]);
|
||||
toast.success("Backup created", {
|
||||
action: {
|
||||
label: "Download",
|
||||
onClick: () => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/backup/${backup.filename}`;
|
||||
a.download = backup.filename;
|
||||
a.click();
|
||||
// Use local state if user has modified, else use query data
|
||||
const displayBackups = backups ?? data?.backups ?? [];
|
||||
|
||||
const createMutation = useMutation(
|
||||
orpc.admin.backups.create.mutationOptions({
|
||||
onSuccess: (backup) => {
|
||||
setBackups((prev) => [
|
||||
backup as BackupInfo,
|
||||
...(prev ?? data?.backups ?? []).filter(
|
||||
(b: BackupInfo) => b.filename !== backup.filename,
|
||||
),
|
||||
]);
|
||||
toast.success("Backup created", {
|
||||
action: {
|
||||
label: "Download",
|
||||
onClick: () => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/backup/${backup.filename}`;
|
||||
a.download = backup.filename;
|
||||
a.click();
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
toast.error("Failed to create backup");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
onError: () => toast.error("Failed to create backup"),
|
||||
}),
|
||||
);
|
||||
|
||||
async function handleDelete(filename: string) {
|
||||
const previous = backups;
|
||||
setDeleting(filename);
|
||||
setBackups((prev) => prev.filter((b) => b.filename !== filename));
|
||||
try {
|
||||
await deleteBackupAction(filename);
|
||||
toast.success("Backup deleted");
|
||||
} catch {
|
||||
setBackups(previous);
|
||||
toast.error("Failed to delete backup");
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
}
|
||||
const deleteMutation = useMutation(
|
||||
orpc.admin.backups.delete.mutationOptions({
|
||||
onMutate: ({ filename }) => {
|
||||
const previous = displayBackups;
|
||||
setBackups(
|
||||
displayBackups.filter((b: BackupInfo) => b.filename !== filename),
|
||||
);
|
||||
return { previous };
|
||||
},
|
||||
onSuccess: () => toast.success("Backup deleted"),
|
||||
onError: (_, __, ctx) => {
|
||||
if (ctx?.previous) setBackups(ctx.previous);
|
||||
toast.error("Failed to delete backup");
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const creating = createMutation.isPending;
|
||||
const deleting = deleteMutation.isPending
|
||||
? (deleteMutation.variables?.filename ?? null)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -107,13 +115,13 @@ export function BackupSection({
|
||||
<div>
|
||||
<CardTitle>Database backups</CardTitle>
|
||||
<CardDescription>
|
||||
{backups.length > 0
|
||||
? `${backups.length} backup${backups.length !== 1 ? "s" : ""} stored`
|
||||
{displayBackups.length > 0
|
||||
? `${displayBackups.length} backup${displayBackups.length !== 1 ? "s" : ""} stored`
|
||||
: "No backups yet"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleCreateBackup} disabled={creating}>
|
||||
<Button onClick={() => createMutation.mutate()} disabled={creating}>
|
||||
{creating ? (
|
||||
<Spinner className="size-3" />
|
||||
) : (
|
||||
@@ -126,10 +134,10 @@ export function BackupSection({
|
||||
|
||||
{/* Backup list */}
|
||||
<AnimatePresence initial={false}>
|
||||
{backups.length > 0 && (
|
||||
{displayBackups.length > 0 && (
|
||||
<CardContent className="border-border/30 border-t pt-4">
|
||||
<div className="space-y-1.5">
|
||||
{backups.map((backup) => (
|
||||
{displayBackups.map((backup: BackupInfo) => (
|
||||
<motion.div
|
||||
key={backup.filename}
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
@@ -248,7 +256,11 @@ export function BackupSection({
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => handleDelete(backup.filename)}
|
||||
onClick={() =>
|
||||
deleteMutation.mutate({
|
||||
filename: backup.filename,
|
||||
})
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IconRefresh,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
@@ -38,11 +39,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
deleteIntegration,
|
||||
regenerateIntegrationToken,
|
||||
saveIntegration,
|
||||
} from "@/lib/actions/settings";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -93,67 +90,66 @@ export function IntegrationCard({
|
||||
setConnections: React.Dispatch<React.SetStateAction<IntegrationConnection[]>>;
|
||||
}) {
|
||||
const { provider, label } = config;
|
||||
const providerInput = provider as
|
||||
| "plex"
|
||||
| "jellyfin"
|
||||
| "emby"
|
||||
| "sonarr"
|
||||
| "radarr";
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
const connectMutation = useMutation(
|
||||
orpc.integrations.create.mutationOptions({
|
||||
onSuccess: (result) => {
|
||||
setConnections((prev) => [...prev, { ...result, recentEvents: [] }]);
|
||||
toast.success(`${label} connected`);
|
||||
},
|
||||
onError: () => 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}`);
|
||||
}
|
||||
}
|
||||
const deleteMutation = useMutation(
|
||||
orpc.integrations.delete.mutationOptions({
|
||||
onMutate: () => {
|
||||
let previous: IntegrationConnection[] = [];
|
||||
setConnections((prev) => {
|
||||
previous = prev;
|
||||
return prev.filter((c) => c.provider !== provider);
|
||||
});
|
||||
return { previous };
|
||||
},
|
||||
onSuccess: () => toast.success(`${label} disconnected`),
|
||||
onError: (_, __, ctx) => {
|
||||
if (ctx?.previous) setConnections(ctx.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 regenerateTokenMutation = useMutation(
|
||||
orpc.integrations.regenerateToken.mutationOptions({
|
||||
onSuccess: (result) => {
|
||||
setConnections((prev) =>
|
||||
prev.map((c) =>
|
||||
c.provider === provider ? { ...c, token: result.token } : c,
|
||||
),
|
||||
);
|
||||
toast.success(`${label} URL regenerated`);
|
||||
},
|
||||
onError: () => toast.error(`Failed to regenerate ${label} URL`),
|
||||
}),
|
||||
);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [cardOpen, setCardOpen] = useState(false);
|
||||
const [setupOpen, setSetupOpen] = useState(false);
|
||||
|
||||
const Icon = config.icon;
|
||||
const connecting = connectMutation.isPending;
|
||||
|
||||
const url =
|
||||
connection && typeof window !== "undefined"
|
||||
? config.buildUrl(connection.token)
|
||||
: null;
|
||||
|
||||
async function onConnect() {
|
||||
setConnecting(true);
|
||||
try {
|
||||
await handleConnect();
|
||||
} finally {
|
||||
setConnecting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
if (!url) return;
|
||||
await navigator.clipboard.writeText(url);
|
||||
@@ -192,7 +188,9 @@ export function IntegrationCard({
|
||||
|
||||
{!connection ? (
|
||||
<Button
|
||||
onClick={onConnect}
|
||||
onClick={() =>
|
||||
connectMutation.mutate({ provider: providerInput })
|
||||
}
|
||||
disabled={connecting}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
@@ -248,7 +246,11 @@ export function IntegrationCard({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRegenerateToken()}
|
||||
onClick={() =>
|
||||
regenerateTokenMutation.mutate({
|
||||
provider: providerInput,
|
||||
})
|
||||
}
|
||||
>
|
||||
<IconRefresh />
|
||||
Regenerate URL
|
||||
@@ -256,7 +258,9 @@ export function IntegrationCard({
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDelete()}
|
||||
onClick={() =>
|
||||
deleteMutation.mutate({ provider: providerInput })
|
||||
}
|
||||
>
|
||||
<IconTrash />
|
||||
Disconnect
|
||||
|
||||
@@ -1,19 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { IconWebhook } from "@tabler/icons-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
import {
|
||||
IntegrationCard,
|
||||
type IntegrationConnection,
|
||||
} from "./integration-card";
|
||||
import { INTEGRATION_CONFIGS } from "./integration-configs";
|
||||
|
||||
export function IntegrationsSection({
|
||||
initialConnections,
|
||||
}: {
|
||||
initialConnections: IntegrationConnection[];
|
||||
}) {
|
||||
const [connections, setConnections] = useState(initialConnections);
|
||||
export function IntegrationsSection() {
|
||||
const { data, isPending } = useQuery(orpc.integrations.list.queryOptions());
|
||||
const [localConnections, setLocalConnections] = useState<
|
||||
IntegrationConnection[] | null
|
||||
>(null);
|
||||
|
||||
// Use local state if user has modified connections, else use query data
|
||||
const connections = localConnections ?? data?.integrations ?? [];
|
||||
|
||||
function handleSetConnections(
|
||||
updater:
|
||||
| IntegrationConnection[]
|
||||
| ((prev: IntegrationConnection[]) => IntegrationConnection[]),
|
||||
) {
|
||||
setLocalConnections((prev) => {
|
||||
const current = prev ?? data?.integrations ?? [];
|
||||
return typeof updater === "function" ? updater(current) : updater;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -26,18 +42,28 @@ export function IntegrationsSection({
|
||||
Integrations
|
||||
</h2>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{INTEGRATION_CONFIGS.map((config) => (
|
||||
<IntegrationCard
|
||||
key={config.provider}
|
||||
config={config}
|
||||
connection={
|
||||
connections.find((c) => c.provider === config.provider) ?? null
|
||||
}
|
||||
setConnections={setConnections}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{isPending ? (
|
||||
<div className="space-y-3">
|
||||
{INTEGRATION_CONFIGS.map((c) => (
|
||||
<Skeleton key={c.provider} className="h-20 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{INTEGRATION_CONFIGS.map((config) => (
|
||||
<IntegrationCard
|
||||
key={config.provider}
|
||||
config={config}
|
||||
connection={
|
||||
connections.find(
|
||||
(c: IntegrationConnection) => c.provider === config.provider,
|
||||
) ?? null
|
||||
}
|
||||
setConnections={handleSetConnections}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { IconDoorEnter } from "@tabler/icons-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useOptimistic, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { toggleRegistration } from "@/lib/actions/settings";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
|
||||
export function RegistrationSection({
|
||||
initialRegistrationOpen,
|
||||
}: {
|
||||
initialRegistrationOpen: boolean;
|
||||
}) {
|
||||
const [registrationOpen, setRegistrationOpen] = useState(
|
||||
initialRegistrationOpen,
|
||||
export function RegistrationSection() {
|
||||
const { data, isPending: isLoading } = useQuery(
|
||||
orpc.admin.registration.queryOptions(),
|
||||
);
|
||||
const [optimisticOpen, setOptimisticOpen] = useOptimistic(registrationOpen);
|
||||
const [registrationOpen, setRegistrationOpen] = useState<boolean | null>(
|
||||
null,
|
||||
);
|
||||
const currentOpen = registrationOpen ?? data?.open ?? false;
|
||||
const [optimisticOpen, setOptimisticOpen] = useOptimistic(currentOpen);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const toggleMutation = useMutation(
|
||||
orpc.admin.toggleRegistration.mutationOptions(),
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<CardContent>
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</CardContent>
|
||||
);
|
||||
}
|
||||
|
||||
function handleToggle(checked: boolean) {
|
||||
startTransition(async () => {
|
||||
setOptimisticOpen(checked);
|
||||
try {
|
||||
await toggleRegistration(checked);
|
||||
await toggleMutation.mutateAsync({ open: checked });
|
||||
setRegistrationOpen(checked);
|
||||
toast.success(checked ? "Registration opened" : "Registration closed");
|
||||
} catch {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
IconPlayerPlay,
|
||||
IconRefresh,
|
||||
} from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { StatusDot } from "@/components/status-dot";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -34,9 +34,8 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useSystemHealth } from "@/hooks/use-system-health";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { triggerJobAction } from "@/lib/actions/settings";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
import type { SystemHealthData } from "@/lib/services/system-health";
|
||||
|
||||
const JOB_LABELS: Record<string, string> = {
|
||||
@@ -130,12 +129,19 @@ function LiveTimeAgo({
|
||||
}
|
||||
|
||||
/** Hydrates system health state and renders the 3 cards */
|
||||
export function SystemHealthCards({
|
||||
initialData,
|
||||
}: {
|
||||
initialData: SystemHealthData;
|
||||
}) {
|
||||
const { data, isRefreshing, refresh } = useSystemHealth(initialData);
|
||||
export function SystemHealthCards() {
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
data: statusData,
|
||||
isPending,
|
||||
isFetching,
|
||||
} = useQuery(orpc.systemStatus.queryOptions());
|
||||
const data = statusData?.health ?? null;
|
||||
const isRefreshing = isFetching;
|
||||
const refresh = () =>
|
||||
queryClient.invalidateQueries({ queryKey: orpc.systemStatus.key() });
|
||||
|
||||
if (isPending || !data) return <SkeletonCards />;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -320,21 +326,22 @@ function BackgroundJobsCard({
|
||||
isRefreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const [triggeringJob, setTriggeringJob] = useState<string | null>(null);
|
||||
|
||||
const handleTrigger = async (jobName: string) => {
|
||||
setTriggeringJob(jobName);
|
||||
try {
|
||||
await triggerJobAction(jobName);
|
||||
toast.success(`${JOB_LABELS[jobName] ?? jobName} triggered`);
|
||||
// Refresh after a brief delay so the run shows up
|
||||
setTimeout(onRefresh, 1500);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to trigger job");
|
||||
} finally {
|
||||
setTriggeringJob(null);
|
||||
}
|
||||
};
|
||||
const triggerJobMutation = useMutation(
|
||||
orpc.admin.triggerJob.mutationOptions({
|
||||
onSuccess: (_, { name }) => {
|
||||
toast.success(`${JOB_LABELS[name] ?? name} triggered`);
|
||||
setTimeout(onRefresh, 1500);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to trigger job",
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
const triggeringJob = triggerJobMutation.isPending
|
||||
? (triggerJobMutation.variables?.name ?? null)
|
||||
: null;
|
||||
|
||||
const sortedJobs = [...jobs].sort((a, b) => {
|
||||
if (a.disabled !== b.disabled) return a.disabled ? 1 : -1;
|
||||
@@ -513,7 +520,9 @@ function BackgroundJobsCard({
|
||||
aria-label="Trigger job"
|
||||
className="size-6"
|
||||
disabled={isRunning || job.disabled}
|
||||
onClick={() => handleTrigger(job.jobName)}
|
||||
onClick={() =>
|
||||
triggerJobMutation.mutate({ name: job.jobName })
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,27 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { IconWorldUpload } from "@tabler/icons-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useOptimistic, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { toggleUpdateCheck } from "@/lib/actions/settings";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
|
||||
export function UpdateCheckSection({
|
||||
initialEnabled,
|
||||
}: {
|
||||
initialEnabled: boolean;
|
||||
}) {
|
||||
const [enabled, setEnabled] = useState(initialEnabled);
|
||||
const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(enabled);
|
||||
export function UpdateCheckSection() {
|
||||
const { data, isPending: isLoading } = useQuery(
|
||||
orpc.admin.updateCheck.queryOptions(),
|
||||
);
|
||||
const [localEnabled, setLocalEnabled] = useState<boolean | null>(null);
|
||||
const currentEnabled = localEnabled ?? data?.enabled ?? true;
|
||||
const [optimisticEnabled, setOptimisticEnabled] =
|
||||
useOptimistic(currentEnabled);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const toggleMutation = useMutation(
|
||||
orpc.admin.toggleUpdateCheck.mutationOptions(),
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<CardContent>
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</CardContent>
|
||||
);
|
||||
}
|
||||
|
||||
function handleToggle(checked: boolean) {
|
||||
startTransition(async () => {
|
||||
setOptimisticEnabled(checked);
|
||||
try {
|
||||
await toggleUpdateCheck(checked);
|
||||
setEnabled(checked);
|
||||
await toggleMutation.mutateAsync({ enabled: checked });
|
||||
setLocalEnabled(checked);
|
||||
toast.success(
|
||||
checked ? "Update checks enabled" : "Update checks disabled",
|
||||
);
|
||||
|
||||
@@ -3,21 +3,10 @@ import {
|
||||
IconServer2,
|
||||
IconShieldLock,
|
||||
} from "@tabler/icons-react";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { TmdbLogo } from "@/components/tmdb-logo";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { integrationEvents, integrations } from "@/lib/db/schema";
|
||||
import { listBackups } from "@/lib/services/backup";
|
||||
import { getSetting } from "@/lib/services/settings";
|
||||
import { getSystemHealth } from "@/lib/services/system-health";
|
||||
import {
|
||||
getCachedUpdateCheck,
|
||||
isUpdateCheckEnabled,
|
||||
} from "@/lib/services/update-check";
|
||||
import { AccountSection } from "./_components/account-section";
|
||||
import { BackupRestoreSection } from "./_components/backup-restore-section";
|
||||
import { BackupScheduleSection } from "./_components/backup-schedule-section";
|
||||
@@ -25,10 +14,7 @@ import { BackupSection } from "./_components/backup-section";
|
||||
import { IntegrationsSection } from "./_components/integrations-section";
|
||||
import { RegistrationSection } from "./_components/registration-section";
|
||||
import { SettingsShell } from "./_components/settings-shell";
|
||||
import {
|
||||
SkeletonCards,
|
||||
SystemHealthCards,
|
||||
} from "./_components/system-health-section";
|
||||
import { SystemHealthCards } from "./_components/system-health-section";
|
||||
import { UpdateCheckSection } from "./_components/update-check-section";
|
||||
|
||||
export default async function SettingsPage() {
|
||||
@@ -37,72 +23,6 @@ export default async function SettingsPage() {
|
||||
|
||||
const isAdmin = session.user.role === "admin";
|
||||
|
||||
const connRows = db
|
||||
.select()
|
||||
.from(integrations)
|
||||
.where(eq(integrations.userId, session.user.id))
|
||||
.all();
|
||||
|
||||
const connIds = connRows.map((c) => c.id);
|
||||
|
||||
// Fetch only the 10 most recent events per connection (index-optimized)
|
||||
const eventsByConn = new Map<
|
||||
string,
|
||||
(typeof integrationEvents.$inferSelect)[]
|
||||
>();
|
||||
for (const connId of connIds) {
|
||||
const events = db
|
||||
.select()
|
||||
.from(integrationEvents)
|
||||
.where(eq(integrationEvents.integrationId, connId))
|
||||
.orderBy(desc(integrationEvents.receivedAt))
|
||||
.limit(10)
|
||||
.all();
|
||||
eventsByConn.set(connId, events);
|
||||
}
|
||||
|
||||
const connections = connRows.map((conn) => ({
|
||||
id: conn.id,
|
||||
provider: conn.provider,
|
||||
type: conn.type,
|
||||
token: conn.token,
|
||||
enabled: conn.enabled,
|
||||
lastEventAt: conn.lastEventAt?.toISOString() ?? null,
|
||||
recentEvents: (eventsByConn.get(conn.id) ?? []).map((e) => ({
|
||||
id: e.id,
|
||||
eventType: e.eventType,
|
||||
mediaType: e.mediaType,
|
||||
mediaTitle: e.mediaTitle,
|
||||
status: e.status,
|
||||
receivedAt: e.receivedAt.toISOString(),
|
||||
})),
|
||||
}));
|
||||
|
||||
const registrationOpen = isAdmin
|
||||
? getSetting("registrationOpen") === "true"
|
||||
: false;
|
||||
|
||||
const backups = isAdmin ? await listBackups() : [];
|
||||
const scheduledBackupsEnabled = isAdmin
|
||||
? getSetting("scheduledBackups") === "true"
|
||||
: false;
|
||||
const maxBackupRetention = isAdmin
|
||||
? Number.parseInt(getSetting("maxBackupRetention") ?? "7", 10)
|
||||
: 7;
|
||||
const backupFrequency = isAdmin
|
||||
? (getSetting("backupScheduleFrequency") ?? "1d")
|
||||
: "1d";
|
||||
const backupTime = isAdmin
|
||||
? (getSetting("backupScheduleTime") ?? "02:00")
|
||||
: "02:00";
|
||||
const backupDow = isAdmin
|
||||
? Number.parseInt(getSetting("backupScheduleDow") ?? "0", 10)
|
||||
: 0;
|
||||
|
||||
const updateCheckEnabled = isAdmin ? isUpdateCheckEnabled() : true;
|
||||
const updateCheck =
|
||||
isAdmin && updateCheckEnabled ? getCachedUpdateCheck() : null;
|
||||
|
||||
const GITHUB_REPO = "jakejarvis/sofa";
|
||||
const APP_VERSION = process.env.APP_VERSION || "0.0.0";
|
||||
const GIT_COMMIT_SHA = process.env.GIT_COMMIT_SHA?.slice(0, 7) || "";
|
||||
@@ -136,25 +56,6 @@ export default async function SettingsPage() {
|
||||
)
|
||||
</>
|
||||
)}
|
||||
{updateCheck?.updateAvailable && (
|
||||
<span className="ml-1.5 inline-flex items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 font-medium text-[10px] text-primary">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-primary" />
|
||||
</span>
|
||||
<a
|
||||
href={
|
||||
updateCheck.releaseUrl ??
|
||||
`https://github.com/${GITHUB_REPO}/releases`
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
v{updateCheck.latestVersion} available
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-col items-center gap-2">
|
||||
<a
|
||||
@@ -182,7 +83,7 @@ export default async function SettingsPage() {
|
||||
role: session.user.role ?? undefined,
|
||||
}}
|
||||
/>
|
||||
<IntegrationsSection initialConnections={connections} />
|
||||
<IntegrationsSection />
|
||||
{isAdmin && (
|
||||
<>
|
||||
{/* Server health */}
|
||||
@@ -199,9 +100,7 @@ export default async function SettingsPage() {
|
||||
Admin only
|
||||
</span>
|
||||
</div>
|
||||
<Suspense fallback={<SkeletonCards />}>
|
||||
<SystemHealthLoader />
|
||||
</Suspense>
|
||||
<SystemHealthCards />
|
||||
</div>
|
||||
|
||||
{/* Security */}
|
||||
@@ -220,12 +119,10 @@ export default async function SettingsPage() {
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Card className="border-l-2 border-l-primary/30">
|
||||
<RegistrationSection
|
||||
initialRegistrationOpen={registrationOpen}
|
||||
/>
|
||||
<RegistrationSection />
|
||||
</Card>
|
||||
<Card className="border-l-2 border-l-primary/30">
|
||||
<UpdateCheckSection initialEnabled={updateCheckEnabled} />
|
||||
<UpdateCheckSection />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
@@ -246,18 +143,10 @@ export default async function SettingsPage() {
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Card className="border-l-2 border-l-primary/30">
|
||||
<BackupSection initialBackups={backups} />
|
||||
<BackupSection />
|
||||
</Card>
|
||||
<Card className="border-l-2 border-l-primary/30">
|
||||
<BackupScheduleSection
|
||||
initialScheduledEnabled={scheduledBackupsEnabled}
|
||||
initialMaxRetention={maxBackupRetention}
|
||||
initialFrequency={
|
||||
backupFrequency as "6h" | "12h" | "1d" | "7d"
|
||||
}
|
||||
initialTime={backupTime}
|
||||
initialDow={backupDow}
|
||||
/>
|
||||
<BackupScheduleSection />
|
||||
</Card>
|
||||
<Card className="border-l-2 border-l-primary/30">
|
||||
<BackupRestoreSection />
|
||||
@@ -269,8 +158,3 @@ export default async function SettingsPage() {
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
async function SystemHealthLoader() {
|
||||
const data = await getSystemHealth();
|
||||
return <SystemHealthCards initialData={data} />;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { IconUser, IconUsers } from "@tabler/icons-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import type { CastMember } from "@/lib/types";
|
||||
import type { CastMember } from "@/lib/orpc/schemas";
|
||||
|
||||
interface CastCarouselProps {
|
||||
actors: CastMember[];
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { IconThumbUp } from "@tabler/icons-react";
|
||||
import { TitleCard } from "@/components/title-card";
|
||||
import type { RecommendedTitle } from "@/lib/types";
|
||||
|
||||
export function RecommendationsGrid({
|
||||
recommendations,
|
||||
userStatuses,
|
||||
}: {
|
||||
recommendations: RecommendedTitle[];
|
||||
userStatuses?: Record<string, "watchlist" | "in_progress" | "completed">;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconThumbUp aria-hidden={true} className="size-5 text-primary" />
|
||||
<h2 className="font-display text-xl tracking-tight">Recommended</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||
{recommendations.slice(0, 12).map((rec, i) => (
|
||||
<div
|
||||
key={rec.id}
|
||||
className="animate-stagger-item"
|
||||
style={{ "--stagger-index": i } as React.CSSProperties}
|
||||
>
|
||||
<TitleCard
|
||||
id={rec.id}
|
||||
tmdbId={rec.tmdbId}
|
||||
type={rec.type}
|
||||
title={rec.title}
|
||||
posterPath={rec.posterPath}
|
||||
releaseDate={rec.releaseDate ?? rec.firstAirDate}
|
||||
voteAverage={rec.voteAverage}
|
||||
userStatus={userStatuses?.[rec.id]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
titleTypeAtom,
|
||||
userRatingAtom,
|
||||
userStatusAtom,
|
||||
} from "@/lib/atoms/title";
|
||||
import { StarRating } from "./star-rating";
|
||||
import { StatusButton } from "./status-button";
|
||||
import { useTitleContext, useTitleUserInfo } from "./title-context";
|
||||
import { useTitleActions } from "./use-title-actions";
|
||||
|
||||
export function TitleActions() {
|
||||
const titleType = useAtomValue(titleTypeAtom);
|
||||
const userStatus = useAtomValue(userStatusAtom);
|
||||
const userRating = useAtomValue(userRatingAtom);
|
||||
const { titleType } = useTitleContext();
|
||||
const { userStatus, userRating } = useTitleUserInfo();
|
||||
const { handleStatusChange, handleRating, handleWatchMovie } =
|
||||
useTitleActions();
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { AvailabilityOffer } from "@/lib/types";
|
||||
import type { AvailabilityOffer } from "@/lib/orpc/schemas";
|
||||
|
||||
const MAX_VISIBLE = 4;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CastMember } from "@/lib/types";
|
||||
import type { CastMember } from "@/lib/orpc/schemas";
|
||||
import { CastCarousel } from "./cast-carousel";
|
||||
|
||||
interface TitleCastProps {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createContext, use } from "react";
|
||||
import { useSession } from "@/lib/auth/client";
|
||||
import type { Season } from "@/lib/orpc/schemas";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
|
||||
interface TitleContextValue {
|
||||
titleId: string;
|
||||
titleType: "movie" | "tv";
|
||||
titleName: string;
|
||||
seasons: Season[];
|
||||
setSeasons: (seasons: Season[]) => void;
|
||||
watchingEp: string | null;
|
||||
setWatchingEp: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export const TitleContext = createContext<TitleContextValue | null>(null);
|
||||
|
||||
export function useTitleContext() {
|
||||
const ctx = use(TitleContext);
|
||||
if (!ctx)
|
||||
throw new Error("useTitleContext must be used within TitleProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useTitleUserInfo() {
|
||||
const { titleId } = useTitleContext();
|
||||
const { data: session } = useSession();
|
||||
const { data } = useQuery({
|
||||
...orpc.titles.userInfo.queryOptions({ input: { id: titleId } }),
|
||||
enabled: !!session,
|
||||
});
|
||||
return {
|
||||
userStatus: data?.status ?? null,
|
||||
userRating: data?.rating ?? 0,
|
||||
episodeWatches: data?.episodeWatches ?? [],
|
||||
};
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import Image from "next/image";
|
||||
import type { ReactNode } from "react";
|
||||
import { ExpandableText } from "@/components/expandable-text";
|
||||
import { TmdbLogo } from "@/components/tmdb-logo";
|
||||
import type { ColorPalette, ResolvedTitle } from "@/lib/types";
|
||||
import type { ColorPalette, ResolvedTitle } from "@/lib/orpc/schemas";
|
||||
import { GenreCollapse } from "./genre-collapse";
|
||||
import { TrailerDialog } from "./trailer-dialog";
|
||||
|
||||
|
||||
@@ -6,14 +6,14 @@ import { useAtomValue } from "jotai";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useProgress } from "@/components/navigation-progress";
|
||||
import { commandPaletteOpenAtom } from "@/lib/atoms/command-palette";
|
||||
import { titleTypeAtom, userStatusAtom } from "@/lib/atoms/title";
|
||||
import { useTitleContext, useTitleUserInfo } from "./title-context";
|
||||
import { useTitleActions } from "./use-title-actions";
|
||||
|
||||
export function TitleKeyboardShortcuts() {
|
||||
const router = useRouter();
|
||||
const progress = useProgress();
|
||||
const titleType = useAtomValue(titleTypeAtom);
|
||||
const userStatus = useAtomValue(userStatusAtom);
|
||||
const { titleType } = useTitleContext();
|
||||
const { userStatus } = useTitleUserInfo();
|
||||
const { handleStatusChange, handleRating, handleWatchMovie } =
|
||||
useTitleActions();
|
||||
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { createStore, Provider } from "jotai";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
episodeWatchesAtom,
|
||||
seasonsAtom,
|
||||
titleIdAtom,
|
||||
titleNameAtom,
|
||||
titleTypeAtom,
|
||||
userRatingAtom,
|
||||
userStatusAtom,
|
||||
} from "@/lib/atoms/title";
|
||||
import type { Season } from "@/lib/types";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Season } from "@/lib/orpc/schemas";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
import { TitleContext } from "./title-context";
|
||||
|
||||
export function TitleProvider({
|
||||
titleId,
|
||||
@@ -20,7 +13,7 @@ export function TitleProvider({
|
||||
initialStatus,
|
||||
initialRating,
|
||||
initialEpisodeWatches,
|
||||
seasons,
|
||||
seasons: initialSeasons,
|
||||
children,
|
||||
}: {
|
||||
titleId: string;
|
||||
@@ -32,17 +25,50 @@ export function TitleProvider({
|
||||
seasons: Season[];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [store] = useState(() => {
|
||||
const s = createStore();
|
||||
s.set(titleIdAtom, titleId);
|
||||
s.set(titleTypeAtom, titleType);
|
||||
s.set(titleNameAtom, titleName);
|
||||
s.set(seasonsAtom, seasons);
|
||||
s.set(userStatusAtom, initialStatus);
|
||||
s.set(userRatingAtom, initialRating);
|
||||
s.set(episodeWatchesAtom, initialEpisodeWatches);
|
||||
return s;
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
const [seasons, setSeasons] = useState(initialSeasons);
|
||||
const [watchingEp, setWatchingEp] = useState<string | null>(null);
|
||||
|
||||
return <Provider store={store}>{children}</Provider>;
|
||||
// Seed the query cache with server-fetched data on mount.
|
||||
// Unlike initialData (which is a no-op when the cache already has an entry),
|
||||
// this ensures revisiting a title or signing into a different account never
|
||||
// renders stale or another user's data.
|
||||
// The parent passes key={title.id}, so React remounts on navigation and the
|
||||
// cache for the new title ID starts empty.
|
||||
useEffect(() => {
|
||||
queryClient.setQueryData(
|
||||
orpc.titles.userInfo.queryKey({ input: { id: titleId } }),
|
||||
{
|
||||
status: initialStatus as
|
||||
| "watchlist"
|
||||
| "in_progress"
|
||||
| "completed"
|
||||
| null,
|
||||
rating: initialRating,
|
||||
episodeWatches: initialEpisodeWatches,
|
||||
},
|
||||
);
|
||||
}, [
|
||||
queryClient,
|
||||
titleId,
|
||||
initialStatus,
|
||||
initialRating,
|
||||
initialEpisodeWatches,
|
||||
]);
|
||||
|
||||
return (
|
||||
<TitleContext
|
||||
value={{
|
||||
titleId,
|
||||
titleType,
|
||||
titleName,
|
||||
seasons,
|
||||
setSeasons,
|
||||
watchingEp,
|
||||
setWatchingEp,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</TitleContext>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,45 +1,64 @@
|
||||
import { cacheLife, cacheTag } from "next/cache";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { getRecommendationsForTitle } from "@/lib/services/discovery";
|
||||
import { getUserStatusesByTitleIds } from "@/lib/services/tracking";
|
||||
import type { RecommendedTitle } from "@/lib/types";
|
||||
import { RecommendationsGrid } from "./recommendations-grid";
|
||||
"use client";
|
||||
|
||||
async function getCachedRecommendations(titleId: string) {
|
||||
"use cache";
|
||||
cacheLife("hours");
|
||||
cacheTag(`recs-${titleId}`);
|
||||
|
||||
return getRecommendationsForTitle(titleId);
|
||||
}
|
||||
|
||||
export async function TitleRecommendations({ titleId }: { titleId: string }) {
|
||||
const recs = await getCachedRecommendations(titleId);
|
||||
if (recs.length === 0) return null;
|
||||
|
||||
const recommendations: RecommendedTitle[] = recs.map((r) => ({
|
||||
id: r.id,
|
||||
tmdbId: r.tmdbId,
|
||||
type: r.type,
|
||||
title: r.title,
|
||||
posterPath: r.posterPath,
|
||||
releaseDate: r.releaseDate,
|
||||
firstAirDate: r.firstAirDate,
|
||||
voteAverage: r.voteAverage,
|
||||
}));
|
||||
|
||||
const session = await getSession();
|
||||
const userStatuses = session
|
||||
? getUserStatusesByTitleIds(
|
||||
session.user.id,
|
||||
recommendations.map((r) => r.id),
|
||||
)
|
||||
: {};
|
||||
import { IconThumbUp } from "@tabler/icons-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { TitleCard, TitleCardSkeleton } from "@/components/title-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
|
||||
function RecommendationsSkeleton() {
|
||||
return (
|
||||
<RecommendationsGrid
|
||||
recommendations={recommendations}
|
||||
userStatuses={userStatuses}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded" />
|
||||
<Skeleton className="h-6 w-36" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||
<TitleCardSkeleton />
|
||||
<TitleCardSkeleton />
|
||||
<TitleCardSkeleton />
|
||||
<TitleCardSkeleton />
|
||||
<TitleCardSkeleton />
|
||||
<TitleCardSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TitleRecommendations({ titleId }: { titleId: string }) {
|
||||
const { data, isLoading } = useQuery(
|
||||
orpc.titles.recommendations.queryOptions({ input: { id: titleId } }),
|
||||
);
|
||||
|
||||
if (isLoading) return <RecommendationsSkeleton />;
|
||||
if (!data || data.recommendations.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconThumbUp aria-hidden={true} className="size-5 text-primary" />
|
||||
<h2 className="font-display text-xl tracking-tight">Recommended</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||
{data.recommendations.slice(0, 12).map((rec, i) => (
|
||||
<div
|
||||
key={rec.id}
|
||||
className="animate-stagger-item"
|
||||
style={{ "--stagger-index": i } as React.CSSProperties}
|
||||
>
|
||||
<TitleCard
|
||||
id={rec.id}
|
||||
tmdbId={rec.tmdbId}
|
||||
type={rec.type}
|
||||
title={rec.title}
|
||||
posterPath={rec.posterPath}
|
||||
releaseDate={rec.releaseDate ?? rec.firstAirDate}
|
||||
voteAverage={rec.voteAverage}
|
||||
userStatus={data.userStatuses[rec.id]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
IconDeviceTvOld,
|
||||
} from "@tabler/icons-react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -25,34 +24,55 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
episodeWatchesAtom,
|
||||
seasonsAtom,
|
||||
userStatusAtom,
|
||||
watchingEpAtom,
|
||||
} from "@/lib/atoms/title";
|
||||
import type { Season } from "@/lib/types";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { Season } from "@/lib/orpc/schemas";
|
||||
import { useTitleContext, useTitleUserInfo } from "./title-context";
|
||||
import { useTitleActions } from "./use-title-actions";
|
||||
|
||||
export function SeasonsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded" />
|
||||
<Skeleton className="h-7 w-28" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{["s1", "s2", "s3"].map((id) => (
|
||||
<div
|
||||
key={id}
|
||||
className="overflow-hidden rounded-xl border border-border/50 bg-card/50"
|
||||
>
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="hidden h-2 w-24 rounded-full sm:block" />
|
||||
<Skeleton className="h-3 w-10" />
|
||||
<Skeleton className="size-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TitleSeasons({
|
||||
seasons: streamedSeasons,
|
||||
}: {
|
||||
seasons?: Season[];
|
||||
} = {}) {
|
||||
const setSeasons = useSetAtom(seasonsAtom);
|
||||
const { seasons, setSeasons, watchingEp } = useTitleContext();
|
||||
const { episodeWatches, userStatus } = useTitleUserInfo();
|
||||
|
||||
// When seasons are streamed via Suspense, sync them into the Jotai store
|
||||
// When seasons are streamed via Suspense, sync them into context
|
||||
useEffect(() => {
|
||||
if (streamedSeasons && streamedSeasons.length > 0) {
|
||||
setSeasons(streamedSeasons);
|
||||
}
|
||||
}, [streamedSeasons, setSeasons]);
|
||||
|
||||
const seasons = useAtomValue(seasonsAtom);
|
||||
const episodeWatches = useAtomValue(episodeWatchesAtom);
|
||||
const watchedSet = useMemo(() => new Set(episodeWatches), [episodeWatches]);
|
||||
const userStatus = useAtomValue(userStatusAtom);
|
||||
const watchingEp = useAtomValue(watchingEpAtom);
|
||||
const {
|
||||
handleWatchEpisode,
|
||||
handleMarkSeason,
|
||||
|
||||
@@ -1,113 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useStore } from "jotai";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
batchWatchEpisodes,
|
||||
markAllWatchedAction,
|
||||
unwatchEpisodeAction,
|
||||
unwatchSeasonAction,
|
||||
updateTitleRating,
|
||||
updateTitleStatus,
|
||||
watchEpisode,
|
||||
watchMovie,
|
||||
watchSeason,
|
||||
} from "@/lib/actions/titles";
|
||||
import {
|
||||
episodeWatchesAtom,
|
||||
seasonsAtom,
|
||||
titleIdAtom,
|
||||
titleNameAtom,
|
||||
userRatingAtom,
|
||||
userStatusAtom,
|
||||
watchingEpAtom,
|
||||
} from "@/lib/atoms/title";
|
||||
import type { Season } from "@/lib/types";
|
||||
import type { Season } from "@/lib/orpc/schemas";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
import { useTitleContext } from "./title-context";
|
||||
|
||||
type UserInfo = {
|
||||
status: "watchlist" | "in_progress" | "completed" | null;
|
||||
rating: number | null;
|
||||
episodeWatches: string[];
|
||||
};
|
||||
|
||||
export function useTitleActions() {
|
||||
const store = useStore();
|
||||
const { titleId, titleName, seasons, setWatchingEp } = useTitleContext();
|
||||
const queryClient = useQueryClient();
|
||||
const userInfoKey = orpc.titles.userInfo.queryKey({ input: { id: titleId } });
|
||||
|
||||
const getUserInfo = useCallback(
|
||||
() =>
|
||||
queryClient.getQueryData<UserInfo>(userInfoKey) ?? {
|
||||
status: null,
|
||||
rating: null,
|
||||
episodeWatches: [],
|
||||
},
|
||||
[queryClient, userInfoKey],
|
||||
);
|
||||
|
||||
const setUserInfo = useCallback(
|
||||
(updater: (old: UserInfo) => UserInfo) => {
|
||||
queryClient.setQueryData<UserInfo>(userInfoKey, (old) =>
|
||||
updater(old ?? { status: null, rating: null, episodeWatches: [] }),
|
||||
);
|
||||
},
|
||||
[queryClient, userInfoKey],
|
||||
);
|
||||
|
||||
const batchWatchMutation = useMutation(
|
||||
orpc.episodes.batchWatch.mutationOptions(),
|
||||
);
|
||||
const updateStatusMutation = useMutation(
|
||||
orpc.titles.updateStatus.mutationOptions(),
|
||||
);
|
||||
const updateRatingMutation = useMutation(
|
||||
orpc.titles.updateRating.mutationOptions(),
|
||||
);
|
||||
const watchMovieMutation = useMutation(
|
||||
orpc.titles.watchMovie.mutationOptions(),
|
||||
);
|
||||
const unwatchEpMutation = useMutation(
|
||||
orpc.episodes.unwatch.mutationOptions(),
|
||||
);
|
||||
const watchEpMutation = useMutation(orpc.episodes.watch.mutationOptions());
|
||||
const watchSeasonMutation = useMutation(orpc.seasons.watch.mutationOptions());
|
||||
const unwatchSeasonMutation = useMutation(
|
||||
orpc.seasons.unwatch.mutationOptions(),
|
||||
);
|
||||
const watchAllMutation = useMutation(orpc.titles.watchAll.mutationOptions());
|
||||
|
||||
const catchUp = useCallback(
|
||||
async (episodeIds: string[]) => {
|
||||
const currentWatches = store.get(episodeWatchesAtom);
|
||||
const prevStatus = store.get(userStatusAtom);
|
||||
const newWatchSet = new Set(currentWatches);
|
||||
const prev = getUserInfo();
|
||||
const newWatchSet = new Set(prev.episodeWatches);
|
||||
for (const id of episodeIds) newWatchSet.add(id);
|
||||
store.set(episodeWatchesAtom, [...newWatchSet]);
|
||||
const newWatches = [...newWatchSet];
|
||||
|
||||
const seasons = store.get(seasonsAtom);
|
||||
const allEpIds = seasons.flatMap((s) => s.episodes.map((ep) => ep.id));
|
||||
if (allEpIds.every((id) => newWatchSet.has(id))) {
|
||||
store.set(userStatusAtom, "completed");
|
||||
}
|
||||
const allWatched = allEpIds.every((id) => newWatchSet.has(id));
|
||||
|
||||
setUserInfo((old) => ({
|
||||
...old,
|
||||
episodeWatches: newWatches,
|
||||
status: allWatched ? "completed" : old.status,
|
||||
}));
|
||||
|
||||
try {
|
||||
await batchWatchEpisodes(episodeIds);
|
||||
await batchWatchMutation.mutateAsync({ episodeIds });
|
||||
toast.success(
|
||||
`Caught up — marked ${episodeIds.length} episode${episodeIds.length > 1 ? "s" : ""} as watched`,
|
||||
);
|
||||
} catch {
|
||||
store.set(episodeWatchesAtom, currentWatches);
|
||||
store.set(userStatusAtom, prevStatus);
|
||||
setUserInfo((old) => ({
|
||||
...old,
|
||||
episodeWatches: prev.episodeWatches,
|
||||
status: prev.status,
|
||||
}));
|
||||
toast.error("Failed to catch up");
|
||||
}
|
||||
},
|
||||
[store],
|
||||
[getUserInfo, setUserInfo, seasons, batchWatchMutation],
|
||||
);
|
||||
|
||||
const handleStatusChange = useCallback(
|
||||
async (status: string | null) => {
|
||||
const prev = store.get(userStatusAtom);
|
||||
const titleId = store.get(titleIdAtom);
|
||||
store.set(
|
||||
userStatusAtom,
|
||||
status === "watchlist" ? "in_progress" : status,
|
||||
);
|
||||
const prevStatus = getUserInfo().status;
|
||||
setUserInfo((old) => ({
|
||||
...old,
|
||||
status:
|
||||
status === "watchlist"
|
||||
? "in_progress"
|
||||
: (status as UserInfo["status"]),
|
||||
}));
|
||||
try {
|
||||
await updateTitleStatus(titleId, status ? "in_progress" : null);
|
||||
await updateStatusMutation.mutateAsync({
|
||||
id: titleId,
|
||||
status: status ? "in_progress" : null,
|
||||
});
|
||||
toast.success(status ? "Added to watchlist" : "Removed from library");
|
||||
} catch {
|
||||
store.set(userStatusAtom, prev);
|
||||
setUserInfo((old) => ({ ...old, status: prevStatus }));
|
||||
toast.error("Failed to update status");
|
||||
}
|
||||
},
|
||||
[store],
|
||||
[getUserInfo, setUserInfo, titleId, updateStatusMutation],
|
||||
);
|
||||
|
||||
const handleRating = useCallback(
|
||||
async (ratingStars: number) => {
|
||||
const prev = store.get(userRatingAtom);
|
||||
const titleId = store.get(titleIdAtom);
|
||||
store.set(userRatingAtom, ratingStars);
|
||||
const prevRating = getUserInfo().rating;
|
||||
setUserInfo((old) => ({ ...old, rating: ratingStars }));
|
||||
try {
|
||||
await updateTitleRating(titleId, ratingStars);
|
||||
await updateRatingMutation.mutateAsync({
|
||||
id: titleId,
|
||||
stars: ratingStars,
|
||||
});
|
||||
toast.success(
|
||||
ratingStars > 0
|
||||
? `Rated ${ratingStars} star${ratingStars > 1 ? "s" : ""}`
|
||||
: "Rating removed",
|
||||
);
|
||||
} catch {
|
||||
store.set(userRatingAtom, prev);
|
||||
setUserInfo((old) => ({ ...old, rating: prevRating }));
|
||||
toast.error("Failed to update rating");
|
||||
}
|
||||
},
|
||||
[store],
|
||||
[getUserInfo, setUserInfo, titleId, updateRatingMutation],
|
||||
);
|
||||
|
||||
const handleWatchMovie = useCallback(async () => {
|
||||
const prev = store.get(userStatusAtom);
|
||||
const titleId = store.get(titleIdAtom);
|
||||
const titleName = store.get(titleNameAtom);
|
||||
store.set(userStatusAtom, "completed");
|
||||
const prevStatus = getUserInfo().status;
|
||||
setUserInfo((old) => ({ ...old, status: "completed" }));
|
||||
try {
|
||||
await watchMovie(titleId);
|
||||
await watchMovieMutation.mutateAsync({ id: titleId });
|
||||
toast.success(`Marked "${titleName}" as watched`);
|
||||
} catch {
|
||||
store.set(userStatusAtom, prev);
|
||||
setUserInfo((old) => ({ ...old, status: prevStatus }));
|
||||
toast.error("Failed to mark as watched");
|
||||
}
|
||||
}, [store]);
|
||||
}, [getUserInfo, setUserInfo, titleId, titleName, watchMovieMutation]);
|
||||
|
||||
const handleWatchEpisode = useCallback(
|
||||
async (
|
||||
@@ -116,42 +157,49 @@ export function useTitleActions() {
|
||||
epNum: number,
|
||||
isWatched: boolean,
|
||||
) => {
|
||||
store.set(watchingEpAtom, episodeId);
|
||||
setWatchingEp(episodeId);
|
||||
|
||||
if (isWatched) {
|
||||
const prevStatus = store.get(userStatusAtom);
|
||||
store.set(
|
||||
episodeWatchesAtom,
|
||||
store.get(episodeWatchesAtom).filter((id) => id !== episodeId),
|
||||
);
|
||||
if (prevStatus === "completed")
|
||||
store.set(userStatusAtom, "in_progress");
|
||||
const prevWatches = getUserInfo().episodeWatches;
|
||||
const prevStatus = getUserInfo().status;
|
||||
setUserInfo((old) => ({
|
||||
...old,
|
||||
episodeWatches: old.episodeWatches.filter((id) => id !== episodeId),
|
||||
status: old.status === "completed" ? "in_progress" : old.status,
|
||||
}));
|
||||
|
||||
try {
|
||||
await unwatchEpisodeAction(episodeId);
|
||||
await unwatchEpMutation.mutateAsync({ id: episodeId });
|
||||
toast.success(`Unwatched S${seasonNum} E${epNum}`);
|
||||
} catch {
|
||||
const w = store.get(episodeWatchesAtom);
|
||||
if (!w.includes(episodeId))
|
||||
store.set(episodeWatchesAtom, [...w, episodeId]);
|
||||
store.set(userStatusAtom, prevStatus);
|
||||
setUserInfo((old) => ({
|
||||
...old,
|
||||
episodeWatches: prevWatches,
|
||||
status: prevStatus,
|
||||
}));
|
||||
toast.error("Failed to unmark episode");
|
||||
}
|
||||
} else {
|
||||
const currentWatches = store.get(episodeWatchesAtom);
|
||||
const prevStatus = store.get(userStatusAtom);
|
||||
if (!currentWatches.includes(episodeId)) {
|
||||
store.set(episodeWatchesAtom, [...currentWatches, episodeId]);
|
||||
}
|
||||
if (prevStatus === null || prevStatus === "watchlist") {
|
||||
store.set(userStatusAtom, "in_progress");
|
||||
}
|
||||
const prevWatches = getUserInfo().episodeWatches;
|
||||
const prevStatus = getUserInfo().status;
|
||||
const currentWatches = prevWatches;
|
||||
const newWatches = currentWatches.includes(episodeId)
|
||||
? currentWatches
|
||||
: [...currentWatches, episodeId];
|
||||
|
||||
setUserInfo((old) => ({
|
||||
...old,
|
||||
episodeWatches: newWatches,
|
||||
status:
|
||||
old.status === null || old.status === "watchlist"
|
||||
? "in_progress"
|
||||
: old.status,
|
||||
}));
|
||||
|
||||
try {
|
||||
await watchEpisode(episodeId);
|
||||
await watchEpMutation.mutateAsync({ id: episodeId });
|
||||
|
||||
const seasons = store.get(seasonsAtom);
|
||||
const watchedSet = new Set(store.get(episodeWatchesAtom));
|
||||
const watchedSet = new Set(getUserInfo().episodeWatches);
|
||||
const previousUnwatched: string[] = [];
|
||||
for (const s of seasons) {
|
||||
for (const ep of s.episodes) {
|
||||
@@ -180,48 +228,57 @@ export function useTitleActions() {
|
||||
toast.success(`Watched S${seasonNum} E${epNum}`);
|
||||
}
|
||||
} catch {
|
||||
store.set(
|
||||
episodeWatchesAtom,
|
||||
store.get(episodeWatchesAtom).filter((id) => id !== episodeId),
|
||||
);
|
||||
store.set(userStatusAtom, prevStatus);
|
||||
setUserInfo((old) => ({
|
||||
...old,
|
||||
episodeWatches: prevWatches,
|
||||
status: prevStatus,
|
||||
}));
|
||||
toast.error("Failed to mark episode");
|
||||
}
|
||||
}
|
||||
|
||||
store.set(watchingEpAtom, null);
|
||||
setWatchingEp(null);
|
||||
},
|
||||
[store, catchUp],
|
||||
[
|
||||
getUserInfo,
|
||||
setUserInfo,
|
||||
setWatchingEp,
|
||||
seasons,
|
||||
catchUp,
|
||||
unwatchEpMutation,
|
||||
watchEpMutation,
|
||||
],
|
||||
);
|
||||
|
||||
const handleMarkSeason = useCallback(
|
||||
async (season: Season) => {
|
||||
const prevWatches = store.get(episodeWatchesAtom);
|
||||
const prevStatus = store.get(userStatusAtom);
|
||||
const prevWatches = getUserInfo().episodeWatches;
|
||||
const prevStatus = getUserInfo().status;
|
||||
const watchedSet = new Set(prevWatches);
|
||||
const unwatched = season.episodes.filter((ep) => !watchedSet.has(ep.id));
|
||||
if (unwatched.length === 0) return;
|
||||
|
||||
const newWatchSet = new Set(watchedSet);
|
||||
for (const ep of unwatched) newWatchSet.add(ep.id);
|
||||
store.set(episodeWatchesAtom, [...newWatchSet]);
|
||||
const newWatches = [...newWatchSet];
|
||||
|
||||
const seasons = store.get(seasonsAtom);
|
||||
const allEpIds = seasons.flatMap((s) => s.episodes.map((ep) => ep.id));
|
||||
if (allEpIds.every((id) => newWatchSet.has(id))) {
|
||||
store.set(userStatusAtom, "completed");
|
||||
} else {
|
||||
const status = store.get(userStatusAtom);
|
||||
if (status === null || status === "watchlist") {
|
||||
store.set(userStatusAtom, "in_progress");
|
||||
}
|
||||
}
|
||||
const allWatched = allEpIds.every((id) => newWatchSet.has(id));
|
||||
|
||||
setUserInfo((old) => ({
|
||||
...old,
|
||||
episodeWatches: newWatches,
|
||||
status: allWatched
|
||||
? "completed"
|
||||
: old.status === null || old.status === "watchlist"
|
||||
? "in_progress"
|
||||
: old.status,
|
||||
}));
|
||||
|
||||
try {
|
||||
await watchSeason(season.id);
|
||||
await watchSeasonMutation.mutateAsync({ id: season.id });
|
||||
|
||||
const seasons = store.get(seasonsAtom);
|
||||
const currentWatchSet = new Set(store.get(episodeWatchesAtom));
|
||||
const currentWatchSet = new Set(getUserInfo().episodeWatches);
|
||||
const previousUnwatched: string[] = [];
|
||||
for (const s of seasons) {
|
||||
if (s.seasonNumber < season.seasonNumber) {
|
||||
@@ -248,57 +305,66 @@ export function useTitleActions() {
|
||||
toast.success(`Watched all of ${seasonLabel}`);
|
||||
}
|
||||
} catch {
|
||||
store.set(episodeWatchesAtom, prevWatches);
|
||||
store.set(userStatusAtom, prevStatus);
|
||||
setUserInfo((old) => ({
|
||||
...old,
|
||||
episodeWatches: prevWatches,
|
||||
status: prevStatus,
|
||||
}));
|
||||
toast.error("Failed to mark some episodes");
|
||||
}
|
||||
},
|
||||
[store, catchUp],
|
||||
[getUserInfo, setUserInfo, seasons, catchUp, watchSeasonMutation],
|
||||
);
|
||||
|
||||
const handleUnmarkSeason = useCallback(
|
||||
async (season: Season) => {
|
||||
const prevWatches = store.get(episodeWatchesAtom);
|
||||
const prevStatus = store.get(userStatusAtom);
|
||||
const prevWatches = getUserInfo().episodeWatches;
|
||||
const prevStatus = getUserInfo().status;
|
||||
const seasonEpIds = new Set(season.episodes.map((ep) => ep.id));
|
||||
store.set(
|
||||
episodeWatchesAtom,
|
||||
store.get(episodeWatchesAtom).filter((id) => !seasonEpIds.has(id)),
|
||||
);
|
||||
const status = store.get(userStatusAtom);
|
||||
if (status === "completed") store.set(userStatusAtom, "in_progress");
|
||||
setUserInfo((old) => ({
|
||||
...old,
|
||||
episodeWatches: old.episodeWatches.filter((id) => !seasonEpIds.has(id)),
|
||||
status: old.status === "completed" ? "in_progress" : old.status,
|
||||
}));
|
||||
|
||||
try {
|
||||
await unwatchSeasonAction(season.id);
|
||||
await unwatchSeasonMutation.mutateAsync({ id: season.id });
|
||||
toast.success(
|
||||
`Unwatched all of ${season.name ?? `Season ${season.seasonNumber}`}`,
|
||||
);
|
||||
} catch {
|
||||
store.set(episodeWatchesAtom, prevWatches);
|
||||
store.set(userStatusAtom, prevStatus);
|
||||
setUserInfo((old) => ({
|
||||
...old,
|
||||
episodeWatches: prevWatches,
|
||||
status: prevStatus,
|
||||
}));
|
||||
toast.error("Failed to unmark some episodes");
|
||||
}
|
||||
},
|
||||
[store],
|
||||
[getUserInfo, setUserInfo, unwatchSeasonMutation],
|
||||
);
|
||||
|
||||
const handleMarkAllWatched = useCallback(async () => {
|
||||
const titleId = store.get(titleIdAtom);
|
||||
const prevStatus = store.get(userStatusAtom);
|
||||
const prevWatches = store.get(episodeWatchesAtom);
|
||||
const seasons = store.get(seasonsAtom);
|
||||
const prevWatches = getUserInfo().episodeWatches;
|
||||
const prevStatus = getUserInfo().status;
|
||||
const allEpIds = seasons.flatMap((s) => s.episodes.map((ep) => ep.id));
|
||||
store.set(episodeWatchesAtom, allEpIds);
|
||||
store.set(userStatusAtom, "completed");
|
||||
setUserInfo((old) => ({
|
||||
...old,
|
||||
episodeWatches: allEpIds,
|
||||
status: "completed",
|
||||
}));
|
||||
try {
|
||||
await markAllWatchedAction(titleId);
|
||||
await watchAllMutation.mutateAsync({ id: titleId });
|
||||
toast.success("Marked all episodes as watched");
|
||||
} catch {
|
||||
store.set(userStatusAtom, prevStatus);
|
||||
store.set(episodeWatchesAtom, prevWatches);
|
||||
setUserInfo((old) => ({
|
||||
...old,
|
||||
episodeWatches: prevWatches,
|
||||
status: prevStatus,
|
||||
}));
|
||||
toast.error("Failed to mark all episodes as watched");
|
||||
}
|
||||
}, [store]);
|
||||
}, [getUserInfo, setUserInfo, seasons, titleId, watchAllMutation]);
|
||||
|
||||
return {
|
||||
handleStatusChange,
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
import { TitleDetailSkeleton } from "@/components/skeletons";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function TitleDetailLoading() {
|
||||
return <TitleDetailSkeleton />;
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<Skeleton className="-mt-6 mr-[calc(-50vw+50%)] ml-[calc(-50vw+50%)] h-80 rounded-none md:h-[28rem]" />
|
||||
<div className="flex flex-col gap-4 md:flex-row md:gap-8">
|
||||
<Skeleton className="aspect-[2/3] w-[140px] shrink-0 self-center rounded-xl md:w-[220px] md:self-start" />
|
||||
<div className="flex-1 space-y-5">
|
||||
<div>
|
||||
<Skeleton className="h-7 w-2/3 md:h-12" />
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-6 rounded" />
|
||||
<Skeleton className="h-4 w-10" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-4/6" />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Skeleton className="h-9 w-28 rounded-lg" />
|
||||
<Skeleton className="h-4 w-px" />
|
||||
<Skeleton className="h-5 w-24 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,17 +2,13 @@ import { eq } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { cache, Suspense } from "react";
|
||||
import {
|
||||
RecommendationsSkeleton,
|
||||
SeasonsSkeleton,
|
||||
} from "@/components/skeletons";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { titles } from "@/lib/db/schema";
|
||||
import { getOrFetchTitle } from "@/lib/services/metadata";
|
||||
import { getUserTitleInfo } from "@/lib/services/tracking";
|
||||
import { getThemeCssProperties } from "@/lib/theme";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
import { getTitleThemeStyle } from "@/lib/utils/title-theme";
|
||||
import { AsyncTitleSeasons } from "./_components/async-title-seasons";
|
||||
import { TitleActions } from "./_components/title-actions";
|
||||
import { TitleAvailability } from "./_components/title-availability";
|
||||
@@ -21,7 +17,7 @@ import { TitleHero } from "./_components/title-hero";
|
||||
import { TitleKeyboardShortcuts } from "./_components/title-keyboard-shortcuts";
|
||||
import { TitleProvider } from "./_components/title-provider";
|
||||
import { TitleRecommendations } from "./_components/title-recommendations";
|
||||
import { TitleSeasons } from "./_components/title-seasons";
|
||||
import { SeasonsSkeleton, TitleSeasons } from "./_components/title-seasons";
|
||||
import { TitleTheme } from "./_components/title-theme";
|
||||
|
||||
const getCachedOrFetchTitle = cache((id: string) => getOrFetchTitle(id));
|
||||
@@ -65,7 +61,7 @@ export default async function TitleDetailPage({
|
||||
|
||||
const { title, seasons, needsHydration, availability, cast } = result;
|
||||
|
||||
const themeStyle = getTitleThemeStyle(title.colorPalette);
|
||||
const themeStyle = getThemeCssProperties(title.colorPalette);
|
||||
|
||||
return (
|
||||
<div className="relative space-y-10" style={themeStyle}>
|
||||
@@ -102,9 +98,7 @@ export default async function TitleDetailPage({
|
||||
<TitleKeyboardShortcuts />
|
||||
</TitleProvider>
|
||||
|
||||
<Suspense fallback={<RecommendationsSkeleton />}>
|
||||
<TitleRecommendations titleId={title.id} />
|
||||
</Suspense>
|
||||
<TitleRecommendations titleId={title.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { isTmdbConfigured } from "@/lib/config";
|
||||
import {
|
||||
getEpisodeProgressByTmdbIds,
|
||||
getUserStatusesByTmdbIds,
|
||||
} from "@/lib/services/tracking";
|
||||
import { discover } from "@/lib/tmdb/client";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!isTmdbConfigured()) {
|
||||
return NextResponse.json(
|
||||
{ error: "TMDB API key is not configured." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const mediaType = req.nextUrl.searchParams.get("mediaType");
|
||||
const genreId = req.nextUrl.searchParams.get("genreId");
|
||||
|
||||
if (mediaType !== "movie" && mediaType !== "tv") {
|
||||
return NextResponse.json(
|
||||
{ error: "mediaType must be movie or tv" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const parsedGenreId = Number(genreId);
|
||||
if (!genreId || !Number.isFinite(parsedGenreId)) {
|
||||
return NextResponse.json(
|
||||
{ error: "genreId must be a number" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await discover(mediaType, {
|
||||
sort_by: "popularity.desc",
|
||||
"vote_count.gte": "50",
|
||||
with_genres: String(parsedGenreId),
|
||||
});
|
||||
|
||||
type DiscoverResult = NonNullable<typeof results.results>[number] & {
|
||||
title?: string;
|
||||
name?: string;
|
||||
release_date?: string;
|
||||
first_air_date?: string;
|
||||
};
|
||||
|
||||
const items = ((results.results ?? []) as DiscoverResult[])
|
||||
.filter((r) => r.poster_path)
|
||||
.map((r) => ({
|
||||
tmdbId: r.id,
|
||||
type: mediaType,
|
||||
title: r.title ?? r.name ?? "",
|
||||
posterPath: tmdbImageUrl(r.poster_path ?? null, "posters"),
|
||||
releaseDate: r.release_date ?? r.first_air_date ?? null,
|
||||
voteAverage: r.vote_average,
|
||||
}));
|
||||
|
||||
const lookups = items.map((r) => ({ tmdbId: r.tmdbId, type: r.type }));
|
||||
const [userStatuses, episodeProgress] =
|
||||
lookups.length > 0
|
||||
? await Promise.all([
|
||||
getUserStatusesByTmdbIds(session.user.id, lookups),
|
||||
getEpisodeProgressByTmdbIds(session.user.id, lookups),
|
||||
])
|
||||
: [{}, {}];
|
||||
|
||||
return NextResponse.json({ items, userStatuses, episodeProgress });
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch discover results" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { isTmdbConfigured } from "@/lib/config";
|
||||
import {
|
||||
searchMovies,
|
||||
searchMulti,
|
||||
searchPerson,
|
||||
searchTv,
|
||||
} from "@/lib/tmdb/client";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!isTmdbConfigured()) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "TMDB API key is not configured. Visit /setup for instructions.",
|
||||
code: "TMDB_NOT_CONFIGURED",
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const query = req.nextUrl.searchParams.get("query")?.trim();
|
||||
const rawType = req.nextUrl.searchParams.get("type");
|
||||
const type: "movie" | "tv" | "person" | null =
|
||||
rawType === "movie" || rawType === "tv" || rawType === "person"
|
||||
? rawType
|
||||
: null;
|
||||
|
||||
if (rawType && !type) {
|
||||
return NextResponse.json(
|
||||
{ error: "type must be movie, tv, or person" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!query)
|
||||
return NextResponse.json(
|
||||
{ error: "query parameter is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
|
||||
try {
|
||||
// Person-specific search
|
||||
if (type === "person") {
|
||||
const personResults = await searchPerson(query);
|
||||
return NextResponse.json({
|
||||
results: (personResults.results ?? []).map((r) => ({
|
||||
tmdbId: r.id,
|
||||
type: "person" as const,
|
||||
title: r.name,
|
||||
profilePath: tmdbImageUrl(r.profile_path ?? null, "profiles"),
|
||||
knownForDepartment: r.known_for_department,
|
||||
knownFor: r.known_for
|
||||
?.slice(0, 3)
|
||||
.map((k) => k.title ?? (k as { name?: string }).name)
|
||||
.filter(Boolean),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const raw =
|
||||
type === "movie"
|
||||
? await searchMovies(query)
|
||||
: type === "tv"
|
||||
? await searchTv(query)
|
||||
: await searchMulti(query);
|
||||
|
||||
// Search endpoints return movie, TV, or multi results with slightly
|
||||
// different fields. Widen to the union we actually access.
|
||||
type SearchResult = {
|
||||
id: number;
|
||||
media_type?: string;
|
||||
title?: string;
|
||||
name?: string;
|
||||
overview?: string;
|
||||
poster_path?: string | null;
|
||||
profile_path?: string | null;
|
||||
release_date?: string;
|
||||
first_air_date?: string;
|
||||
popularity?: number;
|
||||
vote_average?: number;
|
||||
};
|
||||
const mapped = ((raw.results ?? []) as SearchResult[])
|
||||
.map((r) => {
|
||||
// Include person results from multi search
|
||||
if (r.media_type === "person") {
|
||||
return {
|
||||
tmdbId: r.id,
|
||||
type: "person" as const,
|
||||
title: r.name ?? "Unknown",
|
||||
posterPath: null,
|
||||
profilePath: tmdbImageUrl(r.profile_path ?? null, "profiles"),
|
||||
overview: "",
|
||||
releaseDate: null,
|
||||
popularity: r.popularity,
|
||||
voteAverage: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const mediaType =
|
||||
r.media_type === "movie" || r.media_type === "tv"
|
||||
? r.media_type
|
||||
: type;
|
||||
if (!mediaType) return null;
|
||||
|
||||
return {
|
||||
tmdbId: r.id,
|
||||
type: mediaType,
|
||||
title: r.title ?? r.name,
|
||||
overview: r.overview,
|
||||
releaseDate: r.release_date ?? r.first_air_date,
|
||||
posterPath: tmdbImageUrl(r.poster_path ?? null, "posters"),
|
||||
popularity: r.popularity,
|
||||
voteAverage: r.vote_average,
|
||||
};
|
||||
})
|
||||
.filter((r): r is NonNullable<typeof r> => r !== null);
|
||||
|
||||
return NextResponse.json({ results: mapped });
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch search results" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { getWatchCount, getWatchHistory } from "@/lib/services/discovery";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
type: z.enum(["movies", "episodes"]),
|
||||
period: z.enum(["today", "this_week", "this_month", "this_year"]),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsed = paramsSchema.safeParse({
|
||||
type: req.nextUrl.searchParams.get("type"),
|
||||
period: req.nextUrl.searchParams.get("period"),
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid type or period parameter" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { type, period } = parsed.data;
|
||||
const count = getWatchCount(session.user.id, type, period);
|
||||
const history = getWatchHistory(session.user.id, type, period);
|
||||
|
||||
return NextResponse.json({ count, history });
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { isTmdbConfigured } from "@/lib/config";
|
||||
import { getSystemHealth } from "@/lib/services/system-health";
|
||||
|
||||
export async function GET() {
|
||||
const tmdbConfigured = isTmdbConfigured();
|
||||
|
||||
const session = await getSession();
|
||||
if (session?.user.role === "admin") {
|
||||
const health = await getSystemHealth();
|
||||
return NextResponse.json({ tmdbConfigured, health });
|
||||
}
|
||||
|
||||
return NextResponse.json({ tmdbConfigured });
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { openApiHandler } from "@/lib/orpc/openapi-handler";
|
||||
|
||||
async function handleRequest(request: Request) {
|
||||
const { response } = await openApiHandler.handle(request, {
|
||||
prefix: "/api/v1",
|
||||
context: { headers: request.headers },
|
||||
});
|
||||
return response ?? new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
export const GET = handleRequest;
|
||||
export const POST = handleRequest;
|
||||
export const PUT = handleRequest;
|
||||
export const DELETE = handleRequest;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { handler } from "@/lib/orpc/handler";
|
||||
|
||||
async function handleRequest(request: Request) {
|
||||
const { response } = await handler.handle(request, {
|
||||
prefix: "/rpc",
|
||||
context: { headers: request.headers },
|
||||
});
|
||||
return response ?? new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
export const GET = handleRequest;
|
||||
export const POST = handleRequest;
|
||||
@@ -7,9 +7,17 @@
|
||||
"dependencies": {
|
||||
"@base-ui/react": "1.2.0",
|
||||
"@better-auth/drizzle-adapter": "1.5.4",
|
||||
"@orpc/client": "1.13.6",
|
||||
"@orpc/contract": "1.13.6",
|
||||
"@orpc/json-schema": "1.13.6",
|
||||
"@orpc/openapi": "1.13.6",
|
||||
"@orpc/server": "1.13.6",
|
||||
"@orpc/tanstack-query": "1.13.6",
|
||||
"@orpc/zod": "1.13.6",
|
||||
"@player.style/sutro": "0.2.1",
|
||||
"@tabler/icons-react": "3.40.0",
|
||||
"@tanstack/react-hotkeys": "0.4.0",
|
||||
"@tanstack/react-query": "5.90.21",
|
||||
"better-auth": "1.5.4",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
@@ -17,9 +25,9 @@
|
||||
"croner": "10.0.2-dev.2",
|
||||
"date-fns": "4.1.0",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"jotai": "2.18.0",
|
||||
"jotai": "2.18.1",
|
||||
"media-chrome": "4.18.0",
|
||||
"motion": "12.35.1",
|
||||
"motion": "12.35.2",
|
||||
"next": "16.1.6",
|
||||
"node-vibrant": "4.0.4",
|
||||
"openapi-fetch": "0.17.0",
|
||||
@@ -27,9 +35,8 @@
|
||||
"react-day-picker": "9.14.0",
|
||||
"react-dom": "19.2.4",
|
||||
"recharts": "3.8.0",
|
||||
"shadcn": "4.0.0",
|
||||
"shadcn": "4.0.2",
|
||||
"sonner": "2.0.7",
|
||||
"swr": "2.4.1",
|
||||
"tailwind-merge": "3.5.0",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"vaul": "1.1.2",
|
||||
@@ -40,7 +47,7 @@
|
||||
"@biomejs/biome": "2.4.6",
|
||||
"@tailwindcss/postcss": "4.2.1",
|
||||
"@types/bun": "1.3.10",
|
||||
"@types/node": "25.3.5",
|
||||
"@types/node": "25.4.0",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
@@ -411,6 +418,38 @@
|
||||
|
||||
"@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="],
|
||||
|
||||
"@orpc/client": ["@orpc/client@1.13.6", "", { "dependencies": { "@orpc/shared": "1.13.6", "@orpc/standard-server": "1.13.6", "@orpc/standard-server-fetch": "1.13.6", "@orpc/standard-server-peer": "1.13.6" } }, "sha512-M6lYM6fJUFp9GR+It/qglYTeXwspb6sGj46xXWHqHS6iDVquqju0bdYuLOfHx8CGJcUSzi0aKUcqMXiGJhBG3w=="],
|
||||
|
||||
"@orpc/contract": ["@orpc/contract@1.13.6", "", { "dependencies": { "@orpc/client": "1.13.6", "@orpc/shared": "1.13.6", "@standard-schema/spec": "^1.1.0", "openapi-types": "^12.1.3" } }, "sha512-wjnpKMsCBbUE7MxdS+9by1BIDTJ4vnfUk9he4GmxKQ8fvK/MRNHUR5jkNhsBCoLnigBrsAedHrr9AIqNgqquyQ=="],
|
||||
|
||||
"@orpc/interop": ["@orpc/interop@1.13.6", "", {}, "sha512-m28QpydaYIAkP9lRfwX8lRCxbdsqn734F/bSiS0yuakSCv+PP10SXBUkt4w+OkvZxdxRpj0pOJFJP8F5O0nAoA=="],
|
||||
|
||||
"@orpc/json-schema": ["@orpc/json-schema@1.13.6", "", { "dependencies": { "@orpc/contract": "1.13.6", "@orpc/interop": "1.13.6", "@orpc/openapi": "1.13.6", "@orpc/server": "1.13.6", "@orpc/shared": "1.13.6", "json-schema-typed": "^8.0.2" } }, "sha512-kvih/ZN9FzUq18zLpAaU/tngg/OOQW6lQtqX7xza+gq1th8Js3mrI/E+6H+C4Exdx4sWdMpVna2PdA2T8igplw=="],
|
||||
|
||||
"@orpc/openapi": ["@orpc/openapi@1.13.6", "", { "dependencies": { "@orpc/client": "1.13.6", "@orpc/contract": "1.13.6", "@orpc/interop": "1.13.6", "@orpc/openapi-client": "1.13.6", "@orpc/server": "1.13.6", "@orpc/shared": "1.13.6", "@orpc/standard-server": "1.13.6", "json-schema-typed": "^8.0.2", "rou3": "^0.7.12" } }, "sha512-biqyRD9nZL+YOZ74k0Dbw6BIoa6liTk/tsGgIt2rfjztyElFwPnHeZ0OMrgaHnUGVYUTRSKL5dQlRmfp5LTeGg=="],
|
||||
|
||||
"@orpc/openapi-client": ["@orpc/openapi-client@1.13.6", "", { "dependencies": { "@orpc/client": "1.13.6", "@orpc/contract": "1.13.6", "@orpc/shared": "1.13.6", "@orpc/standard-server": "1.13.6" } }, "sha512-d1bAWpJSoK1HdVBPRmMlYCuqkR2nbdF3kztd7Xz2EsRdl5TRhNLqUJ+5CIfBZHuueicrpdBlwrOuLMmSlcGrew=="],
|
||||
|
||||
"@orpc/server": ["@orpc/server@1.13.6", "", { "dependencies": { "@orpc/client": "1.13.6", "@orpc/contract": "1.13.6", "@orpc/interop": "1.13.6", "@orpc/shared": "1.13.6", "@orpc/standard-server": "1.13.6", "@orpc/standard-server-aws-lambda": "1.13.6", "@orpc/standard-server-fastify": "1.13.6", "@orpc/standard-server-fetch": "1.13.6", "@orpc/standard-server-node": "1.13.6", "@orpc/standard-server-peer": "1.13.6", "cookie": "^1.1.1" }, "peerDependencies": { "crossws": ">=0.3.4", "ws": ">=8.18.1" }, "optionalPeers": ["crossws", "ws"] }, "sha512-58NaUE8sppxxpKDyEYtkR65jOSK9ycwP9TMqkvdHEg33wR17FDHIDws9iNDAo17l4bYGWgPKZtMn6ruRpJjTKQ=="],
|
||||
|
||||
"@orpc/shared": ["@orpc/shared@1.13.6", "", { "dependencies": { "radash": "^12.1.1", "type-fest": "^5.4.4" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-XqpXPgmtkg2tviDXZC13Y4a3B0D5r1yuG4Q2qPG3gM1dargxob6/aSIeKE6rs1tNXOoI+IpJaGV53EWWB+x+iA=="],
|
||||
|
||||
"@orpc/standard-server": ["@orpc/standard-server@1.13.6", "", { "dependencies": { "@orpc/shared": "1.13.6" } }, "sha512-GNYZXCWxYLVHsxBWR+bg5F12vDCsQghqxbqoFpMnA4goe58dugNWmuxM+aSDbI0D81YxkKDULSqft5S+GWi5ww=="],
|
||||
|
||||
"@orpc/standard-server-aws-lambda": ["@orpc/standard-server-aws-lambda@1.13.6", "", { "dependencies": { "@orpc/shared": "1.13.6", "@orpc/standard-server": "1.13.6", "@orpc/standard-server-fetch": "1.13.6", "@orpc/standard-server-node": "1.13.6" } }, "sha512-62/53mepru9yDet1AUxUsQ5NdAGbBsobX8UDHSF0qFUsS6RcYcSV7lHNS8vNozGwxzQ8zyn8x+47RItZDd4Akg=="],
|
||||
|
||||
"@orpc/standard-server-fastify": ["@orpc/standard-server-fastify@1.13.6", "", { "dependencies": { "@orpc/shared": "1.13.6", "@orpc/standard-server": "1.13.6", "@orpc/standard-server-node": "1.13.6" }, "peerDependencies": { "fastify": ">=5.6.1" }, "optionalPeers": ["fastify"] }, "sha512-1YnVbkriAJLI4SAP8NiDr1kQ3iaDwcYKPHaEk0fq0MKV64HfafL2ftnGwBKRKGp/TLeYTCvwtgwiRJ13Dw4sPw=="],
|
||||
|
||||
"@orpc/standard-server-fetch": ["@orpc/standard-server-fetch@1.13.6", "", { "dependencies": { "@orpc/shared": "1.13.6", "@orpc/standard-server": "1.13.6" } }, "sha512-O0bK4crjEOU9H4LzJ2abMjku3dvEhs8tcLXP/W5NXyH+Wm7qjBjDr6psxZ3YuaWdVbfd/P7CHtvw2rQDHJCNfQ=="],
|
||||
|
||||
"@orpc/standard-server-node": ["@orpc/standard-server-node@1.13.6", "", { "dependencies": { "@orpc/shared": "1.13.6", "@orpc/standard-server": "1.13.6", "@orpc/standard-server-fetch": "1.13.6" } }, "sha512-Q71ijpi7kLjn222uYKg7ZOr6yxcDoszAtVz7gHmSbNBdhB/7pF7NkJLxlL/MKbpM1YBSm4/lZyi7WDuhDLf1TA=="],
|
||||
|
||||
"@orpc/standard-server-peer": ["@orpc/standard-server-peer@1.13.6", "", { "dependencies": { "@orpc/shared": "1.13.6", "@orpc/standard-server": "1.13.6" } }, "sha512-WTqjNS6A9sxR4HVxWUb9ZoBHeQiesHeANmVBFdM/QjAaPUZYKn6WACYU6Q2eGmsCUeTQFfMssk0BG2EsgRNEYw=="],
|
||||
|
||||
"@orpc/tanstack-query": ["@orpc/tanstack-query@1.13.6", "", { "dependencies": { "@orpc/shared": "1.13.6" }, "peerDependencies": { "@orpc/client": "1.13.6", "@tanstack/query-core": ">=5.80.2" } }, "sha512-OBseuArjkAobKtKLVdzpepiS0fhc0TzW0O0Jixt1gkhkCiWG1xK8z0gZ7daQ85UBXRIoI9SXzwXhl+HVP+j14w=="],
|
||||
|
||||
"@orpc/zod": ["@orpc/zod@1.13.6", "", { "dependencies": { "@orpc/json-schema": "1.13.6", "@orpc/openapi": "1.13.6", "@orpc/shared": "1.13.6", "escape-string-regexp": "^5.0.0", "wildcard-match": "^5.1.4" }, "peerDependencies": { "@orpc/contract": "1.13.6", "@orpc/server": "1.13.6", "zod": ">=3.25.0" } }, "sha512-Sj3KQbtV1Zuy2QnSs3NrFBnuZfttmWnXb39gLarD3rEKaxE4Mugv2yXIZ7vnBOj5cL24V+ysgharU/Dl7Ro0yA=="],
|
||||
|
||||
"@player.style/sutro": ["@player.style/sutro@0.2.1", "", { "peerDependencies": { "media-chrome": ">=1.0.0" } }, "sha512-vWtFAdu4z3+1HcDPAriX+41q+bYSOgM898XO4TxEgoNyeaBq/Aoz2cRgWFpsWaK368+n7uWCbO5n2vAszN5fhw=="],
|
||||
|
||||
"@prisma/client": ["@prisma/client@7.4.2", "", { "dependencies": { "@prisma/client-runtime-utils": "7.4.2" }, "peerDependencies": { "prisma": "*", "typescript": ">=5.4.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-ts2mu+cQHriAhSxngO3StcYubBGTWDtu/4juZhXCUKOwgh26l+s4KD3vT2kMUzFyrYnll9u/3qWrtzRv9CGWzA=="],
|
||||
@@ -519,8 +558,12 @@
|
||||
|
||||
"@tanstack/hotkeys": ["@tanstack/hotkeys@0.4.0", "", { "dependencies": { "@tanstack/store": "^0.9.2" } }, "sha512-VH+jGC5pszD1AmcDJKMcV52AETQVBKiALomY9Xgo13GihTdyEeEwBnS7RI6WDHIV03UFcWpCfwu2k8fBaKN7bw=="],
|
||||
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
|
||||
|
||||
"@tanstack/react-hotkeys": ["@tanstack/react-hotkeys@0.4.0", "", { "dependencies": { "@tanstack/hotkeys": "0.4.0", "@tanstack/react-store": "^0.9.2" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-ttAaGZZtkLOx30xoS3zNCrGvbA93cqvoWu7kaAFyJrHuxMcnswhtEAk4xxz2QbCL0XoQ5uB6fxf6n/PbCZ+pdQ=="],
|
||||
|
||||
"@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="],
|
||||
|
||||
"@tanstack/react-store": ["@tanstack/react-store@0.9.2", "", { "dependencies": { "@tanstack/store": "0.9.2", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ=="],
|
||||
|
||||
"@tanstack/store": ["@tanstack/store@0.9.2", "", {}, "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA=="],
|
||||
@@ -553,7 +596,7 @@
|
||||
|
||||
"@types/mssql": ["@types/mssql@9.1.9", "", { "dependencies": { "@types/node": "*", "tarn": "^3.0.1", "tedious": "*" } }, "sha512-P0nCgw6vzY23UxZMnbI4N7fnLGANt4LI4yvxze1paPj+LuN28cFv5EI+QidP8udnId/BKhkcRhm/BleNsjK65A=="],
|
||||
|
||||
"@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="],
|
||||
"@types/node": ["@types/node@25.4.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
|
||||
@@ -777,8 +820,6 @@
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
@@ -831,6 +872,8 @@
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
@@ -883,7 +926,7 @@
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
"framer-motion": ["framer-motion@12.35.1", "", { "dependencies": { "motion-dom": "^12.35.1", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rL8cLrjYZNShZqKV3U0Qj6Y5WDiZXYEM5giiTLfEqsIZxtspzMDCkKmrO5po76jWfvOg04+Vk+sfBvTD0iMmLw=="],
|
||||
"framer-motion": ["framer-motion@12.35.2", "", { "dependencies": { "motion-dom": "^12.35.2", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-dhfuEMaNo0hc+AEqyHiIfiJRNb9U9UQutE9FoKm5pjf7CMitp9xPEF1iWZihR1q86LBmo6EJ7S8cN8QXEy49AA=="],
|
||||
|
||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
@@ -1013,7 +1056,7 @@
|
||||
|
||||
"jose": ["jose@6.2.0", "", {}, "sha512-xsfE1TcSCbUdo6U07tR0mvhg0flGxU8tPLbF03mirl2ukGQENhUg4ubGYQnhVH0b5stLlPM+WOqDkEl1R1y5sQ=="],
|
||||
|
||||
"jotai": ["jotai@2.18.0", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-XI38kGWAvtxAZ+cwHcTgJsd+kJOJGf3OfL4XYaXWZMZ7IIY8e53abpIHvtVn1eAgJ5dlgwlGFnP4psrZ/vZbtA=="],
|
||||
"jotai": ["jotai@2.18.1", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-e0NOzK+yRFwHo7DOp0DS0Ycq74KMEAObDWFGmfEL28PD9nLqBTt3/Ug7jf9ca72x0gC9LQZG9zH+0ISICmy3iA=="],
|
||||
|
||||
"jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="],
|
||||
|
||||
@@ -1135,9 +1178,9 @@
|
||||
|
||||
"mongodb-connection-string-url": ["mongodb-connection-string-url@7.0.1", "", { "dependencies": { "@types/whatwg-url": "^13.0.0", "whatwg-url": "^14.1.0" } }, "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ=="],
|
||||
|
||||
"motion": ["motion@12.35.1", "", { "dependencies": { "framer-motion": "^12.35.1", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-yEt/49kWC0VU/IEduDfeZw82eDemlPwa1cyo/gcEEUCN4WgpSJpUcxz6BUwakGabvJiTzLQ58J73515I5tfykQ=="],
|
||||
"motion": ["motion@12.35.2", "", { "dependencies": { "framer-motion": "^12.35.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-8zCi1DkNyU6a/tgEHn/GnnXZDcaMpDHbDOGORY1Rg/6lcNMSOuvwDB3i4hMSOvxqMWArc/vrGaw/Xek1OP69/A=="],
|
||||
|
||||
"motion-dom": ["motion-dom@12.35.1", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-7n6r7TtNOsH2UFSAXzTkfzOeO5616v9B178qBIjmu/WgEyJK0uqwytCEhwKBTuM/HJA40ptAw7hLFpxtPAMRZQ=="],
|
||||
"motion-dom": ["motion-dom@12.35.2", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-pWXFMTwvGDbx1Fe9YL5HZebv2NhvGBzRtiNUv58aoK7+XrsuaydQ0JGRKK2r+bTKlwgSWwWxHbP5249Qr/BNpg=="],
|
||||
|
||||
"motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="],
|
||||
|
||||
@@ -1197,6 +1240,8 @@
|
||||
|
||||
"openapi-fetch": ["openapi-fetch@0.17.0", "", { "dependencies": { "openapi-typescript-helpers": "^0.1.0" } }, "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig=="],
|
||||
|
||||
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
|
||||
|
||||
"openapi-typescript-helpers": ["openapi-typescript-helpers@0.1.0", "", {}, "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw=="],
|
||||
|
||||
"ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="],
|
||||
@@ -1267,6 +1312,8 @@
|
||||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
|
||||
"radash": ["radash@12.1.1", "", {}, "sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA=="],
|
||||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
|
||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
@@ -1353,7 +1400,7 @@
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"shadcn": ["shadcn@4.0.0", "", { "dependencies": { "@antfu/ni": "^25.0.0", "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-D3FRsynbtnnIK6P3x0dC1Clez14UoZdOukE46nuuc3R55m0/1awfCm4p1SF14rS+HWVgWOncOWnDyQeZxSW22w=="],
|
||||
"shadcn": ["shadcn@4.0.2", "", { "dependencies": { "@antfu/ni": "^25.0.0", "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-U6BxywcVt5Vy+iLdjoyWTPcuz2RQNad09tjJsW8hyKtxlx7VAvzHIvoNxvXN+gxWt/HX8kt/cVNk8AXcmPUaNQ=="],
|
||||
|
||||
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
|
||||
@@ -1409,8 +1456,6 @@
|
||||
|
||||
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
|
||||
|
||||
"swr": ["swr@2.4.1", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA=="],
|
||||
|
||||
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
|
||||
|
||||
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||
@@ -1505,6 +1550,8 @@
|
||||
|
||||
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
|
||||
|
||||
"wildcard-match": ["wildcard-match@5.1.4", "", {}, "sha512-wldeCaczs8XXq7hj+5d/F38JE2r7EXgb6WQDM84RVwxy81T/sxB5e9+uZLK9Q9oNz1mlvjut+QtvgaOQFPVq/g=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
@@ -1577,6 +1624,12 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@types/mssql/@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="],
|
||||
|
||||
"@types/readable-stream/@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="],
|
||||
|
||||
"bun-types/@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="],
|
||||
|
||||
"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=="],
|
||||
@@ -1627,6 +1680,8 @@
|
||||
|
||||
"sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"tedious/@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useHotkey, useHotkeySequence } from "@tanstack/react-hotkeys";
|
||||
import { skipToken, useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -36,15 +37,13 @@ import {
|
||||
import { Kbd } from "@/components/ui/kbd";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import { useSearch } from "@/hooks/use-search";
|
||||
import { resolvePerson } from "@/lib/actions/people";
|
||||
import { resolveTitle } from "@/lib/actions/titles";
|
||||
import {
|
||||
commandPaletteOpenAtom,
|
||||
helpOpenAtom,
|
||||
MAX_RECENT,
|
||||
recentSearchesAtom,
|
||||
} from "@/lib/atoms/command-palette";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
|
||||
// Static shortcut descriptions for the help dialog.
|
||||
// TanStack's HotkeyManager/SequenceManager handle all actual key listening.
|
||||
@@ -72,7 +71,17 @@ for (const entry of SHORTCUT_DESCRIPTIONS) {
|
||||
groupedShortcuts[entry.scope].push(entry);
|
||||
}
|
||||
|
||||
type SearchResult = ReturnType<typeof useSearch>["results"][number];
|
||||
interface SearchResult {
|
||||
tmdbId: number;
|
||||
type: "movie" | "tv" | "person";
|
||||
title: string;
|
||||
posterPath?: string | null;
|
||||
profilePath?: string | null;
|
||||
releaseDate?: string | null;
|
||||
voteAverage?: number;
|
||||
knownFor?: string[];
|
||||
knownForDepartment?: string;
|
||||
}
|
||||
|
||||
export function CommandPalette() {
|
||||
const router = useRouter();
|
||||
@@ -84,7 +93,13 @@ export function CommandPalette() {
|
||||
const [recentSearches, setRecentSearches] = useAtom(recentSearchesAtom);
|
||||
const [query, setQuery] = useState("");
|
||||
const debouncedQuery = useDebounce(query, 300);
|
||||
const { results, isLoading: loading } = useSearch(debouncedQuery);
|
||||
const trimmedQuery = debouncedQuery.trim();
|
||||
const { data: searchData, isLoading: loading } = useQuery(
|
||||
orpc.search.queryOptions({
|
||||
input: trimmedQuery ? { query: trimmedQuery } : skipToken,
|
||||
}),
|
||||
);
|
||||
const results: SearchResult[] = searchData?.results?.slice(0, 8) ?? [];
|
||||
const enabled = !commandPaletteOpen;
|
||||
|
||||
useHotkey("Mod+K", () => setCommandPaletteOpen((prev) => !prev));
|
||||
@@ -132,33 +147,51 @@ export function CommandPalette() {
|
||||
};
|
||||
}, [debouncedQuery, results.length, setRecentSearches]);
|
||||
|
||||
const resolvePersonMutation = useMutation(
|
||||
orpc.people.resolve.mutationOptions({
|
||||
onSuccess: ({ id }) => {
|
||||
if (id) router.push(`/people/${id}`);
|
||||
else progress.done();
|
||||
},
|
||||
onError: () => {
|
||||
progress.done();
|
||||
toast.error("Failed to load person");
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const resolveTitleMutation = useMutation(
|
||||
orpc.titles.resolve.mutationOptions({
|
||||
onSuccess: ({ id }) => {
|
||||
if (id) router.push(`/titles/${id}`);
|
||||
else progress.done();
|
||||
},
|
||||
onError: () => {
|
||||
progress.done();
|
||||
toast.error("Failed to load title");
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(result: SearchResult) => {
|
||||
setCommandPaletteOpen(false);
|
||||
progress.start();
|
||||
if (result.type === "person") {
|
||||
void resolvePerson(result.tmdbId)
|
||||
.then((id) => {
|
||||
if (id) router.push(`/people/${id}`);
|
||||
else progress.done();
|
||||
})
|
||||
.catch(() => {
|
||||
progress.done();
|
||||
toast.error("Failed to load person");
|
||||
});
|
||||
resolvePersonMutation.mutate({ tmdbId: result.tmdbId });
|
||||
} else {
|
||||
void resolveTitle(result.tmdbId, result.type)
|
||||
.then((id) => {
|
||||
if (id) router.push(`/titles/${id}`);
|
||||
else progress.done();
|
||||
})
|
||||
.catch(() => {
|
||||
progress.done();
|
||||
toast.error("Failed to load title");
|
||||
});
|
||||
resolveTitleMutation.mutate({
|
||||
tmdbId: result.tmdbId,
|
||||
type: result.type,
|
||||
});
|
||||
}
|
||||
},
|
||||
[router, setCommandPaletteOpen, progress],
|
||||
[
|
||||
setCommandPaletteOpen,
|
||||
progress,
|
||||
resolvePersonMutation,
|
||||
resolveTitleMutation,
|
||||
],
|
||||
);
|
||||
|
||||
const handleRecentSearch = useCallback((q: string) => {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { getQueryClient } from "@/lib/query-client";
|
||||
|
||||
export function QueryProvider({ children }: { children: React.ReactNode }) {
|
||||
const client = getQueryClient();
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
function SectionHeading({ width = "w-32" }: { width?: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded" />
|
||||
<Skeleton className={`h-6 ${width}`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TitleCardSkeleton() {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl bg-card ring-1 ring-white/[0.06]">
|
||||
<Skeleton className="aspect-[2/3] w-full rounded-none" />
|
||||
<div className="px-3 pt-2.5 pb-3">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="mt-1.5 h-3 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContinueWatchingSkeleton() {
|
||||
return (
|
||||
<div className="w-64 shrink-0 overflow-hidden rounded-xl bg-card/50 ring-1 ring-white/[0.06] sm:w-72">
|
||||
<Skeleton className="aspect-video w-full rounded-none" />
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-8 shrink-0 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatCardSkeleton() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsSectionSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<StatCardSkeleton />
|
||||
<StatCardSkeleton />
|
||||
<StatCardSkeleton />
|
||||
<StatCardSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContinueWatchingSectionSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SectionHeading width="w-40" />
|
||||
<div className="-mx-4 flex gap-4 overflow-hidden px-4 sm:-mx-0 sm:px-0">
|
||||
<ContinueWatchingSkeleton />
|
||||
<ContinueWatchingSkeleton />
|
||||
<ContinueWatchingSkeleton />
|
||||
<ContinueWatchingSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TitleGridSectionSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SectionHeading />
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
<TitleCardSkeleton />
|
||||
<TitleCardSkeleton />
|
||||
<TitleCardSkeleton />
|
||||
<TitleCardSkeleton />
|
||||
<TitleCardSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PersonDetailSkeleton() {
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<div className="flex flex-col gap-6 sm:flex-row sm:gap-8">
|
||||
<Skeleton className="size-40 shrink-0 self-center rounded-2xl sm:size-56 sm:self-start" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<Skeleton className="h-9 w-2/3 sm:h-12" />
|
||||
<Skeleton className="h-5 w-24 rounded-md" />
|
||||
<div className="flex gap-4">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-4 w-36" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-4/6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SeasonsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded" />
|
||||
<Skeleton className="h-7 w-28" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{["s1", "s2", "s3"].map((id) => (
|
||||
<div
|
||||
key={id}
|
||||
className="overflow-hidden rounded-xl border border-border/50 bg-card/50"
|
||||
>
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="hidden h-2 w-24 rounded-full sm:block" />
|
||||
<Skeleton className="h-3 w-10" />
|
||||
<Skeleton className="size-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecommendationsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SectionHeading width="w-36" />
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||
<TitleCardSkeleton />
|
||||
<TitleCardSkeleton />
|
||||
<TitleCardSkeleton />
|
||||
<TitleCardSkeleton />
|
||||
<TitleCardSkeleton />
|
||||
<TitleCardSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TitleDetailSkeleton() {
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<Skeleton className="-mt-6 mr-[calc(-50vw+50%)] ml-[calc(-50vw+50%)] h-80 rounded-none md:h-[28rem]" />
|
||||
<div className="flex flex-col gap-4 md:flex-row md:gap-8">
|
||||
<Skeleton className="aspect-[2/3] w-[140px] shrink-0 self-center rounded-xl md:w-[220px] md:self-start" />
|
||||
<div className="flex-1 space-y-5">
|
||||
<div>
|
||||
<Skeleton className="h-7 w-2/3 md:h-12" />
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-6 rounded" />
|
||||
<Skeleton className="h-4 w-10" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-4/6" />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Skeleton className="h-9 w-28 rounded-lg" />
|
||||
<Skeleton className="h-4 w-px" />
|
||||
<Skeleton className="h-5 w-24 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+47
-43
@@ -2,7 +2,6 @@
|
||||
|
||||
import {
|
||||
IconBookmarkFilled,
|
||||
IconCheck,
|
||||
IconCircleCheckFilled,
|
||||
IconDeviceTv,
|
||||
IconLoader,
|
||||
@@ -11,21 +10,34 @@ import {
|
||||
IconPlus,
|
||||
IconStarFilled,
|
||||
} from "@tabler/icons-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { type MotionStyle, type MotionValue, motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useProgress } from "@/components/navigation-progress";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useTiltEffect } from "@/hooks/use-tilt-effect";
|
||||
import { resolveTitle } from "@/lib/actions/titles";
|
||||
import { quickAddToWatchlist } from "@/lib/actions/watchlist";
|
||||
import { orpc } from "@/lib/orpc/tanstack";
|
||||
|
||||
export function TitleCardSkeleton() {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl bg-card ring-1 ring-white/[0.06]">
|
||||
<Skeleton className="aspect-[2/3] w-full rounded-none" />
|
||||
<div className="px-3 pt-2.5 pb-3">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="mt-1.5 h-3 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TitleStatus = "watchlist" | "in_progress" | "completed";
|
||||
|
||||
@@ -51,8 +63,6 @@ export interface TitleCardProps extends CardInnerProps {
|
||||
tmdbId: number;
|
||||
}
|
||||
|
||||
type QuickAddState = "idle" | "loading" | "added";
|
||||
|
||||
const statusConfig = {
|
||||
watchlist: {
|
||||
icon: IconBookmarkFilled,
|
||||
@@ -80,9 +90,6 @@ function QuickAddButton({
|
||||
type: "movie" | "tv";
|
||||
userStatus?: TitleStatus | null;
|
||||
}) {
|
||||
const [state, setState] = useState<QuickAddState>(
|
||||
userStatus ? "added" : "idle",
|
||||
);
|
||||
const [addedStatus, setAddedStatus] = useState<TitleStatus | null>(
|
||||
userStatus ?? null,
|
||||
);
|
||||
@@ -90,28 +97,27 @@ function QuickAddButton({
|
||||
// Sync local state when prop changes (e.g. after navigation or SWR revalidation)
|
||||
useEffect(() => {
|
||||
if (userStatus) {
|
||||
setState("added");
|
||||
setAddedStatus(userStatus);
|
||||
}
|
||||
}, [userStatus]);
|
||||
|
||||
const quickAddMutation = useMutation(
|
||||
orpc.watchlist.quickAdd.mutationOptions({
|
||||
onSuccess: () => setAddedStatus("watchlist"),
|
||||
}),
|
||||
);
|
||||
|
||||
const isAdded = addedStatus != null;
|
||||
const config = addedStatus ? statusConfig[addedStatus] : null;
|
||||
|
||||
async function handleClick(e: React.MouseEvent) {
|
||||
function handleClick(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (state === "loading" || state === "added") return;
|
||||
setState("loading");
|
||||
try {
|
||||
await quickAddToWatchlist(tmdbId, type);
|
||||
setState("added");
|
||||
setAddedStatus("watchlist");
|
||||
} catch {
|
||||
setState("idle");
|
||||
}
|
||||
if (quickAddMutation.isPending || isAdded) return;
|
||||
quickAddMutation.mutate({ tmdbId, type });
|
||||
}
|
||||
|
||||
if (state === "added" && config) {
|
||||
if (isAdded && config) {
|
||||
const StatusIcon = config.icon;
|
||||
return (
|
||||
<Tooltip>
|
||||
@@ -133,13 +139,12 @@ function QuickAddButton({
|
||||
className="absolute top-2 right-2 z-10 flex size-8 items-center justify-center rounded-full bg-black/50 text-white opacity-60 backdrop-blur-sm transition-opacity hover:bg-black/70 focus-visible:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
|
||||
render={<button type="button" />}
|
||||
>
|
||||
{state === "idle" && <IconPlus className="size-4" />}
|
||||
{state === "loading" && <IconLoader className="size-4 animate-spin" />}
|
||||
{state === "added" && <IconCheck className="size-4 text-green-400" />}
|
||||
{!quickAddMutation.isPending && <IconPlus className="size-4" />}
|
||||
{quickAddMutation.isPending && (
|
||||
<IconLoader className="size-4 animate-spin" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{state === "added" ? "Added to Watchlist" : "Add to Watchlist"}
|
||||
</TooltipContent>
|
||||
<TooltipContent side="bottom">Add to Watchlist</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -287,7 +292,18 @@ export function TitleCard({
|
||||
const tilt = useTiltEffect();
|
||||
const router = useRouter();
|
||||
const progress = useProgress();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const resolveMutation = useMutation(
|
||||
orpc.titles.resolve.mutationOptions({
|
||||
onSuccess: ({ id: resolvedId }) => {
|
||||
if (resolvedId) router.push(`/titles/${resolvedId}`);
|
||||
else progress.done();
|
||||
},
|
||||
onError: () => {
|
||||
progress.done();
|
||||
toast.error("Failed to load title");
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const cardContent = (
|
||||
<motion.div ref={tilt.ref} style={tilt.containerStyle} {...tilt.handlers}>
|
||||
@@ -320,23 +336,11 @@ export function TitleCard({
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
className={`w-full text-left ${isPending ? "pointer-events-none opacity-70" : "cursor-pointer"}`}
|
||||
disabled={resolveMutation.isPending}
|
||||
className={`w-full text-left ${resolveMutation.isPending ? "pointer-events-none opacity-70" : "cursor-pointer"}`}
|
||||
onClick={() => {
|
||||
progress.start();
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const resolvedId = await resolveTitle(
|
||||
tmdbId,
|
||||
type as "movie" | "tv",
|
||||
);
|
||||
if (resolvedId) router.push(`/titles/${resolvedId}`);
|
||||
else progress.done();
|
||||
} catch {
|
||||
progress.done();
|
||||
toast.error("Failed to load title");
|
||||
}
|
||||
});
|
||||
resolveMutation.mutate({ tmdbId, type: type as "movie" | "tv" });
|
||||
}}
|
||||
>
|
||||
{cardContent}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import useSWR from "swr";
|
||||
import { fetcher } from "@/lib/swr/fetcher";
|
||||
|
||||
type TitleStatus = "watchlist" | "in_progress" | "completed";
|
||||
|
||||
interface TitleRowItem {
|
||||
tmdbId: number;
|
||||
type: "movie" | "tv";
|
||||
title: string;
|
||||
posterPath: string | null;
|
||||
releaseDate: string | null;
|
||||
voteAverage: number;
|
||||
}
|
||||
|
||||
interface DiscoverResponse {
|
||||
items: TitleRowItem[];
|
||||
userStatuses: Record<string, TitleStatus>;
|
||||
episodeProgress: Record<string, { watched: number; total: number }>;
|
||||
}
|
||||
|
||||
export function useDiscover(mediaType: "movie" | "tv", genreId: number | null) {
|
||||
const { data, isLoading } = useSWR<DiscoverResponse>(
|
||||
genreId != null
|
||||
? `/api/discover?mediaType=${mediaType}&genreId=${genreId}`
|
||||
: null,
|
||||
fetcher,
|
||||
{ revalidateOnFocus: false, dedupingInterval: 2_000 },
|
||||
);
|
||||
|
||||
return { data, isLoading };
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import useSWR from "swr";
|
||||
import { fetcher } from "@/lib/swr/fetcher";
|
||||
|
||||
interface SearchResponse {
|
||||
results: {
|
||||
tmdbId: number;
|
||||
type: "movie" | "tv" | "person";
|
||||
title: string;
|
||||
posterPath: string | null;
|
||||
profilePath?: string | null;
|
||||
releaseDate: string | null;
|
||||
voteAverage: number;
|
||||
knownFor?: string[];
|
||||
knownForDepartment?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function useSearch(debouncedQuery: string) {
|
||||
const trimmed = debouncedQuery.trim();
|
||||
const { data, isLoading } = useSWR<SearchResponse>(
|
||||
trimmed ? `/api/search?query=${encodeURIComponent(trimmed)}` : null,
|
||||
fetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 2_000,
|
||||
},
|
||||
);
|
||||
|
||||
const results = useMemo(
|
||||
() => data?.results?.slice(0, 8) ?? [],
|
||||
[data?.results],
|
||||
);
|
||||
|
||||
return {
|
||||
results,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import useSWR from "swr";
|
||||
import type { HistoryBucket, TimePeriod } from "@/lib/services/discovery";
|
||||
import { fetcher } from "@/lib/swr/fetcher";
|
||||
|
||||
interface StatsResponse {
|
||||
count: number;
|
||||
history: HistoryBucket[];
|
||||
}
|
||||
|
||||
export function useStats(type: "movies" | "episodes", period: TimePeriod) {
|
||||
const { data } = useSWR<StatsResponse>(
|
||||
`/api/stats?type=${type}&period=${period}`,
|
||||
fetcher,
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import useSWR from "swr";
|
||||
import type { SystemHealthData } from "@/lib/services/system-health";
|
||||
import { fetcher } from "@/lib/swr/fetcher";
|
||||
|
||||
interface StatusResponse {
|
||||
tmdbConfigured: boolean;
|
||||
health: SystemHealthData;
|
||||
}
|
||||
|
||||
export function useSystemHealth(initialData: SystemHealthData) {
|
||||
const { data, isValidating, mutate } = useSWR<StatusResponse>(
|
||||
"/api/status",
|
||||
fetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
fallbackData: { tmdbConfigured: true, health: initialData },
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
data: data?.health ?? initialData,
|
||||
isRefreshing: isValidating,
|
||||
refresh: () => mutate(),
|
||||
};
|
||||
}
|
||||
@@ -23,6 +23,9 @@ export async function register() {
|
||||
const { runMigrations } = await import("@/lib/db/migrate");
|
||||
runMigrations();
|
||||
|
||||
// Initialize oRPC server-side client for SSR
|
||||
await import("@/lib/orpc/client.server");
|
||||
|
||||
// Initialize job scheduler
|
||||
const { startJobs, stopJobs } = await import("@/lib/cron");
|
||||
startJobs();
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { requireSession } from "@/lib/auth/session";
|
||||
import { getOrFetchPersonByTmdbId } from "@/lib/services/person";
|
||||
|
||||
export async function resolvePerson(tmdbId: number): Promise<string | null> {
|
||||
await requireSession();
|
||||
const person = await getOrFetchPersonByTmdbId(tmdbId);
|
||||
return person?.id ?? null;
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { mkdir, rename } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { requireAdmin, requireSession } from "@/lib/auth/session";
|
||||
import { AVATAR_DIR } from "@/lib/constants";
|
||||
import { type BackupFrequency, rescheduleBackup, triggerJob } from "@/lib/cron";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { integrations } from "@/lib/db/schema";
|
||||
import {
|
||||
type BackupInfo,
|
||||
createBackup,
|
||||
deleteBackup,
|
||||
listBackups,
|
||||
restoreFromBackup,
|
||||
} from "@/lib/services/backup";
|
||||
import { getSetting, setSetting } from "@/lib/services/settings";
|
||||
|
||||
const providerSchema = z.enum(["plex", "jellyfin", "emby", "sonarr", "radarr"]);
|
||||
|
||||
const LIST_PROVIDERS = new Set(["sonarr", "radarr"]);
|
||||
|
||||
function integrationTypeFor(provider: string): "webhook" | "list" {
|
||||
return LIST_PROVIDERS.has(provider) ? "list" : "webhook";
|
||||
}
|
||||
|
||||
function generateToken() {
|
||||
return Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString(
|
||||
"hex",
|
||||
);
|
||||
}
|
||||
|
||||
// --- Integration actions ---
|
||||
|
||||
export async function saveIntegration(provider: string, enabled?: boolean) {
|
||||
const session = await requireSession();
|
||||
const parsed = providerSchema.parse(provider);
|
||||
|
||||
const existing = db
|
||||
.select()
|
||||
.from(integrations)
|
||||
.where(
|
||||
and(
|
||||
eq(integrations.userId, session.user.id),
|
||||
eq(integrations.provider, parsed),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
if (existing) {
|
||||
const row = db
|
||||
.update(integrations)
|
||||
.set({
|
||||
enabled: typeof enabled === "boolean" ? enabled : existing.enabled,
|
||||
})
|
||||
.where(eq(integrations.id, existing.id))
|
||||
.returning()
|
||||
.get();
|
||||
return {
|
||||
...row,
|
||||
lastEventAt: row.lastEventAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const row = db
|
||||
.insert(integrations)
|
||||
.values({
|
||||
userId: session.user.id,
|
||||
provider: parsed,
|
||||
type: integrationTypeFor(parsed),
|
||||
token: generateToken(),
|
||||
enabled: true,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
return {
|
||||
...row,
|
||||
lastEventAt: row.lastEventAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteIntegration(provider: string) {
|
||||
const session = await requireSession();
|
||||
const parsed = providerSchema.parse(provider);
|
||||
|
||||
db.delete(integrations)
|
||||
.where(
|
||||
and(
|
||||
eq(integrations.userId, session.user.id),
|
||||
eq(integrations.provider, parsed),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function regenerateIntegrationToken(provider: string) {
|
||||
const session = await requireSession();
|
||||
const parsed = providerSchema.parse(provider);
|
||||
|
||||
const row = db
|
||||
.update(integrations)
|
||||
.set({ token: generateToken() })
|
||||
.where(
|
||||
and(
|
||||
eq(integrations.userId, session.user.id),
|
||||
eq(integrations.provider, parsed),
|
||||
),
|
||||
)
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
if (!row) throw new Error("Integration not found");
|
||||
|
||||
return {
|
||||
...row,
|
||||
lastEventAt: row.lastEventAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Admin actions ---
|
||||
|
||||
export async function toggleRegistration(open: boolean) {
|
||||
await requireAdmin();
|
||||
setSetting("registrationOpen", String(open));
|
||||
}
|
||||
|
||||
export async function toggleUpdateCheck(enabled: boolean) {
|
||||
await requireAdmin();
|
||||
setSetting("updateCheckEnabled", String(enabled));
|
||||
}
|
||||
|
||||
// --- Backup actions ---
|
||||
|
||||
export async function createBackupAction(): Promise<BackupInfo> {
|
||||
await requireAdmin();
|
||||
return await createBackup();
|
||||
}
|
||||
|
||||
export async function listBackupsAction(): Promise<BackupInfo[]> {
|
||||
await requireAdmin();
|
||||
return await listBackups();
|
||||
}
|
||||
|
||||
export async function deleteBackupAction(filename: string): Promise<void> {
|
||||
await requireAdmin();
|
||||
await deleteBackup(filename);
|
||||
}
|
||||
|
||||
export async function setScheduledBackupAction(
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
await requireAdmin();
|
||||
setSetting("scheduledBackups", String(enabled));
|
||||
}
|
||||
|
||||
export async function getScheduledBackupSettings(): Promise<{
|
||||
enabled: boolean;
|
||||
maxRetention: number;
|
||||
}> {
|
||||
await requireAdmin();
|
||||
return {
|
||||
enabled: getSetting("scheduledBackups") === "true",
|
||||
maxRetention: Number.parseInt(getSetting("maxBackupRetention") ?? "7", 10),
|
||||
};
|
||||
}
|
||||
|
||||
const maxBackupsSchema = z
|
||||
.number()
|
||||
.int()
|
||||
.refine((n) => n === 0 || (n >= 1 && n <= 30), {
|
||||
message: "Max backups must be between 1 and 30, or 0 for unlimited",
|
||||
});
|
||||
|
||||
export async function setMaxBackupsAction(max: number): Promise<void> {
|
||||
await requireAdmin();
|
||||
maxBackupsSchema.parse(max);
|
||||
setSetting("maxBackupRetention", String(max));
|
||||
}
|
||||
|
||||
const backupScheduleSchema = z.object({
|
||||
frequency: z.enum(["6h", "12h", "1d", "7d"]),
|
||||
time: z
|
||||
.string()
|
||||
.regex(/^\d{2}:\d{2}$/, "Invalid time format")
|
||||
.refine(
|
||||
(t) => {
|
||||
const [h, m] = t.split(":").map(Number);
|
||||
return h >= 0 && h <= 23 && m >= 0 && m <= 59;
|
||||
},
|
||||
{ message: "Invalid time value" },
|
||||
),
|
||||
dayOfWeek: z.number().int().min(0).max(6).default(0),
|
||||
});
|
||||
|
||||
export async function setBackupScheduleAction(
|
||||
frequency: BackupFrequency,
|
||||
time: string,
|
||||
dayOfWeek = 0,
|
||||
): Promise<void> {
|
||||
await requireAdmin();
|
||||
const parsed = backupScheduleSchema.parse({ frequency, time, dayOfWeek });
|
||||
setSetting("backupScheduleFrequency", parsed.frequency);
|
||||
setSetting("backupScheduleTime", parsed.time);
|
||||
setSetting("backupScheduleDow", String(parsed.dayOfWeek));
|
||||
rescheduleBackup();
|
||||
}
|
||||
|
||||
// --- Job trigger action ---
|
||||
|
||||
export async function triggerJobAction(
|
||||
jobName: string,
|
||||
): Promise<{ ok: boolean }> {
|
||||
await requireAdmin();
|
||||
if (!jobName || typeof jobName !== "string") {
|
||||
throw new Error("Missing job name");
|
||||
}
|
||||
const triggered = await triggerJob(jobName);
|
||||
if (!triggered) throw new Error("Job not found");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// --- Backup restore action ---
|
||||
|
||||
const MAX_RESTORE_SIZE = 500 * 1024 * 1024; // 500MB
|
||||
|
||||
export async function restoreBackupAction(formData: FormData): Promise<void> {
|
||||
await requireAdmin();
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file) throw new Error("No file provided");
|
||||
if (file.size > MAX_RESTORE_SIZE) throw new Error("File too large");
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await restoreFromBackup(buffer);
|
||||
}
|
||||
|
||||
// --- Avatar actions ---
|
||||
|
||||
const MAX_AVATAR_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
const ALLOWED_AVATAR_TYPES = new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
]);
|
||||
const MIME_TO_EXT: Record<string, string> = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/webp": "webp",
|
||||
"image/gif": "gif",
|
||||
};
|
||||
|
||||
export async function uploadAvatarAction(
|
||||
formData: FormData,
|
||||
): Promise<{ imageUrl: string }> {
|
||||
const session = await requireSession();
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file) throw new Error("No file provided");
|
||||
if (file.size > MAX_AVATAR_SIZE) throw new Error("File too large (max 2MB)");
|
||||
if (!ALLOWED_AVATAR_TYPES.has(file.type))
|
||||
throw new Error("Invalid file type. Use JPEG, PNG, WebP, or GIF.");
|
||||
|
||||
await mkdir(AVATAR_DIR, { recursive: true });
|
||||
|
||||
// Remove any existing avatar for this user
|
||||
const glob = new Bun.Glob(`${session.user.id}.*`);
|
||||
const existing = await Array.fromAsync(glob.scan(AVATAR_DIR));
|
||||
for (const match of existing) {
|
||||
await Bun.file(path.join(AVATAR_DIR, match)).delete();
|
||||
}
|
||||
|
||||
// Write new avatar (atomic: temp file + rename)
|
||||
const ext = MIME_TO_EXT[file.type] || "jpg";
|
||||
const filename = `${session.user.id}.${ext}`;
|
||||
const filePath = path.join(AVATAR_DIR, filename);
|
||||
const tmpPath = `${filePath}.tmp.${Date.now()}`;
|
||||
await Bun.write(tmpPath, file);
|
||||
await rename(tmpPath, filePath);
|
||||
|
||||
// Update user via Better Auth (updates DB + refreshes session cookie)
|
||||
const imageUrl = `/api/avatars/${session.user.id}?v=${Date.now()}`;
|
||||
await auth.api.updateUser({
|
||||
body: { image: imageUrl },
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
return { imageUrl };
|
||||
}
|
||||
|
||||
export async function removeAvatarAction(): Promise<void> {
|
||||
const session = await requireSession();
|
||||
|
||||
// Remove file from disk
|
||||
const glob = new Bun.Glob(`${session.user.id}.*`);
|
||||
const matches = await Array.fromAsync(glob.scan(AVATAR_DIR));
|
||||
for (const match of matches) {
|
||||
await Bun.file(path.join(AVATAR_DIR, match)).delete();
|
||||
}
|
||||
|
||||
// Clear user via Better Auth (updates DB + refreshes session cookie)
|
||||
await auth.api.updateUser({
|
||||
body: { image: "" },
|
||||
headers: await headers(),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Name update action ---
|
||||
|
||||
export async function updateNameAction(name: string): Promise<void> {
|
||||
await requireSession();
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) throw new Error("Name cannot be empty");
|
||||
if (trimmed.length > 100) throw new Error("Name is too long");
|
||||
|
||||
await auth.api.updateUser({
|
||||
body: { name: trimmed },
|
||||
headers: await headers(),
|
||||
});
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/lib/auth/session";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { episodes } from "@/lib/db/schema";
|
||||
import { getOrFetchTitleByTmdbId } from "@/lib/services/metadata";
|
||||
import {
|
||||
logEpisodeWatch,
|
||||
logEpisodeWatchBatch,
|
||||
logMovieWatch,
|
||||
markAllEpisodesWatched,
|
||||
rateTitleStars,
|
||||
removeTitleStatus,
|
||||
setTitleStatus,
|
||||
unwatchEpisode,
|
||||
unwatchSeason,
|
||||
} from "@/lib/services/tracking";
|
||||
|
||||
async function getSessionUserId() {
|
||||
const session = await requireSession();
|
||||
return session.user.id;
|
||||
}
|
||||
|
||||
export async function resolveTitle(
|
||||
tmdbId: number,
|
||||
type: "movie" | "tv",
|
||||
): Promise<string | null> {
|
||||
await requireSession();
|
||||
const title = await getOrFetchTitleByTmdbId(tmdbId, type);
|
||||
return title?.id ?? null;
|
||||
}
|
||||
|
||||
export async function updateTitleStatus(
|
||||
titleId: string,
|
||||
status: "in_progress" | null,
|
||||
) {
|
||||
const userId = await getSessionUserId();
|
||||
if (status === null) {
|
||||
removeTitleStatus(userId, titleId);
|
||||
} else {
|
||||
setTitleStatus(userId, titleId, status);
|
||||
}
|
||||
}
|
||||
|
||||
export async function markAllWatchedAction(titleId: string) {
|
||||
const userId = await getSessionUserId();
|
||||
markAllEpisodesWatched(userId, titleId);
|
||||
}
|
||||
|
||||
const ratingSchema = z.number().int().min(0).max(5);
|
||||
|
||||
export async function updateTitleRating(titleId: string, ratingStars: number) {
|
||||
const userId = await getSessionUserId();
|
||||
rateTitleStars(userId, titleId, ratingSchema.parse(ratingStars));
|
||||
}
|
||||
|
||||
export async function watchMovie(titleId: string) {
|
||||
const userId = await getSessionUserId();
|
||||
logMovieWatch(userId, titleId);
|
||||
}
|
||||
|
||||
export async function watchEpisode(episodeId: string) {
|
||||
const userId = await getSessionUserId();
|
||||
logEpisodeWatch(userId, episodeId);
|
||||
}
|
||||
|
||||
export async function unwatchEpisodeAction(episodeId: string) {
|
||||
const userId = await getSessionUserId();
|
||||
unwatchEpisode(userId, episodeId);
|
||||
}
|
||||
|
||||
export async function watchSeason(seasonId: string) {
|
||||
const userId = await getSessionUserId();
|
||||
const seasonEps = db
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(eq(episodes.seasonId, seasonId))
|
||||
.all();
|
||||
logEpisodeWatchBatch(
|
||||
userId,
|
||||
seasonEps.map((ep) => ep.id),
|
||||
);
|
||||
}
|
||||
|
||||
export async function unwatchSeasonAction(seasonId: string) {
|
||||
const userId = await getSessionUserId();
|
||||
unwatchSeason(userId, seasonId);
|
||||
}
|
||||
|
||||
export async function batchWatchEpisodes(episodeIds: string[]) {
|
||||
const userId = await getSessionUserId();
|
||||
logEpisodeWatchBatch(userId, episodeIds);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { requireSession } from "@/lib/auth/session";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { userTitleStatus } from "@/lib/db/schema";
|
||||
import { getOrFetchTitleByTmdbId } from "@/lib/services/metadata";
|
||||
import { setTitleStatus } from "@/lib/services/tracking";
|
||||
|
||||
export async function quickAddToWatchlist(
|
||||
tmdbId: number,
|
||||
type: "movie" | "tv",
|
||||
) {
|
||||
const session = await requireSession();
|
||||
const userId = session.user.id;
|
||||
|
||||
const title = await getOrFetchTitleByTmdbId(tmdbId, type);
|
||||
if (!title) throw new Error("Failed to import title");
|
||||
|
||||
const existing = db
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, userId),
|
||||
eq(userTitleStatus.titleId, title.id),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
if (existing) {
|
||||
return { success: true, titleId: title.id, alreadyAdded: true };
|
||||
}
|
||||
|
||||
setTitleStatus(userId, title.id, "watchlist");
|
||||
return { success: true, titleId: title.id, alreadyAdded: false };
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { atom } from "jotai";
|
||||
import type { Season } from "@/lib/types";
|
||||
|
||||
export const titleIdAtom = atom("");
|
||||
export const titleTypeAtom = atom<"movie" | "tv">("movie");
|
||||
export const titleNameAtom = atom("");
|
||||
export const seasonsAtom = atom<Season[]>([]);
|
||||
export const userStatusAtom = atom<string | null>(null);
|
||||
export const userRatingAtom = atom(0);
|
||||
export const episodeWatchesAtom = atom<string[]>([]);
|
||||
export const watchingEpAtom = atom<string | null>(null);
|
||||
@@ -44,6 +44,7 @@ export const auth = betterAuth({
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 5 * 60,
|
||||
strategy: "jwt",
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
||||
+1
-1
@@ -301,7 +301,7 @@ async function refreshCreditsJob() {
|
||||
(castEntry.lastFetchedAt && castEntry.lastFetchedAt < stale);
|
||||
|
||||
if (needsRefresh) {
|
||||
await refreshCredits(titleId, { revalidate: false });
|
||||
await refreshCredits(titleId);
|
||||
await Bun.sleep(RATE_LIMIT_MS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import "server-only";
|
||||
import { createRouterClient } from "@orpc/server";
|
||||
import { headers } from "next/headers";
|
||||
import { router } from "./router";
|
||||
|
||||
globalThis.$orpcClient = createRouterClient(router, {
|
||||
context: async () => ({
|
||||
headers: await headers(),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createORPCClient } from "@orpc/client";
|
||||
import { RPCLink } from "@orpc/client/fetch";
|
||||
import type { ContractRouterClient } from "@orpc/contract";
|
||||
import type { contract } from "./contract";
|
||||
|
||||
declare global {
|
||||
var $orpcClient: ContractRouterClient<typeof contract> | undefined;
|
||||
}
|
||||
|
||||
const link = new RPCLink({
|
||||
url: () => {
|
||||
if (typeof window === "undefined") {
|
||||
throw new Error("RPCLink is not allowed on the server side.");
|
||||
}
|
||||
return `${window.location.origin}/rpc`;
|
||||
},
|
||||
});
|
||||
|
||||
export const client: ContractRouterClient<typeof contract> =
|
||||
globalThis.$orpcClient ?? createORPCClient(link);
|
||||
@@ -0,0 +1,8 @@
|
||||
import { implement } from "@orpc/server";
|
||||
import { contract } from "./contract";
|
||||
|
||||
export interface Context {
|
||||
headers: Headers;
|
||||
}
|
||||
|
||||
export const os = implement(contract).$context<Context>();
|
||||
@@ -0,0 +1,323 @@
|
||||
import { oc } from "@orpc/contract";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
BackupCreateOutput,
|
||||
BackupScheduleOutput,
|
||||
BackupsListOutput,
|
||||
BatchWatchInput,
|
||||
ContinueWatchingOutput,
|
||||
CreateIntegrationInput,
|
||||
DashboardRecommendationsOutput,
|
||||
DashboardStatsOutput,
|
||||
DiscoverInput,
|
||||
DiscoverOutput,
|
||||
FilenameParam,
|
||||
GenresOutput,
|
||||
IdParam,
|
||||
IntegrationOutput,
|
||||
IntegrationsListOutput,
|
||||
IntegrationTokenOutput,
|
||||
LibraryOutput,
|
||||
MediaTypeParam,
|
||||
PersonDetailOutput,
|
||||
PersonResolveOutput,
|
||||
PopularOutput,
|
||||
ProviderParam,
|
||||
QuickAddOutput,
|
||||
RegistrationOutput,
|
||||
RestoreBackupInput,
|
||||
SearchInput,
|
||||
SearchOutput,
|
||||
StatsInput,
|
||||
StatsOutput,
|
||||
SystemStatusOutput,
|
||||
TitleDetailOutput,
|
||||
TitleRecommendationsOutput,
|
||||
TitleResolveOutput,
|
||||
TmdbIdTypeParam,
|
||||
ToggleRegistrationInput,
|
||||
ToggleUpdateCheckInput,
|
||||
TrendingOutput,
|
||||
TrendingTypeParam,
|
||||
TriggerJobInput,
|
||||
TriggerJobOutput,
|
||||
UpdateCheckOutput,
|
||||
UpdateNameInput,
|
||||
UpdateRatingInput,
|
||||
UpdateScheduleInput,
|
||||
UpdateStatusInput,
|
||||
UploadAvatarInput,
|
||||
UploadAvatarOutput,
|
||||
UserInfoOutput,
|
||||
} from "./schemas";
|
||||
|
||||
export const contract = {
|
||||
titles: {
|
||||
detail: oc
|
||||
.route({ method: "GET", path: "/titles/{id}", tags: ["Titles"] })
|
||||
.input(IdParam)
|
||||
.output(TitleDetailOutput),
|
||||
resolve: oc
|
||||
.route({ method: "POST", path: "/titles/resolve", tags: ["Titles"] })
|
||||
.input(TmdbIdTypeParam)
|
||||
.output(TitleResolveOutput),
|
||||
updateStatus: oc
|
||||
.route({ method: "PUT", path: "/titles/{id}/status", tags: ["Titles"] })
|
||||
.input(UpdateStatusInput)
|
||||
.output(z.void()),
|
||||
updateRating: oc
|
||||
.route({ method: "PUT", path: "/titles/{id}/rating", tags: ["Titles"] })
|
||||
.input(UpdateRatingInput)
|
||||
.output(z.void()),
|
||||
watchMovie: oc
|
||||
.route({ method: "POST", path: "/titles/{id}/watch", tags: ["Titles"] })
|
||||
.input(IdParam)
|
||||
.output(z.void()),
|
||||
watchAll: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/titles/{id}/watch-all",
|
||||
tags: ["Titles"],
|
||||
})
|
||||
.input(IdParam)
|
||||
.output(z.void()),
|
||||
userInfo: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/titles/{id}/user-info",
|
||||
tags: ["Titles"],
|
||||
})
|
||||
.input(IdParam)
|
||||
.output(UserInfoOutput),
|
||||
recommendations: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/titles/{id}/recommendations",
|
||||
tags: ["Titles"],
|
||||
})
|
||||
.input(IdParam)
|
||||
.output(TitleRecommendationsOutput),
|
||||
},
|
||||
episodes: {
|
||||
watch: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/episodes/{id}/watch",
|
||||
tags: ["Episodes"],
|
||||
})
|
||||
.input(IdParam)
|
||||
.output(z.void()),
|
||||
unwatch: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/episodes/{id}/unwatch",
|
||||
tags: ["Episodes"],
|
||||
})
|
||||
.input(IdParam)
|
||||
.output(z.void()),
|
||||
batchWatch: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/episodes/batch-watch",
|
||||
tags: ["Episodes"],
|
||||
})
|
||||
.input(BatchWatchInput)
|
||||
.output(z.void()),
|
||||
},
|
||||
seasons: {
|
||||
watch: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/seasons/{id}/watch",
|
||||
tags: ["Seasons"],
|
||||
})
|
||||
.input(IdParam)
|
||||
.output(z.void()),
|
||||
unwatch: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/seasons/{id}/unwatch",
|
||||
tags: ["Seasons"],
|
||||
})
|
||||
.input(IdParam)
|
||||
.output(z.void()),
|
||||
},
|
||||
people: {
|
||||
detail: oc
|
||||
.route({ method: "GET", path: "/people/{id}", tags: ["People"] })
|
||||
.input(IdParam)
|
||||
.output(PersonDetailOutput),
|
||||
resolve: oc
|
||||
.route({ method: "POST", path: "/people/resolve", tags: ["People"] })
|
||||
.input(z.object({ tmdbId: z.number().int() }))
|
||||
.output(PersonResolveOutput),
|
||||
},
|
||||
dashboard: {
|
||||
stats: oc
|
||||
.route({ method: "GET", path: "/dashboard/stats", tags: ["Dashboard"] })
|
||||
.output(DashboardStatsOutput),
|
||||
continueWatching: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/dashboard/continue-watching",
|
||||
tags: ["Dashboard"],
|
||||
})
|
||||
.output(ContinueWatchingOutput),
|
||||
library: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/dashboard/library",
|
||||
tags: ["Dashboard"],
|
||||
})
|
||||
.output(LibraryOutput),
|
||||
recommendations: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/dashboard/recommendations",
|
||||
tags: ["Dashboard"],
|
||||
})
|
||||
.output(DashboardRecommendationsOutput),
|
||||
},
|
||||
explore: {
|
||||
trending: oc
|
||||
.route({ method: "GET", path: "/explore/trending", tags: ["Explore"] })
|
||||
.input(TrendingTypeParam)
|
||||
.output(TrendingOutput),
|
||||
popular: oc
|
||||
.route({ method: "GET", path: "/explore/popular", tags: ["Explore"] })
|
||||
.input(MediaTypeParam)
|
||||
.output(PopularOutput),
|
||||
genres: oc
|
||||
.route({ method: "GET", path: "/explore/genres", tags: ["Explore"] })
|
||||
.input(MediaTypeParam)
|
||||
.output(GenresOutput),
|
||||
},
|
||||
search: oc
|
||||
.route({ method: "GET", path: "/search", tags: ["Search"] })
|
||||
.input(SearchInput)
|
||||
.output(SearchOutput),
|
||||
discover: oc
|
||||
.route({ method: "GET", path: "/discover", tags: ["Discover"] })
|
||||
.input(DiscoverInput)
|
||||
.output(DiscoverOutput),
|
||||
stats: oc
|
||||
.route({ method: "GET", path: "/stats", tags: ["Stats"] })
|
||||
.input(StatsInput)
|
||||
.output(StatsOutput),
|
||||
systemStatus: oc
|
||||
.route({ method: "GET", path: "/system-status", tags: ["System"] })
|
||||
.output(SystemStatusOutput),
|
||||
integrations: {
|
||||
list: oc
|
||||
.route({ method: "GET", path: "/integrations", tags: ["Integrations"] })
|
||||
.output(IntegrationsListOutput),
|
||||
create: oc
|
||||
.route({ method: "POST", path: "/integrations", tags: ["Integrations"] })
|
||||
.input(CreateIntegrationInput)
|
||||
.output(IntegrationOutput),
|
||||
delete: oc
|
||||
.route({
|
||||
method: "DELETE",
|
||||
path: "/integrations/{provider}",
|
||||
tags: ["Integrations"],
|
||||
})
|
||||
.input(ProviderParam)
|
||||
.output(z.void()),
|
||||
regenerateToken: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/integrations/{provider}/regenerate-token",
|
||||
tags: ["Integrations"],
|
||||
})
|
||||
.input(ProviderParam)
|
||||
.output(IntegrationTokenOutput),
|
||||
},
|
||||
admin: {
|
||||
backups: {
|
||||
list: oc
|
||||
.route({ method: "GET", path: "/admin/backups", tags: ["Admin"] })
|
||||
.output(BackupsListOutput),
|
||||
create: oc
|
||||
.route({ method: "POST", path: "/admin/backups", tags: ["Admin"] })
|
||||
.input(z.void())
|
||||
.output(BackupCreateOutput),
|
||||
delete: oc
|
||||
.route({
|
||||
method: "DELETE",
|
||||
path: "/admin/backups/{filename}",
|
||||
tags: ["Admin"],
|
||||
})
|
||||
.input(FilenameParam)
|
||||
.output(z.void()),
|
||||
restore: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/admin/backups/restore",
|
||||
tags: ["Admin"],
|
||||
})
|
||||
.input(RestoreBackupInput)
|
||||
.output(z.void()),
|
||||
schedule: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/admin/backups/schedule",
|
||||
tags: ["Admin"],
|
||||
})
|
||||
.output(BackupScheduleOutput),
|
||||
updateSchedule: oc
|
||||
.route({
|
||||
method: "PUT",
|
||||
path: "/admin/backups/schedule",
|
||||
tags: ["Admin"],
|
||||
})
|
||||
.input(UpdateScheduleInput)
|
||||
.output(z.void()),
|
||||
},
|
||||
registration: oc
|
||||
.route({ method: "GET", path: "/admin/registration", tags: ["Admin"] })
|
||||
.output(RegistrationOutput),
|
||||
toggleRegistration: oc
|
||||
.route({ method: "PUT", path: "/admin/registration", tags: ["Admin"] })
|
||||
.input(ToggleRegistrationInput)
|
||||
.output(z.void()),
|
||||
updateCheck: oc
|
||||
.route({ method: "GET", path: "/admin/update-check", tags: ["Admin"] })
|
||||
.output(UpdateCheckOutput),
|
||||
toggleUpdateCheck: oc
|
||||
.route({ method: "PUT", path: "/admin/update-check", tags: ["Admin"] })
|
||||
.input(ToggleUpdateCheckInput)
|
||||
.output(z.void()),
|
||||
triggerJob: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/admin/jobs/trigger",
|
||||
tags: ["Admin"],
|
||||
})
|
||||
.input(TriggerJobInput)
|
||||
.output(TriggerJobOutput),
|
||||
},
|
||||
account: {
|
||||
updateName: oc
|
||||
.route({ method: "PUT", path: "/account/name", tags: ["Account"] })
|
||||
.input(UpdateNameInput)
|
||||
.output(z.void()),
|
||||
uploadAvatar: oc
|
||||
.route({ method: "POST", path: "/account/avatar", tags: ["Account"] })
|
||||
.input(UploadAvatarInput)
|
||||
.output(UploadAvatarOutput),
|
||||
removeAvatar: oc
|
||||
.route({ method: "DELETE", path: "/account/avatar", tags: ["Account"] })
|
||||
.input(z.void())
|
||||
.output(z.void()),
|
||||
},
|
||||
watchlist: {
|
||||
quickAdd: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/watchlist/quick-add",
|
||||
tags: ["Watchlist"],
|
||||
})
|
||||
.input(TmdbIdTypeParam)
|
||||
.output(QuickAddOutput),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { onError } from "@orpc/server";
|
||||
import { RPCHandler } from "@orpc/server/fetch";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { router } from "./router";
|
||||
|
||||
const log = createLogger("orpc");
|
||||
|
||||
export const handler = new RPCHandler(router, {
|
||||
interceptors: [
|
||||
onError((error) => {
|
||||
log.error("oRPC error", { error });
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { oo } from "@orpc/openapi";
|
||||
import { os as baseOs, ORPCError } from "@orpc/server";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
|
||||
const base = baseOs.$context<{ headers: Headers }>();
|
||||
|
||||
export const authed = oo.spec(
|
||||
base.middleware(async ({ context, next }) => {
|
||||
const sessionData = await auth.api.getSession({
|
||||
headers: context.headers,
|
||||
});
|
||||
if (!sessionData?.session || !sessionData?.user) {
|
||||
throw new ORPCError("UNAUTHORIZED");
|
||||
}
|
||||
return next({
|
||||
context: {
|
||||
user: sessionData.user,
|
||||
session: sessionData.session,
|
||||
},
|
||||
});
|
||||
}),
|
||||
{ security: [{ session: [] }] },
|
||||
);
|
||||
|
||||
export const admin = oo.spec(
|
||||
base.middleware(async ({ context, next }) => {
|
||||
const sessionData = await auth.api.getSession({
|
||||
headers: context.headers,
|
||||
});
|
||||
if (!sessionData?.session || !sessionData?.user) {
|
||||
throw new ORPCError("UNAUTHORIZED");
|
||||
}
|
||||
if (sessionData.user.role !== "admin") {
|
||||
throw new ORPCError("FORBIDDEN");
|
||||
}
|
||||
return next({
|
||||
context: {
|
||||
user: sessionData.user,
|
||||
session: sessionData.session,
|
||||
},
|
||||
});
|
||||
}),
|
||||
{ security: [{ session: [] }] },
|
||||
);
|
||||
@@ -0,0 +1,48 @@
|
||||
import { SmartCoercionPlugin } from "@orpc/json-schema";
|
||||
import { OpenAPIHandler } from "@orpc/openapi/fetch";
|
||||
import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins";
|
||||
import { onError } from "@orpc/server";
|
||||
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { router } from "./router";
|
||||
|
||||
const log = createLogger("openapi");
|
||||
|
||||
const isSecure = (process.env.BETTER_AUTH_URL ?? "").startsWith("https://");
|
||||
const sessionCookieName = isSecure
|
||||
? "__Secure-better-auth.session_token"
|
||||
: "better-auth.session_token";
|
||||
|
||||
// https://orpc.dev/docs/openapi/plugins/smart-coercion
|
||||
const schemaConverters = [new ZodToJsonSchemaConverter()];
|
||||
|
||||
export const openApiHandler = new OpenAPIHandler(router, {
|
||||
plugins: [
|
||||
new SmartCoercionPlugin({ schemaConverters }),
|
||||
new OpenAPIReferencePlugin({
|
||||
schemaConverters,
|
||||
specGenerateOptions: {
|
||||
info: {
|
||||
title: "Sofa API",
|
||||
version: process.env.APP_VERSION || "0.0.0",
|
||||
},
|
||||
servers: [{ url: "/api/v1" }],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
session: {
|
||||
type: "apiKey",
|
||||
name: sessionCookieName,
|
||||
in: "cookie",
|
||||
description: "Better Auth session cookie",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
interceptors: [
|
||||
onError((error) => {
|
||||
log.error("OpenAPI error", { error });
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { mkdir, rename } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { AVATAR_DIR } from "@/lib/constants";
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
const MIME_TO_EXT: Record<string, string> = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/webp": "webp",
|
||||
"image/gif": "gif",
|
||||
};
|
||||
|
||||
export const updateName = os.account.updateName
|
||||
.use(authed)
|
||||
.handler(async ({ input, context }) => {
|
||||
await auth.api.updateUser({
|
||||
body: { name: input.name },
|
||||
headers: context.headers,
|
||||
});
|
||||
});
|
||||
|
||||
export const uploadAvatar = os.account.uploadAvatar
|
||||
.use(authed)
|
||||
.handler(async ({ input: file, context }) => {
|
||||
await mkdir(AVATAR_DIR, { recursive: true });
|
||||
|
||||
// Write new avatar first (atomic: temp file + rename)
|
||||
const ext = MIME_TO_EXT[file.type] || "jpg";
|
||||
const filename = `${context.user.id}.${ext}`;
|
||||
const filePath = path.join(AVATAR_DIR, filename);
|
||||
const tmpPath = `${filePath}.tmp.${Date.now()}`;
|
||||
await Bun.write(tmpPath, file);
|
||||
await rename(tmpPath, filePath);
|
||||
|
||||
// Remove any previous avatar with a different extension
|
||||
const glob = new Bun.Glob(`${context.user.id}.*`);
|
||||
const existing = await Array.fromAsync(glob.scan(AVATAR_DIR));
|
||||
for (const match of existing) {
|
||||
if (match !== filename) {
|
||||
await Bun.file(path.join(AVATAR_DIR, match)).delete();
|
||||
}
|
||||
}
|
||||
|
||||
// Update user via Better Auth
|
||||
const imageUrl = `/api/avatars/${context.user.id}?v=${Date.now()}`;
|
||||
await auth.api.updateUser({
|
||||
body: { image: imageUrl },
|
||||
headers: context.headers,
|
||||
});
|
||||
|
||||
return { imageUrl };
|
||||
});
|
||||
|
||||
export const removeAvatar = os.account.removeAvatar
|
||||
.use(authed)
|
||||
.handler(async ({ context }) => {
|
||||
const glob = new Bun.Glob(`${context.user.id}.*`);
|
||||
const matches = await Array.fromAsync(glob.scan(AVATAR_DIR));
|
||||
for (const match of matches) {
|
||||
await Bun.file(path.join(AVATAR_DIR, match)).delete();
|
||||
}
|
||||
await auth.api.updateUser({
|
||||
body: { image: "" },
|
||||
headers: context.headers,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import path from "node:path";
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { BACKUP_DIR } from "@/lib/constants";
|
||||
import { rescheduleBackup, triggerJob } from "@/lib/cron";
|
||||
import {
|
||||
createBackup,
|
||||
deleteBackup,
|
||||
ensureBackupDir,
|
||||
listBackups,
|
||||
restoreFromBackup,
|
||||
} from "@/lib/services/backup";
|
||||
import { getSetting, setSetting } from "@/lib/services/settings";
|
||||
import {
|
||||
getCachedUpdateCheck,
|
||||
isUpdateCheckEnabled,
|
||||
} from "@/lib/services/update-check";
|
||||
import { os } from "../context";
|
||||
import { admin } from "../middleware";
|
||||
|
||||
// ─── Backups ───────────────────────────────────────────────────
|
||||
|
||||
export const backupsList = os.admin.backups.list
|
||||
.use(admin)
|
||||
.handler(async () => {
|
||||
const backups = await listBackups();
|
||||
return { backups };
|
||||
});
|
||||
|
||||
export const backupsCreate = os.admin.backups.create
|
||||
.use(admin)
|
||||
.handler(async () => {
|
||||
return await createBackup();
|
||||
});
|
||||
|
||||
export const backupsDelete = os.admin.backups.delete
|
||||
.use(admin)
|
||||
.handler(async ({ input }) => {
|
||||
await deleteBackup(input.filename);
|
||||
});
|
||||
|
||||
export const backupsRestore = os.admin.backups.restore
|
||||
.use(admin)
|
||||
.handler(async ({ input: file }) => {
|
||||
// Stream upload to disk to avoid buffering the entire file in memory
|
||||
await ensureBackupDir();
|
||||
const tmpPath = path.join(
|
||||
BACKUP_DIR,
|
||||
`.upload-${Date.now()}-${crypto.randomUUID()}.db`,
|
||||
);
|
||||
try {
|
||||
await Bun.write(tmpPath, file);
|
||||
await restoreFromBackup(tmpPath);
|
||||
} catch (err) {
|
||||
// Clean up the upload file if restoreFromBackup didn't consume it
|
||||
const f = Bun.file(tmpPath);
|
||||
if (await f.exists()) await f.delete();
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
export const backupsSchedule = os.admin.backups.schedule
|
||||
.use(admin)
|
||||
.handler(() => {
|
||||
return {
|
||||
enabled: getSetting("scheduledBackups") === "true",
|
||||
maxRetention: Number.parseInt(
|
||||
getSetting("maxBackupRetention") ?? "7",
|
||||
10,
|
||||
),
|
||||
frequency: getSetting("backupScheduleFrequency") ?? "1d",
|
||||
time: getSetting("backupScheduleTime") ?? "02:00",
|
||||
dayOfWeek: Number.parseInt(getSetting("backupScheduleDow") ?? "0", 10),
|
||||
};
|
||||
});
|
||||
|
||||
export const backupsUpdateSchedule = os.admin.backups.updateSchedule
|
||||
.use(admin)
|
||||
.handler(({ input }) => {
|
||||
if (input.enabled !== undefined)
|
||||
setSetting("scheduledBackups", String(input.enabled));
|
||||
if (input.frequency !== undefined)
|
||||
setSetting("backupScheduleFrequency", input.frequency);
|
||||
if (input.time !== undefined) setSetting("backupScheduleTime", input.time);
|
||||
if (input.dayOfWeek !== undefined)
|
||||
setSetting("backupScheduleDow", String(input.dayOfWeek));
|
||||
if (input.maxRetention !== undefined)
|
||||
setSetting("maxBackupRetention", String(input.maxRetention));
|
||||
|
||||
if (input.frequency || input.time || input.dayOfWeek !== undefined) {
|
||||
rescheduleBackup();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Registration ──────────────────────────────────────────────
|
||||
|
||||
export const registration = os.admin.registration.use(admin).handler(() => {
|
||||
return { open: getSetting("registrationOpen") === "true" };
|
||||
});
|
||||
|
||||
export const toggleRegistration = os.admin.toggleRegistration
|
||||
.use(admin)
|
||||
.handler(({ input }) => {
|
||||
setSetting("registrationOpen", String(input.open));
|
||||
});
|
||||
|
||||
// ─── Update Check ──────────────────────────────────────────────
|
||||
|
||||
export const updateCheck = os.admin.updateCheck.use(admin).handler(() => {
|
||||
const enabled = isUpdateCheckEnabled();
|
||||
const check = enabled ? getCachedUpdateCheck() : null;
|
||||
return { enabled, updateCheck: check };
|
||||
});
|
||||
|
||||
export const toggleUpdateCheck = os.admin.toggleUpdateCheck
|
||||
.use(admin)
|
||||
.handler(({ input }) => {
|
||||
setSetting("updateCheckEnabled", String(input.enabled));
|
||||
});
|
||||
|
||||
// ─── Jobs ──────────────────────────────────────────────────────
|
||||
|
||||
export const triggerJobProcedure = os.admin.triggerJob
|
||||
.use(admin)
|
||||
.handler(async ({ input }) => {
|
||||
const triggered = await triggerJob(input.name);
|
||||
if (!triggered) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Job not found" });
|
||||
}
|
||||
return { ok: true as const };
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
getContinueWatchingFeed,
|
||||
getNewAvailableFeed,
|
||||
getRecommendationsFeed,
|
||||
getUserStats,
|
||||
} from "@/lib/services/discovery";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
export const stats = os.dashboard.stats.use(authed).handler(({ context }) => {
|
||||
return getUserStats(context.user.id);
|
||||
});
|
||||
|
||||
export const continueWatching = os.dashboard.continueWatching
|
||||
.use(authed)
|
||||
.handler(({ context }) => {
|
||||
const feed = getContinueWatchingFeed(context.user.id);
|
||||
const items = feed.map((item) => ({
|
||||
title: {
|
||||
id: item.title.id,
|
||||
title: item.title.title,
|
||||
backdropPath: tmdbImageUrl(item.title.backdropPath, "backdrops"),
|
||||
},
|
||||
nextEpisode: item.nextEpisode
|
||||
? {
|
||||
seasonNumber: item.nextEpisode.seasonNumber,
|
||||
episodeNumber: item.nextEpisode.episodeNumber,
|
||||
name: item.nextEpisode.name,
|
||||
stillPath: tmdbImageUrl(item.nextEpisode.stillPath, "stills"),
|
||||
}
|
||||
: null,
|
||||
totalEpisodes: item.totalEpisodes,
|
||||
watchedEpisodes: item.watchedEpisodes,
|
||||
}));
|
||||
return { items };
|
||||
});
|
||||
|
||||
export const library = os.dashboard.library
|
||||
.use(authed)
|
||||
.handler(({ context }) => {
|
||||
const feed = getNewAvailableFeed(context.user.id);
|
||||
const items = feed.slice(0, 10).map((t) => ({
|
||||
id: t.titleId,
|
||||
tmdbId: t.tmdbId,
|
||||
type: t.type,
|
||||
title: t.title,
|
||||
posterPath: tmdbImageUrl(t.posterPath, "posters"),
|
||||
releaseDate: t.releaseDate ?? t.firstAirDate ?? null,
|
||||
voteAverage: t.voteAverage,
|
||||
userStatus: t.userStatus,
|
||||
}));
|
||||
return { items };
|
||||
});
|
||||
|
||||
export const recommendations = os.dashboard.recommendations
|
||||
.use(authed)
|
||||
.handler(({ context }) => {
|
||||
const feed = getRecommendationsFeed(context.user.id);
|
||||
const items = feed
|
||||
.filter((t): t is NonNullable<typeof t> => t != null)
|
||||
.slice(0, 10)
|
||||
.map((t) => ({
|
||||
id: t.id,
|
||||
tmdbId: t.tmdbId,
|
||||
type: t.type,
|
||||
title: t.title,
|
||||
posterPath: tmdbImageUrl(t.posterPath, "posters"),
|
||||
releaseDate: t.releaseDate ?? t.firstAirDate ?? null,
|
||||
voteAverage: t.voteAverage,
|
||||
}));
|
||||
return { items };
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { isTmdbConfigured } from "@/lib/config";
|
||||
import {
|
||||
getEpisodeProgressByTmdbIds,
|
||||
getUserStatusesByTmdbIds,
|
||||
} from "@/lib/services/tracking";
|
||||
import { discover } from "@/lib/tmdb/client";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
export const discoverProcedure = os.discover
|
||||
.use(authed)
|
||||
.handler(async ({ input, context }) => {
|
||||
if (!isTmdbConfigured()) {
|
||||
throw new ORPCError("PRECONDITION_FAILED", {
|
||||
message: "TMDB API key is not configured.",
|
||||
});
|
||||
}
|
||||
|
||||
const results = await discover(input.mediaType, {
|
||||
sort_by: "popularity.desc",
|
||||
"vote_count.gte": "50",
|
||||
with_genres: String(input.genreId),
|
||||
});
|
||||
|
||||
type DiscoverResult = NonNullable<typeof results.results>[number] & {
|
||||
title?: string;
|
||||
name?: string;
|
||||
release_date?: string;
|
||||
first_air_date?: string;
|
||||
};
|
||||
|
||||
const items = ((results.results ?? []) as DiscoverResult[])
|
||||
.filter((r) => r.poster_path)
|
||||
.map((r) => ({
|
||||
tmdbId: r.id,
|
||||
type: input.mediaType,
|
||||
title: r.title ?? r.name ?? "",
|
||||
posterPath: tmdbImageUrl(r.poster_path ?? null, "posters"),
|
||||
releaseDate: r.release_date ?? r.first_air_date ?? null,
|
||||
voteAverage: r.vote_average,
|
||||
}));
|
||||
|
||||
const lookups = items.map((r) => ({ tmdbId: r.tmdbId, type: r.type }));
|
||||
const [userStatuses, episodeProgress] =
|
||||
lookups.length > 0
|
||||
? [
|
||||
getUserStatusesByTmdbIds(context.user.id, lookups),
|
||||
getEpisodeProgressByTmdbIds(context.user.id, lookups),
|
||||
]
|
||||
: [{}, {}];
|
||||
|
||||
return { items, userStatuses, episodeProgress };
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
logEpisodeWatch,
|
||||
logEpisodeWatchBatch,
|
||||
unwatchEpisode,
|
||||
} from "@/lib/services/tracking";
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
export const watch = os.episodes.watch
|
||||
.use(authed)
|
||||
.handler(({ input, context }) => {
|
||||
logEpisodeWatch(context.user.id, input.id);
|
||||
});
|
||||
|
||||
export const unwatch = os.episodes.unwatch
|
||||
.use(authed)
|
||||
.handler(({ input, context }) => {
|
||||
unwatchEpisode(context.user.id, input.id);
|
||||
});
|
||||
|
||||
export const batchWatch = os.episodes.batchWatch
|
||||
.use(authed)
|
||||
.handler(({ input, context }) => {
|
||||
logEpisodeWatchBatch(context.user.id, input.episodeIds);
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { isTmdbConfigured } from "@/lib/config";
|
||||
import {
|
||||
getEpisodeProgressByTmdbIds,
|
||||
getUserStatusesByTmdbIds,
|
||||
} from "@/lib/services/tracking";
|
||||
import { getGenres, getPopular, getTrending } from "@/lib/tmdb/client";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
function requireTmdb() {
|
||||
if (!isTmdbConfigured()) {
|
||||
throw new ORPCError("PRECONDITION_FAILED", {
|
||||
message: "TMDB API key is not configured.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const trending = os.explore.trending
|
||||
.use(authed)
|
||||
.handler(async ({ input, context }) => {
|
||||
requireTmdb();
|
||||
|
||||
const data = await getTrending(input.type, "day");
|
||||
const results = (data.results ?? []) as Record<string, unknown>[];
|
||||
|
||||
const items = results
|
||||
.filter((r) => r.poster_path)
|
||||
.map((r) => {
|
||||
const mediaType =
|
||||
r.media_type === "movie" || r.media_type === "tv"
|
||||
? r.media_type
|
||||
: "movie";
|
||||
return {
|
||||
tmdbId: r.id as number,
|
||||
type: mediaType as "movie" | "tv",
|
||||
title: ((r.title ?? r.name) as string) || "",
|
||||
posterPath: tmdbImageUrl(
|
||||
(r.poster_path as string) ?? null,
|
||||
"posters",
|
||||
),
|
||||
releaseDate: ((r.release_date ?? r.first_air_date) as string) ?? null,
|
||||
voteAverage: r.vote_average as number,
|
||||
};
|
||||
});
|
||||
|
||||
const heroResult = results.find(
|
||||
(r) =>
|
||||
r.backdrop_path && (r.media_type === "movie" || r.media_type === "tv"),
|
||||
);
|
||||
const hero = heroResult
|
||||
? {
|
||||
tmdbId: heroResult.id as number,
|
||||
type: heroResult.media_type as "movie" | "tv",
|
||||
title:
|
||||
((heroResult.title ?? heroResult.name) as string | undefined) ?? "",
|
||||
overview: (heroResult.overview as string | undefined) ?? "",
|
||||
backdropPath: tmdbImageUrl(
|
||||
(heroResult.backdrop_path as string) ?? null,
|
||||
"backdrops",
|
||||
),
|
||||
voteAverage: heroResult.vote_average as number,
|
||||
}
|
||||
: null;
|
||||
|
||||
const lookups = items.map((r) => ({ tmdbId: r.tmdbId, type: r.type }));
|
||||
const [userStatuses, episodeProgress] =
|
||||
lookups.length > 0
|
||||
? [
|
||||
getUserStatusesByTmdbIds(context.user.id, lookups),
|
||||
getEpisodeProgressByTmdbIds(context.user.id, lookups),
|
||||
]
|
||||
: [{}, {}];
|
||||
|
||||
return { items, hero, userStatuses, episodeProgress };
|
||||
});
|
||||
|
||||
export const popular = os.explore.popular
|
||||
.use(authed)
|
||||
.handler(async ({ input, context }) => {
|
||||
requireTmdb();
|
||||
|
||||
const data = await getPopular(input.type);
|
||||
const items = ((data.results ?? []) as Record<string, unknown>[])
|
||||
.filter((r) => r.poster_path)
|
||||
.map((r) => ({
|
||||
tmdbId: r.id as number,
|
||||
type: input.type,
|
||||
title: ((r.title ?? r.name) as string) || "",
|
||||
posterPath: tmdbImageUrl((r.poster_path as string) ?? null, "posters"),
|
||||
releaseDate: ((r.release_date ?? r.first_air_date) as string) ?? null,
|
||||
voteAverage: r.vote_average as number,
|
||||
}));
|
||||
|
||||
const lookups = items.map((r) => ({ tmdbId: r.tmdbId, type: r.type }));
|
||||
const [userStatuses, episodeProgress] =
|
||||
lookups.length > 0
|
||||
? [
|
||||
getUserStatusesByTmdbIds(context.user.id, lookups),
|
||||
getEpisodeProgressByTmdbIds(context.user.id, lookups),
|
||||
]
|
||||
: [{}, {}];
|
||||
|
||||
return { items, userStatuses, episodeProgress };
|
||||
});
|
||||
|
||||
export const genres = os.explore.genres
|
||||
.use(authed)
|
||||
.handler(async ({ input }) => {
|
||||
requireTmdb();
|
||||
const data = await getGenres(input.type);
|
||||
return {
|
||||
genres: (data.genres ?? []).map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name ?? "",
|
||||
})),
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { integrationEvents, integrations } from "@/lib/db/schema";
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
const LIST_PROVIDERS = new Set(["sonarr", "radarr"]);
|
||||
|
||||
function integrationTypeFor(provider: string): "webhook" | "list" {
|
||||
return LIST_PROVIDERS.has(provider) ? "list" : "webhook";
|
||||
}
|
||||
|
||||
function generateToken() {
|
||||
return Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString(
|
||||
"hex",
|
||||
);
|
||||
}
|
||||
|
||||
function serializeIntegration(row: {
|
||||
id: string;
|
||||
provider: string;
|
||||
type: "webhook" | "list";
|
||||
token: string;
|
||||
enabled: boolean;
|
||||
lastEventAt: Date | null;
|
||||
createdAt: Date;
|
||||
}) {
|
||||
return {
|
||||
...row,
|
||||
lastEventAt: row.lastEventAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export const list = os.integrations.list.use(authed).handler(({ context }) => {
|
||||
const userIntegrations = db
|
||||
.select()
|
||||
.from(integrations)
|
||||
.where(eq(integrations.userId, context.user.id))
|
||||
.all();
|
||||
|
||||
const eventsByIntegration = new Map<
|
||||
string,
|
||||
(typeof integrationEvents.$inferSelect)[]
|
||||
>();
|
||||
for (const integration of userIntegrations) {
|
||||
const events = db
|
||||
.select()
|
||||
.from(integrationEvents)
|
||||
.where(eq(integrationEvents.integrationId, integration.id))
|
||||
.orderBy(desc(integrationEvents.receivedAt))
|
||||
.limit(10)
|
||||
.all();
|
||||
eventsByIntegration.set(integration.id, events);
|
||||
}
|
||||
|
||||
const result = userIntegrations.map((integration) => {
|
||||
const events = eventsByIntegration.get(integration.id) ?? [];
|
||||
return {
|
||||
...serializeIntegration(integration),
|
||||
recentEvents: events.map((e) => ({
|
||||
id: e.id,
|
||||
eventType: e.eventType,
|
||||
mediaType: e.mediaType,
|
||||
mediaTitle: e.mediaTitle,
|
||||
status: e.status,
|
||||
receivedAt: e.receivedAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
return { integrations: result };
|
||||
});
|
||||
|
||||
export const create = os.integrations.create
|
||||
.use(authed)
|
||||
.handler(({ input, context }) => {
|
||||
const existing = db
|
||||
.select()
|
||||
.from(integrations)
|
||||
.where(
|
||||
and(
|
||||
eq(integrations.userId, context.user.id),
|
||||
eq(integrations.provider, input.provider),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
if (existing) {
|
||||
if (input.enabled !== undefined) {
|
||||
const row = db
|
||||
.update(integrations)
|
||||
.set({ enabled: input.enabled })
|
||||
.where(eq(integrations.id, existing.id))
|
||||
.returning()
|
||||
.get();
|
||||
return serializeIntegration(row);
|
||||
}
|
||||
return serializeIntegration(existing);
|
||||
}
|
||||
|
||||
const row = db
|
||||
.insert(integrations)
|
||||
.values({
|
||||
userId: context.user.id,
|
||||
provider: input.provider,
|
||||
type: integrationTypeFor(input.provider),
|
||||
token: generateToken(),
|
||||
enabled: input.enabled ?? true,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
return serializeIntegration(row);
|
||||
});
|
||||
|
||||
export const deleteProcedure = os.integrations.delete
|
||||
.use(authed)
|
||||
.handler(({ input, context }) => {
|
||||
db.delete(integrations)
|
||||
.where(
|
||||
and(
|
||||
eq(integrations.userId, context.user.id),
|
||||
eq(integrations.provider, input.provider),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
});
|
||||
|
||||
export const regenerateToken = os.integrations.regenerateToken
|
||||
.use(authed)
|
||||
.handler(({ input, context }) => {
|
||||
const row = db
|
||||
.update(integrations)
|
||||
.set({ token: generateToken() })
|
||||
.where(
|
||||
and(
|
||||
eq(integrations.userId, context.user.id),
|
||||
eq(integrations.provider, input.provider),
|
||||
),
|
||||
)
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
if (!row) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Integration not found" });
|
||||
}
|
||||
|
||||
return serializeIntegration(row);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import {
|
||||
getLocalFilmography,
|
||||
getOrFetchPerson,
|
||||
getOrFetchPersonByTmdbId,
|
||||
} from "@/lib/services/person";
|
||||
import { getUserStatusesByTitleIds } from "@/lib/services/tracking";
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
export const detail = os.people.detail
|
||||
.use(authed)
|
||||
.handler(async ({ input, context }) => {
|
||||
const person = await getOrFetchPerson(input.id);
|
||||
if (!person)
|
||||
throw new ORPCError("NOT_FOUND", { message: "Person not found" });
|
||||
|
||||
const filmography = getLocalFilmography(person.id);
|
||||
const userStatuses = getUserStatusesByTitleIds(
|
||||
context.user.id,
|
||||
filmography.map((c) => c.titleId),
|
||||
);
|
||||
|
||||
return { person, filmography, userStatuses };
|
||||
});
|
||||
|
||||
export const resolve = os.people.resolve
|
||||
.use(authed)
|
||||
.handler(async ({ input }) => {
|
||||
const person = await getOrFetchPersonByTmdbId(input.tmdbId);
|
||||
if (!person)
|
||||
throw new ORPCError("NOT_FOUND", { message: "Person not found" });
|
||||
return { id: person.id };
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { isTmdbConfigured } from "@/lib/config";
|
||||
import {
|
||||
searchMovies,
|
||||
searchMulti,
|
||||
searchPerson,
|
||||
searchTv,
|
||||
} from "@/lib/tmdb/client";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
export const search = os.search.use(authed).handler(async ({ input }) => {
|
||||
if (!isTmdbConfigured()) {
|
||||
throw new ORPCError("PRECONDITION_FAILED", {
|
||||
message: "TMDB API key is not configured.",
|
||||
});
|
||||
}
|
||||
|
||||
const query = input.query.trim();
|
||||
if (!query) {
|
||||
return { results: [] };
|
||||
}
|
||||
const type = input.type ?? null;
|
||||
|
||||
if (type === "person") {
|
||||
const personResults = await searchPerson(query);
|
||||
return {
|
||||
results: (personResults.results ?? []).map((r) => ({
|
||||
tmdbId: r.id,
|
||||
type: "person" as const,
|
||||
title: r.name ?? "",
|
||||
posterPath: null,
|
||||
profilePath: tmdbImageUrl(r.profile_path ?? null, "profiles"),
|
||||
overview: "",
|
||||
releaseDate: null,
|
||||
popularity: r.popularity,
|
||||
voteAverage: 0,
|
||||
knownForDepartment: r.known_for_department,
|
||||
knownFor: r.known_for
|
||||
?.slice(0, 3)
|
||||
.map((k) => k.title ?? (k as { name?: string }).name)
|
||||
.filter(Boolean) as string[] | undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const raw =
|
||||
type === "movie"
|
||||
? await searchMovies(query)
|
||||
: type === "tv"
|
||||
? await searchTv(query)
|
||||
: await searchMulti(query);
|
||||
|
||||
type SearchResult = {
|
||||
id: number;
|
||||
media_type?: string;
|
||||
title?: string;
|
||||
name?: string;
|
||||
overview?: string;
|
||||
poster_path?: string | null;
|
||||
profile_path?: string | null;
|
||||
release_date?: string;
|
||||
first_air_date?: string;
|
||||
popularity?: number;
|
||||
vote_average?: number;
|
||||
};
|
||||
|
||||
const mapped = ((raw.results ?? []) as SearchResult[])
|
||||
.map((r) => {
|
||||
if (r.media_type === "person") {
|
||||
return {
|
||||
tmdbId: r.id,
|
||||
type: "person" as const,
|
||||
title: r.name ?? "Unknown",
|
||||
posterPath: null,
|
||||
profilePath: tmdbImageUrl(r.profile_path ?? null, "profiles"),
|
||||
overview: "",
|
||||
releaseDate: null,
|
||||
popularity: r.popularity ?? 0,
|
||||
voteAverage: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const mediaType =
|
||||
r.media_type === "movie" || r.media_type === "tv" ? r.media_type : type;
|
||||
if (!mediaType) return null;
|
||||
|
||||
return {
|
||||
tmdbId: r.id,
|
||||
type: mediaType,
|
||||
title: r.title ?? r.name ?? "",
|
||||
overview: r.overview ?? "",
|
||||
releaseDate: r.release_date ?? r.first_air_date ?? null,
|
||||
posterPath: tmdbImageUrl(r.poster_path ?? null, "posters"),
|
||||
popularity: r.popularity ?? 0,
|
||||
voteAverage: r.vote_average ?? 0,
|
||||
};
|
||||
})
|
||||
.filter((r): r is NonNullable<typeof r> => r !== null);
|
||||
|
||||
return { results: mapped };
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { episodes } from "@/lib/db/schema";
|
||||
import { logEpisodeWatchBatch, unwatchSeason } from "@/lib/services/tracking";
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
export const watch = os.seasons.watch
|
||||
.use(authed)
|
||||
.handler(({ input, context }) => {
|
||||
const seasonEps = db
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(eq(episodes.seasonId, input.id))
|
||||
.all();
|
||||
logEpisodeWatchBatch(
|
||||
context.user.id,
|
||||
seasonEps.map((ep) => ep.id),
|
||||
);
|
||||
});
|
||||
|
||||
export const unwatch = os.seasons.unwatch
|
||||
.use(authed)
|
||||
.handler(({ input, context }) => {
|
||||
unwatchSeason(context.user.id, input.id);
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { getWatchCount, getWatchHistory } from "@/lib/services/discovery";
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
export const statsProcedure = os.stats
|
||||
.use(authed)
|
||||
.handler(({ input, context }) => {
|
||||
const count = getWatchCount(context.user.id, input.type, input.period);
|
||||
const history = getWatchHistory(context.user.id, input.type, input.period);
|
||||
return { count, history };
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { isTmdbConfigured } from "@/lib/config";
|
||||
import { getSystemHealth } from "@/lib/services/system-health";
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
export const systemStatus = os.systemStatus
|
||||
.use(authed)
|
||||
.handler(async ({ context }) => {
|
||||
const tmdbConfigured = isTmdbConfigured();
|
||||
|
||||
if (context.user.role === "admin") {
|
||||
const health = await getSystemHealth();
|
||||
return { tmdbConfigured, health };
|
||||
}
|
||||
|
||||
return { tmdbConfigured };
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { getRecommendationsForTitle } from "@/lib/services/discovery";
|
||||
import {
|
||||
getOrFetchTitle,
|
||||
getOrFetchTitleByTmdbId,
|
||||
} from "@/lib/services/metadata";
|
||||
import {
|
||||
getUserStatusesByTitleIds,
|
||||
getUserTitleInfo,
|
||||
logMovieWatch,
|
||||
markAllEpisodesWatched,
|
||||
rateTitleStars,
|
||||
removeTitleStatus,
|
||||
setTitleStatus,
|
||||
} from "@/lib/services/tracking";
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
export const detail = os.titles.detail
|
||||
.use(authed)
|
||||
.handler(async ({ input }) => {
|
||||
const result = await getOrFetchTitle(input.id);
|
||||
if (!result)
|
||||
throw new ORPCError("NOT_FOUND", { message: "Title not found" });
|
||||
return result;
|
||||
});
|
||||
|
||||
export const resolve = os.titles.resolve
|
||||
.use(authed)
|
||||
.handler(async ({ input }) => {
|
||||
const title = await getOrFetchTitleByTmdbId(input.tmdbId, input.type);
|
||||
if (!title)
|
||||
throw new ORPCError("NOT_FOUND", { message: "Title not found" });
|
||||
return { id: title.id };
|
||||
});
|
||||
|
||||
export const updateStatus = os.titles.updateStatus
|
||||
.use(authed)
|
||||
.handler(({ input, context }) => {
|
||||
if (input.status === null) {
|
||||
removeTitleStatus(context.user.id, input.id);
|
||||
} else {
|
||||
setTitleStatus(context.user.id, input.id, input.status);
|
||||
}
|
||||
});
|
||||
|
||||
export const updateRating = os.titles.updateRating
|
||||
.use(authed)
|
||||
.handler(({ input, context }) => {
|
||||
rateTitleStars(context.user.id, input.id, input.stars);
|
||||
});
|
||||
|
||||
export const watchMovie = os.titles.watchMovie
|
||||
.use(authed)
|
||||
.handler(({ input, context }) => {
|
||||
logMovieWatch(context.user.id, input.id);
|
||||
});
|
||||
|
||||
export const watchAll = os.titles.watchAll
|
||||
.use(authed)
|
||||
.handler(({ input, context }) => {
|
||||
markAllEpisodesWatched(context.user.id, input.id);
|
||||
});
|
||||
|
||||
export const userInfo = os.titles.userInfo
|
||||
.use(authed)
|
||||
.handler(({ input, context }) => {
|
||||
return getUserTitleInfo(context.user.id, input.id);
|
||||
});
|
||||
|
||||
export const recommendations = os.titles.recommendations
|
||||
.use(authed)
|
||||
.handler(({ input, context }) => {
|
||||
const recs = getRecommendationsForTitle(input.id);
|
||||
const userStatuses = getUserStatusesByTitleIds(
|
||||
context.user.id,
|
||||
recs.map((r) => r.id),
|
||||
);
|
||||
return { recommendations: recs, userStatuses };
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { userTitleStatus } from "@/lib/db/schema";
|
||||
import { getOrFetchTitleByTmdbId } from "@/lib/services/metadata";
|
||||
import { setTitleStatus } from "@/lib/services/tracking";
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
|
||||
export const quickAdd = os.watchlist.quickAdd
|
||||
.use(authed)
|
||||
.handler(async ({ input, context }) => {
|
||||
const title = await getOrFetchTitleByTmdbId(input.tmdbId, input.type);
|
||||
if (!title) {
|
||||
throw new ORPCError("BAD_GATEWAY", {
|
||||
message: "Failed to import title",
|
||||
});
|
||||
}
|
||||
|
||||
const existing = db
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, context.user.id),
|
||||
eq(userTitleStatus.titleId, title.id),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
if (!existing) {
|
||||
setTitleStatus(context.user.id, title.id, "watchlist");
|
||||
}
|
||||
|
||||
return { id: title.id, alreadyAdded: !!existing };
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { os } from "./context";
|
||||
import * as account from "./procedures/account";
|
||||
import * as admin from "./procedures/admin";
|
||||
import * as dashboard from "./procedures/dashboard";
|
||||
import { discoverProcedure } from "./procedures/discover";
|
||||
import * as episodes from "./procedures/episodes";
|
||||
import * as explore from "./procedures/explore";
|
||||
import * as integrations from "./procedures/integrations";
|
||||
import * as people from "./procedures/people";
|
||||
import { search } from "./procedures/search";
|
||||
import * as seasons from "./procedures/seasons";
|
||||
import { statsProcedure } from "./procedures/stats";
|
||||
import { systemStatus } from "./procedures/status";
|
||||
import * as titles from "./procedures/titles";
|
||||
import * as watchlist from "./procedures/watchlist";
|
||||
|
||||
export const router = os.router({
|
||||
titles: {
|
||||
detail: titles.detail,
|
||||
resolve: titles.resolve,
|
||||
updateStatus: titles.updateStatus,
|
||||
updateRating: titles.updateRating,
|
||||
watchMovie: titles.watchMovie,
|
||||
watchAll: titles.watchAll,
|
||||
userInfo: titles.userInfo,
|
||||
recommendations: titles.recommendations,
|
||||
},
|
||||
episodes: {
|
||||
watch: episodes.watch,
|
||||
unwatch: episodes.unwatch,
|
||||
batchWatch: episodes.batchWatch,
|
||||
},
|
||||
seasons: {
|
||||
watch: seasons.watch,
|
||||
unwatch: seasons.unwatch,
|
||||
},
|
||||
people: {
|
||||
detail: people.detail,
|
||||
resolve: people.resolve,
|
||||
},
|
||||
dashboard: {
|
||||
stats: dashboard.stats,
|
||||
continueWatching: dashboard.continueWatching,
|
||||
library: dashboard.library,
|
||||
recommendations: dashboard.recommendations,
|
||||
},
|
||||
explore: {
|
||||
trending: explore.trending,
|
||||
popular: explore.popular,
|
||||
genres: explore.genres,
|
||||
},
|
||||
search,
|
||||
discover: discoverProcedure,
|
||||
stats: statsProcedure,
|
||||
systemStatus,
|
||||
integrations: {
|
||||
list: integrations.list,
|
||||
create: integrations.create,
|
||||
delete: integrations.deleteProcedure,
|
||||
regenerateToken: integrations.regenerateToken,
|
||||
},
|
||||
admin: {
|
||||
backups: {
|
||||
list: admin.backupsList,
|
||||
create: admin.backupsCreate,
|
||||
delete: admin.backupsDelete,
|
||||
restore: admin.backupsRestore,
|
||||
schedule: admin.backupsSchedule,
|
||||
updateSchedule: admin.backupsUpdateSchedule,
|
||||
},
|
||||
registration: admin.registration,
|
||||
toggleRegistration: admin.toggleRegistration,
|
||||
updateCheck: admin.updateCheck,
|
||||
toggleUpdateCheck: admin.toggleUpdateCheck,
|
||||
triggerJob: admin.triggerJobProcedure,
|
||||
},
|
||||
account: {
|
||||
updateName: account.updateName,
|
||||
uploadAvatar: account.uploadAvatar,
|
||||
removeAvatar: account.removeAvatar,
|
||||
},
|
||||
watchlist: {
|
||||
quickAdd: watchlist.quickAdd,
|
||||
},
|
||||
});
|
||||
|
||||
export type Router = typeof router;
|
||||
@@ -0,0 +1,549 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// ─── Shared input schemas ─────────────────────────────────────
|
||||
|
||||
export const IdParam = z.object({ id: z.string() });
|
||||
export const ProviderParam = z.object({
|
||||
provider: z.enum(["plex", "jellyfin", "emby", "sonarr", "radarr"]),
|
||||
});
|
||||
export const FilenameParam = z.object({ filename: z.string() });
|
||||
export const MediaTypeParam = z.object({
|
||||
type: z.enum(["movie", "tv"]),
|
||||
});
|
||||
export const TrendingTypeParam = z.object({
|
||||
type: z.enum(["all", "movie", "tv"]),
|
||||
});
|
||||
export const TmdbIdTypeParam = z.object({
|
||||
tmdbId: z.number().int(),
|
||||
type: z.enum(["movie", "tv"]),
|
||||
});
|
||||
|
||||
// ─── Title inputs ──────────────────────────────────────────────
|
||||
|
||||
export const UpdateStatusInput = z.object({
|
||||
id: z.string(),
|
||||
status: z.enum(["in_progress"]).nullable(),
|
||||
});
|
||||
|
||||
export const UpdateRatingInput = z.object({
|
||||
id: z.string(),
|
||||
stars: z.number().int().min(0).max(5),
|
||||
});
|
||||
|
||||
export const BatchWatchInput = z.object({
|
||||
episodeIds: z.array(z.string()).min(1),
|
||||
});
|
||||
|
||||
// ─── Search / Discover inputs ──────────────────────────────────
|
||||
|
||||
export const SearchInput = z.object({
|
||||
query: z.string().min(1),
|
||||
type: z.enum(["movie", "tv", "person"]).optional(),
|
||||
});
|
||||
|
||||
export const DiscoverInput = z.object({
|
||||
mediaType: z.enum(["movie", "tv"]),
|
||||
genreId: z.number().int(),
|
||||
});
|
||||
|
||||
// ─── Stats input ───────────────────────────────────────────────
|
||||
|
||||
export const StatsInput = z.object({
|
||||
type: z.enum(["movies", "episodes"]),
|
||||
period: z.enum(["today", "this_week", "this_month", "this_year"]),
|
||||
});
|
||||
|
||||
// ─── Integration inputs ────────────────────────────────────────
|
||||
|
||||
export const CreateIntegrationInput = z.object({
|
||||
provider: z.enum(["plex", "jellyfin", "emby", "sonarr", "radarr"]),
|
||||
enabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// ─── Admin inputs ──────────────────────────────────────────────
|
||||
|
||||
export const ToggleRegistrationInput = z.object({ open: z.boolean() });
|
||||
export const ToggleUpdateCheckInput = z.object({ enabled: z.boolean() });
|
||||
export const TriggerJobInput = z.object({ name: z.string() });
|
||||
|
||||
export const UpdateScheduleInput = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
frequency: z.enum(["6h", "12h", "1d", "7d"]).optional(),
|
||||
time: z
|
||||
.string()
|
||||
.regex(/^\d{2}:\d{2}$/, "Invalid time format")
|
||||
.refine(
|
||||
(t) => {
|
||||
const [h, m] = t.split(":").map(Number);
|
||||
return h >= 0 && h <= 23 && m >= 0 && m <= 59;
|
||||
},
|
||||
{ message: "Invalid time value" },
|
||||
)
|
||||
.optional(),
|
||||
dayOfWeek: z.number().int().min(0).max(6).optional(),
|
||||
maxRetention: z
|
||||
.number()
|
||||
.int()
|
||||
.refine((n) => n === 0 || (n >= 1 && n <= 30), {
|
||||
message: "Max backups must be between 1 and 30, or 0 for unlimited",
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// ─── Account inputs ────────────────────────────────────────────
|
||||
|
||||
export const UpdateNameInput = z.object({ name: z.string().min(1) });
|
||||
|
||||
export const UploadAvatarInput = z
|
||||
.file()
|
||||
.mime(["image/jpeg", "image/png", "image/webp", "image/gif"])
|
||||
.max(2 * 1024 * 1024, "File too large (max 2MB)");
|
||||
|
||||
export const UploadAvatarOutput = z.object({ imageUrl: z.string() });
|
||||
|
||||
// ─── Backup inputs ─────────────────────────────────────────────
|
||||
|
||||
export const RestoreBackupInput = z
|
||||
.file()
|
||||
.max(100 * 1024 * 1024, "File too large (max 100 MB)");
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Output schemas
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ─── Shared primitives ─────────────────────────────────────────
|
||||
|
||||
const mediaType = z.enum(["movie", "tv"]);
|
||||
|
||||
const ColorPaletteSchema = z.object({
|
||||
vibrant: z.string().nullable(),
|
||||
darkVibrant: z.string().nullable(),
|
||||
lightVibrant: z.string().nullable(),
|
||||
muted: z.string().nullable(),
|
||||
darkMuted: z.string().nullable(),
|
||||
lightMuted: z.string().nullable(),
|
||||
});
|
||||
|
||||
const EpisodeSchema = z.object({
|
||||
id: z.string(),
|
||||
episodeNumber: z.number(),
|
||||
name: z.string().nullable(),
|
||||
overview: z.string().nullable(),
|
||||
stillPath: z.string().nullable(),
|
||||
airDate: z.string().nullable(),
|
||||
runtimeMinutes: z.number().nullable(),
|
||||
});
|
||||
|
||||
const SeasonSchema = z.object({
|
||||
id: z.string(),
|
||||
seasonNumber: z.number(),
|
||||
name: z.string().nullable(),
|
||||
episodes: z.array(EpisodeSchema),
|
||||
});
|
||||
|
||||
const AvailabilityOfferSchema = z.object({
|
||||
providerId: z.number(),
|
||||
providerName: z.string(),
|
||||
logoPath: z.string().nullable(),
|
||||
offerType: z.string(),
|
||||
watchUrl: z.string().nullable(),
|
||||
});
|
||||
|
||||
const CastMemberSchema = z.object({
|
||||
id: z.string(),
|
||||
personId: z.string(),
|
||||
name: z.string(),
|
||||
character: z.string().nullable(),
|
||||
department: z.string(),
|
||||
job: z.string().nullable(),
|
||||
displayOrder: z.number(),
|
||||
episodeCount: z.number().nullable(),
|
||||
profilePath: z.string().nullable(),
|
||||
tmdbId: z.number(),
|
||||
});
|
||||
|
||||
const ResolvedTitleSchema = z.object({
|
||||
id: z.string(),
|
||||
tmdbId: z.number(),
|
||||
type: mediaType,
|
||||
title: z.string(),
|
||||
originalTitle: z.string().nullable(),
|
||||
overview: z.string().nullable(),
|
||||
releaseDate: z.string().nullable(),
|
||||
firstAirDate: z.string().nullable(),
|
||||
posterPath: z.string().nullable(),
|
||||
backdropPath: z.string().nullable(),
|
||||
popularity: z.number().nullable(),
|
||||
voteAverage: z.number().nullable(),
|
||||
voteCount: z.number().nullable(),
|
||||
status: z.string().nullable(),
|
||||
contentRating: z.string().nullable(),
|
||||
colorPalette: ColorPaletteSchema.nullable(),
|
||||
trailerVideoKey: z.string().nullable(),
|
||||
genres: z.array(z.string()),
|
||||
});
|
||||
|
||||
const PersonSchema = z.object({
|
||||
id: z.string(),
|
||||
tmdbId: z.number(),
|
||||
name: z.string(),
|
||||
biography: z.string().nullable(),
|
||||
birthday: z.string().nullable(),
|
||||
deathday: z.string().nullable(),
|
||||
placeOfBirth: z.string().nullable(),
|
||||
profilePath: z.string().nullable(),
|
||||
knownForDepartment: z.string().nullable(),
|
||||
imdbId: z.string().nullable(),
|
||||
});
|
||||
|
||||
const PersonCreditSchema = z.object({
|
||||
titleId: z.string(),
|
||||
tmdbId: z.number(),
|
||||
type: mediaType,
|
||||
title: z.string(),
|
||||
posterPath: z.string().nullable(),
|
||||
releaseDate: z.string().nullable(),
|
||||
firstAirDate: z.string().nullable(),
|
||||
voteAverage: z.number().nullable(),
|
||||
character: z.string().nullable(),
|
||||
department: z.string(),
|
||||
job: z.string().nullable(),
|
||||
});
|
||||
|
||||
/** Reusable TMDB browse result (trending / popular / discover items) */
|
||||
const TmdbBrowseItem = z.object({
|
||||
tmdbId: z.number(),
|
||||
type: mediaType,
|
||||
title: z.string(),
|
||||
posterPath: z.string().nullable(),
|
||||
releaseDate: z.string().nullable(),
|
||||
voteAverage: z.number(),
|
||||
});
|
||||
|
||||
const userStatusMap = z.record(
|
||||
z.string(),
|
||||
z.enum(["watchlist", "in_progress", "completed"]),
|
||||
);
|
||||
const episodeProgressMap = z.record(
|
||||
z.string(),
|
||||
z.object({ watched: z.number(), total: z.number() }),
|
||||
);
|
||||
|
||||
/** Standard browse response shape (popular, discover) */
|
||||
const BrowseOutput = z.object({
|
||||
items: z.array(TmdbBrowseItem),
|
||||
userStatuses: userStatusMap,
|
||||
episodeProgress: episodeProgressMap,
|
||||
});
|
||||
|
||||
// ─── Title outputs ─────────────────────────────────────────────
|
||||
|
||||
export const TitleDetailOutput = z.object({
|
||||
title: ResolvedTitleSchema,
|
||||
seasons: z.array(SeasonSchema),
|
||||
needsHydration: z.boolean(),
|
||||
availability: z.array(AvailabilityOfferSchema),
|
||||
cast: z.array(CastMemberSchema),
|
||||
});
|
||||
|
||||
export const TitleResolveOutput = z.object({ id: z.string() });
|
||||
|
||||
export const UserInfoOutput = z.object({
|
||||
status: z.enum(["watchlist", "in_progress", "completed"]).nullable(),
|
||||
rating: z.number().nullable(),
|
||||
episodeWatches: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const TitleRecommendationsOutput = z.object({
|
||||
recommendations: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
tmdbId: z.number(),
|
||||
type: mediaType,
|
||||
title: z.string(),
|
||||
posterPath: z.string().nullable(),
|
||||
releaseDate: z.string().nullable(),
|
||||
firstAirDate: z.string().nullable(),
|
||||
voteAverage: z.number().nullable(),
|
||||
}),
|
||||
),
|
||||
userStatuses: userStatusMap,
|
||||
});
|
||||
|
||||
// ─── People outputs ────────────────────────────────────────────
|
||||
|
||||
export const PersonDetailOutput = z.object({
|
||||
person: PersonSchema,
|
||||
filmography: z.array(PersonCreditSchema),
|
||||
userStatuses: userStatusMap,
|
||||
});
|
||||
|
||||
export const PersonResolveOutput = z.object({ id: z.string() });
|
||||
|
||||
// ─── Dashboard outputs ─────────────────────────────────────────
|
||||
|
||||
export const DashboardStatsOutput = z.object({
|
||||
moviesThisMonth: z.number(),
|
||||
episodesThisWeek: z.number(),
|
||||
librarySize: z.number(),
|
||||
completed: z.number(),
|
||||
});
|
||||
|
||||
export const ContinueWatchingOutput = z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
title: z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
backdropPath: z.string().nullable(),
|
||||
}),
|
||||
nextEpisode: z
|
||||
.object({
|
||||
seasonNumber: z.number(),
|
||||
episodeNumber: z.number(),
|
||||
name: z.string().nullable(),
|
||||
stillPath: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
totalEpisodes: z.number(),
|
||||
watchedEpisodes: z.number(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const LibraryOutput = z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
tmdbId: z.number(),
|
||||
type: mediaType,
|
||||
title: z.string(),
|
||||
posterPath: z.string().nullable(),
|
||||
releaseDate: z.string().nullable(),
|
||||
voteAverage: z.number().nullable(),
|
||||
userStatus: z.enum(["watchlist", "in_progress", "completed"]).nullable(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const DashboardRecommendationsOutput = z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
tmdbId: z.number(),
|
||||
type: mediaType,
|
||||
title: z.string(),
|
||||
posterPath: z.string().nullable(),
|
||||
releaseDate: z.string().nullable(),
|
||||
voteAverage: z.number().nullable(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
// ─── Explore outputs ───────────────────────────────────────────
|
||||
|
||||
export const TrendingOutput = z.object({
|
||||
items: z.array(TmdbBrowseItem),
|
||||
hero: z
|
||||
.object({
|
||||
tmdbId: z.number(),
|
||||
type: mediaType,
|
||||
title: z.string(),
|
||||
overview: z.string(),
|
||||
backdropPath: z.string().nullable(),
|
||||
voteAverage: z.number(),
|
||||
})
|
||||
.nullable(),
|
||||
userStatuses: userStatusMap,
|
||||
episodeProgress: episodeProgressMap,
|
||||
});
|
||||
|
||||
export const PopularOutput = BrowseOutput;
|
||||
|
||||
export const GenresOutput = z.object({
|
||||
genres: z.array(z.object({ id: z.number(), name: z.string() })),
|
||||
});
|
||||
|
||||
// ─── Search output ─────────────────────────────────────────────
|
||||
|
||||
export const SearchOutput = z.object({
|
||||
results: z.array(
|
||||
z.object({
|
||||
tmdbId: z.number(),
|
||||
type: z.enum(["movie", "tv", "person"]),
|
||||
title: z.string(),
|
||||
overview: z.string().optional(),
|
||||
posterPath: z.string().nullable().optional(),
|
||||
profilePath: z.string().nullable().optional(),
|
||||
releaseDate: z.string().nullable().optional(),
|
||||
popularity: z.number().optional(),
|
||||
voteAverage: z.number().optional(),
|
||||
knownForDepartment: z.string().optional(),
|
||||
knownFor: z.array(z.string()).optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
// ─── Discover output ───────────────────────────────────────────
|
||||
|
||||
export const DiscoverOutput = BrowseOutput;
|
||||
|
||||
// ─── Stats output ──────────────────────────────────────────────
|
||||
|
||||
export const StatsOutput = z.object({
|
||||
count: z.number(),
|
||||
history: z.array(z.object({ bucket: z.string(), count: z.number() })),
|
||||
});
|
||||
|
||||
// ─── System status output ──────────────────────────────────────
|
||||
|
||||
const JobSchema = z.object({
|
||||
jobName: z.string(),
|
||||
cronPattern: z.string().nullable(),
|
||||
nextRunAt: z.string().nullable(),
|
||||
lastRunAt: z.string().nullable(),
|
||||
lastDurationMs: z.number().nullable(),
|
||||
lastStatus: z.enum(["running", "success", "error"]).nullable(),
|
||||
lastError: z.string().nullable(),
|
||||
isCurrentlyRunning: z.boolean(),
|
||||
disabled: z.boolean(),
|
||||
});
|
||||
|
||||
const SystemHealthSchema = z.object({
|
||||
database: z.object({
|
||||
dbSizeBytes: z.number(),
|
||||
walSizeBytes: z.number(),
|
||||
titleCount: z.number(),
|
||||
episodeCount: z.number(),
|
||||
userCount: z.number(),
|
||||
}),
|
||||
tmdb: z.object({
|
||||
connected: z.boolean(),
|
||||
tokenValid: z.boolean(),
|
||||
tokenConfigured: z.boolean(),
|
||||
responseTimeMs: z.number().nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
jobs: z.array(JobSchema),
|
||||
imageCache: z.object({
|
||||
enabled: z.boolean(),
|
||||
totalSizeBytes: z.number(),
|
||||
imageCount: z.number(),
|
||||
categories: z.record(
|
||||
z.string(),
|
||||
z.object({ count: z.number(), sizeBytes: z.number() }),
|
||||
),
|
||||
}),
|
||||
backups: z.object({
|
||||
lastBackupAt: z.string().nullable(),
|
||||
lastBackupAgeHours: z.number().nullable(),
|
||||
backupCount: z.number(),
|
||||
totalSizeBytes: z.number(),
|
||||
}),
|
||||
environment: z.object({
|
||||
dataDir: z.string(),
|
||||
dataDirWritable: z.boolean(),
|
||||
envVars: z.array(
|
||||
z.object({ name: z.string(), value: z.string().nullable() }),
|
||||
),
|
||||
}),
|
||||
checkedAt: z.string(),
|
||||
});
|
||||
|
||||
export const SystemStatusOutput = z.object({
|
||||
tmdbConfigured: z.boolean(),
|
||||
health: SystemHealthSchema.optional(),
|
||||
});
|
||||
|
||||
// ─── Integration outputs ───────────────────────────────────────
|
||||
|
||||
const IntegrationSchema = z.object({
|
||||
id: z.string(),
|
||||
provider: z.string(),
|
||||
type: z.enum(["webhook", "list"]),
|
||||
token: z.string(),
|
||||
enabled: z.boolean(),
|
||||
lastEventAt: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
});
|
||||
|
||||
const IntegrationEventSchema = z.object({
|
||||
id: z.string(),
|
||||
eventType: z.string().nullable(),
|
||||
mediaType: z.string().nullable(),
|
||||
mediaTitle: z.string().nullable(),
|
||||
status: z.enum(["success", "ignored", "error"]),
|
||||
receivedAt: z.string(),
|
||||
});
|
||||
|
||||
export const IntegrationsListOutput = z.object({
|
||||
integrations: z.array(
|
||||
IntegrationSchema.extend({
|
||||
recentEvents: z.array(IntegrationEventSchema),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const IntegrationOutput = IntegrationSchema;
|
||||
|
||||
export const IntegrationTokenOutput = IntegrationSchema;
|
||||
|
||||
// ─── Admin outputs ─────────────────────────────────────────────
|
||||
|
||||
const BackupSchema = z.object({
|
||||
filename: z.string(),
|
||||
sizeBytes: z.number(),
|
||||
createdAt: z.string(),
|
||||
source: z.enum(["manual", "scheduled", "pre-restore"]),
|
||||
});
|
||||
|
||||
export const BackupsListOutput = z.object({
|
||||
backups: z.array(BackupSchema),
|
||||
});
|
||||
|
||||
export const BackupCreateOutput = BackupSchema;
|
||||
|
||||
export const BackupScheduleOutput = z.object({
|
||||
enabled: z.boolean(),
|
||||
maxRetention: z.number(),
|
||||
frequency: z.string(),
|
||||
time: z.string(),
|
||||
dayOfWeek: z.number(),
|
||||
});
|
||||
|
||||
export const RegistrationOutput = z.object({ open: z.boolean() });
|
||||
|
||||
export const UpdateCheckOutput = z.object({
|
||||
enabled: z.boolean(),
|
||||
updateCheck: z
|
||||
.object({
|
||||
updateAvailable: z.boolean(),
|
||||
currentVersion: z.string(),
|
||||
latestVersion: z.string().nullable(),
|
||||
releaseUrl: z.string().nullable(),
|
||||
lastCheckedAt: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export const TriggerJobOutput = z.object({ ok: z.literal(true) });
|
||||
|
||||
// ─── Watchlist outputs ─────────────────────────────────────────
|
||||
|
||||
export const QuickAddOutput = z.object({
|
||||
id: z.string(),
|
||||
alreadyAdded: z.boolean(),
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Inferred types — use these instead of hand-written interfaces
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export type Episode = z.infer<typeof EpisodeSchema>;
|
||||
export type Season = z.infer<typeof SeasonSchema>;
|
||||
export type AvailabilityOffer = z.infer<typeof AvailabilityOfferSchema>;
|
||||
export type CastMember = z.infer<typeof CastMemberSchema>;
|
||||
export type ColorPalette = z.infer<typeof ColorPaletteSchema>;
|
||||
export type ResolvedTitle = z.infer<typeof ResolvedTitleSchema>;
|
||||
export type ResolvedPerson = z.infer<typeof PersonSchema>;
|
||||
export type PersonCredit = z.infer<typeof PersonCreditSchema>;
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createTanstackQueryUtils } from "@orpc/tanstack-query";
|
||||
import { client } from "./client";
|
||||
|
||||
export const orpc = createTanstackQueryUtils(client);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
function makeQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { staleTime: 30_000, refetchOnWindowFocus: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let browserClient: QueryClient | undefined;
|
||||
|
||||
export function getQueryClient() {
|
||||
if (typeof window === "undefined") return makeQueryClient();
|
||||
if (!browserClient) browserClient = makeQueryClient();
|
||||
return browserClient;
|
||||
}
|
||||
@@ -214,7 +214,9 @@ export async function readBackupFile(filename: string): Promise<Buffer | null> {
|
||||
return Buffer.from(await Bun.file(filePath).arrayBuffer());
|
||||
}
|
||||
|
||||
export async function restoreFromBackup(buffer: Buffer): Promise<void> {
|
||||
export async function restoreFromBackup(
|
||||
source: Buffer | string,
|
||||
): Promise<void> {
|
||||
await withBackupLock(async () => {
|
||||
await ensureBackupDir();
|
||||
|
||||
@@ -228,7 +230,11 @@ export async function restoreFromBackup(buffer: Buffer): Promise<void> {
|
||||
);
|
||||
|
||||
try {
|
||||
await Bun.write(tempPath, buffer);
|
||||
if (typeof source === "string") {
|
||||
renameSync(source, tempPath);
|
||||
} else {
|
||||
await Bun.write(tempPath, source);
|
||||
}
|
||||
validateBackupDatabase(tempPath);
|
||||
|
||||
log.info("Creating pre-restore safety backup...");
|
||||
|
||||
+2
-17
@@ -1,11 +1,10 @@
|
||||
import { eq, inArray, sql } from "drizzle-orm";
|
||||
import { updateTag } from "next/cache";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { persons, titleCast, titles } from "@/lib/db/schema";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import type { CastMember } from "@/lib/orpc/schemas";
|
||||
import { getMovieCredits, getTvAggregateCredits } from "@/lib/tmdb/client";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
import type { CastMember } from "@/lib/types";
|
||||
import { cacheProfilePhotos, imageCacheEnabled } from "./image-cache";
|
||||
|
||||
const log = createLogger("credits");
|
||||
@@ -81,10 +80,7 @@ function batchUpsertPersons(people: PersonData[]): Map<number, string> {
|
||||
return idMap;
|
||||
}
|
||||
|
||||
export async function refreshCredits(
|
||||
titleId: string,
|
||||
{ revalidate = true }: { revalidate?: boolean } = {},
|
||||
) {
|
||||
export async function refreshCredits(titleId: string) {
|
||||
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
||||
if (!title) return;
|
||||
|
||||
@@ -278,17 +274,6 @@ export async function refreshCredits(
|
||||
}
|
||||
}
|
||||
|
||||
if (revalidate) {
|
||||
for (const personId of personIds.values()) {
|
||||
try {
|
||||
updateTag(`person-${personId}`);
|
||||
} catch {
|
||||
// updateTag only works inside Route Handlers / Server Actions;
|
||||
// swallow the error when called from cron jobs or detached promises.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.debug(`Credits refreshed for "${title.title}"`);
|
||||
|
||||
if (imageCacheEnabled()) {
|
||||
|
||||
+14
-30
@@ -1,5 +1,4 @@
|
||||
import { eq, inArray, sql } from "drizzle-orm";
|
||||
import { updateTag } from "next/cache";
|
||||
import { db } from "@/lib/db/client";
|
||||
import {
|
||||
availabilityOffers,
|
||||
@@ -11,6 +10,13 @@ import {
|
||||
titles,
|
||||
} from "@/lib/db/schema";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import type {
|
||||
AvailabilityOffer,
|
||||
CastMember,
|
||||
Episode,
|
||||
ResolvedTitle,
|
||||
Season,
|
||||
} from "@/lib/orpc/schemas";
|
||||
import { generateProviderUrl } from "@/lib/providers";
|
||||
import type {
|
||||
TmdbGenre,
|
||||
@@ -27,13 +33,6 @@ import {
|
||||
getVideos,
|
||||
} from "@/lib/tmdb/client";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
import type {
|
||||
AvailabilityOffer,
|
||||
CastMember,
|
||||
Episode,
|
||||
ResolvedTitle,
|
||||
Season,
|
||||
} from "@/lib/types";
|
||||
import { refreshAvailability } from "./availability";
|
||||
import { extractAndStoreColors, parseColorPalette } from "./colors";
|
||||
import { getCastForTitle, refreshCredits } from "./credits";
|
||||
@@ -173,7 +172,7 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
|
||||
extractAndStoreColors(existing.id, show.poster_path ?? null).catch(
|
||||
(err) => log.debug("Color extraction failed:", err),
|
||||
);
|
||||
refreshCredits(existing.id, { revalidate: false }).catch((err) =>
|
||||
refreshCredits(existing.id).catch((err) =>
|
||||
log.debug("Credits enrichment failed:", err),
|
||||
);
|
||||
refreshTrailer(existing.id).catch((err) =>
|
||||
@@ -227,7 +226,7 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
|
||||
extractAndStoreColors(row.id, movie.poster_path ?? null).catch((err) =>
|
||||
log.debug("Color extraction failed:", err),
|
||||
);
|
||||
refreshCredits(row.id, { revalidate: false }).catch((err) =>
|
||||
refreshCredits(row.id).catch((err) =>
|
||||
log.debug("Credits enrichment failed:", err),
|
||||
);
|
||||
refreshTrailer(row.id).catch((err) =>
|
||||
@@ -276,7 +275,7 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
|
||||
extractAndStoreColors(row.id, show.poster_path ?? null).catch((err) =>
|
||||
log.debug("Color extraction failed:", err),
|
||||
);
|
||||
refreshCredits(row.id, { revalidate: false }).catch((err) =>
|
||||
refreshCredits(row.id).catch((err) =>
|
||||
log.debug("Credits enrichment failed:", err),
|
||||
);
|
||||
refreshTrailer(row.id).catch((err) =>
|
||||
@@ -352,7 +351,7 @@ export async function refreshTitle(titleId: string) {
|
||||
refreshTrailer(updated.id).catch((err) =>
|
||||
log.debug("Trailer enrichment failed:", err),
|
||||
);
|
||||
refreshCredits(updated.id, { revalidate: false }).catch((err) =>
|
||||
refreshCredits(updated.id).catch((err) =>
|
||||
log.debug("Credits enrichment failed:", err),
|
||||
);
|
||||
if (imageCacheEnabled()) {
|
||||
@@ -444,10 +443,7 @@ export async function refreshTvChildren(
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshRecommendations(
|
||||
titleId: string,
|
||||
{ revalidate = true }: { revalidate?: boolean } = {},
|
||||
) {
|
||||
export async function refreshRecommendations(titleId: string) {
|
||||
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
||||
if (!title) return;
|
||||
|
||||
@@ -584,15 +580,6 @@ export async function refreshRecommendations(
|
||||
.run();
|
||||
}
|
||||
});
|
||||
|
||||
if (revalidate) {
|
||||
try {
|
||||
updateTag(`recs-${titleId}`);
|
||||
} catch {
|
||||
// updateTag only works inside Route Handlers / Server Actions;
|
||||
// swallow when called from cron jobs or detached promises.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch seasons from the DB, building the Season[] structure. */
|
||||
@@ -701,12 +688,9 @@ async function ensureEnriched(
|
||||
): Promise<boolean> {
|
||||
const tasks: Promise<void>[] = [];
|
||||
|
||||
// Called during render — skip updateTag calls (not allowed outside Server Actions/Route Handlers)
|
||||
const noRevalidate = { revalidate: false } as const;
|
||||
|
||||
if (!existing.hasCast) {
|
||||
tasks.push(
|
||||
refreshCredits(titleId, noRevalidate).catch((err) =>
|
||||
refreshCredits(titleId).catch((err) =>
|
||||
log.debug("Credits enrichment failed:", err),
|
||||
),
|
||||
);
|
||||
@@ -730,7 +714,7 @@ async function ensureEnriched(
|
||||
.get() != null;
|
||||
if (!hasRecommendations) {
|
||||
tasks.push(
|
||||
refreshRecommendations(titleId, noRevalidate).catch((err) =>
|
||||
refreshRecommendations(titleId).catch((err) =>
|
||||
log.debug("Recommendations enrichment failed:", err),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,9 +2,9 @@ import { eq, inArray } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { persons, titleCast, titles } from "@/lib/db/schema";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import type { PersonCredit, ResolvedPerson } from "@/lib/orpc/schemas";
|
||||
import { getPersonCombinedCredits, getPersonDetails } from "@/lib/tmdb/client";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
import type { PersonCredit, ResolvedPerson } from "@/lib/types";
|
||||
|
||||
const log = createLogger("person");
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export async function fetcher<T>(url: string): Promise<T> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Request failed (${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { getTitleThemeStyle, hexToRelativeLuminance } from "./title-theme";
|
||||
import { getThemeCssProperties, hexToRelativeLuminance } from "./theme";
|
||||
|
||||
describe("hexToRelativeLuminance", () => {
|
||||
test("black has luminance ~0", () => {
|
||||
@@ -34,12 +34,12 @@ describe("hexToRelativeLuminance", () => {
|
||||
|
||||
describe("getTitleThemeStyle", () => {
|
||||
test("returns empty object for null palette", () => {
|
||||
expect(getTitleThemeStyle(null)).toEqual({});
|
||||
expect(getThemeCssProperties(null)).toEqual({});
|
||||
});
|
||||
|
||||
test("returns empty object when vibrant is null", () => {
|
||||
expect(
|
||||
getTitleThemeStyle({
|
||||
getThemeCssProperties({
|
||||
vibrant: null,
|
||||
darkVibrant: "#123456",
|
||||
lightVibrant: null,
|
||||
@@ -51,7 +51,7 @@ describe("getTitleThemeStyle", () => {
|
||||
});
|
||||
|
||||
test("dark color produces light foreground", () => {
|
||||
const style = getTitleThemeStyle({
|
||||
const style = getThemeCssProperties({
|
||||
vibrant: "#1a1a2e",
|
||||
darkVibrant: null,
|
||||
lightVibrant: null,
|
||||
@@ -66,7 +66,7 @@ describe("getTitleThemeStyle", () => {
|
||||
});
|
||||
|
||||
test("bright color produces dark foreground", () => {
|
||||
const style = getTitleThemeStyle({
|
||||
const style = getThemeCssProperties({
|
||||
vibrant: "#ffff00",
|
||||
darkVibrant: null,
|
||||
lightVibrant: null,
|
||||
@@ -81,7 +81,7 @@ describe("getTitleThemeStyle", () => {
|
||||
});
|
||||
|
||||
test("sets all expected CSS custom properties", () => {
|
||||
const style = getTitleThemeStyle({
|
||||
const style = getThemeCssProperties({
|
||||
vibrant: "#e63946",
|
||||
darkVibrant: null,
|
||||
lightVibrant: null,
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ColorPalette } from "@/lib/types";
|
||||
import type { ColorPalette } from "@/lib/orpc/schemas";
|
||||
|
||||
/** @internal */
|
||||
export function hexToRelativeLuminance(hex: string): number {
|
||||
@@ -10,7 +10,7 @@ export function hexToRelativeLuminance(hex: string): number {
|
||||
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
|
||||
}
|
||||
|
||||
export function getTitleThemeStyle(
|
||||
export function getThemeCssProperties(
|
||||
palette: ColorPalette | null,
|
||||
): React.CSSProperties {
|
||||
const color = palette?.vibrant;
|
||||
-105
@@ -1,105 +0,0 @@
|
||||
export interface Episode {
|
||||
id: string;
|
||||
episodeNumber: number;
|
||||
name: string | null;
|
||||
overview: string | null;
|
||||
stillPath: string | null;
|
||||
airDate: string | null;
|
||||
runtimeMinutes: number | null;
|
||||
}
|
||||
|
||||
export interface Season {
|
||||
id: string;
|
||||
seasonNumber: number;
|
||||
name: string | null;
|
||||
episodes: Episode[];
|
||||
}
|
||||
|
||||
export interface AvailabilityOffer {
|
||||
providerId: number;
|
||||
providerName: string;
|
||||
logoPath: string | null;
|
||||
offerType: string;
|
||||
watchUrl: string | null;
|
||||
}
|
||||
|
||||
export interface RecommendedTitle {
|
||||
id: string;
|
||||
tmdbId: number;
|
||||
type: "movie" | "tv";
|
||||
title: string;
|
||||
posterPath: string | null;
|
||||
releaseDate: string | null;
|
||||
firstAirDate: string | null;
|
||||
voteAverage: number | null;
|
||||
}
|
||||
|
||||
export interface ColorPalette {
|
||||
vibrant: string | null;
|
||||
darkVibrant: string | null;
|
||||
lightVibrant: string | null;
|
||||
muted: string | null;
|
||||
darkMuted: string | null;
|
||||
lightMuted: string | null;
|
||||
}
|
||||
|
||||
export interface CastMember {
|
||||
id: string;
|
||||
personId: string;
|
||||
name: string;
|
||||
character: string | null;
|
||||
department: string;
|
||||
job: string | null;
|
||||
displayOrder: number;
|
||||
episodeCount: number | null;
|
||||
profilePath: string | null;
|
||||
tmdbId: number;
|
||||
}
|
||||
|
||||
export interface ResolvedPerson {
|
||||
id: string;
|
||||
tmdbId: number;
|
||||
name: string;
|
||||
biography: string | null;
|
||||
birthday: string | null;
|
||||
deathday: string | null;
|
||||
placeOfBirth: string | null;
|
||||
profilePath: string | null;
|
||||
knownForDepartment: string | null;
|
||||
imdbId: string | null;
|
||||
}
|
||||
|
||||
export interface PersonCredit {
|
||||
titleId: string;
|
||||
tmdbId: number;
|
||||
type: "movie" | "tv";
|
||||
title: string;
|
||||
posterPath: string | null;
|
||||
releaseDate: string | null;
|
||||
firstAirDate: string | null;
|
||||
voteAverage: number | null;
|
||||
character: string | null;
|
||||
department: string;
|
||||
job: string | null;
|
||||
}
|
||||
|
||||
export interface ResolvedTitle {
|
||||
id: string;
|
||||
tmdbId: number;
|
||||
type: "movie" | "tv";
|
||||
title: string;
|
||||
originalTitle: string | null;
|
||||
overview: string | null;
|
||||
releaseDate: string | null;
|
||||
firstAirDate: string | null;
|
||||
posterPath: string | null;
|
||||
backdropPath: string | null;
|
||||
popularity: number | null;
|
||||
voteAverage: number | null;
|
||||
voteCount: number | null;
|
||||
status: string | null;
|
||||
contentRating: string | null;
|
||||
colorPalette: ColorPalette | null;
|
||||
trailerVideoKey: string | null;
|
||||
genres: string[];
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user