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.

Add person detail pages for cast and crew members

Features:
- Backend: New People module with TMDB integration (/people/tmdb/:personId)
- Web: New /person/:personId/:name route with person details and filmography
- Mobile: New /person/:id screen with same features
- Combined chronological filmography list with Movie/TV badges
- Clickable cast/crew cards now navigate to person pages
- Filmography items link to movie/show detail pages

Technical:
- Add use-codegen skill to enforce API code generation patterns
- Manual API client implementation (pending pnpm generate:api)

Closes person detail navigation from movies, shows, and episodes

+1558 -20
+142
.agents/skills/use-codegen/SKILL.md
··· 1 + # Use Codegen for API Clients 2 + 3 + When working with API clients in this repository, always use the auto-generated SDK from `@hey-api/openapi-ts` rather than creating manual implementations. 4 + 5 + ## Rule 6 + 7 + **NEVER create manual API client files.** Always regenerate the API client using the code generator. 8 + 9 + ## Why 10 + 11 + 1. **Single source of truth**: The OpenAPI/Swagger spec is the source of truth for all API contracts 12 + 2. **Type safety**: Generated types are always in sync with the backend 13 + 3. **Consistency**: All API calls follow the same pattern (error handling, request/response formats) 14 + 4. **React Query integration**: Generated hooks include proper caching, error handling, and loading states 15 + 5. **Maintenance**: When backend changes, just regenerate - no manual updates needed 16 + 17 + ## How to Regenerate 18 + 19 + ### Prerequisites 20 + - Backend must be running on `http://127.0.0.1:3001` with Swagger docs available at `/api-json` 21 + 22 + ### Command 23 + ```bash 24 + pnpm generate:api 25 + ``` 26 + 27 + This will: 28 + 1. Fetch the OpenAPI spec from `http://127.0.0.1:3001/api-json` 29 + 2. Generate TypeScript types 30 + 3. Generate API client functions 31 + 4. Generate TanStack Query hooks (queryOptions, mutationOptions, etc.) 32 + 33 + ## What Gets Generated 34 + 35 + The generator creates: 36 + - `packages/api/src/generated/sdk.gen.ts` - API client functions 37 + - `packages/api/src/generated/types.gen.ts` - TypeScript types 38 + - `packages/api/src/generated/@tanstack/react-query.gen.ts` - React Query hooks 39 + - `packages/api/src/generated/index.ts` - Exports 40 + 41 + ## Usage Examples 42 + 43 + ### Query (GET request) 44 + ```typescript 45 + import { peopleControllerGetPersonDetailsOptions } from "@opnshelf/api"; 46 + import { useQuery } from "@tanstack/react-query"; 47 + 48 + const { data } = useQuery({ 49 + ...peopleControllerGetPersonDetailsOptions({ 50 + path: { personId: "40462" }, 51 + }), 52 + }); 53 + ``` 54 + 55 + ### Mutation (POST/PUT/DELETE) 56 + ```typescript 57 + import { moviesControllerMarkWatchedMutation } from "@opnshelf/api"; 58 + import { useMutation } from "@tanstack/react-query"; 59 + 60 + const mutation = useMutation({ 61 + ...moviesControllerMarkWatchedMutation(), 62 + }); 63 + ``` 64 + 65 + ### Direct API Call (rarely needed) 66 + ```typescript 67 + import { peopleControllerGetPersonDetails } from "@opnshelf/api"; 68 + 69 + const { data } = await peopleControllerGetPersonDetails({ 70 + path: { personId: "40462" }, 71 + }); 72 + ``` 73 + 74 + ## Workflow for New Backend Endpoints 75 + 76 + 1. **Create backend endpoint** with proper Swagger decorators 77 + 2. **Start backend** and verify Swagger docs at `http://127.0.0.1:3001/api-json` 78 + 3. **Run codegen**: `pnpm generate:api` 79 + 4. **Use generated code** in frontend/mobile 80 + 81 + ## Anti-Patterns to Avoid 82 + 83 + ❌ **DON'T** create manual client files: 84 + ```typescript 85 + // BAD - packages/api/src/people-client.ts 86 + export const peopleControllerGetPersonDetails = async (options) => { 87 + const { path, ...config } = options; 88 + const url = `/people/tmdb/${path.personId}`; // ❌ Don't do this 89 + const response = await client.request({ ...config, method: "GET", url }); 90 + return response.json(); 91 + }; 92 + ``` 93 + 94 + ❌ **DON'T** create manual query options: 95 + ```typescript 96 + // BAD - packages/api/src/people-queries.ts 97 + export const peopleControllerGetPersonDetailsOptions = (options) => 98 + queryOptions({ 99 + queryFn: async ({ queryKey }) => { 100 + const { data } = await peopleControllerGetPersonDetails({ ...options, ...queryKey[0] }); 101 + return data; 102 + }, 103 + queryKey: createQueryKey("peopleControllerGetPersonDetails", options), 104 + }); 105 + ``` 106 + 107 + ✅ **DO** use generated code: 108 + ```typescript 109 + import { peopleControllerGetPersonDetailsOptions } from "@opnshelf/api"; 110 + ``` 111 + 112 + ## Troubleshooting 113 + 114 + ### "Cannot find exported name" error 115 + The API hasn't been regenerated. Run: 116 + ```bash 117 + pnpm generate:api 118 + ``` 119 + 120 + ### OpenAPI spec fetch fails 121 + Backend isn't running. Start it first: 122 + ```bash 123 + pnpm dev:backend 124 + ``` 125 + 126 + ### Types are out of sync 127 + Backend DTOs changed. Regenerate: 128 + ```bash 129 + pnpm generate:api 130 + ``` 131 + 132 + ## When to Create Manual Types 133 + 134 + Only create manual types in `packages/api/src/` when: 135 + - The backend endpoint is not yet ready but you need types for development 136 + - You're working on a feature branch and can't regenerate yet 137 + 138 + **Always add a TODO comment** to regenerate when the backend is ready: 139 + ```typescript 140 + // TODO: Regenerate API client when backend endpoint is ready 141 + export type MyManualType = { ... }; 142 + ```
+473
apps/mobile/app/person/[id].tsx
··· 1 + import { Ionicons } from "@expo/vector-icons"; 2 + import type { 3 + PersonFilmographyItemDto, 4 + TmdbPersonDetailDto, 5 + } from "@opnshelf/api"; 6 + import { peopleControllerGetPersonDetailsOptions } from "@opnshelf/api"; 7 + import { useQuery } from "@tanstack/react-query"; 8 + import { Image } from "expo-image"; 9 + import { useLocalSearchParams, useRouter } from "expo-router"; 10 + import { useMemo } from "react"; 11 + import { 12 + RefreshControl, 13 + ScrollView, 14 + StyleSheet, 15 + Text, 16 + TouchableOpacity, 17 + View, 18 + } from "react-native"; 19 + import { SafeAreaView } from "react-native-safe-area-context"; 20 + import { DetailHero } from "@/components/detail"; 21 + import { ScrollRevealHeader } from "@/components/ScrollRevealHeader"; 22 + import { borderRadius, spacing } from "@/constants/spacing"; 23 + import { useTheme } from "@/contexts/theme"; 24 + import { useScrollRevealHeader } from "@/hooks/useScrollRevealHeader"; 25 + import { getTmdbPosterUrl } from "@/lib/utils"; 26 + 27 + const POSTER_BASE_URL = "https://image.tmdb.org/t/p/w92"; 28 + 29 + function formatDate(dateString?: string): string | null { 30 + if (!dateString) return null; 31 + return new Date(dateString).toLocaleDateString("en-US", { 32 + year: "numeric", 33 + month: "long", 34 + day: "numeric", 35 + }); 36 + } 37 + 38 + function formatLifespan(birthday?: string, deathday?: string): string | null { 39 + if (!birthday) return null; 40 + const birthYear = new Date(birthday).getFullYear(); 41 + if (deathday) { 42 + const deathYear = new Date(deathday).getFullYear(); 43 + return `${birthYear} - ${deathYear}`; 44 + } 45 + return `${birthYear} - Present`; 46 + } 47 + 48 + export default function PersonDetailScreen() { 49 + const { id: personId } = useLocalSearchParams<{ id: string }>(); 50 + const router = useRouter(); 51 + const { colors } = useTheme(); 52 + const { showCompactHeader, onScroll } = useScrollRevealHeader(); 53 + 54 + const { 55 + data: personData, 56 + isLoading, 57 + isRefetching, 58 + refetch, 59 + } = useQuery({ 60 + ...peopleControllerGetPersonDetailsOptions({ 61 + path: { personId }, 62 + }), 63 + }); 64 + 65 + const person = personData as TmdbPersonDetailDto | undefined; 66 + 67 + const profileUrl = person?.profile_path 68 + ? `${POSTER_BASE_URL}${person.profile_path}` 69 + : null; 70 + 71 + const subtitle = useMemo(() => { 72 + const lifespan = formatLifespan(person?.birthday, person?.deathday); 73 + if (person?.known_for_department && lifespan) { 74 + return `${person.known_for_department} • ${lifespan}`; 75 + } 76 + if (person?.known_for_department) { 77 + return person.known_for_department; 78 + } 79 + if (lifespan) { 80 + return lifespan; 81 + } 82 + return undefined; 83 + }, [person?.birthday, person?.deathday, person?.known_for_department]); 84 + 85 + const handleRefresh = async () => { 86 + await refetch(); 87 + }; 88 + 89 + const handleNavigateToMedia = (item: PersonFilmographyItemDto) => { 90 + if (item.media_type === "movie") { 91 + router.push({ 92 + pathname: "/movie/[id]", 93 + params: { 94 + id: String(item.id), 95 + title: item.title, 96 + }, 97 + }); 98 + } else { 99 + router.push({ 100 + pathname: "/show/[id]", 101 + params: { 102 + id: String(item.id), 103 + title: item.title, 104 + }, 105 + }); 106 + } 107 + }; 108 + 109 + return ( 110 + <SafeAreaView 111 + style={[styles.container, { backgroundColor: colors.background }]} 112 + > 113 + <ScrollView 114 + contentContainerStyle={styles.scrollContent} 115 + onScroll={onScroll} 116 + scrollEventThrottle={16} 117 + refreshControl={ 118 + <RefreshControl 119 + refreshing={isRefetching} 120 + onRefresh={handleRefresh} 121 + tintColor={colors.primary} 122 + colors={[colors.primary]} 123 + progressBackgroundColor={colors.surfaceContainerHigh} 124 + /> 125 + } 126 + > 127 + <DetailHero 128 + title={person?.name || "Person"} 129 + subtitle={subtitle} 130 + backdropUrl={null} 131 + posterUrl={profileUrl} 132 + colors={{ 133 + primary: colors.primary, 134 + secondary: colors.secondary, 135 + accent: colors.tertiary, 136 + muted: colors.surfaceContainerHighest, 137 + }} 138 + onBack={() => router.back()} 139 + isLoading={isLoading} 140 + /> 141 + 142 + <View style={styles.content}> 143 + {/* Personal Info Section */} 144 + <View 145 + style={[ 146 + styles.infoCard, 147 + { backgroundColor: colors.surfaceContainer }, 148 + ]} 149 + > 150 + <Text style={[styles.infoTitle, { color: colors.primary }]}> 151 + Personal Info 152 + </Text> 153 + <View style={styles.infoItems}> 154 + {person?.birthday && ( 155 + <View style={styles.infoItem}> 156 + <Ionicons 157 + name="calendar-outline" 158 + size={16} 159 + color={colors.onSurfaceVariant} 160 + /> 161 + <Text 162 + style={[ 163 + styles.infoText, 164 + { color: colors.onSurfaceVariant }, 165 + ]} 166 + > 167 + {person.deathday 168 + ? `Born: ${formatDate(person.birthday)}` 169 + : `Birthday: ${formatDate(person.birthday)}`} 170 + </Text> 171 + </View> 172 + )} 173 + {person?.deathday && ( 174 + <View style={styles.infoItem}> 175 + <Ionicons 176 + name="calendar-outline" 177 + size={16} 178 + color={colors.onSurfaceVariant} 179 + /> 180 + <Text 181 + style={[ 182 + styles.infoText, 183 + { color: colors.onSurfaceVariant }, 184 + ]} 185 + > 186 + Died: {formatDate(person.deathday)} 187 + </Text> 188 + </View> 189 + )} 190 + {person?.place_of_birth && ( 191 + <View style={styles.infoItem}> 192 + <Ionicons 193 + name="location-outline" 194 + size={16} 195 + color={colors.onSurfaceVariant} 196 + /> 197 + <Text 198 + style={[ 199 + styles.infoText, 200 + { color: colors.onSurfaceVariant }, 201 + ]} 202 + > 203 + {person.place_of_birth} 204 + </Text> 205 + </View> 206 + )} 207 + {person?.popularity !== undefined && ( 208 + <View style={styles.infoItem}> 209 + <Ionicons 210 + name="star-outline" 211 + size={16} 212 + color={colors.onSurfaceVariant} 213 + /> 214 + <Text 215 + style={[ 216 + styles.infoText, 217 + { color: colors.onSurfaceVariant }, 218 + ]} 219 + > 220 + Popularity: {person.popularity.toFixed(1)} 221 + </Text> 222 + </View> 223 + )} 224 + </View> 225 + </View> 226 + 227 + {/* Biography Section */} 228 + {person?.biography && ( 229 + <View style={styles.section}> 230 + <Text style={[styles.sectionTitle, { color: colors.primary }]}> 231 + Biography 232 + </Text> 233 + <Text 234 + style={[styles.biography, { color: colors.onSurfaceVariant }]} 235 + > 236 + {person.biography} 237 + </Text> 238 + </View> 239 + )} 240 + 241 + {/* Filmography Section */} 242 + <View style={styles.section}> 243 + <Text style={[styles.sectionTitle, { color: colors.primary }]}> 244 + Filmography 245 + <Text style={[styles.count, { color: colors.onSurfaceVariant }]}> 246 + {" "} 247 + ({person?.filmography?.length || 0} titles) 248 + </Text> 249 + </Text> 250 + <View style={styles.filmographyList}> 251 + {person?.filmography?.map((item) => ( 252 + <FilmographyItem 253 + key={`${item.media_type}-${item.id}-${item.character || item.job || ""}`} 254 + item={item} 255 + onPress={() => handleNavigateToMedia(item)} 256 + colors={colors} 257 + /> 258 + ))} 259 + </View> 260 + </View> 261 + </View> 262 + </ScrollView> 263 + 264 + <ScrollRevealHeader 265 + visible={showCompactHeader} 266 + onBack={() => router.back()} 267 + title={person?.name || "Person"} 268 + /> 269 + </SafeAreaView> 270 + ); 271 + } 272 + 273 + interface FilmographyItemProps { 274 + item: PersonFilmographyItemDto; 275 + onPress: () => void; 276 + colors: { 277 + surfaceContainer: string; 278 + onSurface: string; 279 + onSurfaceVariant: string; 280 + outline: string; 281 + primary: string; 282 + }; 283 + } 284 + 285 + function FilmographyItem({ item, onPress, colors }: FilmographyItemProps) { 286 + const year = item.release_date 287 + ? new Date(item.release_date).getFullYear() 288 + : item.first_air_date 289 + ? new Date(item.first_air_date).getFullYear() 290 + : null; 291 + 292 + const posterUrl = getTmdbPosterUrl(item.poster_path, "w92"); 293 + 294 + return ( 295 + <TouchableOpacity 296 + onPress={onPress} 297 + style={[ 298 + styles.filmographyItem, 299 + { backgroundColor: colors.surfaceContainer }, 300 + ]} 301 + activeOpacity={0.8} 302 + > 303 + {/* Poster */} 304 + <View style={styles.posterContainer}> 305 + {posterUrl ? ( 306 + <Image 307 + source={{ uri: posterUrl }} 308 + style={styles.poster} 309 + contentFit="cover" 310 + /> 311 + ) : ( 312 + <View 313 + style={[ 314 + styles.posterPlaceholder, 315 + { backgroundColor: colors.surfaceContainer }, 316 + ]} 317 + > 318 + <Ionicons 319 + name={item.media_type === "movie" ? "film-outline" : "tv-outline"} 320 + size={24} 321 + color={colors.onSurfaceVariant} 322 + /> 323 + </View> 324 + )} 325 + </View> 326 + 327 + {/* Info */} 328 + <View style={styles.filmographyInfo}> 329 + <Text 330 + style={[styles.filmographyTitle, { color: colors.onSurface }]} 331 + numberOfLines={1} 332 + > 333 + {item.title} 334 + </Text> 335 + <Text 336 + style={[styles.filmographyRole, { color: colors.onSurfaceVariant }]} 337 + numberOfLines={1} 338 + > 339 + {item.character || item.job || ""} 340 + {(item.character || item.job) && item.department && ( 341 + <Text style={{ color: colors.outline }}> • </Text> 342 + )} 343 + {item.department} 344 + </Text> 345 + </View> 346 + 347 + {/* Type Badge & Year */} 348 + <View style={styles.filmographyMeta}> 349 + <View 350 + style={[ 351 + styles.typeBadge, 352 + { backgroundColor: colors.surfaceContainer }, 353 + ]} 354 + > 355 + <Text 356 + style={[styles.typeBadgeText, { color: colors.onSurfaceVariant }]} 357 + > 358 + {item.media_type === "movie" ? "Movie" : "TV"} 359 + </Text> 360 + </View> 361 + {year && ( 362 + <Text style={[styles.yearText, { color: colors.primary }]}> 363 + {year} 364 + </Text> 365 + )} 366 + </View> 367 + </TouchableOpacity> 368 + ); 369 + } 370 + 371 + const styles = StyleSheet.create({ 372 + container: { 373 + flex: 1, 374 + }, 375 + scrollContent: { 376 + paddingBottom: spacing.xxl, 377 + }, 378 + content: { 379 + paddingHorizontal: spacing.md, 380 + paddingTop: spacing.lg, 381 + gap: spacing.lg, 382 + }, 383 + infoCard: { 384 + padding: spacing.md, 385 + borderRadius: borderRadius.lg, 386 + }, 387 + infoTitle: { 388 + fontSize: 16, 389 + fontWeight: "600", 390 + marginBottom: spacing.sm, 391 + }, 392 + infoItems: { 393 + gap: spacing.xs, 394 + }, 395 + infoItem: { 396 + flexDirection: "row", 397 + alignItems: "center", 398 + gap: spacing.sm, 399 + }, 400 + infoText: { 401 + fontSize: 14, 402 + }, 403 + section: { 404 + gap: spacing.sm, 405 + }, 406 + sectionTitle: { 407 + fontSize: 18, 408 + fontWeight: "600", 409 + }, 410 + count: { 411 + fontSize: 14, 412 + fontWeight: "normal", 413 + }, 414 + biography: { 415 + fontSize: 15, 416 + lineHeight: 22, 417 + }, 418 + filmographyList: { 419 + gap: spacing.sm, 420 + }, 421 + filmographyItem: { 422 + flexDirection: "row", 423 + alignItems: "center", 424 + padding: spacing.sm, 425 + borderRadius: borderRadius.md, 426 + }, 427 + posterContainer: { 428 + width: 48, 429 + height: 72, 430 + borderRadius: borderRadius.sm, 431 + overflow: "hidden", 432 + marginRight: spacing.md, 433 + }, 434 + poster: { 435 + width: 48, 436 + height: 72, 437 + }, 438 + posterPlaceholder: { 439 + width: 48, 440 + height: 72, 441 + justifyContent: "center", 442 + alignItems: "center", 443 + }, 444 + filmographyInfo: { 445 + flex: 1, 446 + justifyContent: "center", 447 + }, 448 + filmographyTitle: { 449 + fontSize: 14, 450 + fontWeight: "500", 451 + marginBottom: 2, 452 + }, 453 + filmographyRole: { 454 + fontSize: 12, 455 + }, 456 + filmographyMeta: { 457 + alignItems: "flex-end", 458 + gap: spacing.xs, 459 + }, 460 + typeBadge: { 461 + paddingHorizontal: spacing.sm, 462 + paddingVertical: 4, 463 + borderRadius: borderRadius.full, 464 + }, 465 + typeBadgeText: { 466 + fontSize: 11, 467 + fontWeight: "500", 468 + }, 469 + yearText: { 470 + fontSize: 13, 471 + fontWeight: "600", 472 + }, 473 + });
+27 -1
apps/mobile/components/detail/sections/CastSection.tsx
··· 1 1 import { Image } from "expo-image"; 2 2 import { LinearGradient } from "expo-linear-gradient"; 3 + import { useRouter } from "expo-router"; 3 4 import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from "react-native"; 4 5 import { borderRadius, spacing } from "@/constants/spacing"; 5 6 import { useTheme } from "@/contexts/theme"; ··· 17 18 cast?: CastMember[]; 18 19 }; 19 20 21 + function getPersonSlug(name: string): string { 22 + return name 23 + .toLowerCase() 24 + .replace(/[^a-z0-9]+/g, "-") 25 + .replace(/-+/g, "-") 26 + .replace(/^-|-$/g, ""); 27 + } 28 + 20 29 export function CastSection({ titleColor, cast }: CastSectionProps) { 21 30 const { colors } = useTheme(); 31 + const router = useRouter(); 22 32 23 33 if (!cast?.length) { 24 34 return null; 25 35 } 26 36 37 + const handlePress = (person: CastMember) => { 38 + const personSlug = getPersonSlug(person.name); 39 + router.push({ 40 + pathname: "/person/[id]", 41 + params: { 42 + id: String(person.id), 43 + name: person.name, 44 + }, 45 + }); 46 + }; 47 + 27 48 return ( 28 49 <View style={styles.section}> 29 50 <Text style={[styles.sectionTitle, { color: titleColor ?? colors.primary }]}>Cast</Text> ··· 36 57 {cast.map((person) => { 37 58 const profileUrl = getTmdbProfileUrl(person.profile_path); 38 59 return ( 39 - <TouchableOpacity key={person.id} style={styles.castCard} activeOpacity={0.8}> 60 + <TouchableOpacity 61 + key={person.id} 62 + style={styles.castCard} 63 + activeOpacity={0.8} 64 + onPress={() => handlePress(person)} 65 + > 40 66 <View style={styles.castImageContainer}> 41 67 {profileUrl ? ( 42 68 <Image source={{ uri: profileUrl }} style={styles.castImage} contentFit="cover" />
+21
apps/mobile/components/detail/sections/CrewSection.tsx
··· 1 + import { useRouter } from "expo-router"; 1 2 import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; 2 3 import { borderRadius, spacing } from "@/constants/spacing"; 3 4 import { useTheme } from "@/contexts/theme"; ··· 14 15 crew?: CrewMember[]; 15 16 }; 16 17 18 + function getPersonSlug(name: string): string { 19 + return name 20 + .toLowerCase() 21 + .replace(/[^a-z0-9]+/g, "-") 22 + .replace(/-+/g, "-") 23 + .replace(/^-|-$/g, ""); 24 + } 25 + 17 26 export function CrewSection({ titleColor, crew }: CrewSectionProps) { 18 27 const { colors } = useTheme(); 28 + const router = useRouter(); 19 29 20 30 if (!crew?.length) { 21 31 return null; 22 32 } 23 33 34 + const handlePress = (person: CrewMember) => { 35 + router.push({ 36 + pathname: "/person/[id]", 37 + params: { 38 + id: String(person.id), 39 + name: person.name, 40 + }, 41 + }); 42 + }; 43 + 24 44 return ( 25 45 <View style={styles.section}> 26 46 <Text style={[styles.sectionTitle, { color: titleColor ?? colors.primary }]}>Crew</Text> ··· 33 53 { backgroundColor: colors.surfaceContainer }, 34 54 ]} 35 55 activeOpacity={0.8} 56 + onPress={() => handlePress(person)} 36 57 > 37 58 <Text style={[styles.crewName, { color: colors.onSurface }]} numberOfLines={1}> 38 59 {person.name}
+27 -6
apps/web/src/components/CastSection.tsx
··· 1 1 import type { TmdbCastDto } from "@opnshelf/api"; 2 + import { Link } from "@tanstack/react-router"; 2 3 import { getTmdbProfileUrl } from "@/lib/utils"; 3 4 4 5 interface CastSectionProps { ··· 8 9 primary?: string; 9 10 muted?: string; 10 11 }; 12 + } 13 + 14 + function getPersonSlug(name: string): string { 15 + return name 16 + .toLowerCase() 17 + .replace(/[^a-z0-9]+/g, "-") 18 + .replace(/-+/g, "-") 19 + .replace(/^-|-$/g, ""); 11 20 } 12 21 13 22 export function CastSection({ cast, guestStars, colors }: CastSectionProps) { ··· 30 39 <div className="flex gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-transparent w-full pr-8"> 31 40 {cast.map((person) => { 32 41 const profileUrl = getTmdbProfileUrl(person.profile_path); 42 + const personSlug = getPersonSlug(person.name); 33 43 return ( 34 - <div 44 + <Link 35 45 key={`cast-${person.id}`} 46 + to="/person/$personId/$name" 47 + params={{ 48 + personId: String(person.id), 49 + name: personSlug, 50 + }} 36 51 className="shrink-0 w-32 group cursor-pointer" 37 52 > 38 53 <div ··· 66 81 </div> 67 82 <div className="space-y-0.5"> 68 83 <p 69 - className="text-sm font-medium line-clamp-2 transition-colors duration-200" 84 + className="text-sm font-medium line-clamp-2 transition-colors duration-200 group-hover:text-(--md-sys-color-primary)" 70 85 style={{ 71 86 color: "var(--md-sys-color-on-surface)", 72 87 }} ··· 82 97 </p> 83 98 )} 84 99 </div> 85 - </div> 100 + </Link> 86 101 ); 87 102 })} 88 103 </div> ··· 109 124 <div className="flex gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-transparent w-full pr-8"> 110 125 {guestStars.map((person) => { 111 126 const profileUrl = getTmdbProfileUrl(person.profile_path); 127 + const personSlug = getPersonSlug(person.name); 112 128 return ( 113 - <div 129 + <Link 114 130 key={`guest-${person.id}`} 131 + to="/person/$personId/$name" 132 + params={{ 133 + personId: String(person.id), 134 + name: personSlug, 135 + }} 115 136 className="shrink-0 w-32 group cursor-pointer" 116 137 > 117 138 <div ··· 145 166 </div> 146 167 <div className="space-y-0.5"> 147 168 <p 148 - className="text-sm font-medium line-clamp-2 transition-colors duration-200" 169 + className="text-sm font-medium line-clamp-2 transition-colors duration-200 group-hover:text-(--md-sys-color-primary)" 149 170 style={{ 150 171 color: "var(--md-sys-color-on-surface)", 151 172 }} ··· 161 182 </p> 162 183 )} 163 184 </div> 164 - </div> 185 + </Link> 165 186 ); 166 187 })} 167 188 </div>
+30 -13
apps/web/src/components/CrewSection.tsx
··· 1 1 import type { TmdbCrewDto } from "@opnshelf/api"; 2 + import { Link } from "@tanstack/react-router"; 2 3 3 4 interface CrewSectionProps { 4 5 crew: TmdbCrewDto[] | undefined; ··· 8 9 }; 9 10 } 10 11 12 + function getPersonSlug(name: string): string { 13 + return name 14 + .toLowerCase() 15 + .replace(/[^a-z0-9]+/g, "-") 16 + .replace(/-+/g, "-") 17 + .replace(/^-|-$/g, ""); 18 + } 19 + 11 20 export function CrewSection({ crew, colors }: CrewSectionProps) { 12 21 if (!crew || crew.length === 0) return null; 13 22 ··· 20 29 Crew 21 30 </h2> 22 31 <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3"> 23 - {crew.map((person) => ( 24 - <div 25 - key={`${person.id}-${person.job}`} 26 - className="group p-3 rounded-lg bg-(--md-sys-color-surface-container)/30 hover:bg-(--md-sys-color-surface-container)/60 transition-all duration-200 cursor-pointer" 27 - > 28 - <p className="text-sm font-medium text-(--md-sys-color-on-surface) line-clamp-1 transition-colors duration-200 group-hover:brightness-110"> 29 - {person.name} 30 - </p> 31 - <p className="text-xs mt-0.5" style={{ color: colors.muted }}> 32 - {person.job} 33 - </p> 34 - </div> 35 - ))} 32 + {crew.map((person) => { 33 + const personSlug = getPersonSlug(person.name); 34 + return ( 35 + <Link 36 + key={`${person.id}-${person.job}`} 37 + to="/person/$personId/$name" 38 + params={{ 39 + personId: String(person.id), 40 + name: personSlug, 41 + }} 42 + className="group p-3 rounded-lg bg-(--md-sys-color-surface-container)/30 hover:bg-(--md-sys-color-surface-container)/60 transition-all duration-200 cursor-pointer" 43 + > 44 + <p className="text-sm font-medium text-(--md-sys-color-on-surface) line-clamp-1 transition-colors duration-200 group-hover:text-(--md-sys-color-primary)"> 45 + {person.name} 46 + </p> 47 + <p className="text-xs mt-0.5" style={{ color: colors.muted }}> 48 + {person.job} 49 + </p> 50 + </Link> 51 + ); 52 + })} 36 53 </div> 37 54 </section> 38 55 );
+21
apps/web/src/routeTree.gen.ts
··· 29 29 import { Route as ProfileHandleFollowingRouteImport } from './routes/profile.$handle.following' 30 30 import { Route as ProfileHandleFollowersRouteImport } from './routes/profile.$handle.followers' 31 31 import { Route as ProfileHandleCalendarRouteImport } from './routes/profile.$handle.calendar' 32 + import { Route as PersonPersonIdNameRouteImport } from './routes/person.$personId.$name' 32 33 import { Route as MoviesMovieIdTitleRouteImport } from './routes/movies.$movieId.$title' 33 34 import { Route as ProfileHandleListSlugRouteImport } from './routes/profile.$handle.list.$slug' 34 35 import { Route as ShowsShowIdTitleSeasonsSeasonNumberRouteImport } from './routes/shows.$showId.$title.seasons.$seasonNumber' ··· 133 134 id: '/calendar', 134 135 path: '/calendar', 135 136 getParentRoute: () => ProfileHandleRoute, 137 + } as any) 138 + const PersonPersonIdNameRoute = PersonPersonIdNameRouteImport.update({ 139 + id: '/person/$personId/$name', 140 + path: '/person/$personId/$name', 141 + getParentRoute: () => rootRouteImport, 136 142 } as any) 137 143 const MoviesMovieIdTitleRoute = MoviesMovieIdTitleRouteImport.update({ 138 144 id: '/movies/$movieId/$title', ··· 170 176 '/profile/$handle': typeof ProfileHandleRouteWithChildren 171 177 '/profile/': typeof ProfileIndexRoute 172 178 '/movies/$movieId/$title': typeof MoviesMovieIdTitleRoute 179 + '/person/$personId/$name': typeof PersonPersonIdNameRoute 173 180 '/profile/$handle/calendar': typeof ProfileHandleCalendarRoute 174 181 '/profile/$handle/followers': typeof ProfileHandleFollowersRoute 175 182 '/profile/$handle/following': typeof ProfileHandleFollowingRoute ··· 195 202 '/profile/$handle': typeof ProfileHandleRouteWithChildren 196 203 '/profile': typeof ProfileIndexRoute 197 204 '/movies/$movieId/$title': typeof MoviesMovieIdTitleRoute 205 + '/person/$personId/$name': typeof PersonPersonIdNameRoute 198 206 '/profile/$handle/calendar': typeof ProfileHandleCalendarRoute 199 207 '/profile/$handle/followers': typeof ProfileHandleFollowersRoute 200 208 '/profile/$handle/following': typeof ProfileHandleFollowingRoute ··· 222 230 '/profile/$handle': typeof ProfileHandleRouteWithChildren 223 231 '/profile/': typeof ProfileIndexRoute 224 232 '/movies/$movieId/$title': typeof MoviesMovieIdTitleRoute 233 + '/person/$personId/$name': typeof PersonPersonIdNameRoute 225 234 '/profile/$handle/calendar': typeof ProfileHandleCalendarRoute 226 235 '/profile/$handle/followers': typeof ProfileHandleFollowersRoute 227 236 '/profile/$handle/following': typeof ProfileHandleFollowingRoute ··· 250 259 | '/profile/$handle' 251 260 | '/profile/' 252 261 | '/movies/$movieId/$title' 262 + | '/person/$personId/$name' 253 263 | '/profile/$handle/calendar' 254 264 | '/profile/$handle/followers' 255 265 | '/profile/$handle/following' ··· 275 285 | '/profile/$handle' 276 286 | '/profile' 277 287 | '/movies/$movieId/$title' 288 + | '/person/$personId/$name' 278 289 | '/profile/$handle/calendar' 279 290 | '/profile/$handle/followers' 280 291 | '/profile/$handle/following' ··· 301 312 | '/profile/$handle' 302 313 | '/profile/' 303 314 | '/movies/$movieId/$title' 315 + | '/person/$personId/$name' 304 316 | '/profile/$handle/calendar' 305 317 | '/profile/$handle/followers' 306 318 | '/profile/$handle/following' ··· 326 338 SearchRoute: typeof SearchRoute 327 339 AuthCompleteRoute: typeof AuthCompleteRoute 328 340 MoviesMovieIdTitleRoute: typeof MoviesMovieIdTitleRoute 341 + PersonPersonIdNameRoute: typeof PersonPersonIdNameRoute 329 342 ShowsShowIdTitleRoute: typeof ShowsShowIdTitleRouteWithChildren 330 343 } 331 344 ··· 471 484 preLoaderRoute: typeof ProfileHandleCalendarRouteImport 472 485 parentRoute: typeof ProfileHandleRoute 473 486 } 487 + '/person/$personId/$name': { 488 + id: '/person/$personId/$name' 489 + path: '/person/$personId/$name' 490 + fullPath: '/person/$personId/$name' 491 + preLoaderRoute: typeof PersonPersonIdNameRouteImport 492 + parentRoute: typeof rootRouteImport 493 + } 474 494 '/movies/$movieId/$title': { 475 495 id: '/movies/$movieId/$title' 476 496 path: '/movies/$movieId/$title' ··· 581 601 SearchRoute: SearchRoute, 582 602 AuthCompleteRoute: AuthCompleteRoute, 583 603 MoviesMovieIdTitleRoute: MoviesMovieIdTitleRoute, 604 + PersonPersonIdNameRoute: PersonPersonIdNameRoute, 584 605 ShowsShowIdTitleRoute: ShowsShowIdTitleRouteWithChildren, 585 606 } 586 607 export const routeTree = rootRouteImport
+362
apps/web/src/routes/person.$personId.$name.tsx
··· 1 + import { 2 + type PersonFilmographyItemDto, 3 + peopleControllerGetPersonDetailsOptions, 4 + type TmdbPersonDetailDto, 5 + } from "@opnshelf/api"; 6 + import { useQuery } from "@tanstack/react-query"; 7 + import { createFileRoute, Link, useRouter } from "@tanstack/react-router"; 8 + import { Calendar, Film, MapPin, Star, Tv } from "lucide-react"; 9 + import { useMemo } from "react"; 10 + import { DetailHero } from "@/components/detail"; 11 + import { useTheme } from "@/components/theme-provider"; 12 + import { getTmdbPosterUrl, getTmdbProfileUrl } from "@/lib/utils"; 13 + 14 + export const Route = createFileRoute("/person/$personId/$name")({ 15 + loader: async ({ params, context }) => { 16 + const { personId } = params; 17 + const { queryClient } = context; 18 + 19 + const data = await queryClient.fetchQuery({ 20 + ...peopleControllerGetPersonDetailsOptions({ 21 + path: { personId }, 22 + }), 23 + }); 24 + 25 + return data as TmdbPersonDetailDto; 26 + }, 27 + head: ({ loaderData }) => { 28 + const profileUrl = loaderData?.profile_path 29 + ? `https://image.tmdb.org/t/p/w500${loaderData.profile_path}` 30 + : null; 31 + const title = loaderData 32 + ? `${loaderData.name} | OpnShelf` 33 + : "Person | OpnShelf"; 34 + const url = typeof window !== "undefined" ? window.location.href : ""; 35 + 36 + return { 37 + meta: [ 38 + { title }, 39 + { 40 + name: "description", 41 + content: loaderData?.biography?.slice(0, 160) || "", 42 + }, 43 + { property: "og:title", content: title }, 44 + { 45 + property: "og:description", 46 + content: loaderData?.biography?.slice(0, 160) || "", 47 + }, 48 + { property: "og:type", content: "profile" }, 49 + { property: "og:url", content: url }, 50 + ...(profileUrl ? [{ property: "og:image", content: profileUrl }] : []), 51 + { name: "twitter:card", content: "summary_large_image" }, 52 + { name: "twitter:title", content: title }, 53 + { 54 + name: "twitter:description", 55 + content: loaderData?.biography?.slice(0, 160) || "", 56 + }, 57 + ...(profileUrl ? [{ name: "twitter:image", content: profileUrl }] : []), 58 + ], 59 + }; 60 + }, 61 + component: PersonDetailPage, 62 + }); 63 + 64 + function formatDate(dateString?: string): string | null { 65 + if (!dateString) return null; 66 + return new Date(dateString).toLocaleDateString("en-US", { 67 + year: "numeric", 68 + month: "long", 69 + day: "numeric", 70 + }); 71 + } 72 + 73 + function formatLifespan(birthday?: string, deathday?: string): string | null { 74 + if (!birthday) return null; 75 + 76 + const birthYear = new Date(birthday).getFullYear(); 77 + 78 + if (deathday) { 79 + const deathYear = new Date(deathday).getFullYear(); 80 + return `${birthYear} - ${deathYear}`; 81 + } 82 + 83 + return `${birthYear} - Present`; 84 + } 85 + 86 + function PersonDetailPage() { 87 + const { personId, name } = Route.useParams(); 88 + const router = useRouter(); 89 + const { seedColor } = useTheme(); 90 + 91 + const { data: personData, isLoading: isPersonLoading } = useQuery({ 92 + ...peopleControllerGetPersonDetailsOptions({ 93 + path: { personId }, 94 + }), 95 + }); 96 + 97 + const person = personData as TmdbPersonDetailDto | undefined; 98 + 99 + const colors = { 100 + primary: seedColor, 101 + secondary: seedColor, 102 + accent: seedColor, 103 + muted: "var(--md-sys-color-surface-container)", 104 + }; 105 + 106 + const profileUrl = getTmdbProfileUrl(person?.profile_path); 107 + 108 + const subtitle = useMemo(() => { 109 + const lifespan = formatLifespan(person?.birthday, person?.deathday); 110 + if (person?.known_for_department && lifespan) { 111 + return `${person.known_for_department} • ${lifespan}`; 112 + } 113 + if (person?.known_for_department) { 114 + return person.known_for_department; 115 + } 116 + if (lifespan) { 117 + return lifespan; 118 + } 119 + return null; 120 + }, [person?.birthday, person?.deathday, person?.known_for_department]); 121 + 122 + const metadataItems = useMemo(() => { 123 + const items = []; 124 + 125 + if (person?.birthday) { 126 + const birthLabel = person.deathday 127 + ? `Born: ${formatDate(person.birthday)}` 128 + : `Birthday: ${formatDate(person.birthday)}`; 129 + items.push({ 130 + icon: <Calendar className="w-4 h-4" />, 131 + label: birthLabel, 132 + }); 133 + } 134 + 135 + if (person?.deathday) { 136 + items.push({ 137 + icon: <Calendar className="w-4 h-4" />, 138 + label: `Died: ${formatDate(person.deathday)}`, 139 + }); 140 + } 141 + 142 + if (person?.place_of_birth) { 143 + items.push({ 144 + icon: <MapPin className="w-4 h-4" />, 145 + label: person.place_of_birth, 146 + }); 147 + } 148 + 149 + if (person?.popularity) { 150 + items.push({ 151 + icon: <Star className="w-4 h-4" />, 152 + label: `Popularity: ${person.popularity.toFixed(1)}`, 153 + }); 154 + } 155 + 156 + return items; 157 + }, [person]); 158 + 159 + return ( 160 + <div 161 + className="min-h-screen m3-background m3-on-background" 162 + style={{ 163 + backgroundColor: "var(--md-sys-color-background)", 164 + color: "var(--md-sys-color-on-background)", 165 + }} 166 + > 167 + <DetailHero 168 + title={person?.name || name.replace(/-/g, " ")} 169 + subtitle={subtitle ?? undefined} 170 + backdropUrl={null} 171 + posterUrl={profileUrl} 172 + colors={colors} 173 + isLoading={isPersonLoading} 174 + onBack={() => router.history.back()} 175 + /> 176 + 177 + <div className="container mx-auto px-4 py-6 max-w-7xl"> 178 + <div className="grid grid-cols-1 md:grid-cols-[300px_1fr] gap-8 min-w-0"> 179 + <div className="space-y-4 min-w-0"> 180 + {/* Sidebar content - could add actions later */} 181 + <div className="m3-surface-container rounded-xl p-4"> 182 + <h3 183 + className="m3-title-medium mb-3" 184 + style={{ color: colors.primary }} 185 + > 186 + Personal Info 187 + </h3> 188 + <div className="space-y-2"> 189 + {metadataItems.map((item) => ( 190 + <div 191 + key={item.label} 192 + className="flex items-center gap-2 text-sm" 193 + style={{ color: "var(--md-sys-color-on-surface-variant)" }} 194 + > 195 + {item.icon} 196 + <span>{item.label}</span> 197 + </div> 198 + ))} 199 + </div> 200 + </div> 201 + </div> 202 + 203 + <div className="space-y-6 min-w-0"> 204 + {/* Biography Section */} 205 + {person?.biography && ( 206 + <section> 207 + <h2 208 + className="text-xl font-semibold mb-3" 209 + style={{ color: colors.primary }} 210 + > 211 + Biography 212 + </h2> 213 + <p className="text-(--md-sys-color-on-surface-variant) leading-relaxed whitespace-pre-line"> 214 + {person.biography} 215 + </p> 216 + </section> 217 + )} 218 + 219 + {/* Filmography Section */} 220 + <section> 221 + <h2 222 + className="text-xl font-semibold mb-4" 223 + style={{ color: colors.primary }} 224 + > 225 + Filmography 226 + <span className="ml-2 text-sm font-normal text-(--md-sys-color-on-surface-variant)"> 227 + ({person?.filmography?.length || 0} titles) 228 + </span> 229 + </h2> 230 + <div className="space-y-3"> 231 + {person?.filmography?.map((item: PersonFilmographyItemDto) => ( 232 + <FilmographyItem 233 + key={`${item.media_type}-${item.id}-${item.character || item.job || ""}`} 234 + item={item} 235 + colors={colors} 236 + /> 237 + ))} 238 + </div> 239 + </section> 240 + </div> 241 + </div> 242 + </div> 243 + 244 + {isPersonLoading && ( 245 + <div 246 + className="fixed inset-0 flex items-center justify-center z-50" 247 + style={{ 248 + backgroundColor: "var(--md-sys-color-background)", 249 + }} 250 + > 251 + <div 252 + className="animate-spin rounded-full h-16 w-16 border-b-2" 253 + style={{ borderColor: colors.primary }} 254 + /> 255 + </div> 256 + )} 257 + </div> 258 + ); 259 + } 260 + 261 + interface FilmographyItemProps { 262 + item: PersonFilmographyItemDto; 263 + colors: { primary: string }; 264 + } 265 + 266 + function FilmographyItem({ item, colors }: FilmographyItemProps) { 267 + const year = item.release_date 268 + ? new Date(item.release_date).getFullYear() 269 + : item.first_air_date 270 + ? new Date(item.first_air_date).getFullYear() 271 + : null; 272 + 273 + const posterUrl = getTmdbPosterUrl(item.poster_path, "w92"); 274 + 275 + const role = item.character || item.job || ""; 276 + const department = item.department || ""; 277 + 278 + // Determine the route based on media type 279 + const routeTo = 280 + item.media_type === "movie" 281 + ? { 282 + to: "/movies/$movieId/$title", 283 + params: { 284 + movieId: String(item.id), 285 + title: item.title.toLowerCase().replace(/\s+/g, "-"), 286 + }, 287 + } 288 + : { 289 + to: "/shows/$showId/$title", 290 + params: { 291 + showId: String(item.id), 292 + title: item.title.toLowerCase().replace(/\s+/g, "-"), 293 + }, 294 + }; 295 + 296 + return ( 297 + <Link 298 + to={routeTo.to} 299 + params={routeTo.params} 300 + className="flex gap-4 p-3 rounded-lg transition-colors hover:bg-(--md-sys-color-surface-container) group" 301 + > 302 + {/* Poster */} 303 + <div className="shrink-0 w-16 aspect-2/3 rounded-md overflow-hidden bg-(--md-sys-color-surface-container-high)"> 304 + {posterUrl ? ( 305 + <img 306 + src={posterUrl} 307 + alt={item.title} 308 + className="w-full h-full object-cover" 309 + loading="lazy" 310 + /> 311 + ) : ( 312 + <div className="w-full h-full flex items-center justify-center"> 313 + {item.media_type === "movie" ? ( 314 + <Film className="w-6 h-6 text-(--md-sys-color-on-surface-variant)" /> 315 + ) : ( 316 + <Tv className="w-6 h-6 text-(--md-sys-color-on-surface-variant)" /> 317 + )} 318 + </div> 319 + )} 320 + </div> 321 + 322 + {/* Info */} 323 + <div className="flex-1 min-w-0"> 324 + <div className="flex items-start justify-between gap-2"> 325 + <div className="flex-1 min-w-0"> 326 + <h3 className="font-medium line-clamp-1 group-hover:text-(--md-sys-color-primary) transition-colors"> 327 + {item.title} 328 + </h3> 329 + <p className="text-sm text-(--md-sys-color-on-surface-variant) line-clamp-1"> 330 + {role && <span>{role}</span>} 331 + {role && department && ( 332 + <span className="text-(--md-sys-color-outline)"> • </span> 333 + )} 334 + {department && <span>{department}</span>} 335 + </p> 336 + </div> 337 + <div className="flex items-center gap-2 shrink-0"> 338 + {/* Media Type Badge */} 339 + <span 340 + className="text-xs px-2 py-1 rounded-full" 341 + style={{ 342 + backgroundColor: "var(--md-sys-color-surface-container-high)", 343 + color: "var(--md-sys-color-on-surface-variant)", 344 + }} 345 + > 346 + {item.media_type === "movie" ? "Movie" : "TV"} 347 + </span> 348 + {/* Year */} 349 + {year && ( 350 + <span 351 + className="text-sm font-medium" 352 + style={{ color: colors.primary }} 353 + > 354 + {year} 355 + </span> 356 + )} 357 + </div> 358 + </div> 359 + </div> 360 + </Link> 361 + ); 362 + }
+2
backend/src/app.module.ts
··· 4 4 import { IngesterModule } from "./ingester/ingester.module"; 5 5 import { ListsModule } from "./lists/lists.module"; 6 6 import { MoviesModule } from "./movies/movies.module"; 7 + import { PeopleModule } from "./people/people.module"; 7 8 import { PrismaModule } from "./prisma/prisma.module"; 8 9 import { SearchModule } from "./search/search.module"; 9 10 import { SocialModule } from "./social/social.module"; ··· 24 25 ShelfModule, 25 26 SearchModule, 26 27 SocialModule, 28 + PeopleModule, 27 29 ], 28 30 }) 29 31 export class AppModule {}
+79
backend/src/people/dto/person.dto.ts
··· 1 + import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; 2 + import { IsDateString, IsNumber, IsOptional, IsString } from "class-validator"; 3 + 4 + export class PersonFilmographyItemDto { 5 + @ApiProperty() 6 + id: number; 7 + 8 + @ApiProperty() 9 + media_type: "movie" | "tv"; 10 + 11 + @ApiProperty() 12 + title: string; 13 + 14 + @ApiPropertyOptional() 15 + poster_path?: string; 16 + 17 + @ApiPropertyOptional() 18 + release_date?: string; 19 + 20 + @ApiPropertyOptional() 21 + first_air_date?: string; 22 + 23 + @ApiPropertyOptional() 24 + character?: string; 25 + 26 + @ApiPropertyOptional() 27 + job?: string; 28 + 29 + @ApiPropertyOptional() 30 + department?: string; 31 + 32 + @ApiPropertyOptional() 33 + order?: number; 34 + 35 + @ApiPropertyOptional() 36 + vote_average?: number; 37 + } 38 + 39 + export class TmdbPersonDetailDto { 40 + @ApiProperty() 41 + id: number; 42 + 43 + @ApiProperty() 44 + name: string; 45 + 46 + @ApiPropertyOptional() 47 + profile_path?: string; 48 + 49 + @ApiPropertyOptional() 50 + biography?: string; 51 + 52 + @ApiPropertyOptional() 53 + @IsOptional() 54 + @IsDateString() 55 + birthday?: string; 56 + 57 + @ApiPropertyOptional() 58 + @IsOptional() 59 + @IsDateString() 60 + deathday?: string; 61 + 62 + @ApiPropertyOptional() 63 + @IsOptional() 64 + @IsString() 65 + place_of_birth?: string; 66 + 67 + @ApiPropertyOptional() 68 + @IsOptional() 69 + @IsString() 70 + known_for_department?: string; 71 + 72 + @ApiPropertyOptional() 73 + @IsOptional() 74 + @IsNumber() 75 + popularity?: number; 76 + 77 + @ApiProperty({ type: [PersonFilmographyItemDto] }) 78 + filmography: PersonFilmographyItemDto[]; 79 + }
+184
backend/src/people/people-tmdb.service.ts
··· 1 + import { Injectable, Logger } from "@nestjs/common"; 2 + import { ConfigService } from "@nestjs/config"; 3 + import type { PersonFilmographyItemDto } from "./dto/person.dto"; 4 + 5 + export interface TMDBPerson { 6 + id: number; 7 + name: string; 8 + profile_path?: string; 9 + biography?: string; 10 + birthday?: string; 11 + deathday?: string; 12 + place_of_birth?: string; 13 + known_for_department?: string; 14 + popularity?: number; 15 + } 16 + 17 + export interface TMDBMovieCredit { 18 + id: number; 19 + title: string; 20 + poster_path?: string; 21 + release_date?: string; 22 + vote_average?: number; 23 + character?: string; 24 + job?: string; 25 + department?: string; 26 + order?: number; 27 + } 28 + 29 + export interface TMDBTvCredit { 30 + id: number; 31 + name: string; 32 + poster_path?: string; 33 + first_air_date?: string; 34 + vote_average?: number; 35 + character?: string; 36 + job?: string; 37 + department?: string; 38 + order?: number; 39 + } 40 + 41 + export interface TMDBMovieCreditsResponse { 42 + cast: TMDBMovieCredit[]; 43 + crew: TMDBMovieCredit[]; 44 + } 45 + 46 + export interface TMDBTvCreditsResponse { 47 + cast: TMDBTvCredit[]; 48 + crew: TMDBTvCredit[]; 49 + } 50 + 51 + @Injectable() 52 + export class PeopleTmdbService { 53 + private readonly logger = new Logger(PeopleTmdbService.name); 54 + private readonly tmdbApiKey: string; 55 + private readonly tmdbBaseUrl = "https://api.themoviedb.org/3"; 56 + 57 + constructor(private config: ConfigService) { 58 + this.tmdbApiKey = this.config.get("TMDB_API_KEY") ?? ""; 59 + } 60 + 61 + async getPersonDetails(personId: string): Promise<TMDBPerson> { 62 + const response = await fetch( 63 + `${this.tmdbBaseUrl}/person/${personId}?api_key=${this.tmdbApiKey}`, 64 + ); 65 + 66 + if (!response.ok) { 67 + throw new Error("Person not found"); 68 + } 69 + 70 + return response.json() as Promise<TMDBPerson>; 71 + } 72 + 73 + async getPersonMovieCredits( 74 + personId: string, 75 + ): Promise<TMDBMovieCreditsResponse> { 76 + const response = await fetch( 77 + `${this.tmdbBaseUrl}/person/${personId}/movie_credits?api_key=${this.tmdbApiKey}`, 78 + ); 79 + 80 + if (!response.ok) { 81 + this.logger.warn(`Failed to fetch movie credits for person ${personId}`); 82 + return { cast: [], crew: [] }; 83 + } 84 + 85 + return response.json() as Promise<TMDBMovieCreditsResponse>; 86 + } 87 + 88 + async getPersonTvCredits(personId: string): Promise<TMDBTvCreditsResponse> { 89 + const response = await fetch( 90 + `${this.tmdbBaseUrl}/person/${personId}/tv_credits?api_key=${this.tmdbApiKey}`, 91 + ); 92 + 93 + if (!response.ok) { 94 + this.logger.warn(`Failed to fetch TV credits for person ${personId}`); 95 + return { cast: [], crew: [] }; 96 + } 97 + 98 + return response.json() as Promise<TMDBTvCreditsResponse>; 99 + } 100 + 101 + async getCombinedFilmography( 102 + personId: string, 103 + ): Promise<PersonFilmographyItemDto[]> { 104 + const [movieCredits, tvCredits] = await Promise.all([ 105 + this.getPersonMovieCredits(personId), 106 + this.getPersonTvCredits(personId), 107 + ]); 108 + 109 + // Transform movie credits 110 + const movieItems: PersonFilmographyItemDto[] = [ 111 + ...movieCredits.cast.map( 112 + (credit): PersonFilmographyItemDto => ({ 113 + id: credit.id, 114 + media_type: "movie", 115 + title: credit.title, 116 + poster_path: credit.poster_path, 117 + release_date: credit.release_date, 118 + character: credit.character, 119 + department: "Acting", 120 + order: credit.order, 121 + vote_average: credit.vote_average, 122 + }), 123 + ), 124 + ...movieCredits.crew.map( 125 + (credit): PersonFilmographyItemDto => ({ 126 + id: credit.id, 127 + media_type: "movie", 128 + title: credit.title, 129 + poster_path: credit.poster_path, 130 + release_date: credit.release_date, 131 + job: credit.job, 132 + department: credit.department, 133 + vote_average: credit.vote_average, 134 + }), 135 + ), 136 + ]; 137 + 138 + // Transform TV credits 139 + const tvItems: PersonFilmographyItemDto[] = [ 140 + ...tvCredits.cast.map( 141 + (credit): PersonFilmographyItemDto => ({ 142 + id: credit.id, 143 + media_type: "tv", 144 + title: credit.name, 145 + poster_path: credit.poster_path, 146 + first_air_date: credit.first_air_date, 147 + character: credit.character, 148 + department: "Acting", 149 + order: credit.order, 150 + vote_average: credit.vote_average, 151 + }), 152 + ), 153 + ...tvCredits.crew.map( 154 + (credit): PersonFilmographyItemDto => ({ 155 + id: credit.id, 156 + media_type: "tv", 157 + title: credit.name, 158 + poster_path: credit.poster_path, 159 + first_air_date: credit.first_air_date, 160 + job: credit.job, 161 + department: credit.department, 162 + vote_average: credit.vote_average, 163 + }), 164 + ), 165 + ]; 166 + 167 + // Combine all items 168 + const allItems = [...movieItems, ...tvItems]; 169 + 170 + // Sort by release date (newest first), with unknown dates at the end 171 + allItems.sort((a, b) => { 172 + const dateA = a.release_date || a.first_air_date || ""; 173 + const dateB = b.release_date || b.first_air_date || ""; 174 + 175 + if (!dateA && !dateB) return 0; 176 + if (!dateA) return 1; // Unknown dates go to the end 177 + if (!dateB) return -1; 178 + 179 + return new Date(dateB).getTime() - new Date(dateA).getTime(); 180 + }); 181 + 182 + return allItems; 183 + } 184 + }
+36
backend/src/people/people.controller.ts
··· 1 + import { Controller, Get, NotFoundException, Param } from "@nestjs/common"; 2 + import { ApiOperation, ApiParam, ApiResponse, ApiTags } from "@nestjs/swagger"; 3 + import { TmdbPersonDetailDto } from "./dto/person.dto"; 4 + import { PeopleService } from "./people.service"; 5 + 6 + @ApiTags("People") 7 + @Controller("people") 8 + export class PeopleController { 9 + constructor(private readonly peopleService: PeopleService) {} 10 + 11 + @Get("tmdb/:personId") 12 + @ApiOperation({ summary: "Get person details from TMDB" }) 13 + @ApiParam({ 14 + name: "personId", 15 + description: "TMDB person ID", 16 + type: String, 17 + }) 18 + @ApiResponse({ 19 + status: 200, 20 + description: "Person details retrieved successfully", 21 + type: TmdbPersonDetailDto, 22 + }) 23 + @ApiResponse({ status: 404, description: "Person not found" }) 24 + async getPersonDetails( 25 + @Param("personId") personId: string, 26 + ): Promise<TmdbPersonDetailDto> { 27 + try { 28 + return await this.peopleService.getPersonDetails(personId); 29 + } catch (error) { 30 + if (error instanceof Error && error.message === "Person not found") { 31 + throw new NotFoundException("Person not found"); 32 + } 33 + throw error; 34 + } 35 + } 36 + }
+10
backend/src/people/people.module.ts
··· 1 + import { Module } from "@nestjs/common"; 2 + import { PeopleController } from "./people.controller"; 3 + import { PeopleService } from "./people.service"; 4 + import { PeopleTmdbService } from "./people-tmdb.service"; 5 + 6 + @Module({ 7 + controllers: [PeopleController], 8 + providers: [PeopleService, PeopleTmdbService], 9 + }) 10 + export class PeopleModule {}
+28
backend/src/people/people.service.ts
··· 1 + import { Injectable } from "@nestjs/common"; 2 + import { PeopleTmdbService } from "./people-tmdb.service"; 3 + import type { TmdbPersonDetailDto } from "./dto/person.dto"; 4 + 5 + @Injectable() 6 + export class PeopleService { 7 + constructor(private readonly peopleTmdbService: PeopleTmdbService) {} 8 + 9 + async getPersonDetails(personId: string): Promise<TmdbPersonDetailDto> { 10 + const [person, filmography] = await Promise.all([ 11 + this.peopleTmdbService.getPersonDetails(personId), 12 + this.peopleTmdbService.getCombinedFilmography(personId), 13 + ]); 14 + 15 + return { 16 + id: person.id, 17 + name: person.name, 18 + profile_path: person.profile_path, 19 + biography: person.biography, 20 + birthday: person.birthday, 21 + deathday: person.deathday, 22 + place_of_birth: person.place_of_birth, 23 + known_for_department: person.known_for_department, 24 + popularity: person.popularity, 25 + filmography, 26 + }; 27 + } 28 + }
+14
packages/api/src/index.ts
··· 46 46 isKnownTraktImportStatus, 47 47 isTerminalTraktImportStatus, 48 48 } from "./trakt-import-status"; 49 + 50 + // TODO: Remove these manual exports after running `pnpm generate:api` 51 + // People API - temporary manual implementation until backend codegen is run 52 + export type { 53 + PersonFilmographyItemDto, 54 + TmdbPersonDetailDto, 55 + PeopleControllerGetPersonDetailsData, 56 + PeopleControllerGetPersonDetailsResponse, 57 + } from "./people-temp"; 58 + export { peopleControllerGetPersonDetails } from "./people-temp"; 59 + export { 60 + peopleControllerGetPersonDetailsOptions, 61 + peopleControllerGetPersonDetailsQueryKey, 62 + } from "./people-temp";
+102
packages/api/src/people-temp.ts
··· 1 + // TODO: Regenerate API client when backend is ready - this is a temporary manual implementation 2 + // Run: pnpm generate:api 3 + 4 + import { client } from "./generated/client.gen"; 5 + import { type DefaultError, queryOptions } from "@tanstack/react-query"; 6 + 7 + // Types 8 + export type PersonFilmographyItemDto = { 9 + id: number; 10 + media_type: "movie" | "tv"; 11 + title: string; 12 + poster_path?: string; 13 + release_date?: string; 14 + first_air_date?: string; 15 + character?: string; 16 + job?: string; 17 + department?: string; 18 + order?: number; 19 + vote_average?: number; 20 + }; 21 + 22 + export type TmdbPersonDetailDto = { 23 + id: number; 24 + name: string; 25 + profile_path?: string; 26 + biography?: string; 27 + birthday?: string; 28 + deathday?: string; 29 + place_of_birth?: string; 30 + known_for_department?: string; 31 + popularity?: number; 32 + filmography: PersonFilmographyItemDto[]; 33 + }; 34 + 35 + export type PeopleControllerGetPersonDetailsData = { 36 + body?: never; 37 + path: { 38 + personId: string; 39 + }; 40 + query?: never; 41 + url: "/people/tmdb/{personId}"; 42 + }; 43 + 44 + export type PeopleControllerGetPersonDetailsResponse = TmdbPersonDetailDto; 45 + 46 + // API Client 47 + export const peopleControllerGetPersonDetails = async ( 48 + options: PeopleControllerGetPersonDetailsData, 49 + ): Promise<{ data: PeopleControllerGetPersonDetailsResponse }> => { 50 + const response = await (options.client ?? client).get<{ 51 + 200: PeopleControllerGetPersonDetailsResponse; 52 + }>({ 53 + url: "/people/tmdb/{personId}", 54 + ...options, 55 + }); 56 + 57 + if (!response.data) { 58 + throw new Error("Failed to fetch person details"); 59 + } 60 + 61 + return { data: response.data }; 62 + }; 63 + 64 + // Query Key 65 + type QueryKey<T> = [ 66 + T & { 67 + _id: string; 68 + tags?: ReadonlyArray<string>; 69 + }, 70 + ]; 71 + 72 + const createQueryKey = <T>(id: string, options?: T): [QueryKey<T>[0]] => { 73 + const params = { _id: id } as QueryKey<T>[0]; 74 + if (options && "path" in (options as Record<string, unknown>)) { 75 + (params as Record<string, unknown>).path = (options as Record<string, unknown>).path; 76 + } 77 + return [params]; 78 + }; 79 + 80 + export const peopleControllerGetPersonDetailsQueryKey = ( 81 + options: PeopleControllerGetPersonDetailsData, 82 + ) => createQueryKey("peopleControllerGetPersonDetails", options); 83 + 84 + // React Query Options 85 + export const peopleControllerGetPersonDetailsOptions = ( 86 + options: PeopleControllerGetPersonDetailsData, 87 + ) => 88 + queryOptions< 89 + PeopleControllerGetPersonDetailsResponse, 90 + DefaultError, 91 + PeopleControllerGetPersonDetailsResponse, 92 + ReturnType<typeof peopleControllerGetPersonDetailsQueryKey> 93 + >({ 94 + queryFn: async ({ queryKey }) => { 95 + const { data } = await peopleControllerGetPersonDetails({ 96 + ...options, 97 + ...queryKey[0], 98 + }); 99 + return data; 100 + }, 101 + queryKey: peopleControllerGetPersonDetailsQueryKey(options), 102 + });