A personal media tracker built on the AT Protocol opnshelf.xyz
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add people detail page with linked filmography

- Add /people/$personId/$personName route with hero, biography,
known-for highlights, and paginated filmography grid
- Create usePersonDetails and usePersonFilmography hooks
- Add buildPersonUrl and buildPersonPageMeta utilities
- Update PersonGrid to link each cast/crew card to person pages
- Use ActionableMediaCard for watch/list actions on filmography items

+437 -2
+6 -2
apps/web/src/components/PersonGrid.tsx
··· 1 + import { Link } from "@tanstack/react-router"; 2 + import { buildPersonUrl } from "#/lib/url-utils"; 3 + 1 4 interface Person { 2 5 id: number; 3 6 name: string; ··· 44 47 ) : ( 45 48 <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> 46 49 {uniquePeople.map((person) => ( 47 - <div 50 + <Link 48 51 key={person.id} 52 + to={buildPersonUrl(person.id, person.name)} 49 53 className="card card-interactive flex items-center gap-3 p-3" 50 54 > 51 55 <img ··· 60 64 {person.role} 61 65 </p> 62 66 </div> 63 - </div> 67 + </Link> 64 68 ))} 65 69 </div> 66 70 )}
+1
apps/web/src/lib/hooks/index.ts
··· 4 4 export * from "./useLists"; 5 5 export * from "./useMedia"; 6 6 export * from "./useMediaWatchStatus"; 7 + export * from "./usePerson"; 7 8 export * from "./useWatchActions";
+26
apps/web/src/lib/hooks/usePerson.ts
··· 1 + import { 2 + peopleControllerGetPersonDetailsOptions, 3 + peopleControllerGetPersonFilmographyInfiniteOptions, 4 + } from "@opnshelf/api"; 5 + import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; 6 + 7 + // Person detail hook 8 + export function usePersonDetails(personId: string) { 9 + return useQuery({ 10 + ...peopleControllerGetPersonDetailsOptions({ 11 + path: { personId }, 12 + }), 13 + enabled: !!personId, 14 + }); 15 + } 16 + 17 + // Person filmography infinite query hook 18 + export function usePersonFilmography(personId: string, pageSize = 20) { 19 + return useInfiniteQuery({ 20 + ...peopleControllerGetPersonFilmographyInfiniteOptions({ 21 + path: { personId }, 22 + query: { pageSize }, 23 + }), 24 + enabled: !!personId, 25 + }); 26 + }
+21
apps/web/src/lib/media-meta.ts
··· 1 1 import type { 2 2 TmdbEpisodeDto, 3 3 TmdbMovieDetailDto, 4 + TmdbPersonDetailDto, 4 5 TmdbSeasonDetailDto, 5 6 TmdbShowDetailDto, 6 7 } from "@opnshelf/api"; ··· 132 133 ), 133 134 }; 134 135 } 136 + 137 + export function buildPersonPageMeta( 138 + person?: TmdbPersonDetailDto | null, 139 + fallbackTitle = "Person", 140 + ): PageMeta { 141 + if (!person) { 142 + return { 143 + title: `${fallbackTitle} | People | OpnShelf`, 144 + description: `View filmography, biography, and details for ${fallbackTitle} on OpnShelf.`, 145 + }; 146 + } 147 + 148 + return { 149 + title: `${person.name} | People | OpnShelf`, 150 + description: getDescription( 151 + person.biography, 152 + `Explore the filmography and biography of ${person.name} on OpnShelf.`, 153 + ), 154 + }; 155 + }
+12
apps/web/src/lib/url-utils.ts
··· 61 61 const slug = slugifyName(movieName); 62 62 return `/movies/${movieId}/${slug}`; 63 63 } 64 + 65 + /** 66 + * Build a person detail URL 67 + * Format: /people/[personId]/[personName] 68 + */ 69 + export function buildPersonUrl( 70 + personId: string | number, 71 + personName: string, 72 + ): string { 73 + const slug = slugifyName(personName); 74 + return `/people/${personId}/${slug}`; 75 + }
+22
apps/web/src/routeTree.gen.ts
··· 25 25 import { Route as ProfileHandleShelfRouteImport } from './routes/profile.$handle/shelf' 26 26 import { Route as ProfileHandleListsRouteImport } from './routes/profile.$handle/lists' 27 27 import { Route as ProfileHandleConnectionsRouteImport } from './routes/profile.$handle/connections' 28 + import { Route as PeoplePersonIdPersonNameRouteImport } from './routes/people/$personId/$personName' 28 29 import { Route as MoviesMovieIdMovieNameRouteImport } from './routes/movies/$movieId/$movieName' 29 30 import { Route as ShowsShowIdShowNameIndexRouteImport } from './routes/shows/$showId/$showName/index' 30 31 import { Route as ProfileHandleListsIndexRouteImport } from './routes/profile.$handle/lists.index' ··· 114 115 path: '/connections', 115 116 getParentRoute: () => ProfileHandleRoute, 116 117 } as any) 118 + const PeoplePersonIdPersonNameRoute = 119 + PeoplePersonIdPersonNameRouteImport.update({ 120 + id: '/people/$personId/$personName', 121 + path: '/people/$personId/$personName', 122 + getParentRoute: () => rootRouteImport, 123 + } as any) 117 124 const MoviesMovieIdMovieNameRoute = MoviesMovieIdMovieNameRouteImport.update({ 118 125 id: '/movies/$movieId/$movieName', 119 126 path: '/movies/$movieId/$movieName', ··· 169 176 '/auth/complete': typeof AuthCompleteRoute 170 177 '/profile/$handle': typeof ProfileHandleRouteWithChildren 171 178 '/movies/$movieId/$movieName': typeof MoviesMovieIdMovieNameRoute 179 + '/people/$personId/$personName': typeof PeoplePersonIdPersonNameRoute 172 180 '/profile/$handle/connections': typeof ProfileHandleConnectionsRoute 173 181 '/profile/$handle/lists': typeof ProfileHandleListsRouteWithChildren 174 182 '/profile/$handle/shelf': typeof ProfileHandleShelfRoute ··· 193 201 '/settings': typeof SettingsRoute 194 202 '/auth/complete': typeof AuthCompleteRoute 195 203 '/movies/$movieId/$movieName': typeof MoviesMovieIdMovieNameRoute 204 + '/people/$personId/$personName': typeof PeoplePersonIdPersonNameRoute 196 205 '/profile/$handle/connections': typeof ProfileHandleConnectionsRoute 197 206 '/profile/$handle/shelf': typeof ProfileHandleShelfRoute 198 207 '/profile/$handle/up-next': typeof ProfileHandleUpNextRoute ··· 216 225 '/auth/complete': typeof AuthCompleteRoute 217 226 '/profile/$handle': typeof ProfileHandleRouteWithChildren 218 227 '/movies/$movieId/$movieName': typeof MoviesMovieIdMovieNameRoute 228 + '/people/$personId/$personName': typeof PeoplePersonIdPersonNameRoute 219 229 '/profile/$handle/connections': typeof ProfileHandleConnectionsRoute 220 230 '/profile/$handle/lists': typeof ProfileHandleListsRouteWithChildren 221 231 '/profile/$handle/shelf': typeof ProfileHandleShelfRoute ··· 243 253 | '/auth/complete' 244 254 | '/profile/$handle' 245 255 | '/movies/$movieId/$movieName' 256 + | '/people/$personId/$personName' 246 257 | '/profile/$handle/connections' 247 258 | '/profile/$handle/lists' 248 259 | '/profile/$handle/shelf' ··· 267 278 | '/settings' 268 279 | '/auth/complete' 269 280 | '/movies/$movieId/$movieName' 281 + | '/people/$personId/$personName' 270 282 | '/profile/$handle/connections' 271 283 | '/profile/$handle/shelf' 272 284 | '/profile/$handle/up-next' ··· 289 301 | '/auth/complete' 290 302 | '/profile/$handle' 291 303 | '/movies/$movieId/$movieName' 304 + | '/people/$personId/$personName' 292 305 | '/profile/$handle/connections' 293 306 | '/profile/$handle/lists' 294 307 | '/profile/$handle/shelf' ··· 315 328 AuthCompleteRoute: typeof AuthCompleteRoute 316 329 ProfileHandleRoute: typeof ProfileHandleRouteWithChildren 317 330 MoviesMovieIdMovieNameRoute: typeof MoviesMovieIdMovieNameRoute 331 + PeoplePersonIdPersonNameRoute: typeof PeoplePersonIdPersonNameRoute 318 332 ShowsShowIdShowNameRoute: typeof ShowsShowIdShowNameRouteWithChildren 319 333 } 320 334 ··· 432 446 preLoaderRoute: typeof ProfileHandleConnectionsRouteImport 433 447 parentRoute: typeof ProfileHandleRoute 434 448 } 449 + '/people/$personId/$personName': { 450 + id: '/people/$personId/$personName' 451 + path: '/people/$personId/$personName' 452 + fullPath: '/people/$personId/$personName' 453 + preLoaderRoute: typeof PeoplePersonIdPersonNameRouteImport 454 + parentRoute: typeof rootRouteImport 455 + } 435 456 '/movies/$movieId/$movieName': { 436 457 id: '/movies/$movieId/$movieName' 437 458 path: '/movies/$movieId/$movieName' ··· 561 582 AuthCompleteRoute: AuthCompleteRoute, 562 583 ProfileHandleRoute: ProfileHandleRouteWithChildren, 563 584 MoviesMovieIdMovieNameRoute: MoviesMovieIdMovieNameRoute, 585 + PeoplePersonIdPersonNameRoute: PeoplePersonIdPersonNameRoute, 564 586 ShowsShowIdShowNameRoute: ShowsShowIdShowNameRouteWithChildren, 565 587 } 566 588 export const routeTree = rootRouteImport
+349
apps/web/src/routes/people/$personId/$personName.tsx
··· 1 + import { 2 + peopleControllerGetPersonDetailsOptions, 3 + peopleControllerGetPersonFilmographyInfiniteOptions, 4 + } from "@opnshelf/api"; 5 + import { useInfiniteQuery } from "@tanstack/react-query"; 6 + import { createFileRoute, Link } from "@tanstack/react-router"; 7 + import { 8 + Calendar, 9 + ChevronLeft, 10 + Clapperboard, 11 + Loader2, 12 + MapPin, 13 + Star, 14 + } from "lucide-react"; 15 + import { useMemo } from "react"; 16 + import { setupApiClient } from "#/lib/api"; 17 + import { useAuth } from "#/lib/auth-context"; 18 + import { formatDate } from "#/lib/date-utils"; 19 + import { usePersonDetails } from "#/lib/hooks"; 20 + import { buildPersonPageMeta } from "#/lib/media-meta"; 21 + import ActionableMediaCard from "../../../components/ActionableMediaCard"; 22 + import DetailsCard from "../../../components/DetailsCard"; 23 + import ErrorState from "../../../components/ErrorState"; 24 + import LoadingState from "../../../components/LoadingState"; 25 + 26 + setupApiClient(); 27 + 28 + export const Route = createFileRoute("/people/$personId/$personName")({ 29 + loader: async ({ context, params }) => { 30 + return context.queryClient.ensureQueryData( 31 + peopleControllerGetPersonDetailsOptions({ 32 + path: { personId: params.personId }, 33 + }), 34 + ); 35 + }, 36 + head: ({ loaderData, params }) => { 37 + const meta = buildPersonPageMeta(loaderData, params.personName); 38 + return { 39 + meta: [ 40 + { title: meta.title }, 41 + { 42 + name: "description", 43 + content: meta.description, 44 + }, 45 + ], 46 + }; 47 + }, 48 + component: PersonDetailPage, 49 + }); 50 + 51 + function PersonDetailPage() { 52 + const { personId } = Route.useParams(); 53 + const { userSettings } = useAuth(); 54 + const userTimezone = userSettings?.timezone; 55 + 56 + const { data: person, isLoading, error } = usePersonDetails(personId); 57 + 58 + const { 59 + data: filmographyData, 60 + fetchNextPage, 61 + hasNextPage, 62 + isFetchingNextPage, 63 + } = useInfiniteQuery({ 64 + ...peopleControllerGetPersonFilmographyInfiniteOptions({ 65 + path: { personId }, 66 + query: { pageSize: 20 }, 67 + }), 68 + enabled: !!personId, 69 + }); 70 + 71 + const filmographyItems = useMemo(() => { 72 + if (!filmographyData?.pages) return []; 73 + return filmographyData.pages.flatMap((page) => page.items); 74 + }, [filmographyData]); 75 + 76 + const knownForItems = useMemo(() => { 77 + if (!person?.filmography) return []; 78 + return [...person.filmography] 79 + .filter((item) => item.vote_average && item.vote_average > 0) 80 + .sort((a, b) => (b.vote_average ?? 0) - (a.vote_average ?? 0)) 81 + .slice(0, 6); 82 + }, [person?.filmography]); 83 + 84 + if (isLoading) return <LoadingState />; 85 + if (error || !person) { 86 + return ( 87 + <ErrorState 88 + message="Failed to load person" 89 + backTo="/" 90 + backLabel="Back to Dashboard" 91 + /> 92 + ); 93 + } 94 + 95 + const profileUrl = person.profile_path 96 + ? `https://image.tmdb.org/t/p/w500${person.profile_path}` 97 + : ""; 98 + 99 + const birthYear = person.birthday 100 + ? new Date(person.birthday).getFullYear() 101 + : null; 102 + const deathYear = person.deathday 103 + ? new Date(person.deathday).getFullYear() 104 + : null; 105 + const age = 106 + birthYear && !Number.isNaN(birthYear) 107 + ? deathYear && !Number.isNaN(deathYear) 108 + ? deathYear - birthYear 109 + : new Date().getFullYear() - birthYear 110 + : null; 111 + 112 + return ( 113 + <div className="min-h-screen pb-8"> 114 + {/* Hero Section */} 115 + <div className="relative z-10 min-h-[40vh] overflow-hidden"> 116 + {/* Subtle gradient background */} 117 + <div className="absolute inset-0 h-[50vh] bg-linear-to-br from-gray-800 to-gray-900" /> 118 + <div className="absolute inset-0 bg-linear-to-t from-(--background) via-(--background)/60 to-transparent" /> 119 + <div className="absolute inset-0 bg-linear-to-r from-(--background) via-(--background)/40 to-transparent" /> 120 + 121 + <div className="container-app relative pt-8"> 122 + <Link to="/" className="btn btn-secondary mb-6 inline-flex gap-2"> 123 + <ChevronLeft className="size-4" /> 124 + Back to Dashboard 125 + </Link> 126 + 127 + <div className="grid gap-8 lg:grid-cols-[280px_1fr] lg:gap-12"> 128 + {/* Profile Photo — Desktop */} 129 + <div className="hidden lg:block"> 130 + <div className="aspect-2/3 overflow-hidden rounded-xl shadow-2xl"> 131 + {profileUrl ? ( 132 + <img 133 + src={profileUrl} 134 + alt={person.name} 135 + className="h-full w-full object-cover" 136 + /> 137 + ) : ( 138 + <div className="flex h-full w-full items-center justify-center bg-linear-to-br from-gray-700 to-gray-800"> 139 + <span className="text-gray-400">No Photo</span> 140 + </div> 141 + )} 142 + </div> 143 + </div> 144 + 145 + {/* Info */} 146 + <div className="flex flex-col justify-end pb-8 lg:pb-16"> 147 + {/* Mobile Photo + Name */} 148 + <div className="mb-6 flex gap-4 lg:hidden"> 149 + <div className="h-40 w-28 shrink-0 overflow-hidden rounded-lg"> 150 + {profileUrl ? ( 151 + <img 152 + src={profileUrl} 153 + alt={person.name} 154 + className="h-full w-full object-cover" 155 + /> 156 + ) : ( 157 + <div className="h-full w-full bg-linear-to-br from-gray-700 to-gray-800" /> 158 + )} 159 + </div> 160 + <div className="flex flex-col justify-center"> 161 + <h1 className="text-display-2">{person.name}</h1> 162 + </div> 163 + </div> 164 + 165 + {/* Desktop Name */} 166 + <div className="hidden lg:block"> 167 + <h1 className="text-display-2">{person.name}</h1> 168 + </div> 169 + 170 + {/* Meta */} 171 + <div className="mt-4 flex flex-wrap items-center gap-3 text-sm"> 172 + {person.known_for_department && ( 173 + <span className="badge badge-accent"> 174 + <Clapperboard className="mr-1 size-3" /> 175 + {person.known_for_department} 176 + </span> 177 + )} 178 + {person.birthday && ( 179 + <span className="flex items-center gap-1 text-(--foreground-muted)"> 180 + <Calendar className="size-4" /> 181 + Born {formatDate(person.birthday, userTimezone)} 182 + {age !== null && !person.deathday && ` (${age} years)`} 183 + </span> 184 + )} 185 + {person.deathday && ( 186 + <span className="flex items-center gap-1 text-(--foreground-muted)"> 187 + <Calendar className="size-4" /> 188 + Died {formatDate(person.deathday, userTimezone)} 189 + {age !== null && ` (aged ${age})`} 190 + </span> 191 + )} 192 + {person.place_of_birth && ( 193 + <span className="flex items-center gap-1 text-(--foreground-muted)"> 194 + <MapPin className="size-4" /> 195 + {person.place_of_birth} 196 + </span> 197 + )} 198 + {person.popularity !== undefined && ( 199 + <span className="flex items-center gap-1 text-(--foreground-muted)"> 200 + <Star className="size-4 fill-yellow-500 text-yellow-500" /> 201 + {Math.round(person.popularity * 10) / 10} 202 + </span> 203 + )} 204 + </div> 205 + </div> 206 + </div> 207 + </div> 208 + </div> 209 + 210 + {/* Main Content */} 211 + <div className="container-app relative z-20 mt-8"> 212 + <div className="grid gap-8 lg:grid-cols-[2fr_1fr] lg:gap-12"> 213 + {/* Left Column */} 214 + <div className="space-y-8"> 215 + {/* Biography */} 216 + {person.biography && ( 217 + <section> 218 + <h2 className="mb-4 text-display-3">Biography</h2> 219 + <p className="whitespace-pre-line text-(--foreground-muted) leading-relaxed"> 220 + {person.biography} 221 + </p> 222 + </section> 223 + )} 224 + 225 + {/* Known For */} 226 + {knownForItems.length > 0 && ( 227 + <section> 228 + <h2 className="mb-4 text-display-3">Known For</h2> 229 + <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> 230 + {knownForItems.map((item) => ( 231 + <ActionableMediaCard 232 + key={`known-${item.id}-${item.media_type}`} 233 + id={item.id} 234 + title={item.title} 235 + posterUrl={ 236 + item.poster_path 237 + ? `https://image.tmdb.org/t/p/w300${item.poster_path}` 238 + : "" 239 + } 240 + type={item.media_type === "movie" ? "movie" : "show"} 241 + rating={ 242 + item.vote_average 243 + ? Math.round(item.vote_average * 10) / 10 244 + : undefined 245 + } 246 + size="sm" 247 + layout="poster" 248 + /> 249 + ))} 250 + </div> 251 + </section> 252 + )} 253 + 254 + {/* Full Filmography */} 255 + <section> 256 + <h2 className="mb-4 text-display-3">Filmography</h2> 257 + {filmographyItems.length === 0 && !isFetchingNextPage ? ( 258 + <p className="text-(--foreground-muted) text-sm"> 259 + No filmography available. 260 + </p> 261 + ) : ( 262 + <> 263 + <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> 264 + {filmographyItems.map((item) => ( 265 + <ActionableMediaCard 266 + key={`film-${item.id}-${item.media_type}`} 267 + id={item.id} 268 + title={item.title} 269 + posterUrl={ 270 + item.poster_path 271 + ? `https://image.tmdb.org/t/p/w300${item.poster_path}` 272 + : "" 273 + } 274 + type={item.media_type === "movie" ? "movie" : "show"} 275 + rating={ 276 + item.vote_average 277 + ? Math.round(item.vote_average * 10) / 10 278 + : undefined 279 + } 280 + size="sm" 281 + layout="poster" 282 + /> 283 + ))} 284 + </div> 285 + {hasNextPage && ( 286 + <div className="mt-6 flex justify-center"> 287 + <button 288 + type="button" 289 + onClick={() => fetchNextPage()} 290 + disabled={isFetchingNextPage} 291 + className="btn btn-secondary gap-2" 292 + > 293 + {isFetchingNextPage ? ( 294 + <> 295 + <Loader2 className="size-4 animate-spin" /> 296 + Loading... 297 + </> 298 + ) : ( 299 + "Load more" 300 + )} 301 + </button> 302 + </div> 303 + )} 304 + </> 305 + )} 306 + </section> 307 + </div> 308 + 309 + {/* Right Column - Sidebar */} 310 + <div className="space-y-6"> 311 + <DetailsCard 312 + items={[ 313 + { 314 + label: "Department", 315 + value: person.known_for_department || "Unknown", 316 + }, 317 + { 318 + label: "Born", 319 + value: person.birthday 320 + ? formatDate(person.birthday, userTimezone) 321 + : "Unknown", 322 + }, 323 + { 324 + label: "Birthplace", 325 + value: person.place_of_birth || "Unknown", 326 + }, 327 + { 328 + label: "Popularity", 329 + value: 330 + person.popularity !== undefined 331 + ? String(Math.round(person.popularity * 10) / 10) 332 + : "N/A", 333 + }, 334 + ...(person.deathday 335 + ? [ 336 + { 337 + label: "Died", 338 + value: formatDate(person.deathday, userTimezone), 339 + }, 340 + ] 341 + : []), 342 + ]} 343 + /> 344 + </div> 345 + </div> 346 + </div> 347 + </div> 348 + ); 349 + }