feat: redesign person page with Cards, SwipeTabs, collapsible bio
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
Reference in New Issue
Block a user