feat: rich person detail page with bio, dates, links
This commit is contained in:
@@ -45,6 +45,14 @@ export const personDtoSchema = z.object({
|
|||||||
name: z.string(),
|
name: z.string(),
|
||||||
known_for_department: z.string().optional(),
|
known_for_department: z.string().optional(),
|
||||||
profile_path: z.string().optional(),
|
profile_path: z.string().optional(),
|
||||||
|
biography: z.string().optional(),
|
||||||
|
birthday: z.string().optional(),
|
||||||
|
deathday: z.string().optional(),
|
||||||
|
place_of_birth: z.string().optional(),
|
||||||
|
also_known_as: z.array(z.string()).default([]),
|
||||||
|
homepage: z.string().optional(),
|
||||||
|
imdb_url: z.string().optional(),
|
||||||
|
enriched: z.boolean().default(false),
|
||||||
})
|
})
|
||||||
export type PersonDto = z.infer<typeof personDtoSchema>
|
export type PersonDto = z.infer<typeof personDtoSchema>
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,16 @@
|
|||||||
"searchPlaceholder": "Search entries...",
|
"searchPlaceholder": "Search entries...",
|
||||||
"watchedAgo": "Watched {{when}}"
|
"watchedAgo": "Watched {{when}}"
|
||||||
},
|
},
|
||||||
|
"person": {
|
||||||
|
"biography": "Biography",
|
||||||
|
"birthday": "Born",
|
||||||
|
"deathday": "Died",
|
||||||
|
"placeOfBirth": "Birthplace",
|
||||||
|
"alsoKnownAs": "Also Known As",
|
||||||
|
"links": "Links",
|
||||||
|
"homepage": "Homepage",
|
||||||
|
"imdb": "IMDb"
|
||||||
|
},
|
||||||
"social": {
|
"social": {
|
||||||
"title": "Social",
|
"title": "Social",
|
||||||
"following": "Following",
|
"following": "Following",
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { Film, User } from "lucide-react"
|
import { Calendar, 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 { Badge } from "@/components/ui/badge"
|
||||||
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"
|
||||||
import { useDocumentTitle } from "@/hooks/use-document-title"
|
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||||
|
import { shortDate } from "@/lib/date"
|
||||||
|
|
||||||
export const Route = createFileRoute("/_app/people/$id")({
|
export const Route = createFileRoute("/_app/people/$id")({
|
||||||
component: PersonDetailPage,
|
component: PersonDetailPage,
|
||||||
@@ -28,24 +30,80 @@ function PersonDetailPage() {
|
|||||||
<div className="space-y-4 p-4">
|
<div className="space-y-4 p-4">
|
||||||
<BackButton />
|
<BackButton />
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
{/* Header */}
|
||||||
<div className="size-16 flex-shrink-0 overflow-hidden rounded-full bg-muted">
|
<div className="flex gap-4">
|
||||||
|
<div className="size-20 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-6 text-muted-foreground/40" />
|
<User className="size-8 text-muted-foreground/40" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0 flex-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>
|
<p className="text-sm text-muted-foreground">{person.known_for_department}</p>
|
||||||
)}
|
)}
|
||||||
|
{person.birthday && (
|
||||||
|
<p className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Calendar className="size-3" />
|
||||||
|
{shortDate(person.birthday)}
|
||||||
|
{person.deathday && ` — ${shortDate(person.deathday)}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{person.place_of_birth && (
|
||||||
|
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<MapPin className="size-3" />
|
||||||
|
{person.place_of_birth}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Links */}
|
||||||
|
{(person.homepage || person.imdb_url) && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{person.imdb_url && (
|
||||||
|
<a href={person.imdb_url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<ExternalLink className="size-3" />
|
||||||
|
{t("person.imdb")}
|
||||||
|
</Badge>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{person.homepage && (
|
||||||
|
<a href={person.homepage} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<Globe className="size-3" />
|
||||||
|
{t("person.homepage")}
|
||||||
|
</Badge>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Biography */}
|
||||||
|
{person.biography && (
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">{person.biography}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Also known as */}
|
||||||
|
{person.also_known_as.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-xs font-medium text-muted-foreground">{t("person.alsoKnownAs")}</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{person.also_known_as.map((name) => (
|
||||||
|
<Badge key={name} variant="outline" className="text-xs font-normal">
|
||||||
|
{name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cast credits */}
|
||||||
{cast.length > 0 && (
|
{cast.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<h2 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
@@ -70,6 +128,7 @@ function PersonDetailPage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Crew credits */}
|
||||||
{crew.length > 0 && (
|
{crew.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<h2 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
|||||||
Reference in New Issue
Block a user