- React + TanStack Router + shadcn/ui SPA under spa/ - serve spa/dist at /app/ with index.html fallback for client routing - Dockerfile: node build stage for SPA, copy dist into runtime image - README: document SPA, CORS_ORIGINS env var, architecture entry - vite base set to /app/, manifest.json paths fixed
122 lines
3.8 KiB
TypeScript
122 lines
3.8 KiB
TypeScript
import { createFileRoute, Link } from "@tanstack/react-router"
|
|
import { useTranslation } from "react-i18next"
|
|
import { ArrowLeft, Film, User } from "lucide-react"
|
|
import { MovieCard } from "@/components/movie-card"
|
|
import { EmptyState } from "@/components/empty-state"
|
|
import { Skeleton } from "@/components/ui/skeleton"
|
|
import { tmdbProfileUrl } from "@/lib/api/client"
|
|
import { usePersonCredits } from "@/hooks/use-search"
|
|
|
|
export const Route = createFileRoute("/_app/people/$id")({
|
|
component: PersonDetailPage,
|
|
})
|
|
|
|
function PersonDetailPage() {
|
|
const { t } = useTranslation()
|
|
const { id } = Route.useParams()
|
|
const { data, isPending } = usePersonCredits(id)
|
|
|
|
if (isPending) return <PersonSkeleton />
|
|
if (!data) return null
|
|
|
|
const { person, cast, crew } = data
|
|
|
|
return (
|
|
<div className="space-y-4 p-4">
|
|
<Link to="/" className="inline-flex items-center gap-1 text-sm text-muted-foreground">
|
|
<ArrowLeft className="size-4" /> {t("common.back")}
|
|
</Link>
|
|
|
|
<div className="flex items-center gap-4">
|
|
<div className="size-16 flex-shrink-0 overflow-hidden rounded-full bg-muted">
|
|
{person.profile_path ? (
|
|
<img src={tmdbProfileUrl(person.profile_path)} alt="" className="size-full object-cover" />
|
|
) : (
|
|
<div className="flex size-full items-center justify-center">
|
|
<User className="size-6 text-muted-foreground/40" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<h1 className="text-xl font-bold">{person.name}</h1>
|
|
{person.known_for_department && (
|
|
<p className="text-sm text-muted-foreground">{person.known_for_department}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{cast.length > 0 && (
|
|
<section>
|
|
<h2 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
{t("movie.castCredits", { count: cast.length })}
|
|
</h2>
|
|
<div className="space-y-1">
|
|
{cast.map((c) => (
|
|
<MovieCard
|
|
key={`${c.movie_id}-${c.character}`}
|
|
movie={{
|
|
id: c.movie_id,
|
|
title: c.title,
|
|
release_year: c.release_year ?? 0,
|
|
poster_path: c.poster_path,
|
|
genres: [],
|
|
}}
|
|
subtitle={c.character}
|
|
variant="compact"
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{crew.length > 0 && (
|
|
<section>
|
|
<h2 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
{t("movie.crewCredits", { count: crew.length })}
|
|
</h2>
|
|
<div className="space-y-1">
|
|
{crew.map((c) => (
|
|
<MovieCard
|
|
key={`${c.movie_id}-${c.job}`}
|
|
movie={{
|
|
id: c.movie_id,
|
|
title: c.title,
|
|
release_year: c.release_year ?? 0,
|
|
poster_path: c.poster_path,
|
|
genres: [],
|
|
}}
|
|
subtitle={`${c.job} (${c.department})`}
|
|
variant="compact"
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{cast.length === 0 && crew.length === 0 && (
|
|
<EmptyState icon={Film} title={t("movie.noCredits")} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PersonSkeleton() {
|
|
return (
|
|
<div className="space-y-4 p-4">
|
|
<Skeleton className="h-5 w-16" />
|
|
<div className="flex items-center gap-4">
|
|
<Skeleton className="size-16 rounded-full" />
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-6 w-32" />
|
|
<Skeleton className="h-4 w-20" />
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{[1, 2, 3, 4].map((i) => (
|
|
<Skeleton key={i} className="h-10 rounded-lg" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|