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,124 @@
import { createFileRoute } from "@tanstack/react-router"
import { useCallback, useState } from "react"
import { useTranslation } from "react-i18next"
import { Search as SearchIcon, Film, Users } from "lucide-react"
import { Input } from "@/components/ui/input"
import { MovieCard } from "@/components/movie-card"
import { PersonRow } from "@/components/person-row"
import { EmptyState } from "@/components/empty-state"
import { InfiniteScroll } from "@/components/infinite-scroll"
import { Skeleton } from "@/components/ui/skeleton"
import { useInfiniteSearch } from "@/hooks/use-search"
import { useDebounce } from "@/hooks/use-debounce"
export const Route = createFileRoute("/_app/search")({
component: SearchPage,
})
function SearchPage() {
const { t } = useTranslation()
const [query, setQuery] = useState("")
const debouncedQuery = useDebounce(query, 300)
const {
data,
isPending,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useInfiniteSearch({ q: debouncedQuery || undefined })
const hasQuery = debouncedQuery.length > 0
const movies = data?.pages.flatMap((p) => p.movies.items) ?? []
const people = data?.pages[0]?.people.items ?? []
const loadMore = useCallback(() => fetchNextPage(), [fetchNextPage])
return (
<div className="space-y-4 p-4">
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("search.placeholder")}
className="pl-9"
autoFocus
/>
</div>
{!hasQuery && <EmptyState icon={SearchIcon} title={t("search.searchPrompt")} />}
{hasQuery && isPending && <SearchSkeleton />}
{hasQuery && data && (
<div className="space-y-6">
{people.length > 0 && (
<section>
<h2 className="mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<Users className="size-3.5" /> {t("search.people")}
</h2>
<div className="space-y-1">
{people.map((person) => (
<PersonRow
key={person.person_id}
id={person.person_id}
name={person.name}
subtitle={person.known_for_department}
imagePath={person.profile_path}
/>
))}
</div>
</section>
)}
{movies.length > 0 && (
<section>
<h2 className="mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<Film className="size-3.5" /> {t("search.movies")}
</h2>
<div className="space-y-2">
{movies.map((hit) => (
<MovieCard
key={hit.movie_id}
movie={{
id: hit.movie_id,
title: hit.title,
release_year: hit.release_year ?? 0,
director: hit.director,
poster_path: hit.poster_path,
genres: hit.genres,
}}
variant="full"
/>
))}
</div>
<InfiniteScroll
hasMore={!!hasNextPage}
isFetching={isFetchingNextPage}
onLoadMore={loadMore}
/>
</section>
)}
{movies.length === 0 && people.length === 0 && (
<EmptyState icon={SearchIcon} title={t("search.noResults")} description={t("search.noResultsDesc")} />
)}
</div>
)}
</div>
)
}
function SearchSkeleton() {
return (
<div className="space-y-2">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex gap-3 rounded-xl bg-card p-3">
<Skeleton className="h-[84px] w-14 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
</div>
</div>
))}
</div>
)
}