feat: add SPA, serve at /app/, update Dockerfile and README

- 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
This commit is contained in:
2026-06-04 04:20:15 +02:00
parent 15dc0e526b
commit b9c0b10740
153 changed files with 24329 additions and 1 deletions

View File

@@ -0,0 +1,121 @@
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>
)
}