feat: redesign person page with Cards, SwipeTabs, collapsible bio

This commit is contained in:
2026-06-11 14:07:27 +02:00
parent e39fcf6802
commit 7dc372a7b6
2 changed files with 173 additions and 106 deletions

View File

@@ -23,7 +23,9 @@
"run": "Run", "run": "Run",
"reviews": "{{count}} reviews", "reviews": "{{count}} reviews",
"films": "{{count}} films", "films": "{{count}} films",
"filmsAvg": "{{count}} films, avg {{avg}}" "filmsAvg": "{{count}} films, avg {{avg}}",
"more": "Show more",
"less": "Show less"
}, },
"nav": { "nav": {
"home": "Home", "home": "Home",

View File

@@ -1,10 +1,15 @@
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute } from "@tanstack/react-router"
import { useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Calendar, ExternalLink, Film, Globe, MapPin, User } from "lucide-react" import { Calendar, ChevronDown, ExternalLink, Film, Globe, MapPin, User } from "lucide-react"
import { BackButton } from "@/components/back-button" import { BackButton } from "@/components/back-button"
import { MovieCard } from "@/components/movie-card" import { MovieCard } from "@/components/movie-card"
import { EmptyState } from "@/components/empty-state" import { EmptyState } from "@/components/empty-state"
import { SwipeTabs } from "@/components/swipe-tabs"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { tmdbProfileUrl } from "@/lib/api/client" import { tmdbProfileUrl } from "@/lib/api/client"
import { usePersonCredits } from "@/hooks/use-search" import { usePersonCredits } from "@/hooks/use-search"
@@ -27,152 +32,212 @@ function PersonDetailPage() {
const { person, cast, crew } = data const { person, cast, crew } = data
const age = person.birthday
? differenceInYears(
person.deathday ? parseISO(person.deathday) : new Date(),
parseISO(person.birthday),
)
: null
const creditTabs = [
...(cast.length > 0 ? [{ value: "cast", label: t("movie.cast") + ` (${cast.length})` }] : []),
...(crew.length > 0 ? [{ value: "crew", label: t("movie.crew") + ` (${crew.length})` }] : []),
] as const
return ( return (
<div className="space-y-4 p-4"> <div className="space-y-4 p-4">
<BackButton /> <BackButton />
{/* Header */} {/* Header */}
<div className="flex gap-4"> <div className="flex gap-4">
<div className="size-20 flex-shrink-0 overflow-hidden rounded-xl bg-muted"> <div className="size-24 flex-shrink-0 overflow-hidden rounded-xl bg-muted">
{person.profile_path ? ( {person.profile_path ? (
<img src={tmdbProfileUrl(person.profile_path)} alt="" className="size-full object-cover" /> <img src={tmdbProfileUrl(person.profile_path)} alt="" className="size-full object-cover" />
) : ( ) : (
<div className="flex size-full items-center justify-center"> <div className="flex size-full items-center justify-center">
<User className="size-8 text-muted-foreground/40" /> <User className="size-10 text-muted-foreground/40" />
</div> </div>
)} )}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1 space-y-1">
<h1 className="text-xl font-bold">{person.name}</h1> <h1 className="text-xl font-bold">{person.name}</h1>
{person.known_for_department && ( {person.known_for_department && (
<p className="text-sm text-muted-foreground">{person.known_for_department}</p> <Badge variant="secondary">{person.known_for_department}</Badge>
)} )}
{person.birthday && ( {(person.homepage || person.imdb_url) && (
<p className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground"> <div className="flex gap-1.5 pt-1">
<Calendar className="size-3" /> {person.imdb_url && (
{shortDate(person.birthday)} <a href={person.imdb_url} target="_blank" rel="noopener noreferrer">
{person.deathday && `${shortDate(person.deathday)}`} <Badge variant="outline" className="gap-1 text-[10px]">
{` (${differenceInYears(person.deathday ? parseISO(person.deathday) : new Date(), parseISO(person.birthday))})`} <ExternalLink className="size-2.5" />
</p> IMDb
)} </Badge>
{person.place_of_birth && ( </a>
<p className="flex items-center gap-1.5 text-xs text-muted-foreground"> )}
<MapPin className="size-3" /> {person.homepage && (
{person.place_of_birth} <a href={person.homepage} target="_blank" rel="noopener noreferrer">
</p> <Badge variant="outline" className="gap-1 text-[10px]">
<Globe className="size-2.5" />
{t("person.homepage")}
</Badge>
</a>
)}
</div>
)} )}
</div> </div>
</div> </div>
{/* Links */} {/* Details card */}
{(person.homepage || person.imdb_url) && ( {(person.birthday || person.place_of_birth) && (
<div className="flex gap-2"> <Card size="sm">
{person.imdb_url && ( <CardContent className="space-y-2">
<a href={person.imdb_url} target="_blank" rel="noopener noreferrer"> {person.birthday && (
<Badge variant="secondary" className="gap-1"> <div className="flex items-center gap-2 text-sm">
<ExternalLink className="size-3" /> <Calendar className="size-3.5 text-muted-foreground" />
{t("person.imdb")} <span>{shortDate(person.birthday)}</span>
</Badge> {age != null && (
</a> <span className="text-muted-foreground">({age})</span>
)} )}
{person.homepage && ( </div>
<a href={person.homepage} target="_blank" rel="noopener noreferrer"> )}
<Badge variant="secondary" className="gap-1"> {person.deathday && (
<Globe className="size-3" /> <div className="flex items-center gap-2 text-sm">
{t("person.homepage")} <Calendar className="size-3.5 text-muted-foreground" />
</Badge> <span>{shortDate(person.deathday)}</span>
</a> <span className="text-muted-foreground">({t("person.deathday").toLowerCase()})</span>
)} </div>
</div> )}
{person.place_of_birth && (
<div className="flex items-center gap-2 text-sm">
<MapPin className="size-3.5 text-muted-foreground" />
<span>{person.place_of_birth}</span>
</div>
)}
</CardContent>
</Card>
)} )}
{/* Biography */} {/* Biography */}
{person.biography && ( {person.biography && <BiographySection text={person.biography} />}
<p className="text-sm leading-relaxed text-muted-foreground">{person.biography}</p>
)}
{/* Also known as */} {/* Also known as */}
{person.also_known_as?.length > 0 && ( {person.also_known_as?.length > 0 && (
<div> <Card size="sm">
<p className="mb-1 text-xs font-medium text-muted-foreground">{t("person.alsoKnownAs")}</p> <CardHeader>
<div className="flex flex-wrap gap-1"> <CardTitle className="text-xs">{t("person.alsoKnownAs")}</CardTitle>
{person.also_known_as.map((name) => ( </CardHeader>
<Badge key={name} variant="outline" className="text-xs font-normal"> <CardContent>
{name} <div className="flex flex-wrap gap-1">
</Badge> {person.also_known_as.map((name) => (
))} <Badge key={name} variant="outline" className="text-xs font-normal">
</div> {name}
</div> </Badge>
))}
</div>
</CardContent>
</Card>
)} )}
{/* Cast credits */} <Separator />
{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 credits */} {/* Credits */}
{crew.length > 0 && ( {creditTabs.length > 0 ? (
<section> <SwipeTabs tabs={creditTabs} defaultValue={creditTabs[0].value} tabsListClassName="w-full">
<h2 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground"> {(tab) => (
{t("movie.crewCredits", { count: crew.length })} <>
</h2> {tab === "cast" && (
<div className="space-y-1"> <div className="space-y-1">
{crew.map((c) => ( {cast.map((c) => (
<MovieCard <MovieCard
key={`${c.movie_id}-${c.job}`} key={`${c.movie_id}-${c.character}`}
movie={{ movie={{
id: c.movie_id, id: c.movie_id,
title: c.title, title: c.title,
release_year: c.release_year ?? 0, release_year: c.release_year ?? 0,
poster_path: c.poster_path, poster_path: c.poster_path,
genres: [], genres: [],
}} }}
subtitle={`${c.job} (${c.department})`} subtitle={c.character}
variant="compact" variant="compact"
/> />
))} ))}
</div> </div>
</section> )}
)} {tab === "crew" && (
<div className="space-y-1">
{cast.length === 0 && crew.length === 0 && ( {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>
)}
</>
)}
</SwipeTabs>
) : (
<EmptyState icon={Film} title={t("movie.noCredits")} /> <EmptyState icon={Film} title={t("movie.noCredits")} />
)} )}
</div> </div>
) )
} }
const BIO_COLLAPSE_THRESHOLD = 300
function BiographySection({ text }: { text: string }) {
const { t } = useTranslation()
const isLong = text.length > BIO_COLLAPSE_THRESHOLD
const [expanded, setExpanded] = useState(!isLong)
return (
<Card size="sm">
<CardHeader>
<CardTitle className="text-xs">{t("person.biography")}</CardTitle>
</CardHeader>
<CardContent>
<p className={`text-sm leading-relaxed text-muted-foreground ${!expanded ? "line-clamp-4" : ""}`}>
{text}
</p>
{isLong && (
<Button
variant="ghost"
size="sm"
className="mt-1 h-auto p-0 text-xs text-primary"
onClick={() => setExpanded((v) => !v)}
>
<ChevronDown className={`mr-1 size-3 transition-transform ${expanded ? "rotate-180" : ""}`} />
{expanded ? t("common.less") : t("common.more")}
</Button>
)}
</CardContent>
</Card>
)
}
function PersonSkeleton() { function PersonSkeleton() {
return ( return (
<div className="space-y-4 p-4"> <div className="space-y-4 p-4">
<Skeleton className="h-5 w-16" /> <Skeleton className="h-5 w-16" />
<div className="flex items-center gap-4"> <div className="flex gap-4">
<Skeleton className="size-16 rounded-full" /> <Skeleton className="size-24 rounded-xl" />
<div className="space-y-2"> <div className="flex-1 space-y-2">
<Skeleton className="h-6 w-32" /> <Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-20" /> <Skeleton className="h-5 w-20 rounded-full" />
<Skeleton className="h-4 w-24" />
</div> </div>
</div> </div>
<Skeleton className="h-20 rounded-xl" />
<Skeleton className="h-32 rounded-xl" />
<Skeleton className="h-1 w-full" />
<div className="space-y-2"> <div className="space-y-2">
{[1, 2, 3, 4].map((i) => ( {[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-10 rounded-lg" /> <Skeleton key={i} className="h-10 rounded-lg" />