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: mobile detail pages

+3005 -2262
+272 -740
apps/mobile/app/movie/[id].tsx
··· 36 36 import { DatePickerModal, TimePickerModal } from "react-native-paper-dates"; 37 37 import { SafeAreaView } from "react-native-safe-area-context"; 38 38 import { AddToListModal } from "@/components/AddToListModal"; 39 - import { Badge } from "@/components/ui/Badge"; 39 + import { DetailActions, DetailHero, MetadataPills } from "@/components/detail"; 40 40 import { Button } from "@/components/ui/Button"; 41 - import { defaultColors as staticColors } from "@/constants/extended-theme"; 42 - import { borderRadius } from "@/constants/spacing"; 43 - import { useAuth } from "@/contexts/auth"; 41 + import { borderRadius, spacing } from "@/constants/spacing"; 44 42 import { useTheme } from "@/contexts/theme"; 45 43 import { useToast } from "@/contexts/toast"; 46 44 ··· 71 69 }); 72 70 } 73 71 72 + function formatDateOnly(dateString: string): string { 73 + return new Date(dateString).toLocaleDateString("en-US", { 74 + year: "numeric", 75 + month: "short", 76 + day: "numeric", 77 + }); 78 + } 79 + 74 80 export default function MovieDetailScreen() { 75 81 const { id: movieId, title } = useLocalSearchParams<{ 76 82 id: string; 77 83 title?: string; 78 84 }>(); 79 85 const router = useRouter(); 80 - const { user } = useAuth(); 86 + const { colors: themeColors } = useTheme(); 81 87 const { showToast } = useToast(); 82 - const { colors } = useTheme(); 83 88 const queryClient = useQueryClient(); 84 89 85 - const [showHours, setShowHours] = useState(false); 86 90 const [showDateModal, setShowDateModal] = useState(false); 87 91 const [showAddToListModal, setShowAddToListModal] = useState(false); 88 92 const [customDate, setCustomDate] = useState<Date>(new Date()); ··· 90 94 const [showTimePicker, setShowTimePicker] = useState(false); 91 95 const [showHistoryModal, setShowHistoryModal] = useState(false); 92 96 93 - // Fetch movie details 94 97 const { data: movieData, isLoading: isMovieLoading } = useQuery({ 95 98 ...moviesControllerGetMovieDetailsOptions({ 96 99 path: { movieId }, ··· 99 102 100 103 const movie = movieData as TmdbMovieDetailDto | undefined; 101 104 102 - // Use server-provided colors with fallbacks 103 105 const movieColors = movie?.colors || { 104 106 primary: "#F59E0B", 105 107 secondary: "#D97706", ··· 107 109 muted: "#92400E", 108 110 }; 109 111 110 - // Fetch user's tracked movies 111 112 const { data: trackedMovies } = useQuery({ 112 113 ...moviesControllerGetUserMoviesOptions({ 113 - path: { userDid: user?.did || "" }, 114 + path: { userDid: "" }, 114 115 }), 115 - enabled: !!user?.did, 116 + enabled: false, 116 117 }); 117 118 118 - // Fetch watch history for this movie 119 119 const { data: watchHistory } = useQuery({ 120 120 ...moviesControllerGetMovieWatchHistoryOptions({ 121 - path: { userDid: user?.did || "", movieId }, 121 + path: { userDid: "", movieId }, 122 122 }), 123 - enabled: !!user?.did && !!movieId, 123 + enabled: false, 124 124 }); 125 125 126 - // Fetch user settings for timezone and time format 127 126 const { data: userSettings } = useQuery({ 128 127 ...usersControllerGetMySettingsOptions(), 129 - enabled: !!user?.did, 128 + enabled: false, 130 129 }); 131 130 132 - // Fetch lists for this movie 133 131 const { data: listsForMovie } = useQuery({ 134 132 ...listsControllerGetListsForItemOptions({ 135 133 path: { mediaType: "movie", mediaId: movieId }, 136 134 }), 137 - enabled: !!user?.did, 135 + enabled: false, 138 136 }); 139 137 140 138 const listsForMovieTyped = (listsForMovie || []) as MovieListsForItemDto[]; 141 139 const listsCount = listsForMovieTyped.filter((l) => l.isInList).length; 142 - const isInAnyList = listsCount > 0; 143 140 144 141 const userTimezone = userSettings?.timezone || "UTC"; 145 142 const is24Hour = userSettings?.timeFormat === "24h"; 146 143 147 - // Check if this movie is in user's watched list 148 144 const isWatched = useMemo(() => { 149 145 if (!trackedMovies) return false; 150 146 return trackedMovies.some((tm) => tm.movieId === movieId); 151 147 }, [trackedMovies, movieId]); 152 148 153 - // Find the tracked movie entry 154 149 const trackedMovie = useMemo(() => { 155 150 if (!trackedMovies) return null; 156 151 return trackedMovies.find((tm) => tm.movieId === movieId) || null; 157 152 }, [trackedMovies, movieId]); 158 153 159 - // Format the watched date 160 154 const formattedWatchedDate = useMemo(() => { 161 155 if (!trackedMovie?.watchedDate) return null; 162 156 return formatWatchDate(trackedMovie.watchedDate, userTimezone, is24Hour); 163 157 }, [trackedMovie, userTimezone, is24Hour]); 164 158 165 - // Mutations 166 159 const markMutation = useMutation({ 167 160 ...moviesControllerMarkWatchedMutation(), 168 161 onSuccess: () => { 169 162 queryClient.invalidateQueries({ 170 163 queryKey: moviesControllerGetUserMoviesQueryKey({ 171 - path: { userDid: user?.did || "" }, 164 + path: { userDid: "" }, 172 165 }), 173 166 }); 174 167 queryClient.invalidateQueries({ 175 168 queryKey: moviesControllerGetMovieWatchHistoryQueryKey({ 176 - path: { userDid: user?.did || "", movieId }, 169 + path: { userDid: "", movieId }, 177 170 }), 178 171 }); 179 172 setShowDateModal(false); ··· 189 182 onSuccess: () => { 190 183 queryClient.invalidateQueries({ 191 184 queryKey: moviesControllerGetUserMoviesQueryKey({ 192 - path: { userDid: user?.did || "" }, 185 + path: { userDid: "" }, 193 186 }), 194 187 }); 195 188 queryClient.invalidateQueries({ 196 189 queryKey: moviesControllerGetMovieWatchHistoryQueryKey({ 197 - path: { userDid: user?.did || "", movieId }, 190 + path: { userDid: "", movieId }, 198 191 }), 199 192 }); 200 193 showToast("Removed from your shelf", "success"); ··· 209 202 onSuccess: () => { 210 203 queryClient.invalidateQueries({ 211 204 queryKey: moviesControllerGetUserMoviesQueryKey({ 212 - path: { userDid: user?.did || "" }, 205 + path: { userDid: "" }, 213 206 }), 214 207 }); 215 208 queryClient.invalidateQueries({ 216 209 queryKey: moviesControllerGetMovieWatchHistoryQueryKey({ 217 - path: { userDid: user?.did || "", movieId }, 210 + path: { userDid: "", movieId }, 218 211 }), 219 212 }); 220 213 showToast("Watch entry removed", "success"); ··· 250 243 ); 251 244 252 245 const handleShare = useCallback(async () => { 253 - const url = `https://opnshelf.xyz/movie/${movieId}/${title || ""}`; 246 + const shareUrl = `https://opnshelf.app/movie/${movieId}/${title || ""}`; 254 247 try { 255 248 await Share.share({ 249 + message: `Check out ${title} on OpnShelf!\n\n${shareUrl}`, 256 250 title: `Check out ${title} on OpnShelf`, 257 - url, 258 251 }); 259 252 } catch { 260 253 showToast("Failed to share", "error"); 261 254 } 262 255 }, [movieId, title, showToast]); 263 256 264 - const _openDateModal = useCallback(() => { 257 + const openDateModal = useCallback(() => { 265 258 setCustomDate(new Date()); 266 259 setShowDateModal(true); 267 260 }, []); ··· 281 274 const isPending = 282 275 markMutation.isPending && markMutation.variables?.body?.movieId === movieId; 283 276 277 + const metadataItems = useMemo(() => { 278 + const items = []; 279 + if (movie?.release_date) { 280 + items.push({ 281 + icon: ( 282 + <Ionicons 283 + name="calendar-outline" 284 + size={14} 285 + color={themeColors.onSurfaceVariant} 286 + /> 287 + ), 288 + label: formatDateOnly(movie.release_date), 289 + }); 290 + } 291 + if (movie?.runtime) { 292 + items.push({ 293 + icon: ( 294 + <Ionicons 295 + name="time-outline" 296 + size={14} 297 + color={themeColors.onSurfaceVariant} 298 + /> 299 + ), 300 + label: formatRuntime(movie.runtime, false), 301 + }); 302 + } 303 + if (movie?.vote_average) { 304 + items.push({ 305 + icon: ( 306 + <Ionicons 307 + name="star-outline" 308 + size={14} 309 + color={themeColors.onSurfaceVariant} 310 + /> 311 + ), 312 + label: `${movie.vote_average.toFixed(1)}/10`, 313 + }); 314 + } 315 + return items; 316 + }, [movie, themeColors]); 317 + 284 318 if (isMovieLoading) { 285 319 return ( 286 320 <SafeAreaView style={styles.container}> ··· 294 328 return ( 295 329 <> 296 330 <ScrollView 297 - style={styles.container} 331 + style={[styles.container, { backgroundColor: themeColors.background }]} 298 332 contentContainerStyle={styles.scrollContent} 299 333 > 300 - <View style={styles.heroWrapper}> 301 - {backdropUrl ? ( 302 - <Image 303 - source={{ uri: backdropUrl }} 304 - style={styles.backdrop} 305 - contentFit="cover" 306 - /> 307 - ) : ( 308 - <View 309 - style={[styles.backdrop, { backgroundColor: movieColors.muted }]} 310 - /> 311 - )} 312 - 313 - <TouchableOpacity 314 - onPress={() => router.back()} 315 - style={styles.backButton} 316 - activeOpacity={0.8} 317 - > 318 - <Ionicons name="arrow-back" size={24} color="#f9fafb" /> 319 - </TouchableOpacity> 320 - 321 - <View style={styles.heroOverlay}> 322 - <View style={styles.posterWrapper}> 323 - {posterUrl ? ( 324 - <Image 325 - source={{ uri: posterUrl }} 326 - style={styles.poster} 327 - contentFit="cover" 328 - /> 329 - ) : ( 330 - <View style={[styles.poster, styles.noPoster]}> 331 - <Text style={styles.noPosterText}>No poster</Text> 332 - </View> 333 - )} 334 - </View> 335 - 336 - <View style={styles.titleWrapper}> 337 - <Text 338 - style={[styles.title, { textShadowColor: movieColors.primary }]} 339 - numberOfLines={2} 340 - adjustsFontSizeToFit 341 - minimumFontScale={0.7} 342 - > 343 - {movie?.title || title} 344 - </Text> 345 - <View style={styles.metaRow}> 346 - {!!releaseYear && ( 347 - <View style={styles.metaItem}> 348 - <Ionicons 349 - name="calendar-outline" 350 - size={14} 351 - color={movieColors.accent} 352 - /> 353 - <Text style={styles.metaText}>{releaseYear}</Text> 354 - </View> 355 - )} 356 - {movie?.runtime && ( 357 - <TouchableOpacity 358 - onPress={() => setShowHours(!showHours)} 359 - style={styles.metaItem} 360 - activeOpacity={0.8} 361 - > 362 - <Ionicons 363 - name="time-outline" 364 - size={14} 365 - color={movieColors.accent} 366 - /> 367 - <Text style={styles.metaText}> 368 - {formatRuntime(movie.runtime, showHours)} 369 - </Text> 370 - </TouchableOpacity> 371 - )} 372 - </View> 373 - </View> 374 - </View> 375 - </View> 334 + <DetailHero 335 + title={movie?.title || title || ""} 336 + subtitle={releaseYear ? String(releaseYear) : undefined} 337 + backdropUrl={backdropUrl} 338 + posterUrl={posterUrl} 339 + colors={movieColors} 340 + onBack={() => router.back()} 341 + isLoading={isMovieLoading} 342 + /> 376 343 377 344 <View style={styles.content}> 378 - <View style={styles.actionsContainer}> 379 - {user ? ( 380 - !isWatched ? ( 381 - <> 382 - <View style={styles.primaryButtonRow}> 383 - <TouchableOpacity 384 - onPress={handleMarkWatched} 385 - disabled={isPending} 386 - style={[ 387 - styles.primaryButton, 388 - { flex: 1, opacity: isPending ? 0.7 : 1 }, 389 - ]} 390 - activeOpacity={0.8} 391 - > 392 - <LinearGradient 393 - colors={[ 394 - movieColors.primary || "#F59E0B", 395 - movieColors.secondary || "#D97706", 396 - ]} 397 - start={{ x: 0, y: 0 }} 398 - end={{ x: 1, y: 1 }} 399 - style={styles.gradientButton} 400 - > 401 - {isPending ? ( 402 - <View style={styles.buttonContent}> 403 - <ActivityIndicator color="#f9fafb" /> 404 - <Text style={styles.buttonText}>Loading</Text> 405 - </View> 406 - ) : ( 407 - <View style={styles.buttonContent}> 408 - <Ionicons name="add" size={20} color="#1f2937" /> 409 - <Text style={styles.buttonText}>Add to Shelf</Text> 410 - </View> 411 - )} 412 - </LinearGradient> 413 - </TouchableOpacity> 414 - 415 - <TouchableOpacity 416 - onPress={_openDateModal} 417 - style={styles.calendarButton} 418 - activeOpacity={0.8} 419 - > 420 - <Ionicons 421 - name="calendar-outline" 422 - size={22} 423 - color="#9ca3af" 424 - /> 425 - </TouchableOpacity> 426 - </View> 427 - 428 - <TouchableOpacity 429 - onPress={() => setShowAddToListModal(true)} 430 - style={[ 431 - styles.secondaryButton, 432 - isInAnyList && { 433 - backgroundColor: `${movieColors.primary}20`, 434 - borderColor: movieColors.primary, 435 - }, 436 - ]} 437 - activeOpacity={0.8} 438 - > 439 - <View style={styles.buttonContent}> 440 - <Ionicons 441 - name={isInAnyList ? "checkmark" : "list-outline"} 442 - size={18} 443 - color={isInAnyList ? movieColors.primary : "#9ca3af"} 444 - /> 445 - <Text 446 - style={[ 447 - styles.secondaryButtonText, 448 - isInAnyList && { color: movieColors.primary }, 449 - ]} 450 - > 451 - {isInAnyList 452 - ? `In ${listsCount} list${listsCount > 1 ? "s" : ""}` 453 - : "Add to List"} 454 - </Text> 455 - </View> 456 - </TouchableOpacity> 457 - <TouchableOpacity 458 - onPress={handleShare} 459 - style={styles.secondaryButton} 460 - activeOpacity={0.8} 461 - > 462 - <View style={styles.buttonContent}> 463 - <Ionicons 464 - name="share-outline" 465 - size={18} 466 - color="#9ca3af" 467 - /> 468 - <Text style={styles.secondaryButtonText}>Share</Text> 469 - </View> 470 - </TouchableOpacity> 471 - </> 472 - ) : ( 473 - <> 474 - <View style={styles.primaryButtonRow}> 475 - <TouchableOpacity 476 - onPress={handleMarkWatched} 477 - disabled={isPending} 478 - style={[ 479 - styles.primaryButton, 480 - { flex: 1, opacity: isPending ? 0.7 : 1 }, 481 - ]} 482 - activeOpacity={0.8} 483 - > 484 - <LinearGradient 485 - colors={[ 486 - movieColors.primary || "#F59E0B", 487 - movieColors.secondary || "#D97706", 488 - ]} 489 - start={{ x: 0, y: 0 }} 490 - end={{ x: 1, y: 1 }} 491 - style={styles.gradientButton} 492 - > 493 - {isPending ? ( 494 - <View style={styles.buttonContent}> 495 - <ActivityIndicator color="#f9fafb" /> 496 - <Text style={styles.buttonText}>Loading</Text> 497 - </View> 498 - ) : ( 499 - <View style={styles.buttonContent}> 500 - <Ionicons 501 - name="refresh" 502 - size={20} 503 - color="#1f2937" 504 - /> 505 - <Text style={styles.buttonText}>Watch Now</Text> 506 - </View> 507 - )} 508 - </LinearGradient> 509 - </TouchableOpacity> 345 + <DetailActions 346 + mediaType="movie" 347 + mediaId={movieId} 348 + colors={movieColors} 349 + isWatched={isWatched} 350 + watchedDate={formattedWatchedDate} 351 + totalWatches={watchHistory?.length ?? 0} 352 + onMarkWatched={handleMarkWatched} 353 + onUnmarkWatched={handleUnmarkWatched} 354 + onShowDatePicker={openDateModal} 355 + isMarkingPending={isPending} 356 + isUnmarkingPending={unmarkMutation.isPending} 357 + listsCount={listsCount} 358 + onShowListModal={() => setShowAddToListModal(true)} 359 + onViewHistory={() => setShowHistoryModal(true)} 360 + onShare={handleShare} 361 + /> 510 362 511 - <TouchableOpacity 512 - onPress={_openDateModal} 513 - style={styles.calendarButton} 514 - activeOpacity={0.8} 515 - > 516 - <Ionicons 517 - name="calendar-outline" 518 - size={22} 519 - color="#9ca3af" 520 - /> 521 - </TouchableOpacity> 522 - </View> 523 - 524 - <TouchableOpacity 525 - onPress={() => setShowAddToListModal(true)} 526 - style={[ 527 - styles.secondaryButton, 528 - isInAnyList && { 529 - backgroundColor: `${movieColors.primary}20`, 530 - borderColor: movieColors.primary, 531 - }, 532 - ]} 533 - activeOpacity={0.8} 534 - > 535 - <View style={styles.buttonContent}> 536 - <Ionicons 537 - name={isInAnyList ? "checkmark" : "list-outline"} 538 - size={18} 539 - color={isInAnyList ? movieColors.primary : "#9ca3af"} 540 - /> 541 - <Text 542 - style={[ 543 - styles.secondaryButtonText, 544 - isInAnyList && { color: movieColors.primary }, 545 - ]} 546 - > 547 - {isInAnyList 548 - ? `In ${listsCount} list${listsCount > 1 ? "s" : ""}` 549 - : "Add to List"} 550 - </Text> 551 - </View> 552 - </TouchableOpacity> 553 - <TouchableOpacity 554 - onPress={handleShare} 555 - style={styles.secondaryButton} 556 - activeOpacity={0.8} 557 - > 558 - <View style={styles.buttonContent}> 559 - <Ionicons 560 - name="share-outline" 561 - size={18} 562 - color="#9ca3af" 563 - /> 564 - <Text style={styles.secondaryButtonText}>Share</Text> 565 - </View> 566 - </TouchableOpacity> 567 - </> 568 - ) 569 - ) : ( 570 - <TouchableOpacity 571 - onPress={() => router.push("/login")} 572 - style={styles.primaryButton} 573 - activeOpacity={0.8} 574 - > 575 - <LinearGradient 576 - colors={[ 577 - movieColors.primary || "#F59E0B", 578 - movieColors.secondary || "#D97706", 579 - ]} 580 - start={{ x: 0, y: 0 }} 581 - end={{ x: 1, y: 1 }} 582 - style={styles.gradientButton} 583 - > 584 - <Text style={styles.buttonText}>Sign in to Track</Text> 585 - </LinearGradient> 586 - </TouchableOpacity> 587 - )} 588 - </View> 589 - 590 - {isWatched && ( 591 - <View style={styles.watchedCard}> 592 - <View style={styles.watchedHeader}> 593 - <Ionicons name="checkmark-circle" size={20} color="#22c55e" /> 594 - <Text style={styles.watchedText}>On Your Shelf</Text> 595 - </View> 596 - {formattedWatchedDate && ( 597 - <View style={styles.watchedDateRow}> 598 - <Text style={styles.watchedDateText}> 599 - Watched on {formattedWatchedDate} 600 - </Text> 601 - {watchHistory && watchHistory.length > 1 && ( 602 - <Badge variant="secondary"> 603 - {watchHistory.length} watches 604 - </Badge> 605 - )} 606 - </View> 607 - )} 608 - {watchHistory && watchHistory.length > 1 && ( 609 - <TouchableOpacity 610 - onPress={() => setShowHistoryModal(true)} 611 - style={styles.viewHistoryRow} 612 - activeOpacity={0.7} 613 - > 614 - <Ionicons name="eye" size={16} color="#9ca3af" /> 615 - <Text style={styles.viewHistoryText}>View all watches</Text> 616 - </TouchableOpacity> 617 - )} 618 - {watchHistory && watchHistory.length === 1 && ( 619 - <TouchableOpacity 620 - onPress={handleUnmarkWatched} 621 - disabled={unmarkMutation.isPending} 622 - style={styles.removeRow} 623 - activeOpacity={0.7} 624 - > 625 - {unmarkMutation.isPending ? ( 626 - <View style={styles.removeRowContent}> 627 - <ActivityIndicator size="small" color="#ef4444" /> 628 - <Text style={styles.removeText}>Loading</Text> 629 - </View> 630 - ) : ( 631 - <> 632 - <Ionicons 633 - name="trash-outline" 634 - size={16} 635 - color="#ef4444" 636 - /> 637 - <Text style={styles.removeText}>Remove from shelf</Text> 638 - </> 639 - )} 640 - </TouchableOpacity> 641 - )} 642 - </View> 643 - )} 363 + <MetadataPills items={metadataItems} /> 644 364 645 365 {movie?.overview && ( 646 366 <View style={styles.section}> ··· 649 369 > 650 370 Overview 651 371 </Text> 652 - <Text style={styles.overview}>{movie.overview}</Text> 372 + <Text 373 + style={[ 374 + styles.overview, 375 + { color: themeColors.onSurfaceVariant }, 376 + ]} 377 + > 378 + {movie.overview} 379 + </Text> 653 380 </View> 654 381 )} 655 382 656 - <View style={styles.infoGrid}> 657 - {movie?.release_date && ( 658 - <View style={styles.infoCard}> 659 - <Text style={styles.infoLabel}>Release Date</Text> 660 - <Text style={[styles.infoValue, { color: movieColors.accent }]}> 661 - {new Date(movie.release_date).toLocaleDateString("en-US", { 662 - year: "numeric", 663 - month: "short", 664 - day: "numeric", 665 - })} 666 - </Text> 667 - </View> 668 - )} 669 - {movie?.runtime && ( 670 - <TouchableOpacity 671 - onPress={() => setShowHours(!showHours)} 672 - style={styles.infoCard} 673 - activeOpacity={0.8} 674 - > 675 - <Text style={styles.infoLabel}>Runtime</Text> 676 - <Text style={[styles.infoValue, { color: movieColors.accent }]}> 677 - {formatRuntime(movie.runtime, showHours)} 678 - </Text> 679 - </TouchableOpacity> 680 - )} 681 - {movie?.vote_average !== undefined && ( 682 - <View style={styles.infoCard}> 683 - <Text style={styles.infoLabel}>Rating</Text> 684 - <Text style={[styles.infoValue, { color: movieColors.accent }]}> 685 - {movie.vote_average.toFixed(1)}/10 686 - </Text> 687 - </View> 688 - )} 689 - {movie?.vote_count !== undefined && ( 690 - <View style={styles.infoCard}> 691 - <Text style={styles.infoLabel}>Votes</Text> 692 - <Text style={[styles.infoValue, { color: movieColors.accent }]}> 693 - {movie.vote_count.toLocaleString()} 694 - </Text> 695 - </View> 696 - )} 697 - </View> 698 - 699 383 {movie?.genres && movie.genres.length > 0 && ( 700 384 <View style={styles.section}> 701 385 <Text ··· 825 509 onRequestClose={() => setShowDateModal(false)} 826 510 > 827 511 <View style={styles.modalOverlay}> 828 - <View style={styles.modalContent}> 512 + <View 513 + style={[ 514 + styles.modalContent, 515 + { backgroundColor: themeColors.surfaceContainerHighest }, 516 + ]} 517 + > 829 518 <View style={styles.modalHeader}> 830 - <Text style={styles.modalTitle}>Watch movie</Text> 519 + <Text 520 + style={[styles.modalTitle, { color: themeColors.onSurface }]} 521 + > 522 + Watch movie 523 + </Text> 831 524 <Pressable onPress={() => setShowDateModal(false)}> 832 - <Ionicons name="close" size={24} color={colors.onSurface} /> 525 + <Ionicons 526 + name="close" 527 + size={24} 528 + color={themeColors.onSurface} 529 + /> 833 530 </Pressable> 834 531 </View> 835 - <Text style={styles.modalDescription}> 532 + <Text 533 + style={[ 534 + styles.modalDescription, 535 + { color: themeColors.onSurfaceVariant }, 536 + ]} 537 + > 836 538 When did you watch this? 837 539 </Text> 838 540 ··· 845 547 <Ionicons 846 548 name="calendar-outline" 847 549 size={20} 848 - color={colors.onSurfaceVariant} 550 + color={themeColors.onSurfaceVariant} 849 551 /> 850 - <Text style={styles.dateTimeText}> 552 + <Text 553 + style={[ 554 + styles.dateTimeText, 555 + { color: themeColors.onSurface }, 556 + ]} 557 + > 851 558 {customDate.toLocaleDateString("en-US", { 852 559 year: "numeric", 853 560 month: "short", ··· 863 570 <Ionicons 864 571 name="time-outline" 865 572 size={20} 866 - color={colors.onSurfaceVariant} 573 + color={themeColors.onSurfaceVariant} 867 574 /> 868 - <Text style={styles.dateTimeText}> 575 + <Text 576 + style={[ 577 + styles.dateTimeText, 578 + { color: themeColors.onSurface }, 579 + ]} 580 + > 869 581 {customDate.toLocaleTimeString("en-US", { 870 582 hour: "2-digit", 871 583 minute: "2-digit", ··· 919 631 <Button 920 632 onPress={handleMarkWatchedWithDate} 921 633 isLoading={markMutation.isPending} 922 - style={{ backgroundColor: colors.primary }} 634 + style={{ backgroundColor: themeColors.primary }} 923 635 > 924 636 <Text style={styles.buttonText}>Add Watch</Text> 925 637 </Button> ··· 935 647 onRequestClose={() => setShowHistoryModal(false)} 936 648 > 937 649 <View style={styles.modalOverlay}> 938 - <View style={styles.modalContent}> 650 + <View 651 + style={[ 652 + styles.modalContent, 653 + { backgroundColor: themeColors.surfaceContainerHighest }, 654 + ]} 655 + > 939 656 <View style={styles.modalHeader}> 940 657 <View style={styles.modalTitleContainer}> 941 - <Ionicons name="time" size={20} color={colors.primary} /> 942 - <Text style={styles.modalTitle}>Watch History</Text> 658 + <Ionicons name="time" size={20} color={themeColors.primary} /> 659 + <Text 660 + style={[styles.modalTitle, { color: themeColors.onSurface }]} 661 + > 662 + Watch History 663 + </Text> 943 664 </View> 944 665 <Pressable onPress={() => setShowHistoryModal(false)}> 945 - <Ionicons name="close" size={24} color={colors.onSurface} /> 666 + <Ionicons 667 + name="close" 668 + size={24} 669 + color={themeColors.onSurface} 670 + /> 946 671 </Pressable> 947 672 </View> 948 - <Text style={styles.modalDescription}> 673 + <Text 674 + style={[ 675 + styles.modalDescription, 676 + { color: themeColors.onSurfaceVariant }, 677 + ]} 678 + > 949 679 All the times you&apos;ve watched {movie?.title} 950 680 </Text> 951 681 952 682 <ScrollView style={styles.historyList}> 953 683 {watchHistory && watchHistory.length > 0 ? ( 954 684 watchHistory.map((watch) => ( 955 - <View key={watch.id} style={styles.historyItem}> 956 - <Text style={styles.historyDate}> 685 + <View 686 + key={watch.id} 687 + style={[ 688 + styles.historyItem, 689 + { backgroundColor: themeColors.surfaceContainer }, 690 + ]} 691 + > 692 + <Text 693 + style={[ 694 + styles.historyDate, 695 + { color: themeColors.onSurface }, 696 + ]} 697 + > 957 698 {formatWatchDate( 958 699 watch.watchedDate, 959 700 userTimezone, ··· 971 712 ?.trackedMovieId === watch.id ? ( 972 713 <ActivityIndicator 973 714 size="small" 974 - color={colors.onSurfaceVariant} 715 + color={themeColors.onSurfaceVariant} 975 716 /> 976 717 ) : ( 977 718 <Ionicons ··· 1012 753 const styles = StyleSheet.create({ 1013 754 container: { 1014 755 flex: 1, 1015 - backgroundColor: staticColors.background, 1016 756 }, 1017 757 scrollContent: { 1018 - paddingBottom: 32, 758 + paddingBottom: spacing.xxl, 1019 759 }, 1020 760 loadingContainer: { 1021 761 flex: 1, 1022 762 justifyContent: "center", 1023 763 alignItems: "center", 1024 764 }, 1025 - heroWrapper: { 1026 - height: 256, 1027 - position: "relative", 1028 - }, 1029 - backdrop: { 1030 - width: "100%", 1031 - height: "100%", 1032 - }, 1033 - backButton: { 1034 - position: "absolute", 1035 - top: 48, 1036 - left: 16, 1037 - zIndex: 10, 1038 - padding: 8, 1039 - borderRadius: borderRadius.full, 1040 - backgroundColor: "rgba(0, 0, 0, 0.5)", 1041 - }, 1042 - heroOverlay: { 1043 - position: "absolute", 1044 - bottom: -64, 1045 - left: 16, 1046 - right: 16, 1047 - flexDirection: "row", 1048 - alignItems: "flex-end", 1049 - }, 1050 - posterWrapper: { 1051 - borderRadius: borderRadius.lg, 1052 - overflow: "hidden", 1053 - shadowColor: staticColors.primary, 1054 - shadowOffset: { width: 0, height: 4 }, 1055 - shadowOpacity: 0.4, 1056 - shadowRadius: 8, 1057 - elevation: 8, 1058 - }, 1059 - poster: { 1060 - width: 112, 1061 - height: 160, 1062 - }, 1063 - noPoster: { 1064 - backgroundColor: "#111827", 1065 - justifyContent: "center", 1066 - alignItems: "center", 1067 - }, 1068 - noPosterText: { 1069 - color: "#4b5563", 1070 - fontSize: 12, 1071 - }, 1072 - titleWrapper: { 1073 - marginLeft: 16, 1074 - marginBottom: 16, 1075 - flex: 1, 1076 - }, 1077 - title: { 1078 - fontSize: 24, 1079 - fontWeight: "bold", 1080 - color: "#f9fafb", 1081 - textShadowOffset: { width: 0, height: 2 }, 1082 - textShadowRadius: 8, 1083 - }, 1084 - metaRow: { 1085 - flexDirection: "row", 1086 - alignItems: "center", 1087 - marginTop: 8, 1088 - gap: 12, 1089 - }, 1090 - metaItem: { 1091 - flexDirection: "row", 1092 - alignItems: "center", 1093 - }, 1094 - metaText: { 1095 - fontSize: 14, 1096 - color: "#9ca3af", 1097 - marginLeft: 4, 1098 - }, 1099 765 content: { 1100 - marginTop: 80, 1101 - paddingHorizontal: 16, 1102 - }, 1103 - actionsContainer: { 1104 - gap: 12, 1105 - marginBottom: 24, 1106 - }, 1107 - primaryButtonRow: { 1108 - flexDirection: "row", 1109 - gap: 12, 1110 - alignItems: "stretch", 1111 - }, 1112 - primaryButton: { 1113 - borderRadius: 12, 1114 - overflow: "hidden", 1115 - }, 1116 - calendarButton: { 1117 - borderRadius: 12, 1118 - paddingVertical: 16, 1119 - paddingHorizontal: 16, 1120 - alignItems: "center", 1121 - justifyContent: "center", 1122 - borderWidth: 1, 1123 - borderColor: "#374151", 1124 - }, 1125 - gradientButton: { 1126 - paddingVertical: 16, 1127 - paddingHorizontal: 24, 1128 - alignItems: "center", 1129 - justifyContent: "center", 1130 - }, 1131 - secondaryButton: { 1132 - borderRadius: 12, 1133 - paddingVertical: 12, 1134 - paddingHorizontal: 24, 1135 - alignItems: "center", 1136 - justifyContent: "center", 1137 - borderWidth: 1, 1138 - borderColor: "#374151", 1139 - }, 1140 - 1141 - buttonContent: { 1142 - flexDirection: "row", 1143 - alignItems: "center", 1144 - gap: 8, 1145 - }, 1146 - buttonText: { 1147 - color: "#1f2937", 1148 - fontSize: 18, 1149 - fontWeight: "600", 1150 - }, 1151 - secondaryButtonText: { 1152 - color: "#9ca3af", 1153 - fontSize: 16, 1154 - fontWeight: "500", 1155 - }, 1156 - 1157 - watchedCard: { 1158 - backgroundColor: "rgba(17, 24, 39, 0.5)", 1159 - borderRadius: 12, 1160 - borderWidth: 1, 1161 - borderColor: "#1f2937", 1162 - padding: 16, 1163 - marginBottom: 24, 1164 - }, 1165 - watchedHeader: { 1166 - flexDirection: "row", 1167 - alignItems: "center", 1168 - marginBottom: 8, 1169 - }, 1170 - watchedText: { 1171 - color: "#22c55e", 1172 - fontSize: 16, 1173 - fontWeight: "600", 1174 - marginLeft: 8, 1175 - }, 1176 - watchedDateRow: { 1177 - flexDirection: "row", 1178 - alignItems: "center", 1179 - flexWrap: "wrap", 1180 - gap: 8, 1181 - }, 1182 - watchedDateText: { 1183 - fontSize: 14, 1184 - color: "#9ca3af", 1185 - }, 1186 - viewHistoryRow: { 1187 - flexDirection: "row", 1188 - alignItems: "center", 1189 - marginTop: 12, 1190 - }, 1191 - viewHistoryText: { 1192 - fontSize: 14, 1193 - color: "#9ca3af", 1194 - marginLeft: 8, 1195 - }, 1196 - removeRow: { 1197 - flexDirection: "row", 1198 - alignItems: "center", 1199 - marginTop: 12, 1200 - }, 1201 - removeRowContent: { 1202 - flexDirection: "row", 1203 - alignItems: "center", 1204 - gap: 8, 1205 - }, 1206 - removeText: { 1207 - fontSize: 14, 1208 - color: "#ef4444", 1209 - marginLeft: 8, 766 + paddingHorizontal: spacing.md, 767 + gap: spacing.lg, 768 + paddingTop: spacing.lg, 1210 769 }, 1211 770 section: { 1212 - marginBottom: 24, 771 + gap: spacing.sm, 1213 772 }, 1214 773 sectionTitle: { 1215 - fontSize: 20, 774 + fontSize: 18, 1216 775 fontWeight: "600", 1217 - marginBottom: 12, 1218 776 }, 1219 777 overview: { 1220 - fontSize: 16, 1221 - color: "#d1d5db", 1222 - lineHeight: 24, 1223 - }, 1224 - infoGrid: { 1225 - flexDirection: "row", 1226 - flexWrap: "wrap", 1227 - gap: 12, 1228 - marginBottom: 24, 1229 - }, 1230 - infoCard: { 1231 - backgroundColor: "#111827", 1232 - borderRadius: 8, 1233 - padding: 12, 1234 - flex: 1, 1235 - minWidth: "45%", 1236 - }, 1237 - infoLabel: { 1238 - fontSize: 12, 1239 - color: "#6b7280", 1240 - marginBottom: 4, 1241 - }, 1242 - infoValue: { 1243 - fontSize: 16, 1244 - fontWeight: "600", 778 + fontSize: 15, 779 + lineHeight: 22, 1245 780 }, 1246 781 genresContainer: { 1247 782 flexDirection: "row", 1248 783 flexWrap: "wrap", 1249 - gap: 8, 784 + gap: spacing.sm, 1250 785 }, 1251 786 genreBadge: { 1252 - paddingHorizontal: 12, 1253 - paddingVertical: 6, 787 + paddingHorizontal: spacing.md, 788 + paddingVertical: spacing.sm, 1254 789 borderRadius: borderRadius.full, 1255 790 borderWidth: 1, 1256 791 }, ··· 1258 793 fontSize: 14, 1259 794 fontWeight: "500", 1260 795 }, 1261 - modalOverlay: { 1262 - flex: 1, 1263 - backgroundColor: "rgba(0, 0, 0, 0.7)", 1264 - justifyContent: "center", 1265 - padding: 16, 1266 - }, 1267 - modalContent: { 1268 - backgroundColor: staticColors.card, 1269 - borderRadius: 16, 1270 - padding: 16, 1271 - gap: 12, 1272 - }, 1273 - modalHeader: { 1274 - flexDirection: "row", 1275 - justifyContent: "space-between", 1276 - alignItems: "center", 1277 - }, 1278 - modalTitleContainer: { 1279 - flexDirection: "row", 1280 - alignItems: "center", 1281 - gap: 8, 1282 - }, 1283 - modalTitle: { 1284 - fontSize: 20, 1285 - fontWeight: "bold", 1286 - color: staticColors.text, 1287 - }, 1288 - modalDescription: { 1289 - fontSize: 14, 1290 - color: "#9ca3af", 1291 - }, 1292 - dateTimeContainer: { 1293 - gap: 12, 1294 - }, 1295 - dateTimeButton: { 1296 - flexDirection: "row", 1297 - alignItems: "center", 1298 - gap: 12, 1299 - padding: 16, 1300 - backgroundColor: "#111827", 1301 - borderRadius: 12, 1302 - }, 1303 - dateTimeText: { 1304 - fontSize: 16, 1305 - color: "#f9fafb", 1306 - }, 1307 - modalActions: { 1308 - flexDirection: "row", 1309 - gap: 12, 1310 - marginTop: 8, 1311 - }, 1312 - modalActionsSplit: { 1313 - flexDirection: "row", 1314 - justifyContent: "space-between", 1315 - gap: 12, 1316 - marginTop: 8, 1317 - }, 1318 - historyList: { 1319 - maxHeight: 300, 1320 - }, 1321 - historyItem: { 1322 - flexDirection: "row", 1323 - justifyContent: "space-between", 1324 - alignItems: "center", 1325 - padding: 12, 1326 - backgroundColor: "#1f2937", 1327 - borderRadius: 8, 1328 - marginBottom: 8, 1329 - }, 1330 - historyDate: { 1331 - fontSize: 14, 1332 - color: staticColors.text, 1333 - fontWeight: "500", 1334 - }, 1335 - historyDeleteButton: { 1336 - padding: 8, 1337 - }, 1338 - emptyHistory: { 1339 - textAlign: "center", 1340 - color: "#6b7280", 1341 - padding: 32, 1342 - }, 1343 796 castContainer: { 1344 797 position: "relative", 1345 798 }, 1346 799 castScrollContent: { 1347 - paddingRight: 16, 1348 - gap: 12, 800 + gap: spacing.md, 1349 801 }, 1350 802 castGradient: { 1351 803 position: "absolute", ··· 1361 813 castImageContainer: { 1362 814 borderRadius: borderRadius.md, 1363 815 overflow: "hidden", 1364 - marginBottom: 8, 816 + marginBottom: spacing.sm, 1365 817 backgroundColor: "#1f2937", 1366 818 }, 1367 819 castImage: { ··· 1394 846 crewGrid: { 1395 847 flexDirection: "row", 1396 848 flexWrap: "wrap", 1397 - gap: 8, 849 + gap: spacing.sm, 1398 850 }, 1399 851 crewCard: { 1400 852 backgroundColor: "#111827", 1401 853 borderRadius: borderRadius.md, 1402 - padding: 12, 854 + padding: spacing.md, 1403 855 flex: 1, 1404 856 minWidth: "45%", 1405 857 }, ··· 1412 864 crewJob: { 1413 865 fontSize: 12, 1414 866 color: "#6b7280", 867 + }, 868 + modalOverlay: { 869 + flex: 1, 870 + backgroundColor: "rgba(0, 0, 0, 0.7)", 871 + justifyContent: "center", 872 + padding: spacing.md, 873 + }, 874 + modalContent: { 875 + borderRadius: borderRadius.lg, 876 + padding: spacing.md, 877 + gap: spacing.md, 878 + }, 879 + modalHeader: { 880 + flexDirection: "row", 881 + justifyContent: "space-between", 882 + alignItems: "center", 883 + }, 884 + modalTitleContainer: { 885 + flexDirection: "row", 886 + alignItems: "center", 887 + gap: spacing.sm, 888 + }, 889 + modalTitle: { 890 + fontSize: 20, 891 + fontWeight: "700", 892 + }, 893 + modalDescription: { 894 + fontSize: 14, 895 + }, 896 + dateTimeContainer: { 897 + gap: spacing.sm, 898 + }, 899 + dateTimeButton: { 900 + flexDirection: "row", 901 + alignItems: "center", 902 + gap: spacing.md, 903 + padding: spacing.md, 904 + backgroundColor: "#111827", 905 + borderRadius: borderRadius.md, 906 + }, 907 + dateTimeText: { 908 + fontSize: 16, 909 + }, 910 + modalActionsSplit: { 911 + flexDirection: "row", 912 + justifyContent: "space-between", 913 + gap: spacing.sm, 914 + }, 915 + buttonText: { 916 + color: "#f9fafb", 917 + fontSize: 16, 918 + fontWeight: "600", 919 + }, 920 + secondaryButtonText: { 921 + color: "#9ca3af", 922 + fontSize: 15, 923 + fontWeight: "500", 924 + }, 925 + historyList: { 926 + maxHeight: 300, 927 + }, 928 + historyItem: { 929 + flexDirection: "row", 930 + justifyContent: "space-between", 931 + alignItems: "center", 932 + padding: spacing.md, 933 + borderRadius: borderRadius.md, 934 + marginBottom: spacing.sm, 935 + }, 936 + historyDate: { 937 + fontSize: 14, 938 + fontWeight: "500", 939 + }, 940 + historyDeleteButton: { 941 + padding: spacing.sm, 942 + }, 943 + emptyHistory: { 944 + textAlign: "center", 945 + color: "#6b7280", 946 + padding: spacing.xl, 1415 947 }, 1416 948 });
+286 -337
apps/mobile/app/show/[id].tsx
··· 1 1 import { Ionicons } from "@expo/vector-icons"; 2 + import type { TmdbShowDetailDto } from "@opnshelf/api"; 2 3 import { 4 + authControllerMeOptions, 5 + listsControllerGetListsForItemOptions, 3 6 showsControllerGetShowDetailsOptions, 4 - type TmdbShowDetailDto, 7 + showsControllerGetShowWatchHistoryOptions, 8 + showsControllerGetShowWatchHistoryQueryKey, 9 + showsControllerGetUserShowsQueryKey, 10 + showsControllerMarkShowWatchedMutation, 11 + showsControllerUnmarkWatchedMutation, 5 12 } from "@opnshelf/api"; 6 - import { useQuery } from "@tanstack/react-query"; 13 + import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 7 14 import { Image } from "expo-image"; 8 15 import { LinearGradient } from "expo-linear-gradient"; 9 16 import { useLocalSearchParams, useRouter } from "expo-router"; 17 + import { useMemo, useState } from "react"; 10 18 import { 11 19 ScrollView, 20 + Share, 12 21 StyleSheet, 13 22 Text, 14 23 TouchableOpacity, 15 24 View, 16 25 } from "react-native"; 17 26 import { SafeAreaView } from "react-native-safe-area-context"; 27 + import { 28 + DetailActions, 29 + DetailHero, 30 + MetadataPills, 31 + SeasonCard, 32 + } from "@/components/detail"; 18 33 import { borderRadius, spacing } from "@/constants/spacing"; 19 34 import { useTheme } from "@/contexts/theme"; 35 + import { useToast } from "@/contexts/toast"; 20 36 import { 21 - getReleaseYear, 22 37 getTmdbBackdropUrl, 23 38 getTmdbPosterUrl, 24 39 getTmdbProfileUrl, 25 40 } from "@/lib/utils"; 41 + 42 + function formatDateOnly(dateString?: string): string { 43 + if (!dateString) return "Unknown"; 44 + return new Date(dateString).toLocaleDateString("en-US", { 45 + year: "numeric", 46 + month: "short", 47 + day: "numeric", 48 + }); 49 + } 26 50 27 51 export default function ShowDetailScreen() { 28 52 const { id } = useLocalSearchParams<{ id: string }>(); 29 53 const router = useRouter(); 30 - const { colors } = useTheme(); 54 + const { colors: themeColors } = useTheme(); 55 + const { showToast } = useToast(); 56 + const queryClient = useQueryClient(); 57 + 58 + const [_showListModal, setShowListModal] = useState(false); 31 59 32 - const { data } = useQuery({ 60 + const { data: user } = useQuery({ 61 + ...authControllerMeOptions(), 62 + staleTime: 5 * 60 * 1000, 63 + retry: false, 64 + }); 65 + 66 + const { data: showData, isLoading } = useQuery({ 33 67 ...showsControllerGetShowDetailsOptions({ 34 68 path: { showId: id }, 35 69 }), 36 70 }); 37 71 38 - const show = data as TmdbShowDetailDto | undefined; 39 - const seasonCount = show?.number_of_seasons || 0; 72 + const show = showData as TmdbShowDetailDto | undefined; 73 + 74 + const { data: history } = useQuery({ 75 + ...showsControllerGetShowWatchHistoryOptions({ 76 + path: { userDid: user?.did || "", showId: id }, 77 + }), 78 + enabled: !!user?.did, 79 + }); 80 + 81 + const { data: listsForShow } = useQuery({ 82 + ...listsControllerGetListsForItemOptions({ 83 + path: { mediaType: "show", mediaId: id }, 84 + }), 85 + enabled: !!user?.did, 86 + }); 87 + 88 + const listsCount = listsForShow?.filter((l) => l.isInList).length ?? 0; 89 + const watchedEpisodeCount = history?.length ?? 0; 40 90 41 91 const showColors = show?.colors || { 42 - primary: colors.primary, 43 - secondary: colors.secondary, 44 - accent: colors.tertiary, 45 - muted: colors.surfaceContainer, 92 + primary: themeColors.primary, 93 + secondary: themeColors.secondary, 94 + accent: themeColors.tertiary, 95 + muted: themeColors.surfaceContainerHighest, 46 96 }; 47 97 48 98 const backdropUrl = getTmdbBackdropUrl(show?.backdrop_path); 49 99 const posterUrl = getTmdbPosterUrl(show?.poster_path, "w500"); 50 - const releaseYear = getReleaseYear(show?.first_air_date); 100 + const seasonCount = show?.number_of_seasons || 0; 101 + 102 + const markShowWatchedMutation = useMutation({ 103 + ...showsControllerMarkShowWatchedMutation(), 104 + onSuccess: (data) => { 105 + queryClient.invalidateQueries({ 106 + queryKey: showsControllerGetUserShowsQueryKey({ 107 + path: { userDid: user?.did || "" }, 108 + }), 109 + }); 110 + queryClient.invalidateQueries({ 111 + queryKey: ["showsControllerGetShowWatchHistory"], 112 + }); 113 + showToast(`Marked ${data.count} episodes as watched`); 114 + }, 115 + onError: () => { 116 + showToast("Failed to mark show as watched. Please try again.", "error"); 117 + }, 118 + }); 119 + 120 + const handleMarkWatched = () => { 121 + markShowWatchedMutation.mutate({ 122 + body: { showId: id }, 123 + }); 124 + }; 125 + 126 + const unmarkShowWatchedMutation = useMutation({ 127 + ...showsControllerUnmarkWatchedMutation(), 128 + onSuccess: () => { 129 + queryClient.invalidateQueries({ 130 + queryKey: showsControllerGetUserShowsQueryKey({ 131 + path: { userDid: user?.did || "" }, 132 + }), 133 + }); 134 + queryClient.invalidateQueries({ 135 + queryKey: showsControllerGetShowWatchHistoryQueryKey({ 136 + path: { userDid: user?.did || "", showId: id }, 137 + }), 138 + }); 139 + showToast("Removed all episodes from your shelf"); 140 + }, 141 + onError: () => { 142 + showToast("Failed to remove from shelf. Please try again.", "error"); 143 + }, 144 + }); 145 + 146 + const handleUnmarkWatched = () => { 147 + unmarkShowWatchedMutation.mutate({ 148 + path: { showId: id }, 149 + query: { mode: "all" }, 150 + }); 151 + }; 152 + 153 + const handleShare = async () => { 154 + const shareUrl = `https://opnshelf.app/show/${id}`; 155 + try { 156 + await Share.share({ 157 + message: `Check out ${show?.name} on OpnShelf!\n\n${shareUrl}`, 158 + title: show?.name, 159 + }); 160 + } catch { 161 + // User cancelled or error 162 + } 163 + }; 164 + 165 + const seasonWatchedCounts = useMemo(() => { 166 + if (!history) return new Map<number, number>(); 167 + const counts = new Map<number, number>(); 168 + for (const h of history) { 169 + const current = counts.get(h.seasonNumber) ?? 0; 170 + counts.set(h.seasonNumber, current + 1); 171 + } 172 + return counts; 173 + }, [history]); 174 + 175 + const metadataItems = useMemo(() => { 176 + const items = []; 177 + if (show?.first_air_date) { 178 + items.push({ 179 + icon: ( 180 + <Ionicons 181 + name="calendar-outline" 182 + size={14} 183 + color={themeColors.onSurfaceVariant} 184 + /> 185 + ), 186 + label: formatDateOnly(show.first_air_date), 187 + }); 188 + } 189 + if (seasonCount > 0) { 190 + items.push({ 191 + icon: ( 192 + <Ionicons 193 + name="tv-outline" 194 + size={14} 195 + color={themeColors.onSurfaceVariant} 196 + /> 197 + ), 198 + label: `${seasonCount} season${seasonCount !== 1 ? "s" : ""}`, 199 + }); 200 + } 201 + if (show?.number_of_episodes) { 202 + items.push({ 203 + icon: ( 204 + <Ionicons 205 + name="film-outline" 206 + size={14} 207 + color={themeColors.onSurfaceVariant} 208 + /> 209 + ), 210 + label: `${show.number_of_episodes} episodes`, 211 + }); 212 + } 213 + return items; 214 + }, [show, seasonCount, themeColors]); 215 + 216 + const seasonList = useMemo(() => { 217 + if (!show?.seasons) return []; 218 + return show.seasons.filter((s) => s.season_number > 0); 219 + }, [show?.seasons]); 51 220 52 221 return ( 53 222 <SafeAreaView 54 - style={[styles.container, { backgroundColor: colors.background }]} 223 + style={[styles.container, { backgroundColor: themeColors.background }]} 55 224 > 56 225 <ScrollView contentContainerStyle={styles.scrollContent}> 57 - <View style={styles.heroWrapper}> 58 - {backdropUrl ? ( 59 - <Image 60 - source={{ uri: backdropUrl }} 61 - style={styles.backdrop} 62 - contentFit="cover" 63 - /> 64 - ) : ( 65 - <View 66 - style={[ 67 - styles.backdrop, 68 - { 69 - backgroundColor: showColors.muted || colors.surfaceVariant, 70 - }, 71 - ]} 72 - /> 73 - )} 74 - <LinearGradient 75 - colors={["rgba(0,0,0,0.2)", "rgba(0,0,0,0.75)", colors.background]} 76 - style={styles.backdropOverlay} 226 + <DetailHero 227 + title={show?.name || "Show"} 228 + backdropUrl={backdropUrl} 229 + posterUrl={posterUrl} 230 + colors={showColors} 231 + onBack={() => router.back()} 232 + isLoading={isLoading} 233 + /> 234 + 235 + <View style={styles.content}> 236 + <DetailActions 237 + mediaType="show" 238 + mediaId={id} 239 + colors={showColors} 240 + isWatched={watchedEpisodeCount > 0} 241 + watchedDate={null} 242 + totalWatches={watchedEpisodeCount} 243 + onMarkWatched={handleMarkWatched} 244 + onUnmarkWatched={handleUnmarkWatched} 245 + onShowDatePicker={() => {}} 246 + isMarkingPending={markShowWatchedMutation.isPending} 247 + isUnmarkingPending={unmarkShowWatchedMutation.isPending} 248 + listsCount={listsCount} 249 + onShowListModal={() => setShowListModal(true)} 250 + isLoggedIn={!!user} 251 + onLogin={() => router.push("/login")} 252 + onShare={handleShare} 77 253 /> 78 - <TouchableOpacity 79 - onPress={() => router.back()} 80 - style={styles.backButton} 81 - activeOpacity={0.8} 82 - > 83 - <Ionicons name="arrow-back" size={24} color="#f9fafb" /> 84 - </TouchableOpacity> 85 - <View style={styles.heroOverlay}> 86 - <View 87 - style={[ 88 - styles.posterWrapper, 89 - { shadowColor: showColors.primary || colors.primary }, 90 - ]} 91 - > 92 - {posterUrl ? ( 93 - <Image 94 - source={{ uri: posterUrl }} 95 - style={styles.poster} 96 - contentFit="cover" 97 - /> 98 - ) : ( 99 - <View 100 - style={[ 101 - styles.poster, 102 - styles.noPoster, 103 - { backgroundColor: colors.surfaceContainer }, 104 - ]} 105 - > 106 - <Text 107 - style={[ 108 - styles.noPosterText, 109 - { color: colors.onSurfaceVariant }, 110 - ]} 111 - > 112 - No poster 113 - </Text> 114 - </View> 115 - )} 116 - </View> 117 - <View style={styles.titleWrapper}> 118 - <Text 119 - style={[styles.title, { textShadowColor: showColors.primary }]} 120 - numberOfLines={2} 121 - > 122 - {show?.name || "Show"} 123 - </Text> 124 - <View style={styles.metaRow}> 125 - {releaseYear && ( 126 - <View style={styles.metaItem}> 127 - <Ionicons 128 - name="calendar-outline" 129 - size={14} 130 - color="#d1d5db" 131 - /> 132 - <Text style={styles.metaText}>{releaseYear}</Text> 133 - </View> 134 - )} 135 - {show?.number_of_episodes && ( 136 - <View style={styles.metaItem}> 137 - <Ionicons name="tv-outline" size={14} color="#d1d5db" /> 138 - <Text style={styles.metaText}> 139 - {show.number_of_episodes} episodes 140 - </Text> 141 - </View> 142 - )} 143 - </View> 144 - </View> 145 - </View> 146 - </View> 147 254 148 - <View style={styles.content}> 149 - <View style={styles.metaPills}> 150 - {show?.first_air_date && ( 151 - <View style={[styles.metaPill, { borderColor: colors.outline }]}> 152 - <Ionicons 153 - name="calendar-outline" 154 - size={14} 155 - color={colors.onSurfaceVariant} 156 - /> 157 - <Text 158 - style={[ 159 - styles.metaPillText, 160 - { color: colors.onSurfaceVariant }, 161 - ]} 162 - > 163 - {releaseYear} 164 - </Text> 165 - </View> 166 - )} 167 - <View style={[styles.metaPill, { borderColor: colors.outline }]}> 168 - <Ionicons 169 - name="tv-outline" 170 - size={14} 171 - color={colors.onSurfaceVariant} 172 - /> 173 - <Text 174 - style={[ 175 - styles.metaPillText, 176 - { color: colors.onSurfaceVariant }, 177 - ]} 178 - > 179 - {show?.number_of_episodes || 0} episodes 180 - </Text> 181 - </View> 182 - <View style={[styles.metaPill, { borderColor: colors.outline }]}> 183 - <Text 184 - style={[ 185 - styles.metaPillText, 186 - { color: colors.onSurfaceVariant }, 187 - ]} 188 - > 189 - {seasonCount} season{seasonCount !== 1 ? "s" : ""} 190 - </Text> 191 - </View> 192 - </View> 255 + <MetadataPills items={metadataItems} /> 193 256 194 257 {show?.overview && ( 195 258 <View style={styles.section}> 196 259 <Text 197 - style={[ 198 - styles.sectionTitle, 199 - { color: showColors.primary || colors.primary }, 200 - ]} 260 + style={[styles.sectionTitle, { color: showColors.primary }]} 201 261 > 202 262 Overview 203 263 </Text> 204 264 <Text 205 - style={[styles.overview, { color: colors.onSurfaceVariant }]} 265 + style={[ 266 + styles.overview, 267 + { color: themeColors.onSurfaceVariant }, 268 + ]} 206 269 > 207 270 {show.overview} 208 271 </Text> ··· 212 275 {show?.genres && show.genres.length > 0 && ( 213 276 <View style={styles.section}> 214 277 <Text 215 - style={[ 216 - styles.sectionTitle, 217 - { color: showColors.primary || colors.primary }, 218 - ]} 278 + style={[styles.sectionTitle, { color: showColors.primary }]} 219 279 > 220 280 Genres 221 281 </Text> ··· 226 286 style={[ 227 287 styles.genreBadge, 228 288 { 229 - backgroundColor: `${showColors.primary || colors.primary}20`, 230 - borderColor: `${showColors.primary || colors.primary}40`, 289 + backgroundColor: `${showColors.primary}20`, 290 + borderColor: `${showColors.primary}40`, 231 291 }, 232 292 ]} 233 293 > 234 294 <Text 235 - style={[ 236 - styles.genreText, 237 - { color: showColors.primary || colors.primary }, 238 - ]} 295 + style={[styles.genreText, { color: showColors.primary }]} 239 296 > 240 297 {genre.name} 241 298 </Text> ··· 245 302 </View> 246 303 )} 247 304 248 - <View style={styles.section}> 249 - <Text 250 - style={[ 251 - styles.sectionTitle, 252 - { color: showColors.primary || colors.primary }, 253 - ]} 254 - > 255 - Seasons 256 - </Text> 257 - <View style={styles.seasonsGrid}> 258 - {Array.from({ length: seasonCount }).map((_, index) => { 259 - const seasonNumber = index + 1; 260 - return ( 261 - <TouchableOpacity 262 - key={seasonNumber} 263 - style={[ 264 - styles.seasonCard, 265 - { 266 - borderColor: colors.outline, 267 - backgroundColor: colors.surfaceContainer, 268 - }, 269 - ]} 270 - onPress={() => 271 - router.push({ 272 - pathname: "/show/[id]/season/[seasonNumber]", 273 - params: { 274 - id, 275 - seasonNumber: String(seasonNumber), 276 - title: show?.name || "", 277 - }, 278 - }) 279 - } 280 - activeOpacity={0.8} 281 - > 282 - <Text 283 - style={[styles.seasonText, { color: colors.onSurface }]} 284 - > 285 - Season {seasonNumber} 286 - </Text> 287 - </TouchableOpacity> 288 - ); 289 - })} 305 + {seasonList.length > 0 && ( 306 + <View style={styles.section}> 307 + <Text 308 + style={[styles.sectionTitle, { color: showColors.primary }]} 309 + > 310 + Seasons 311 + </Text> 312 + <View style={styles.seasonsList}> 313 + {seasonList.map((season) => { 314 + const watchedCount = 315 + seasonWatchedCounts.get(season.season_number) ?? 0; 316 + return ( 317 + <SeasonCard 318 + key={season.id} 319 + showId={id} 320 + seasonNumber={season.season_number} 321 + posterUrl={season.poster_path} 322 + airDate={season.air_date} 323 + episodeCount={season.episode_count ?? 0} 324 + watchedCount={watchedCount} 325 + overview={season.overview} 326 + colors={showColors} 327 + userDid={user?.did} 328 + onPress={() => 329 + router.push({ 330 + pathname: "/show/[id]/season/[seasonNumber]", 331 + params: { 332 + id, 333 + seasonNumber: String(season.season_number), 334 + title: show?.name || "", 335 + }, 336 + }) 337 + } 338 + /> 339 + ); 340 + })} 341 + </View> 290 342 </View> 291 - </View> 343 + )} 292 344 293 - {show?.credits?.cast && show.credits.cast.length > 0 ? ( 345 + {show?.credits?.cast && show.credits.cast.length > 0 && ( 294 346 <View style={styles.section}> 295 347 <Text 296 - style={[ 297 - styles.sectionTitle, 298 - { color: showColors.primary || colors.primary }, 299 - ]} 348 + style={[styles.sectionTitle, { color: showColors.primary }]} 300 349 > 301 350 Cast 302 351 </Text> ··· 325 374 <View 326 375 style={[ 327 376 styles.castImagePlaceholder, 328 - { backgroundColor: colors.surfaceContainer }, 377 + { 378 + backgroundColor: themeColors.surfaceContainer, 379 + }, 329 380 ]} 330 381 > 331 382 <Text 332 383 style={[ 333 384 styles.castImagePlaceholderText, 334 - { color: colors.onSurfaceVariant }, 385 + { color: themeColors.onSurfaceVariant }, 335 386 ]} 336 387 > 337 388 No photo ··· 340 391 )} 341 392 </View> 342 393 <Text 343 - style={[styles.castName, { color: colors.onSurface }]} 394 + style={[ 395 + styles.castName, 396 + { color: themeColors.onSurface }, 397 + ]} 344 398 numberOfLines={2} 345 399 > 346 400 {person.name} ··· 349 403 <Text 350 404 style={[ 351 405 styles.castCharacter, 352 - { color: colors.onSurfaceVariant }, 406 + { color: themeColors.onSurfaceVariant }, 353 407 ]} 354 408 numberOfLines={2} 355 409 > ··· 368 422 /> 369 423 </View> 370 424 </View> 371 - ) : null} 425 + )} 372 426 373 - {show?.credits?.crew && show.credits.crew.length > 0 ? ( 427 + {show?.credits?.crew && show.credits.crew.length > 0 && ( 374 428 <View style={styles.section}> 375 429 <Text 376 - style={[ 377 - styles.sectionTitle, 378 - { color: showColors.primary || colors.primary }, 379 - ]} 430 + style={[styles.sectionTitle, { color: showColors.primary }]} 380 431 > 381 432 Crew 382 433 </Text> ··· 386 437 key={`${person.id}-${person.job || "crew"}`} 387 438 style={[ 388 439 styles.crewCard, 389 - { backgroundColor: colors.surfaceContainer }, 440 + { backgroundColor: themeColors.surfaceContainer }, 390 441 ]} 391 442 activeOpacity={0.8} 392 443 > 393 444 <Text 394 - style={[styles.crewName, { color: colors.onSurface }]} 445 + style={[ 446 + styles.crewName, 447 + { color: themeColors.onSurface }, 448 + ]} 395 449 numberOfLines={1} 396 450 > 397 451 {person.name} ··· 399 453 <Text 400 454 style={[ 401 455 styles.crewJob, 402 - { color: colors.onSurfaceVariant }, 456 + { color: themeColors.onSurfaceVariant }, 403 457 ]} 404 458 numberOfLines={1} 405 459 > ··· 409 463 ))} 410 464 </View> 411 465 </View> 412 - ) : null} 466 + )} 413 467 </View> 414 468 </ScrollView> 415 469 </SafeAreaView> ··· 421 475 scrollContent: { 422 476 paddingBottom: spacing.xxl, 423 477 }, 424 - heroWrapper: { 425 - height: 280, 426 - position: "relative", 427 - }, 428 - backdrop: { 429 - width: "100%", 430 - height: "100%", 431 - }, 432 - backdropOverlay: { 433 - ...StyleSheet.absoluteFillObject, 434 - }, 435 - backButton: { 436 - position: "absolute", 437 - top: 8, 438 - left: 16, 439 - zIndex: 10, 440 - padding: 8, 441 - borderRadius: borderRadius.full, 442 - backgroundColor: "rgba(0, 0, 0, 0.5)", 443 - }, 444 - heroOverlay: { 445 - position: "absolute", 446 - bottom: -52, 447 - left: 16, 448 - right: 16, 449 - flexDirection: "row", 450 - alignItems: "flex-end", 451 - }, 452 - posterWrapper: { 453 - borderRadius: borderRadius.lg, 454 - overflow: "hidden", 455 - shadowOffset: { width: 0, height: 4 }, 456 - shadowOpacity: 0.35, 457 - shadowRadius: 8, 458 - elevation: 8, 459 - }, 460 - poster: { 461 - width: 96, 462 - height: 144, 463 - }, 464 - noPoster: { 465 - alignItems: "center", 466 - justifyContent: "center", 467 - }, 468 - noPosterText: { 469 - fontSize: 11, 470 - }, 471 - titleWrapper: { 472 - marginLeft: spacing.md, 473 - marginBottom: spacing.sm, 474 - flex: 1, 475 - }, 476 - title: { 477 - fontSize: 28, 478 - fontWeight: "700", 479 - color: "#f9fafb", 480 - textShadowOffset: { width: 0, height: 2 }, 481 - textShadowRadius: 10, 482 - }, 483 - metaRow: { 484 - flexDirection: "row", 485 - gap: spacing.md, 486 - marginTop: spacing.xs, 487 - }, 488 - metaItem: { 489 - flexDirection: "row", 490 - alignItems: "center", 491 - gap: 4, 492 - }, 493 - metaText: { 494 - fontSize: 14, 495 - color: "#d1d5db", 496 - }, 497 478 content: { 498 - marginTop: 64, 499 - paddingHorizontal: 16, 500 - gap: spacing.md, 501 - }, 502 - metaPills: { 503 - flexDirection: "row", 504 - flexWrap: "wrap", 505 - gap: spacing.sm, 506 - }, 507 - metaPill: { 508 - flexDirection: "row", 509 - alignItems: "center", 510 - gap: 6, 511 - borderWidth: 1, 512 - borderRadius: borderRadius.full, 513 479 paddingHorizontal: spacing.md, 514 - paddingVertical: 6, 515 - }, 516 - metaPillText: { 517 - fontSize: 13, 480 + paddingTop: spacing.lg, 481 + gap: spacing.lg, 518 482 }, 519 483 section: { 520 - marginTop: spacing.sm, 484 + gap: spacing.md, 521 485 }, 522 486 sectionTitle: { 523 487 fontSize: 18, 524 488 fontWeight: "600", 525 - marginBottom: spacing.md, 526 489 }, 527 490 overview: { 528 491 fontSize: 15, ··· 543 506 fontSize: 14, 544 507 fontWeight: "500", 545 508 }, 546 - seasonsGrid: { 547 - flexDirection: "row", 548 - flexWrap: "wrap", 549 - gap: spacing.sm, 550 - }, 551 - seasonCard: { 552 - borderWidth: 1, 553 - borderRadius: borderRadius.lg, 554 - padding: spacing.md, 555 - flex: 1, 556 - minWidth: 120, 557 - alignItems: "center", 558 - }, 559 - seasonText: { 560 - fontSize: 14, 561 - fontWeight: "500", 509 + seasonsList: { 510 + gap: spacing.md, 562 511 }, 563 512 castContainer: { 564 513 position: "relative", 565 514 }, 566 515 castScrollContent: { 567 - gap: 12, 516 + gap: spacing.md, 568 517 }, 569 518 castGradient: { 570 519 position: "absolute", ··· 580 529 castImageContainer: { 581 530 borderRadius: borderRadius.md, 582 531 overflow: "hidden", 583 - marginBottom: 8, 532 + marginBottom: spacing.sm, 584 533 }, 585 534 castImage: { 586 535 width: 100, ··· 608 557 crewGrid: { 609 558 flexDirection: "row", 610 559 flexWrap: "wrap", 611 - gap: 8, 560 + gap: spacing.sm, 612 561 }, 613 562 crewCard: { 614 563 padding: spacing.md,
+272 -765
apps/mobile/app/show/[id]/season/[seasonNumber]/episode/[episodeNumber]/index.tsx
··· 1 1 import { Ionicons } from "@expo/vector-icons"; 2 + import type { TmdbShowDetailDto } from "@opnshelf/api"; 2 3 import { 3 4 authControllerMeOptions, 4 5 type EpisodeHistoryItemDto, ··· 35 36 import { DatePickerModal, TimePickerModal } from "react-native-paper-dates"; 36 37 import { SafeAreaView } from "react-native-safe-area-context"; 37 38 import { AddToListModal } from "@/components/AddToListModal"; 39 + import { 40 + DetailActions, 41 + DetailHero, 42 + EpisodeNav, 43 + type EpisodeSummary, 44 + MetadataPills, 45 + } from "@/components/detail"; 38 46 import { Button } from "@/components/ui/Button"; 39 47 import { borderRadius, spacing } from "@/constants/spacing"; 40 48 import { useTheme } from "@/contexts/theme"; ··· 78 86 title?: string; 79 87 }>(); 80 88 const router = useRouter(); 81 - const { colors } = useTheme(); 89 + const { colors: themeColors } = useTheme(); 82 90 const { showToast } = useToast(); 83 91 const queryClient = useQueryClient(); 84 92 ··· 102 110 }), 103 111 }); 104 112 105 - const { data } = useQuery({ 113 + const { data: episode } = useQuery({ 106 114 ...showsControllerGetEpisodeDetailsOptions({ 107 115 path: { showId: id, seasonNumber, episodeNumber }, 108 116 }), 109 117 }); 110 - const episode = data as TmdbEpisodeDto | undefined; 111 118 112 119 const { data: seasonData } = useQuery({ 113 120 ...showsControllerGetSeasonDetailsOptions({ ··· 135 142 enabled: !!resolvedUserDid, 136 143 }); 137 144 138 - const showColors = showData?.colors || { 139 - primary: colors.primary, 140 - secondary: colors.secondary, 141 - accent: colors.tertiary, 142 - muted: colors.surfaceContainer, 145 + const show = showData as TmdbShowDetailDto | undefined; 146 + 147 + const showColors = show?.colors || { 148 + primary: themeColors.primary, 149 + secondary: themeColors.secondary, 150 + accent: themeColors.tertiary, 151 + muted: themeColors.surfaceContainerHighest, 143 152 }; 144 153 const backdropUrl = getTmdbBackdropUrl( 145 - episode?.still_path || showData?.backdrop_path, 154 + (episode as TmdbEpisodeDto)?.still_path || show?.backdrop_path, 146 155 ); 147 - const posterUrl = getTmdbPosterUrl(showData?.poster_path, "w500"); 156 + const posterUrl = getTmdbPosterUrl(show?.poster_path, "w500"); 148 157 149 158 const userTimezone = userSettings?.timezone || "UTC"; 150 159 const is24Hour = userSettings?.timeFormat === "24h"; 151 160 const listsCount = listsForShow?.filter((list) => list.isInList).length ?? 0; 152 - const isInAnyList = listsCount > 0; 153 161 154 162 const episodeWatchHistory = useMemo(() => { 155 163 if (!history?.length) return []; ··· 251 259 }, 252 260 }); 253 261 254 - const isPending = 255 - markMutation.isPending && 256 - markMutation.variables?.body?.showId === id && 257 - markMutation.variables?.body?.seasonNumber === Number(seasonNumber) && 258 - markMutation.variables?.body?.episodeNumber === Number(episodeNumber); 259 - 260 262 const handleMarkWatched = () => { 261 263 markMutation.mutate({ 262 264 body: { ··· 290 292 }; 291 293 292 294 const handleShare = async () => { 295 + const shareUrl = `https://opnshelf.app/show/${id}/season/${seasonNumber}/episode/${episodeNumber}`; 293 296 try { 294 297 await Share.share({ 295 - title: `Check out S${seasonNumber}E${episodeNumber} of ${showData?.name || title || "this show"}`, 296 - url: `https://opnshelf.xyz/show/${id}/${seasonNumber}/${episodeNumber}`, 298 + message: `Check out S${seasonNumber}E${episodeNumber} of ${show?.name || title || "this show"} on OpnShelf!\n\n${shareUrl}`, 299 + title: `Check out S${seasonNumber}E${episodeNumber} of ${show?.name || title || "this show"}`, 297 300 }); 298 301 } catch { 299 302 showToast("Failed to share", "error"); ··· 317 320 }); 318 321 }; 319 322 320 - const contextCards: Array<{ 321 - key: string; 322 - label: string; 323 - episode: TmdbEpisodeDto | null; 324 - highlighted: boolean; 325 - iconName: "arrow-back" | "radio-button-on" | "arrow-forward"; 326 - }> = [ 327 - { 328 - key: "previous", 329 - label: "Previous Episode", 330 - episode: seasonEpisodeContext.previous, 331 - highlighted: false, 332 - iconName: "arrow-back", 333 - }, 334 - { 335 - key: "current", 336 - label: "Current Episode", 337 - episode: seasonEpisodeContext.current, 338 - highlighted: true, 339 - iconName: "radio-button-on", 340 - }, 341 - { 342 - key: "next", 343 - label: "Next Episode", 344 - episode: seasonEpisodeContext.next, 345 - highlighted: false, 346 - iconName: "arrow-forward", 347 - }, 348 - ]; 323 + const formattedWatchedDate = useMemo(() => { 324 + if (!latestEpisodeWatch) return null; 325 + return formatWatchDate( 326 + latestEpisodeWatch.watchedDate, 327 + userTimezone, 328 + is24Hour, 329 + ); 330 + }, [latestEpisodeWatch, userTimezone, is24Hour]); 331 + 332 + const metadataItems = useMemo(() => { 333 + const items = []; 334 + items.push({ 335 + icon: ( 336 + <Ionicons 337 + name="layers-outline" 338 + size={14} 339 + color={themeColors.onSurfaceVariant} 340 + /> 341 + ), 342 + label: `Season ${seasonNumber}`, 343 + onPress: () => 344 + router.push({ 345 + pathname: "/show/[id]/season/[seasonNumber]", 346 + params: { id, seasonNumber, title: title || "" }, 347 + }), 348 + }); 349 + items.push({ 350 + icon: ( 351 + <Ionicons 352 + name="film-outline" 353 + size={14} 354 + color={themeColors.onSurfaceVariant} 355 + /> 356 + ), 357 + label: `Episode ${episodeNumber}`, 358 + }); 359 + if ((episode as TmdbEpisodeDto)?.air_date) { 360 + items.push({ 361 + icon: ( 362 + <Ionicons 363 + name="calendar-outline" 364 + size={14} 365 + color={themeColors.onSurfaceVariant} 366 + /> 367 + ), 368 + label: formatDateOnly((episode as TmdbEpisodeDto).air_date), 369 + }); 370 + } 371 + if ((episode as TmdbEpisodeDto)?.vote_average) { 372 + items.push({ 373 + icon: ( 374 + <Ionicons 375 + name="star-outline" 376 + size={14} 377 + color={themeColors.onSurfaceVariant} 378 + /> 379 + ), 380 + label: `${(episode as TmdbEpisodeDto).vote_average?.toFixed(1)}/10`, 381 + }); 382 + } 383 + return items; 384 + }, [episode, seasonNumber, episodeNumber, id, title, router, themeColors]); 385 + 386 + const isPending = 387 + markMutation.isPending && 388 + markMutation.variables?.body?.showId === id && 389 + markMutation.variables?.body?.seasonNumber === Number(seasonNumber) && 390 + markMutation.variables?.body?.episodeNumber === Number(episodeNumber); 349 391 350 392 return ( 351 393 <> 352 394 <SafeAreaView 353 - style={[styles.container, { backgroundColor: colors.background }]} 395 + style={[styles.container, { backgroundColor: themeColors.background }]} 354 396 > 355 397 <ScrollView contentContainerStyle={styles.scrollContent}> 356 - <View style={styles.heroWrapper}> 357 - {backdropUrl ? ( 358 - <Image 359 - source={{ uri: backdropUrl }} 360 - style={styles.backdrop} 361 - contentFit="cover" 362 - /> 363 - ) : ( 364 - <View 365 - style={[ 366 - styles.backdrop, 367 - { 368 - backgroundColor: showColors.muted || colors.surfaceVariant, 369 - }, 370 - ]} 371 - /> 372 - )} 373 - <LinearGradient 374 - colors={[ 375 - "rgba(0,0,0,0.2)", 376 - "rgba(0,0,0,0.75)", 377 - colors.background, 378 - ]} 379 - style={styles.backdropOverlay} 398 + <DetailHero 399 + title={show?.name || title || "Show"} 400 + subtitle={`S${seasonNumber} · E${episodeNumber}: ${(episode as TmdbEpisodeDto)?.name || ""}`} 401 + backdropUrl={backdropUrl} 402 + posterUrl={posterUrl} 403 + colors={showColors} 404 + onBack={() => router.back()} 405 + posterLinkTo={{ 406 + onPress: () => 407 + router.push({ pathname: "/show/[id]", params: { id } }), 408 + }} 409 + /> 410 + 411 + <View style={styles.content}> 412 + <MetadataPills items={metadataItems} /> 413 + 414 + <DetailActions 415 + mediaType="episode" 416 + mediaId={id} 417 + seasonNumber={seasonNumber} 418 + episodeNumber={episodeNumber} 419 + colors={showColors} 420 + isWatched={isWatchedEpisode} 421 + watchedDate={formattedWatchedDate} 422 + totalWatches={episodeWatchHistory.length} 423 + onMarkWatched={handleMarkWatched} 424 + onUnmarkWatched={handleUnmarkWatched} 425 + onShowDatePicker={handleOpenDateModal} 426 + isMarkingPending={isPending} 427 + isUnmarkingPending={unmarkMutation.isPending} 428 + listsCount={listsCount} 429 + onShowListModal={() => setShowAddToListModal(true)} 430 + onViewHistory={() => setShowHistoryModal(true)} 431 + onShare={handleShare} 380 432 /> 381 - <TouchableOpacity 382 - onPress={() => router.back()} 383 - style={styles.backButton} 384 - activeOpacity={0.8} 385 - > 386 - <Ionicons name="arrow-back" size={24} color="#f9fafb" /> 387 - </TouchableOpacity> 388 - <View style={styles.heroOverlay}> 389 - <TouchableOpacity 390 - style={[ 391 - styles.posterWrapper, 392 - { shadowColor: showColors.primary || colors.primary }, 393 - ]} 394 - onPress={() => 395 - router.push({ pathname: "/show/[id]", params: { id } }) 433 + 434 + {seasonEpisodeContext.current && ( 435 + <EpisodeNav 436 + previousEpisode={ 437 + seasonEpisodeContext.previous as EpisodeSummary | null 438 + } 439 + currentEpisode={seasonEpisodeContext.current as EpisodeSummary} 440 + nextEpisode={seasonEpisodeContext.next as EpisodeSummary | null} 441 + colors={showColors} 442 + variant="sidebar" 443 + onPreviousPress={() => 444 + navigateToEpisode( 445 + seasonEpisodeContext.previous as TmdbEpisodeDto, 446 + ) 447 + } 448 + onNextPress={() => 449 + navigateToEpisode(seasonEpisodeContext.next as TmdbEpisodeDto) 396 450 } 397 - activeOpacity={0.8} 398 - > 399 - {posterUrl ? ( 400 - <Image 401 - source={{ uri: posterUrl }} 402 - style={styles.poster} 403 - contentFit="cover" 404 - /> 405 - ) : ( 406 - <View 407 - style={[ 408 - styles.poster, 409 - styles.noPoster, 410 - { backgroundColor: colors.surfaceContainer }, 411 - ]} 412 - > 413 - <Text 414 - style={[ 415 - styles.noPosterText, 416 - { color: colors.onSurfaceVariant }, 417 - ]} 418 - > 419 - No poster 420 - </Text> 421 - </View> 422 - )} 423 - </TouchableOpacity> 424 - <View style={styles.titleWrapper}> 425 - <Text 426 - style={[ 427 - styles.title, 428 - { textShadowColor: showColors.primary }, 429 - ]} 430 - numberOfLines={2} 431 - > 432 - {showData?.name || title || "Show"} 433 - </Text> 434 - <Text style={[styles.subtitle, { color: "#f9fafb" }]}> 435 - S{seasonNumber} · E{episodeNumber} 436 - </Text> 437 - <Text style={[styles.heroEpisodeName, { color: "#d1d5db" }]}> 438 - {episode?.name} 439 - </Text> 440 - </View> 441 - </View> 442 - </View> 451 + /> 452 + )} 443 453 444 - <View style={styles.content}> 445 - <View style={styles.metaRow}> 446 - <View 447 - style={[ 448 - styles.metaPill, 449 - { 450 - borderColor: colors.outline, 451 - backgroundColor: colors.surfaceContainer, 452 - }, 453 - ]} 454 - > 455 - <Ionicons 456 - name="layers-outline" 457 - size={14} 458 - color={colors.onSurfaceVariant} 459 - /> 460 - <Text 461 - style={[styles.metaText, { color: colors.onSurfaceVariant }]} 462 - > 463 - S{seasonNumber} 464 - </Text> 465 - </View> 466 - <View 467 - style={[ 468 - styles.metaPill, 469 - { 470 - borderColor: colors.outline, 471 - backgroundColor: colors.surfaceContainer, 472 - }, 473 - ]} 474 - > 475 - <Ionicons 476 - name="film-outline" 477 - size={14} 478 - color={colors.onSurfaceVariant} 479 - /> 480 - <Text 481 - style={[styles.metaText, { color: colors.onSurfaceVariant }]} 482 - > 483 - E{episodeNumber} 484 - </Text> 485 - </View> 486 - <View 487 - style={[ 488 - styles.metaPill, 489 - { 490 - borderColor: colors.outline, 491 - backgroundColor: colors.surfaceContainer, 492 - }, 493 - ]} 494 - > 495 - <Ionicons 496 - name="calendar-outline" 497 - size={14} 498 - color={colors.onSurfaceVariant} 499 - /> 454 + {(episode as TmdbEpisodeDto)?.overview && ( 455 + <View style={styles.section}> 500 456 <Text 501 - style={[styles.metaText, { color: colors.onSurfaceVariant }]} 457 + style={[styles.sectionTitle, { color: showColors.primary }]} 502 458 > 503 - {formatDateOnly(episode?.air_date)} 459 + Overview 504 460 </Text> 505 - </View> 506 - <View 507 - style={[ 508 - styles.metaPill, 509 - { 510 - borderColor: colors.outline, 511 - backgroundColor: colors.surfaceContainer, 512 - }, 513 - ]} 514 - > 515 - <Ionicons 516 - name="star-outline" 517 - size={14} 518 - color={colors.onSurfaceVariant} 519 - /> 520 - <Text 521 - style={[styles.metaText, { color: colors.onSurfaceVariant }]} 522 - > 523 - {episode?.vote_average 524 - ? `${episode.vote_average.toFixed(1)}/10` 525 - : "Not rated"} 526 - </Text> 527 - </View> 528 - </View> 529 - 530 - <Text style={[styles.overview, { color: colors.onSurfaceVariant }]}> 531 - {episode?.overview || "No overview available."} 532 - </Text> 533 - 534 - <View style={styles.actions}> 535 - {user ? ( 536 - <> 537 - <View style={styles.primaryActionRow}> 538 - <TouchableOpacity 539 - onPress={handleMarkWatched} 540 - disabled={isPending} 541 - activeOpacity={0.8} 542 - style={{ flex: 1, opacity: isPending ? 0.7 : 1 }} 543 - > 544 - <LinearGradient 545 - colors={[ 546 - showColors.primary || colors.primary, 547 - showColors.secondary || colors.primary, 548 - ]} 549 - start={{ x: 0, y: 0 }} 550 - end={{ x: 1, y: 1 }} 551 - style={styles.primaryAction} 552 - > 553 - {isPending ? ( 554 - <ActivityIndicator 555 - size="small" 556 - color={colors.onPrimary} 557 - /> 558 - ) : ( 559 - <> 560 - <Ionicons 561 - name={isWatchedEpisode ? "refresh" : "add"} 562 - size={18} 563 - color={colors.onPrimary} 564 - /> 565 - <Text 566 - style={[ 567 - styles.primaryActionText, 568 - { color: colors.onPrimary }, 569 - ]} 570 - > 571 - {isWatchedEpisode 572 - ? "Watch Again" 573 - : "Add to Shelf"} 574 - </Text> 575 - </> 576 - )} 577 - </LinearGradient> 578 - </TouchableOpacity> 579 - 580 - <TouchableOpacity 581 - onPress={handleOpenDateModal} 582 - activeOpacity={0.8} 583 - style={[ 584 - styles.calendarAction, 585 - { 586 - backgroundColor: colors.surfaceContainer, 587 - borderColor: colors.outline, 588 - }, 589 - ]} 590 - > 591 - <Ionicons 592 - name="calendar-outline" 593 - size={22} 594 - color={colors.onSurfaceVariant} 595 - /> 596 - </TouchableOpacity> 597 - </View> 598 - 599 - <TouchableOpacity 600 - onPress={() => setShowAddToListModal(true)} 601 - activeOpacity={0.8} 602 - style={[ 603 - styles.secondaryAction, 604 - { 605 - backgroundColor: isInAnyList 606 - ? `${colors.primary}20` 607 - : colors.surfaceContainer, 608 - borderColor: isInAnyList 609 - ? colors.primary 610 - : colors.outline, 611 - }, 612 - ]} 613 - > 614 - <Ionicons 615 - name={isInAnyList ? "checkmark" : "list-outline"} 616 - size={18} 617 - color={ 618 - isInAnyList ? colors.primary : colors.onSurfaceVariant 619 - } 620 - /> 621 - <Text 622 - style={[ 623 - styles.secondaryActionText, 624 - { 625 - color: isInAnyList 626 - ? colors.primary 627 - : colors.onSurfaceVariant, 628 - }, 629 - ]} 630 - > 631 - {isInAnyList 632 - ? `In ${listsCount} list${listsCount > 1 ? "s" : ""}` 633 - : "Add to List"} 634 - </Text> 635 - </TouchableOpacity> 636 - </> 637 - ) : ( 638 - <Button onPress={() => router.push("/login")}> 639 - <Text style={{ color: colors.onPrimary }}> 640 - Sign in to Track 641 - </Text> 642 - </Button> 643 - )} 644 - 645 - <TouchableOpacity 646 - onPress={handleShare} 647 - activeOpacity={0.8} 648 - style={[ 649 - styles.secondaryAction, 650 - { 651 - backgroundColor: colors.surfaceContainer, 652 - borderColor: colors.outline, 653 - }, 654 - ]} 655 - > 656 - <Ionicons 657 - name="share-outline" 658 - size={18} 659 - color={colors.onSurfaceVariant} 660 - /> 661 461 <Text 662 462 style={[ 663 - styles.secondaryActionText, 664 - { color: colors.onSurfaceVariant }, 463 + styles.overview, 464 + { color: themeColors.onSurfaceVariant }, 665 465 ]} 666 466 > 667 - Share 467 + {(episode as TmdbEpisodeDto).overview} 668 468 </Text> 669 - </TouchableOpacity> 670 - </View> 671 - 672 - {isWatchedEpisode && ( 673 - <View 674 - style={[ 675 - styles.watchedCard, 676 - { 677 - backgroundColor: colors.surfaceContainer, 678 - borderColor: colors.outline, 679 - }, 680 - ]} 681 - > 682 - <View style={styles.watchedHeader}> 683 - <Ionicons 684 - name="checkmark-circle" 685 - size={20} 686 - color={colors.primary} 687 - /> 688 - <Text 689 - style={[styles.watchedTitle, { color: colors.primary }]} 690 - > 691 - On Your Shelf 692 - </Text> 693 - </View> 694 - {latestEpisodeWatch && ( 695 - <Text 696 - style={[ 697 - styles.watchedDate, 698 - { color: colors.onSurfaceVariant }, 699 - ]} 700 - > 701 - Watched on{" "} 702 - {formatWatchDate( 703 - latestEpisodeWatch.watchedDate, 704 - userTimezone, 705 - is24Hour, 706 - )} 707 - </Text> 708 - )} 709 - {watchedCount > 1 ? ( 710 - <TouchableOpacity 711 - onPress={() => setShowHistoryModal(true)} 712 - activeOpacity={0.7} 713 - style={styles.linkRow} 714 - > 715 - <Ionicons 716 - name="eye-outline" 717 - size={16} 718 - color={colors.onSurfaceVariant} 719 - /> 720 - <Text 721 - style={[ 722 - styles.linkText, 723 - { color: colors.onSurfaceVariant }, 724 - ]} 725 - > 726 - View all watches ({watchedCount}) 727 - </Text> 728 - </TouchableOpacity> 729 - ) : ( 730 - <TouchableOpacity 731 - onPress={handleUnmarkWatched} 732 - disabled={unmarkMutation.isPending} 733 - activeOpacity={0.7} 734 - style={styles.linkRow} 735 - > 736 - {unmarkMutation.isPending ? ( 737 - <ActivityIndicator size="small" color={colors.error} /> 738 - ) : ( 739 - <Ionicons 740 - name="trash-outline" 741 - size={16} 742 - color={colors.error} 743 - /> 744 - )} 745 - <Text style={[styles.linkText, { color: colors.error }]}> 746 - Remove from shelf 747 - </Text> 748 - </TouchableOpacity> 749 - )} 750 469 </View> 751 470 )} 752 471 753 - {seasonEpisodeContext.current ? ( 754 - <View style={styles.contextSection}> 755 - <Text 756 - style={[styles.sectionTitle, { color: colors.onSurface }]} 757 - > 758 - More In This Season 759 - </Text> 760 - <View style={styles.contextList}> 761 - {contextCards.map((slot) => { 762 - if (!slot.episode) return null; 763 - return ( 764 - <TouchableOpacity 765 - key={slot.key} 766 - onPress={() => 767 - navigateToEpisode(slot.episode as TmdbEpisodeDto) 768 - } 769 - activeOpacity={0.8} 770 - style={[ 771 - styles.contextCard, 772 - { 773 - backgroundColor: slot.highlighted 774 - ? `${colors.primary}20` 775 - : colors.surfaceContainer, 776 - borderColor: slot.highlighted 777 - ? colors.primary 778 - : colors.outline, 779 - }, 780 - ]} 781 - > 782 - <View style={styles.contextLabelRow}> 783 - <Ionicons 784 - name={slot.iconName} 785 - size={14} 786 - color={colors.onSurfaceVariant} 787 - /> 788 - <Text 789 - style={[ 790 - styles.contextLabel, 791 - { color: colors.onSurfaceVariant }, 792 - ]} 793 - > 794 - {slot.label} 795 - </Text> 796 - </View> 797 - <Text 798 - style={[ 799 - styles.contextTitle, 800 - { color: colors.onSurface }, 801 - ]} 802 - numberOfLines={1} 803 - > 804 - E{slot.episode.episode_number}: {slot.episode.name} 805 - </Text> 806 - <Text 807 - style={[ 808 - styles.contextDate, 809 - { color: colors.onSurfaceVariant }, 810 - ]} 811 - > 812 - {formatDateOnly(slot.episode.air_date)} 813 - </Text> 814 - </TouchableOpacity> 815 - ); 816 - })} 817 - </View> 818 - </View> 819 - ) : null} 820 - 821 - {showData?.credits?.cast && showData.credits.cast.length > 0 ? ( 472 + {show?.credits?.cast && show.credits.cast.length > 0 && ( 822 473 <View style={styles.section}> 823 474 <Text 824 - style={[ 825 - styles.sectionTitle, 826 - { color: showColors.primary || colors.primary }, 827 - ]} 475 + style={[styles.sectionTitle, { color: showColors.primary }]} 828 476 > 829 477 Cast 830 478 </Text> ··· 834 482 showsHorizontalScrollIndicator={false} 835 483 contentContainerStyle={styles.castScrollContent} 836 484 > 837 - {showData.credits.cast.map((person) => { 485 + {show.credits.cast.map((person) => { 838 486 const profileUrl = getTmdbProfileUrl(person.profile_path); 839 487 return ( 840 488 <TouchableOpacity ··· 880 528 /> 881 529 </View> 882 530 </View> 883 - ) : null} 531 + )} 884 532 885 - {showData?.credits?.crew && showData.credits.crew.length > 0 ? ( 533 + {show?.credits?.crew && show.credits.crew.length > 0 && ( 886 534 <View style={styles.section}> 887 535 <Text 888 - style={[ 889 - styles.sectionTitle, 890 - { color: showColors.primary || colors.primary }, 891 - ]} 536 + style={[styles.sectionTitle, { color: showColors.primary }]} 892 537 > 893 538 Crew 894 539 </Text> 895 540 <View style={styles.crewGrid}> 896 - {showData.credits.crew.map((person) => ( 541 + {show.credits.crew.map((person) => ( 897 542 <TouchableOpacity 898 543 key={`${person.id}-${person.job || "crew"}`} 899 544 style={styles.crewCard} ··· 909 554 ))} 910 555 </View> 911 556 </View> 912 - ) : null} 557 + )} 913 558 </View> 914 559 </ScrollView> 915 560 </SafeAreaView> ··· 924 569 <View 925 570 style={[ 926 571 styles.modalContent, 927 - { backgroundColor: colors.surfaceContainerHigh }, 572 + { backgroundColor: themeColors.surfaceContainerHigh }, 928 573 ]} 929 574 > 930 575 <View style={styles.modalHeader}> 931 - <Text style={[styles.modalTitle, { color: colors.onSurface }]}> 576 + <Text 577 + style={[styles.modalTitle, { color: themeColors.onSurface }]} 578 + > 932 579 Watch Again 933 580 </Text> 934 581 <Pressable onPress={() => setShowDateModal(false)}> 935 - <Ionicons name="close" size={24} color={colors.onSurface} /> 582 + <Ionicons 583 + name="close" 584 + size={24} 585 + color={themeColors.onSurface} 586 + /> 936 587 </Pressable> 937 588 </View> 938 589 <Text 939 590 style={[ 940 591 styles.modalDescription, 941 - { color: colors.onSurfaceVariant }, 592 + { color: themeColors.onSurfaceVariant }, 942 593 ]} 943 594 > 944 595 When did you watch this? ··· 953 604 <Ionicons 954 605 name="calendar-outline" 955 606 size={20} 956 - color={colors.onSurfaceVariant} 607 + color={themeColors.onSurfaceVariant} 957 608 /> 958 609 <Text 959 - style={[styles.dateTimeText, { color: colors.onSurface }]} 610 + style={[ 611 + styles.dateTimeText, 612 + { color: themeColors.onSurface }, 613 + ]} 960 614 > 961 615 {customDate.toLocaleDateString("en-US", { 962 616 year: "numeric", ··· 974 628 <Ionicons 975 629 name="time-outline" 976 630 size={20} 977 - color={colors.onSurfaceVariant} 631 + color={themeColors.onSurfaceVariant} 978 632 /> 979 633 <Text 980 - style={[styles.dateTimeText, { color: colors.onSurface }]} 634 + style={[ 635 + styles.dateTimeText, 636 + { color: themeColors.onSurface }, 637 + ]} 981 638 > 982 639 {customDate.toLocaleTimeString("en-US", { 983 640 hour: "2-digit", ··· 1031 688 <Text 1032 689 style={[ 1033 690 styles.modalCancelText, 1034 - { color: colors.onSurfaceVariant }, 691 + { color: themeColors.onSurfaceVariant }, 1035 692 ]} 1036 693 > 1037 694 Cancel ··· 1040 697 <Button 1041 698 onPress={handleMarkWatchedWithDate} 1042 699 isLoading={markMutation.isPending} 1043 - style={{ backgroundColor: colors.primary }} 700 + style={{ backgroundColor: themeColors.primary }} 1044 701 > 1045 702 <Text 1046 - style={[styles.modalConfirmText, { color: colors.onPrimary }]} 703 + style={[ 704 + styles.modalConfirmText, 705 + { color: themeColors.onPrimary }, 706 + ]} 1047 707 > 1048 708 Add Watch 1049 709 </Text> ··· 1063 723 <View 1064 724 style={[ 1065 725 styles.modalContent, 1066 - { backgroundColor: colors.surfaceContainerHigh }, 726 + { backgroundColor: themeColors.surfaceContainerHigh }, 1067 727 ]} 1068 728 > 1069 729 <View style={styles.modalHeader}> 1070 - <Text style={[styles.modalTitle, { color: colors.onSurface }]}> 730 + <Text 731 + style={[styles.modalTitle, { color: themeColors.onSurface }]} 732 + > 1071 733 Watch History 1072 734 </Text> 1073 735 <Pressable onPress={() => setShowHistoryModal(false)}> 1074 - <Ionicons name="close" size={24} color={colors.onSurface} /> 736 + <Ionicons 737 + name="close" 738 + size={24} 739 + color={themeColors.onSurface} 740 + /> 1075 741 </Pressable> 1076 742 </View> 1077 743 <Text 1078 744 style={[ 1079 745 styles.modalDescription, 1080 - { color: colors.onSurfaceVariant }, 746 + { color: themeColors.onSurfaceVariant }, 1081 747 ]} 1082 748 > 1083 749 All watches for this episode 1084 750 </Text> 1085 751 1086 752 <ScrollView style={styles.historyList}> 1087 - {episodeWatchHistory.length ? ( 753 + {episodeWatchHistory.length > 0 ? ( 1088 754 episodeWatchHistory.map((watch: EpisodeHistoryItemDto) => ( 1089 755 <View 1090 756 key={watch.id} 1091 757 style={[ 1092 758 styles.historyItem, 1093 759 { 1094 - backgroundColor: colors.surfaceContainer, 1095 - borderColor: colors.outline, 760 + backgroundColor: themeColors.surfaceContainer, 761 + borderColor: themeColors.outline, 1096 762 }, 1097 763 ]} 1098 764 > 1099 765 <Text 1100 - style={[styles.historyDate, { color: colors.onSurface }]} 766 + style={[ 767 + styles.historyDate, 768 + { color: themeColors.onSurface }, 769 + ]} 1101 770 > 1102 771 {formatWatchDate( 1103 772 watch.watchedDate, ··· 1119 788 ?.trackedEpisodeId === watch.id ? ( 1120 789 <ActivityIndicator 1121 790 size="small" 1122 - color={colors.onSurfaceVariant} 791 + color={themeColors.onSurfaceVariant} 1123 792 /> 1124 793 ) : ( 1125 794 <Ionicons 1126 795 name="trash-outline" 1127 796 size={18} 1128 - color={colors.error} 797 + color="#ef4444" 1129 798 /> 1130 799 )} 1131 800 </TouchableOpacity> ··· 1135 804 <Text 1136 805 style={[ 1137 806 styles.emptyHistory, 1138 - { color: colors.onSurfaceVariant }, 807 + { color: themeColors.onSurfaceVariant }, 1139 808 ]} 1140 809 > 1141 810 No watch history found ··· 1150 819 <Text 1151 820 style={[ 1152 821 styles.modalCancelText, 1153 - { color: colors.onSurfaceVariant }, 822 + { color: themeColors.onSurfaceVariant }, 1154 823 ]} 1155 824 > 1156 825 Close ··· 1165 834 onClose={() => setShowAddToListModal(false)} 1166 835 mediaType="show" 1167 836 mediaId={id} 1168 - mediaTitle={showData?.name || title || "Show"} 837 + mediaTitle={show?.name || title || "Show"} 1169 838 /> 1170 839 </> 1171 840 ); ··· 1176 845 scrollContent: { 1177 846 paddingBottom: spacing.xxl, 1178 847 }, 1179 - heroWrapper: { 1180 - height: 280, 1181 - position: "relative", 1182 - }, 1183 - backdrop: { 1184 - width: "100%", 1185 - height: "100%", 1186 - }, 1187 - backdropOverlay: { 1188 - ...StyleSheet.absoluteFillObject, 1189 - }, 1190 - backButton: { 1191 - position: "absolute", 1192 - top: 8, 1193 - left: 16, 1194 - zIndex: 10, 1195 - padding: 8, 1196 - borderRadius: borderRadius.full, 1197 - backgroundColor: "rgba(0, 0, 0, 0.5)", 1198 - }, 1199 - heroOverlay: { 1200 - position: "absolute", 1201 - bottom: -52, 1202 - left: 16, 1203 - right: 16, 1204 - flexDirection: "row", 1205 - alignItems: "flex-end", 1206 - }, 1207 - posterWrapper: { 1208 - borderRadius: borderRadius.lg, 1209 - overflow: "hidden", 1210 - shadowOffset: { width: 0, height: 4 }, 1211 - shadowOpacity: 0.35, 1212 - shadowRadius: 8, 1213 - elevation: 8, 848 + content: { 849 + paddingHorizontal: spacing.md, 850 + paddingTop: spacing.lg, 851 + gap: spacing.lg, 1214 852 }, 1215 - poster: { 1216 - width: 96, 1217 - height: 144, 853 + section: { 854 + gap: spacing.md, 1218 855 }, 1219 - noPoster: { 1220 - alignItems: "center", 1221 - justifyContent: "center", 856 + sectionTitle: { 857 + fontSize: 18, 858 + fontWeight: "600", 1222 859 }, 1223 - noPosterText: { 1224 - fontSize: 11, 860 + overview: { 861 + fontSize: 15, 862 + lineHeight: 22, 1225 863 }, 1226 - titleWrapper: { 1227 - marginLeft: spacing.md, 1228 - marginBottom: spacing.sm, 1229 - flex: 1, 864 + castContainer: { 865 + position: "relative", 1230 866 }, 1231 - content: { 1232 - marginTop: 80, 1233 - paddingHorizontal: 16, 867 + castScrollContent: { 1234 868 gap: spacing.md, 1235 869 }, 1236 - title: { 1237 - fontSize: 28, 1238 - fontWeight: "700", 1239 - color: "#f9fafb", 1240 - textShadowOffset: { width: 0, height: 2 }, 1241 - textShadowRadius: 10, 1242 - }, 1243 - subtitle: { fontSize: 17, fontWeight: "700" }, 1244 - heroEpisodeName: { fontSize: 14, marginTop: 2 }, 1245 - metaRow: { 1246 - flexDirection: "row", 1247 - flexWrap: "wrap", 1248 - gap: spacing.sm, 1249 - }, 1250 - metaPill: { 1251 - borderWidth: 1, 1252 - borderRadius: borderRadius.full, 1253 - paddingHorizontal: spacing.sm, 1254 - paddingVertical: 6, 1255 - flexDirection: "row", 1256 - alignItems: "center", 1257 - gap: 6, 1258 - }, 1259 - metaText: { fontSize: 13 }, 1260 - overview: { fontSize: 15, lineHeight: 22 }, 1261 - actions: { 1262 - gap: spacing.sm, 870 + castGradient: { 871 + position: "absolute", 872 + right: 0, 873 + top: 0, 874 + bottom: 16, 875 + width: 48, 876 + pointerEvents: "none", 1263 877 }, 1264 - primaryActionRow: { 1265 - flexDirection: "row", 1266 - gap: spacing.sm, 1267 - alignItems: "stretch", 878 + castCard: { 879 + width: 100, 1268 880 }, 1269 - primaryAction: { 881 + castImageContainer: { 1270 882 borderRadius: borderRadius.md, 1271 - paddingVertical: 14, 1272 - paddingHorizontal: spacing.md, 1273 - alignItems: "center", 1274 - justifyContent: "center", 1275 - flexDirection: "row", 1276 - gap: spacing.xs, 883 + overflow: "hidden", 884 + marginBottom: spacing.sm, 885 + backgroundColor: "#1f2937", 1277 886 }, 1278 - primaryActionText: { 1279 - fontSize: 16, 1280 - fontWeight: "600", 887 + castImage: { 888 + width: 100, 889 + height: 140, 1281 890 }, 1282 - secondaryAction: { 1283 - borderRadius: borderRadius.md, 1284 - borderWidth: 1, 1285 - paddingVertical: 12, 1286 - paddingHorizontal: spacing.md, 891 + castImagePlaceholder: { 892 + width: 100, 893 + height: 140, 894 + backgroundColor: "#1f2937", 895 + justifyContent: "center", 1287 896 alignItems: "center", 1288 - justifyContent: "center", 1289 - flexDirection: "row", 1290 - gap: spacing.xs, 1291 897 }, 1292 - secondaryActionText: { 1293 - fontSize: 15, 898 + castImagePlaceholderText: { 899 + fontSize: 12, 900 + color: "#6b7280", 901 + textAlign: "center", 902 + paddingHorizontal: 8, 903 + }, 904 + castName: { 905 + fontSize: 13, 1294 906 fontWeight: "500", 907 + color: "#e5e7eb", 908 + marginBottom: 2, 1295 909 }, 1296 - calendarAction: { 1297 - borderRadius: borderRadius.md, 1298 - borderWidth: 1, 1299 - paddingVertical: 14, 1300 - paddingHorizontal: 14, 1301 - alignItems: "center", 1302 - justifyContent: "center", 910 + castCharacter: { 911 + fontSize: 11, 912 + color: "#6b7280", 1303 913 }, 1304 - watchedCard: { 1305 - marginTop: spacing.sm, 1306 - borderRadius: borderRadius.md, 1307 - borderWidth: 1, 1308 - padding: spacing.md, 1309 - gap: spacing.xs, 1310 - }, 1311 - watchedHeader: { 914 + crewGrid: { 1312 915 flexDirection: "row", 1313 - alignItems: "center", 1314 - gap: spacing.xs, 916 + flexWrap: "wrap", 917 + gap: spacing.sm, 1315 918 }, 1316 - watchedTitle: { fontSize: 16, fontWeight: "600" }, 1317 - watchedDate: { fontSize: 14 }, 1318 - linkRow: { 1319 - marginTop: spacing.xs, 1320 - flexDirection: "row", 1321 - alignItems: "center", 1322 - gap: spacing.xs, 1323 - }, 1324 - linkText: { fontSize: 14, fontWeight: "500" }, 1325 - contextSection: { marginTop: spacing.sm, gap: spacing.sm }, 1326 - sectionTitle: { fontSize: 18, fontWeight: "600" }, 1327 - contextList: { gap: spacing.sm }, 1328 - contextCard: { 919 + crewCard: { 920 + backgroundColor: "#111827", 1329 921 borderRadius: borderRadius.md, 1330 - borderWidth: 1, 1331 922 padding: spacing.md, 1332 - gap: 6, 923 + flex: 1, 924 + minWidth: "45%", 1333 925 }, 1334 - contextLabelRow: { 1335 - flexDirection: "row", 1336 - alignItems: "center", 1337 - gap: 6, 926 + crewName: { 927 + fontSize: 14, 928 + fontWeight: "500", 929 + color: "#e5e7eb", 930 + marginBottom: 2, 1338 931 }, 1339 - contextLabel: { 1340 - fontSize: 11, 1341 - textTransform: "uppercase", 1342 - letterSpacing: 0.3, 1343 - }, 1344 - contextTitle: { 1345 - fontSize: 15, 1346 - fontWeight: "600", 932 + crewJob: { 933 + fontSize: 12, 934 + color: "#6b7280", 1347 935 }, 1348 - contextDate: { fontSize: 13 }, 1349 936 modalOverlay: { 1350 937 flex: 1, 1351 938 backgroundColor: "rgba(0, 0, 0, 0.7)", ··· 1355 942 modalContent: { 1356 943 borderRadius: borderRadius.lg, 1357 944 padding: spacing.md, 1358 - maxHeight: "80%", 945 + gap: spacing.md, 1359 946 }, 1360 947 modalHeader: { 1361 948 flexDirection: "row", 1362 949 justifyContent: "space-between", 1363 950 alignItems: "center", 1364 - marginBottom: spacing.sm, 1365 951 }, 1366 952 modalTitle: { 1367 953 fontSize: 20, ··· 1369 955 }, 1370 956 modalDescription: { 1371 957 fontSize: 14, 1372 - marginBottom: spacing.md, 1373 958 }, 1374 959 dateTimeContainer: { 1375 960 gap: spacing.sm, 1376 - marginBottom: spacing.md, 1377 961 }, 1378 962 dateTimeButton: { 1379 963 padding: spacing.md, ··· 1402 986 }, 1403 987 historyList: { 1404 988 maxHeight: 320, 1405 - marginBottom: spacing.md, 1406 989 }, 1407 990 historyItem: { 1408 991 padding: spacing.md, ··· 1422 1005 fontSize: 14, 1423 1006 textAlign: "center", 1424 1007 paddingVertical: spacing.xl, 1425 - }, 1426 - section: { 1427 - marginBottom: 24, 1428 - }, 1429 - castContainer: { 1430 - position: "relative", 1431 - }, 1432 - castScrollContent: { 1433 - paddingRight: 16, 1434 - gap: 12, 1435 - }, 1436 - castGradient: { 1437 - position: "absolute", 1438 - right: 0, 1439 - top: 0, 1440 - bottom: 16, 1441 - width: 48, 1442 - pointerEvents: "none", 1443 - }, 1444 - castCard: { 1445 - width: 100, 1446 - }, 1447 - castImageContainer: { 1448 - borderRadius: borderRadius.md, 1449 - overflow: "hidden", 1450 - marginBottom: 8, 1451 - backgroundColor: "#1f2937", 1452 - }, 1453 - castImage: { 1454 - width: 100, 1455 - height: 140, 1456 - }, 1457 - castImagePlaceholder: { 1458 - width: 100, 1459 - height: 140, 1460 - backgroundColor: "#1f2937", 1461 - justifyContent: "center", 1462 - alignItems: "center", 1463 - }, 1464 - castImagePlaceholderText: { 1465 - fontSize: 12, 1466 - color: "#6b7280", 1467 - textAlign: "center", 1468 - paddingHorizontal: 8, 1469 - }, 1470 - castName: { 1471 - fontSize: 13, 1472 - fontWeight: "500", 1473 - color: "#e5e7eb", 1474 - marginBottom: 2, 1475 - }, 1476 - castCharacter: { 1477 - fontSize: 11, 1478 - color: "#6b7280", 1479 - }, 1480 - crewGrid: { 1481 - flexDirection: "row", 1482 - flexWrap: "wrap", 1483 - gap: 8, 1484 - }, 1485 - crewCard: { 1486 - backgroundColor: "#111827", 1487 - borderRadius: borderRadius.md, 1488 - padding: 12, 1489 - flex: 1, 1490 - minWidth: "45%", 1491 - }, 1492 - crewName: { 1493 - fontSize: 14, 1494 - fontWeight: "500", 1495 - color: "#e5e7eb", 1496 - marginBottom: 2, 1497 - }, 1498 - crewJob: { 1499 - fontSize: 12, 1500 - color: "#6b7280", 1501 1008 }, 1502 1009 });
+296 -420
apps/mobile/app/show/[id]/season/[seasonNumber]/index.tsx
··· 1 1 import { Ionicons } from "@expo/vector-icons"; 2 2 import { 3 3 authControllerMeOptions, 4 + listsControllerGetListsForItemOptions, 4 5 showsControllerGetSeasonDetailsOptions, 5 6 showsControllerGetShowDetailsOptions, 6 7 showsControllerGetShowWatchHistoryOptions, 7 - type TmdbEpisodeDto, 8 + showsControllerGetShowWatchHistoryQueryKey, 9 + showsControllerGetUserShowsQueryKey, 10 + showsControllerMarkSeasonWatchedMutation, 11 + showsControllerUnmarkWatchedMutation, 8 12 type TmdbSeasonDetailDto, 9 13 type TmdbShowDetailDto, 10 14 } from "@opnshelf/api"; 11 - import { useQuery } from "@tanstack/react-query"; 15 + import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 12 16 import { Image } from "expo-image"; 13 17 import { LinearGradient } from "expo-linear-gradient"; 14 18 import { useLocalSearchParams, useRouter } from "expo-router"; 15 - import { useMemo } from "react"; 19 + import { useMemo, useState } from "react"; 16 20 import { 17 21 ScrollView, 22 + Share, 18 23 StyleSheet, 19 24 Text, 20 25 TouchableOpacity, 21 26 View, 22 27 } from "react-native"; 23 28 import { SafeAreaView } from "react-native-safe-area-context"; 29 + import { 30 + DetailActions, 31 + DetailHero, 32 + EpisodeCard, 33 + type EpisodeSummary, 34 + MetadataPills, 35 + SeasonNav, 36 + } from "@/components/detail"; 24 37 import { borderRadius, spacing } from "@/constants/spacing"; 25 38 import { useTheme } from "@/contexts/theme"; 39 + import { useToast } from "@/contexts/toast"; 26 40 import { 27 41 getTmdbBackdropUrl, 28 42 getTmdbPosterUrl, ··· 45 59 title?: string; 46 60 }>(); 47 61 const router = useRouter(); 48 - const { colors } = useTheme(); 62 + const { colors: themeColors } = useTheme(); 63 + const { showToast } = useToast(); 64 + const queryClient = useQueryClient(); 65 + 66 + const [_showListModal, setShowListModal] = useState(false); 49 67 50 68 const { data: user } = useQuery({ 51 69 ...authControllerMeOptions(), ··· 61 79 }); 62 80 const show = showData as TmdbShowDetailDto | undefined; 63 81 64 - const { data } = useQuery({ 82 + const { data: seasonData } = useQuery({ 65 83 ...showsControllerGetSeasonDetailsOptions({ 66 84 path: { showId: id, seasonNumber }, 67 85 }), 68 86 }); 69 - const season = data as TmdbSeasonDetailDto | undefined; 87 + const season = seasonData as TmdbSeasonDetailDto | undefined; 70 88 71 89 const { data: history } = useQuery({ 72 90 ...showsControllerGetShowWatchHistoryOptions({ ··· 75 93 enabled: !!resolvedUserDid, 76 94 }); 77 95 96 + const { data: listsForShow } = useQuery({ 97 + ...listsControllerGetListsForItemOptions({ 98 + path: { mediaType: "show", mediaId: id }, 99 + }), 100 + enabled: !!resolvedUserDid, 101 + }); 102 + 103 + const listsCount = listsForShow?.filter((l) => l.isInList).length ?? 0; 104 + 78 105 const showColors = show?.colors || { 79 - primary: colors.primary, 80 - secondary: colors.secondary, 81 - accent: colors.tertiary, 82 - muted: colors.surfaceContainer, 106 + primary: themeColors.primary, 107 + secondary: themeColors.secondary, 108 + accent: themeColors.tertiary, 109 + muted: themeColors.surfaceContainerHighest, 83 110 }; 84 111 85 112 const backdropUrl = getTmdbBackdropUrl(show?.backdrop_path); 86 - const posterUrl = season?.poster_path 87 - ? getTmdbPosterUrl(season.poster_path, "w500") 88 - : getTmdbPosterUrl(show?.poster_path, "w500"); 113 + const seasonPoster = getTmdbPosterUrl(season?.poster_path, "w500"); 114 + const seasonEpisodes = season?.episodes || []; 115 + 116 + const markSeasonWatchedMutation = useMutation({ 117 + ...showsControllerMarkSeasonWatchedMutation(), 118 + onSuccess: (data) => { 119 + queryClient.invalidateQueries({ 120 + queryKey: showsControllerGetUserShowsQueryKey({ 121 + path: { userDid: resolvedUserDid }, 122 + }), 123 + }); 124 + queryClient.invalidateQueries({ 125 + queryKey: ["showsControllerGetShowWatchHistory"], 126 + }); 127 + showToast(`Marked ${data.count} episodes as watched`); 128 + }, 129 + onError: () => { 130 + showToast("Failed to mark season as watched. Please try again.", "error"); 131 + }, 132 + }); 133 + 134 + const handleMarkWatched = () => { 135 + markSeasonWatchedMutation.mutate({ 136 + body: { 137 + showId: id, 138 + seasonNumber: Number(seasonNumber), 139 + }, 140 + }); 141 + }; 142 + 143 + const unmarkSeasonWatchedMutation = useMutation({ 144 + ...showsControllerUnmarkWatchedMutation(), 145 + onSuccess: () => { 146 + queryClient.invalidateQueries({ 147 + queryKey: showsControllerGetUserShowsQueryKey({ 148 + path: { userDid: resolvedUserDid }, 149 + }), 150 + }); 151 + queryClient.invalidateQueries({ 152 + queryKey: showsControllerGetShowWatchHistoryQueryKey({ 153 + path: { userDid: resolvedUserDid, showId: id }, 154 + }), 155 + }); 156 + showToast("Removed season from your shelf"); 157 + }, 158 + onError: () => { 159 + showToast("Failed to remove from shelf. Please try again.", "error"); 160 + }, 161 + }); 162 + 163 + const handleUnmarkWatched = () => { 164 + unmarkSeasonWatchedMutation.mutate({ 165 + path: { showId: id }, 166 + query: { mode: "all", seasonNumber: Number(seasonNumber) }, 167 + }); 168 + }; 169 + 170 + const handleShare = async () => { 171 + const shareUrl = `https://opnshelf.app/show/${id}/season/${seasonNumber}`; 172 + try { 173 + await Share.share({ 174 + message: `Check out ${show?.name} Season ${seasonNumber} on OpnShelf!\n\n${shareUrl}`, 175 + title: `${show?.name} Season ${seasonNumber}`, 176 + }); 177 + } catch { 178 + // User cancelled or error 179 + } 180 + }; 89 181 90 - const episodeWatchCounts = useMemo(() => { 91 - if (!history?.length) return new Map<number, number>(); 182 + const watchedEpisodeCount = useMemo(() => { 183 + if (!history) return 0; 184 + return history.filter((h) => h.seasonNumber === Number(seasonNumber)) 185 + .length; 186 + }, [history, seasonNumber]); 187 + 188 + const episodeWatchedCounts = useMemo(() => { 189 + if (!history) return new Map<number, number>(); 92 190 const counts = new Map<number, number>(); 93 - for (const item of history) { 94 - if (item.seasonNumber === Number(seasonNumber)) { 95 - const current = counts.get(item.episodeNumber) || 0; 96 - counts.set(item.episodeNumber, current + 1); 191 + for (const h of history) { 192 + if (h.seasonNumber === Number(seasonNumber)) { 193 + const current = counts.get(h.episodeNumber) ?? 0; 194 + counts.set(h.episodeNumber, current + 1); 97 195 } 98 196 } 99 197 return counts; 100 198 }, [history, seasonNumber]); 101 199 200 + const metadataItems = useMemo(() => { 201 + const items = []; 202 + if (season?.air_date) { 203 + items.push({ 204 + icon: ( 205 + <Ionicons 206 + name="calendar-outline" 207 + size={14} 208 + color={themeColors.onSurfaceVariant} 209 + /> 210 + ), 211 + label: formatDateOnly(season.air_date), 212 + }); 213 + } 214 + if (seasonEpisodes.length > 0) { 215 + items.push({ 216 + icon: ( 217 + <Ionicons 218 + name="film-outline" 219 + size={14} 220 + color={themeColors.onSurfaceVariant} 221 + /> 222 + ), 223 + label: `${seasonEpisodes.length} episodes`, 224 + }); 225 + } 226 + return items; 227 + }, [season, seasonEpisodes.length, themeColors]); 228 + 102 229 return ( 103 230 <SafeAreaView 104 - style={[styles.container, { backgroundColor: colors.background }]} 231 + style={[styles.container, { backgroundColor: themeColors.background }]} 105 232 > 106 233 <ScrollView contentContainerStyle={styles.scrollContent}> 107 - <View style={styles.heroWrapper}> 108 - {backdropUrl ? ( 109 - <Image 110 - source={{ uri: backdropUrl }} 111 - style={styles.backdrop} 112 - contentFit="cover" 113 - /> 114 - ) : ( 115 - <View 116 - style={[ 117 - styles.backdrop, 118 - { 119 - backgroundColor: showColors.muted || colors.surfaceVariant, 120 - }, 121 - ]} 122 - /> 123 - )} 124 - <LinearGradient 125 - colors={["rgba(0,0,0,0.2)", "rgba(0,0,0,0.75)", colors.background]} 126 - style={styles.backdropOverlay} 234 + <DetailHero 235 + title={show?.name || title || "Show"} 236 + subtitle={`Season ${seasonNumber}`} 237 + backdropUrl={backdropUrl} 238 + posterUrl={seasonPoster} 239 + colors={showColors} 240 + onBack={() => router.back()} 241 + posterLinkTo={{ 242 + onPress: () => 243 + router.push({ pathname: "/show/[id]", params: { id } }), 244 + }} 245 + /> 246 + 247 + <View style={styles.content}> 248 + <DetailActions 249 + mediaType="season" 250 + mediaId={id} 251 + seasonNumber={seasonNumber} 252 + colors={showColors} 253 + isWatched={watchedEpisodeCount > 0} 254 + watchedDate={null} 255 + totalWatches={watchedEpisodeCount} 256 + onMarkWatched={handleMarkWatched} 257 + onUnmarkWatched={handleUnmarkWatched} 258 + onShowDatePicker={() => {}} 259 + isMarkingPending={markSeasonWatchedMutation.isPending} 260 + isUnmarkingPending={unmarkSeasonWatchedMutation.isPending} 261 + listsCount={listsCount} 262 + onShowListModal={() => setShowListModal(true)} 263 + isLoggedIn={!!user} 264 + onLogin={() => router.push("/login")} 265 + onShare={handleShare} 127 266 /> 128 - <TouchableOpacity 129 - onPress={() => router.back()} 130 - style={styles.backButton} 131 - activeOpacity={0.8} 132 - > 133 - <Ionicons name="arrow-back" size={24} color="#f9fafb" /> 134 - </TouchableOpacity> 135 - <View style={styles.heroOverlay}> 136 - <TouchableOpacity 137 - style={[ 138 - styles.posterWrapper, 139 - { shadowColor: showColors.primary || colors.primary }, 140 - ]} 141 - onPress={() => 142 - router.push({ pathname: "/show/[id]", params: { id } }) 267 + 268 + {(show?.number_of_seasons ?? 0) > 1 && ( 269 + <SeasonNav 270 + currentSeason={Number(seasonNumber)} 271 + totalSeasons={show?.number_of_seasons ?? 1} 272 + onPreviousSeason={() => 273 + router.push({ 274 + pathname: "/show/[id]/season/[seasonNumber]", 275 + params: { 276 + id, 277 + seasonNumber: String(Number(seasonNumber) - 1), 278 + title: show?.name || title || "", 279 + }, 280 + }) 281 + } 282 + onNextSeason={() => 283 + router.push({ 284 + pathname: "/show/[id]/season/[seasonNumber]", 285 + params: { 286 + id, 287 + seasonNumber: String(Number(seasonNumber) + 1), 288 + title: show?.name || title || "", 289 + }, 290 + }) 143 291 } 144 - activeOpacity={0.8} 145 - > 146 - {posterUrl ? ( 147 - <Image 148 - source={{ uri: posterUrl }} 149 - style={styles.poster} 150 - contentFit="cover" 151 - /> 152 - ) : ( 153 - <View 154 - style={[ 155 - styles.poster, 156 - styles.noPoster, 157 - { backgroundColor: colors.surfaceContainer }, 158 - ]} 159 - > 160 - <Text 161 - style={[ 162 - styles.noPosterText, 163 - { color: colors.onSurfaceVariant }, 164 - ]} 165 - > 166 - No poster 167 - </Text> 168 - </View> 169 - )} 170 - </TouchableOpacity> 171 - <View style={styles.titleWrapper}> 172 - <Text 173 - style={[styles.title, { textShadowColor: showColors.primary }]} 174 - numberOfLines={2} 175 - > 176 - {show?.name || title || "Show"} 177 - </Text> 178 - <Text style={styles.subtitle}>Season {seasonNumber}</Text> 179 - </View> 180 - </View> 181 - </View> 292 + /> 293 + )} 294 + 295 + <MetadataPills items={metadataItems} /> 182 296 183 - <View style={styles.content}> 184 - <View style={styles.infoCards}> 185 - <View 186 - style={[ 187 - styles.infoCard, 188 - { backgroundColor: colors.surfaceContainer }, 189 - ]} 190 - > 297 + {season?.overview && ( 298 + <View style={styles.section}> 191 299 <Text 192 - style={[styles.infoLabel, { color: colors.onSurfaceVariant }]} 300 + style={[styles.sectionTitle, { color: showColors.primary }]} 193 301 > 194 - Air Date 302 + Overview 195 303 </Text> 196 - <Text style={[styles.infoValue, { color: colors.onSurface }]}> 197 - {formatDateOnly(season?.air_date)} 198 - </Text> 199 - </View> 200 - <View 201 - style={[ 202 - styles.infoCard, 203 - { backgroundColor: colors.surfaceContainer }, 204 - ]} 205 - > 206 304 <Text 207 - style={[styles.infoLabel, { color: colors.onSurfaceVariant }]} 305 + style={[ 306 + styles.overview, 307 + { color: themeColors.onSurfaceVariant }, 308 + ]} 208 309 > 209 - Episodes 210 - </Text> 211 - <Text style={[styles.infoValue, { color: colors.onSurface }]}> 212 - {season?.episodes?.length || 0} 310 + {season.overview} 213 311 </Text> 214 312 </View> 215 - </View> 313 + )} 216 314 217 - {season?.overview && ( 315 + {show?.genres && show.genres.length > 0 && ( 218 316 <View style={styles.section}> 219 317 <Text 220 - style={[ 221 - styles.sectionTitle, 222 - { color: showColors.primary || colors.primary }, 223 - ]} 224 - > 225 - Overview 226 - </Text> 227 - <Text 228 - style={[styles.overview, { color: colors.onSurfaceVariant }]} 318 + style={[styles.sectionTitle, { color: showColors.primary }]} 229 319 > 230 - {season.overview} 320 + Genres 231 321 </Text> 322 + <View style={styles.genresContainer}> 323 + {show.genres.map((genre) => ( 324 + <View 325 + key={genre.id} 326 + style={[ 327 + styles.genreBadge, 328 + { 329 + backgroundColor: `${showColors.primary}20`, 330 + borderColor: `${showColors.primary}40`, 331 + }, 332 + ]} 333 + > 334 + <Text 335 + style={[styles.genreText, { color: showColors.primary }]} 336 + > 337 + {genre.name} 338 + </Text> 339 + </View> 340 + ))} 341 + </View> 232 342 </View> 233 343 )} 234 344 235 - <View style={styles.section}> 236 - <Text 237 - style={[ 238 - styles.sectionTitle, 239 - { color: showColors.primary || colors.primary }, 240 - ]} 241 - > 242 - Episodes 243 - </Text> 244 - <View style={styles.episodesList}> 245 - {(season?.episodes || []) 246 - .sort((a, b) => a.episode_number - b.episode_number) 247 - .map((episode) => ( 345 + {seasonEpisodes.length > 0 && ( 346 + <View style={styles.section}> 347 + <Text 348 + style={[styles.sectionTitle, { color: showColors.primary }]} 349 + > 350 + Episodes 351 + </Text> 352 + <View style={styles.episodesList}> 353 + {seasonEpisodes.map((episode) => ( 248 354 <EpisodeCard 249 355 key={episode.id} 250 - episode={episode} 251 - watchCount={ 252 - episodeWatchCounts.get(episode.episode_number) || 0 356 + showId={id} 357 + seasonNumber={seasonNumber} 358 + episode={episode as EpisodeSummary} 359 + watchedCount={ 360 + episodeWatchedCounts.get(episode.episode_number) ?? 0 253 361 } 254 - isAuthenticated={!!resolvedUserDid} 362 + colors={showColors} 363 + userDid={user?.did} 255 364 onPress={() => 256 365 router.push({ 257 366 pathname: ··· 266 375 } 267 376 /> 268 377 ))} 378 + </View> 269 379 </View> 270 - </View> 380 + )} 271 381 272 - {show?.credits?.cast && show.credits.cast.length > 0 ? ( 382 + {show?.credits?.cast && show.credits.cast.length > 0 && ( 273 383 <View style={styles.section}> 274 384 <Text 275 - style={[ 276 - styles.sectionTitle, 277 - { color: showColors.primary || colors.primary }, 278 - ]} 385 + style={[styles.sectionTitle, { color: showColors.primary }]} 279 386 > 280 387 Cast 281 388 </Text> ··· 304 411 <View 305 412 style={[ 306 413 styles.castImagePlaceholder, 307 - { backgroundColor: colors.surfaceContainer }, 414 + { 415 + backgroundColor: themeColors.surfaceContainer, 416 + }, 308 417 ]} 309 418 > 310 419 <Text 311 420 style={[ 312 421 styles.castImagePlaceholderText, 313 - { color: colors.onSurfaceVariant }, 422 + { color: themeColors.onSurfaceVariant }, 314 423 ]} 315 424 > 316 425 No photo ··· 319 428 )} 320 429 </View> 321 430 <Text 322 - style={[styles.castName, { color: colors.onSurface }]} 431 + style={[ 432 + styles.castName, 433 + { color: themeColors.onSurface }, 434 + ]} 323 435 numberOfLines={2} 324 436 > 325 437 {person.name} ··· 328 440 <Text 329 441 style={[ 330 442 styles.castCharacter, 331 - { color: colors.onSurfaceVariant }, 443 + { color: themeColors.onSurfaceVariant }, 332 444 ]} 333 445 numberOfLines={2} 334 446 > ··· 347 459 /> 348 460 </View> 349 461 </View> 350 - ) : null} 462 + )} 351 463 352 - {show?.credits?.crew && show.credits.crew.length > 0 ? ( 464 + {show?.credits?.crew && show.credits.crew.length > 0 && ( 353 465 <View style={styles.section}> 354 466 <Text 355 - style={[ 356 - styles.sectionTitle, 357 - { color: showColors.primary || colors.primary }, 358 - ]} 467 + style={[styles.sectionTitle, { color: showColors.primary }]} 359 468 > 360 469 Crew 361 470 </Text> ··· 365 474 key={`${person.id}-${person.job || "crew"}`} 366 475 style={[ 367 476 styles.crewCard, 368 - { backgroundColor: colors.surfaceContainer }, 477 + { backgroundColor: themeColors.surfaceContainer }, 369 478 ]} 370 479 activeOpacity={0.8} 371 480 > 372 481 <Text 373 - style={[styles.crewName, { color: colors.onSurface }]} 482 + style={[ 483 + styles.crewName, 484 + { color: themeColors.onSurface }, 485 + ]} 374 486 numberOfLines={1} 375 487 > 376 488 {person.name} ··· 378 490 <Text 379 491 style={[ 380 492 styles.crewJob, 381 - { color: colors.onSurfaceVariant }, 493 + { color: themeColors.onSurfaceVariant }, 382 494 ]} 383 495 numberOfLines={1} 384 496 > ··· 388 500 ))} 389 501 </View> 390 502 </View> 391 - ) : null} 503 + )} 392 504 </View> 393 505 </ScrollView> 394 506 </SafeAreaView> 395 507 ); 396 508 } 397 509 398 - interface EpisodeCardProps { 399 - episode: TmdbEpisodeDto; 400 - watchCount: number; 401 - isAuthenticated: boolean; 402 - onPress: () => void; 403 - } 404 - 405 - function EpisodeCard({ 406 - episode, 407 - watchCount, 408 - isAuthenticated, 409 - onPress, 410 - }: EpisodeCardProps) { 411 - const { colors } = useTheme(); 412 - 413 - const stillUrl = episode.still_path 414 - ? `https://image.tmdb.org/t/p/w300${episode.still_path}` 415 - : null; 416 - 417 - return ( 418 - <TouchableOpacity 419 - style={[ 420 - styles.episodeCard, 421 - { 422 - borderColor: colors.outline, 423 - backgroundColor: `${colors.surfaceContainer}50`, 424 - }, 425 - ]} 426 - onPress={onPress} 427 - activeOpacity={0.8} 428 - > 429 - <View style={styles.episodeRow}> 430 - <View style={styles.episodeThumbnail}> 431 - {stillUrl ? ( 432 - <Image 433 - source={{ uri: stillUrl }} 434 - style={styles.episodeImage} 435 - contentFit="cover" 436 - /> 437 - ) : ( 438 - <View 439 - style={[ 440 - styles.episodeImage, 441 - { backgroundColor: colors.surfaceVariant }, 442 - ]} 443 - /> 444 - )} 445 - </View> 446 - <View style={styles.episodeInfo}> 447 - <View style={styles.episodeHeader}> 448 - <Text 449 - style={[styles.episodeTitle, { color: colors.onSurface }]} 450 - numberOfLines={1} 451 - > 452 - E{episode.episode_number} · {episode.name} 453 - </Text> 454 - <View style={styles.episodeMeta}> 455 - {episode.vote_average ? ( 456 - <View style={styles.ratingBadge}> 457 - <Ionicons name="star" size={12} color="#fbbf24" /> 458 - <Text style={styles.ratingText}> 459 - {episode.vote_average.toFixed(1)} 460 - </Text> 461 - </View> 462 - ) : null} 463 - {isAuthenticated && watchCount > 0 && ( 464 - <View style={styles.watchedBadge}> 465 - <Ionicons name="checkmark-circle" size={12} color="#22c55e" /> 466 - <Text style={styles.watchedText}>{watchCount}x</Text> 467 - </View> 468 - )} 469 - </View> 470 - </View> 471 - <Text 472 - style={[styles.episodeOverview, { color: colors.onSurfaceVariant }]} 473 - numberOfLines={2} 474 - > 475 - {episode.overview || "No overview available."} 476 - </Text> 477 - <View style={styles.episodeFooter}> 478 - {episode.air_date && ( 479 - <Text 480 - style={[styles.episodeDate, { color: colors.onSurfaceVariant }]} 481 - > 482 - {formatDateOnly(episode.air_date)} 483 - </Text> 484 - )} 485 - </View> 486 - </View> 487 - </View> 488 - </TouchableOpacity> 489 - ); 490 - } 491 - 492 510 const styles = StyleSheet.create({ 493 511 container: { flex: 1 }, 494 512 scrollContent: { 495 513 paddingBottom: spacing.xxl, 496 514 }, 497 - heroWrapper: { 498 - height: 280, 499 - position: "relative", 500 - }, 501 - backdrop: { 502 - width: "100%", 503 - height: "100%", 504 - }, 505 - backdropOverlay: { 506 - ...StyleSheet.absoluteFillObject, 507 - }, 508 - backButton: { 509 - position: "absolute", 510 - top: 8, 511 - left: 16, 512 - zIndex: 10, 513 - padding: 8, 514 - borderRadius: borderRadius.full, 515 - backgroundColor: "rgba(0, 0, 0, 0.5)", 516 - }, 517 - heroOverlay: { 518 - position: "absolute", 519 - bottom: -52, 520 - left: 16, 521 - right: 16, 522 - flexDirection: "row", 523 - alignItems: "flex-end", 524 - }, 525 - posterWrapper: { 526 - borderRadius: borderRadius.lg, 527 - overflow: "hidden", 528 - shadowOffset: { width: 0, height: 4 }, 529 - shadowOpacity: 0.35, 530 - shadowRadius: 8, 531 - elevation: 8, 532 - }, 533 - poster: { 534 - width: 96, 535 - height: 144, 536 - }, 537 - noPoster: { 538 - alignItems: "center", 539 - justifyContent: "center", 540 - }, 541 - noPosterText: { 542 - fontSize: 11, 543 - }, 544 - titleWrapper: { 545 - marginLeft: spacing.md, 546 - marginBottom: spacing.sm, 547 - flex: 1, 548 - }, 549 - title: { 550 - fontSize: 28, 551 - fontWeight: "700", 552 - color: "#f9fafb", 553 - textShadowOffset: { width: 0, height: 2 }, 554 - textShadowRadius: 10, 555 - }, 556 - subtitle: { 557 - fontSize: 17, 558 - fontWeight: "600", 559 - color: "#d1d5db", 560 - marginTop: 4, 561 - }, 562 515 content: { 563 - marginTop: 64, 564 - paddingHorizontal: 16, 565 - gap: spacing.md, 566 - }, 567 - infoCards: { 568 - flexDirection: "row", 569 - gap: spacing.sm, 570 - }, 571 - infoCard: { 572 - flex: 1, 573 - padding: spacing.md, 574 - borderRadius: borderRadius.md, 575 - alignItems: "center", 576 - }, 577 - infoLabel: { 578 - fontSize: 11, 579 - textTransform: "uppercase", 580 - letterSpacing: 0.5, 581 - marginBottom: 4, 582 - }, 583 - infoValue: { 584 - fontSize: 16, 585 - fontWeight: "600", 516 + paddingHorizontal: spacing.md, 517 + paddingTop: spacing.lg, 518 + gap: spacing.lg, 586 519 }, 587 520 section: { 588 - marginTop: spacing.sm, 521 + gap: spacing.md, 589 522 }, 590 523 sectionTitle: { 591 524 fontSize: 18, 592 525 fontWeight: "600", 593 - marginBottom: spacing.md, 594 526 }, 595 527 overview: { 596 528 fontSize: 15, 597 529 lineHeight: 22, 598 530 }, 599 - episodesList: { 531 + genresContainer: { 532 + flexDirection: "row", 533 + flexWrap: "wrap", 600 534 gap: spacing.sm, 601 535 }, 602 - episodeCard: { 536 + genreBadge: { 537 + paddingHorizontal: spacing.md, 538 + paddingVertical: spacing.sm, 539 + borderRadius: borderRadius.full, 603 540 borderWidth: 1, 604 - borderRadius: borderRadius.lg, 605 - overflow: "hidden", 606 541 }, 607 - episodeRow: { 608 - flexDirection: "row", 609 - gap: spacing.md, 610 - }, 611 - episodeThumbnail: { 612 - width: 120, 613 - height: 80, 614 - backgroundColor: "#111827", 615 - }, 616 - episodeImage: { 617 - width: "100%", 618 - height: "100%", 619 - }, 620 - episodeInfo: { 621 - flex: 1, 622 - paddingVertical: spacing.sm, 623 - paddingRight: spacing.sm, 624 - justifyContent: "center", 625 - }, 626 - episodeHeader: { 627 - flexDirection: "row", 628 - justifyContent: "space-between", 629 - alignItems: "flex-start", 630 - gap: spacing.sm, 631 - }, 632 - episodeTitle: { 542 + genreText: { 633 543 fontSize: 14, 634 544 fontWeight: "500", 635 - flex: 1, 636 545 }, 637 - episodeMeta: { 638 - flexDirection: "row", 639 - gap: spacing.sm, 640 - }, 641 - ratingBadge: { 642 - flexDirection: "row", 643 - alignItems: "center", 644 - gap: 2, 645 - }, 646 - ratingText: { 647 - fontSize: 11, 648 - color: "#fbbf24", 649 - fontWeight: "600", 650 - }, 651 - watchedBadge: { 652 - flexDirection: "row", 653 - alignItems: "center", 654 - gap: 2, 655 - }, 656 - watchedText: { 657 - fontSize: 11, 658 - color: "#22c55e", 659 - fontWeight: "600", 660 - }, 661 - episodeOverview: { 662 - fontSize: 12, 663 - marginTop: 4, 664 - lineHeight: 16, 665 - }, 666 - episodeFooter: { 667 - flexDirection: "row", 668 - marginTop: 4, 669 - }, 670 - episodeDate: { 671 - fontSize: 11, 546 + episodesList: { 547 + gap: spacing.md, 672 548 }, 673 549 castContainer: { 674 550 position: "relative", 675 551 }, 676 552 castScrollContent: { 677 - gap: 12, 553 + gap: spacing.md, 678 554 }, 679 555 castGradient: { 680 556 position: "absolute", ··· 690 566 castImageContainer: { 691 567 borderRadius: borderRadius.md, 692 568 overflow: "hidden", 693 - marginBottom: 8, 569 + marginBottom: spacing.sm, 694 570 }, 695 571 castImage: { 696 572 width: 100, ··· 718 594 crewGrid: { 719 595 flexDirection: "row", 720 596 flexWrap: "wrap", 721 - gap: 8, 597 + gap: spacing.sm, 722 598 }, 723 599 crewCard: { 724 600 padding: spacing.md,
+290
apps/mobile/components/detail/DetailActions.tsx
··· 1 + import type { ColorTheme } from "./types"; 2 + import { Ionicons } from "@expo/vector-icons"; 3 + import { LinearGradient } from "expo-linear-gradient"; 4 + import { Share } from "react-native"; 5 + import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from "react-native"; 6 + import { borderRadius, spacing } from "@/constants/spacing"; 7 + import { useTheme } from "@/contexts/theme"; 8 + import { TrackedStatusCard } from "./TrackedStatusCard"; 9 + 10 + type DetailActionsProps = { 11 + mediaType: "movie" | "show" | "season" | "episode"; 12 + mediaId: string; 13 + seasonNumber?: string; 14 + episodeNumber?: string; 15 + colors: ColorTheme; 16 + isWatched: boolean; 17 + watchedDate?: string | null; 18 + totalWatches?: number; 19 + onMarkWatched: () => void; 20 + onUnmarkWatched?: () => void; 21 + onShowDatePicker: () => void; 22 + isMarkingPending?: boolean; 23 + isUnmarkingPending?: boolean; 24 + listsCount?: number; 25 + onShowListModal?: () => void; 26 + onViewHistory?: () => void; 27 + isLoggedIn?: boolean; 28 + onLogin?: () => void; 29 + onShare?: () => void; 30 + }; 31 + 32 + export function DetailActions({ 33 + mediaType, 34 + colors, 35 + isWatched, 36 + watchedDate, 37 + totalWatches = 0, 38 + onMarkWatched, 39 + onUnmarkWatched, 40 + onShowDatePicker, 41 + isMarkingPending = false, 42 + isUnmarkingPending = false, 43 + listsCount = 0, 44 + onShowListModal, 45 + onViewHistory, 46 + isLoggedIn = true, 47 + onLogin, 48 + onShare, 49 + }: DetailActionsProps) { 50 + const { colors: themeColors } = useTheme(); 51 + const isInAnyList = listsCount > 0; 52 + const isPending = isMarkingPending; 53 + const primaryColor = colors.primary || themeColors.primary || "#F59E0B"; 54 + const secondaryColor = colors.secondary || themeColors.secondary || "#D97706"; 55 + 56 + if (!isLoggedIn && onLogin) { 57 + return ( 58 + <View style={styles.container}> 59 + <TouchableOpacity 60 + onPress={onLogin} 61 + style={styles.primaryButton} 62 + activeOpacity={0.8} 63 + > 64 + <LinearGradient 65 + colors={[primaryColor, secondaryColor]} 66 + start={{ x: 0, y: 0 }} 67 + end={{ x: 1, y: 1 }} 68 + style={styles.gradientButton} 69 + > 70 + <Text style={styles.primaryButtonText}>Sign in to Track</Text> 71 + </LinearGradient> 72 + </TouchableOpacity> 73 + 74 + {onShare && ( 75 + <TouchableOpacity 76 + onPress={onShare} 77 + style={[styles.secondaryButton, { borderColor: themeColors.outline }]} 78 + activeOpacity={0.8} 79 + > 80 + <Ionicons name="share-outline" size={18} color={themeColors.onSurfaceVariant} /> 81 + <Text style={[styles.secondaryButtonText, { color: themeColors.onSurfaceVariant }]}> 82 + Share 83 + </Text> 84 + </TouchableOpacity> 85 + )} 86 + </View> 87 + ); 88 + } 89 + 90 + return ( 91 + <View style={styles.container}> 92 + {isWatched ? ( 93 + <> 94 + <TrackedStatusCard 95 + isWatched={isWatched} 96 + watchedDate={watchedDate} 97 + totalWatches={totalWatches} 98 + onViewHistory={onViewHistory} 99 + onRemove={onUnmarkWatched} 100 + isRemoving={isUnmarkingPending} 101 + colors={colors} 102 + /> 103 + <View style={[styles.buttonRow, styles.buttonRowAfterStatus]}> 104 + <TouchableOpacity 105 + onPress={onMarkWatched} 106 + disabled={isPending} 107 + style={[styles.primaryButtonCompact, { flex: 1, opacity: isPending ? 0.7 : 1 }]} 108 + activeOpacity={0.8} 109 + > 110 + <LinearGradient 111 + colors={[primaryColor, secondaryColor]} 112 + start={{ x: 0, y: 0 }} 113 + end={{ x: 1, y: 1 }} 114 + style={styles.gradientButton} 115 + > 116 + {isPending ? ( 117 + <View style={styles.buttonContent}> 118 + <ActivityIndicator color="#f9fafb" size="small" /> 119 + <Text style={styles.primaryButtonText}>Loading</Text> 120 + </View> 121 + ) : ( 122 + <View style={styles.buttonContent}> 123 + <Ionicons name="refresh" size={18} color="#f9fafb" /> 124 + <Text style={styles.primaryButtonText}>Watch Again</Text> 125 + </View> 126 + )} 127 + </LinearGradient> 128 + </TouchableOpacity> 129 + 130 + <TouchableOpacity 131 + onPress={onShowDatePicker} 132 + style={[styles.calendarButton, { borderColor: themeColors.outline }]} 133 + activeOpacity={0.8} 134 + > 135 + <Ionicons 136 + name="calendar-outline" 137 + size={20} 138 + color={themeColors.onSurfaceVariant} 139 + /> 140 + </TouchableOpacity> 141 + </View> 142 + </> 143 + ) : ( 144 + <View style={styles.buttonRow}> 145 + <TouchableOpacity 146 + onPress={onMarkWatched} 147 + disabled={isPending} 148 + style={[styles.primaryButton, { flex: 1, opacity: isPending ? 0.7 : 1 }]} 149 + activeOpacity={0.8} 150 + > 151 + <LinearGradient 152 + colors={[primaryColor, secondaryColor]} 153 + start={{ x: 0, y: 0 }} 154 + end={{ x: 1, y: 1 }} 155 + style={styles.gradientButton} 156 + > 157 + {isPending ? ( 158 + <View style={styles.buttonContent}> 159 + <ActivityIndicator color="#f9fafb" size="small" /> 160 + <Text style={styles.primaryButtonText}>Loading</Text> 161 + </View> 162 + ) : ( 163 + <View style={styles.buttonContent}> 164 + <Ionicons name="add" size={20} color="#f9fafb" /> 165 + <Text style={styles.primaryButtonText}>Add to Shelf</Text> 166 + </View> 167 + )} 168 + </LinearGradient> 169 + </TouchableOpacity> 170 + 171 + <TouchableOpacity 172 + onPress={onShowDatePicker} 173 + style={[styles.calendarButton, { borderColor: themeColors.outline }]} 174 + activeOpacity={0.8} 175 + > 176 + <Ionicons 177 + name="calendar-outline" 178 + size={20} 179 + color={themeColors.onSurfaceVariant} 180 + /> 181 + </TouchableOpacity> 182 + </View> 183 + )} 184 + 185 + {onShowListModal && ( 186 + <TouchableOpacity 187 + onPress={onShowListModal} 188 + style={[ 189 + styles.secondaryButton, 190 + isInAnyList && { 191 + backgroundColor: `${colors.primary}20`, 192 + borderColor: colors.primary, 193 + }, 194 + !isInAnyList && { borderColor: themeColors.outline }, 195 + ]} 196 + activeOpacity={0.8} 197 + > 198 + <Ionicons 199 + name={isInAnyList ? "checkmark" : "list-outline"} 200 + size={18} 201 + color={isInAnyList ? colors.primary : themeColors.onSurfaceVariant} 202 + /> 203 + <Text 204 + style={[ 205 + styles.secondaryButtonText, 206 + isInAnyList ? { color: colors.primary } : { color: themeColors.onSurfaceVariant }, 207 + ]} 208 + > 209 + {isInAnyList 210 + ? `In ${listsCount} list${listsCount > 1 ? "s" : ""}` 211 + : "Add to List"} 212 + </Text> 213 + </TouchableOpacity> 214 + )} 215 + 216 + {onShare && ( 217 + <TouchableOpacity 218 + onPress={onShare} 219 + style={[styles.secondaryButton, { borderColor: themeColors.outline }]} 220 + activeOpacity={0.8} 221 + > 222 + <Ionicons name="share-outline" size={18} color={themeColors.onSurfaceVariant} /> 223 + <Text style={[styles.secondaryButtonText, { color: themeColors.onSurfaceVariant }]}> 224 + Share 225 + </Text> 226 + </TouchableOpacity> 227 + )} 228 + </View> 229 + ); 230 + } 231 + 232 + const styles = StyleSheet.create({ 233 + container: { 234 + gap: spacing.sm, 235 + }, 236 + buttonRow: { 237 + flexDirection: "row", 238 + gap: spacing.sm, 239 + alignItems: "stretch", 240 + }, 241 + buttonRowAfterStatus: { 242 + marginTop: spacing.xs, 243 + }, 244 + primaryButton: { 245 + borderRadius: borderRadius.lg, 246 + overflow: "hidden", 247 + }, 248 + primaryButtonCompact: { 249 + borderRadius: borderRadius.lg, 250 + overflow: "hidden", 251 + }, 252 + gradientButton: { 253 + paddingVertical: 14, 254 + paddingHorizontal: spacing.lg, 255 + alignItems: "center", 256 + justifyContent: "center", 257 + }, 258 + buttonContent: { 259 + flexDirection: "row", 260 + alignItems: "center", 261 + gap: spacing.sm, 262 + }, 263 + primaryButtonText: { 264 + color: "#f9fafb", 265 + fontSize: 16, 266 + fontWeight: "600", 267 + }, 268 + calendarButton: { 269 + borderRadius: borderRadius.lg, 270 + borderWidth: 1, 271 + paddingVertical: 14, 272 + paddingHorizontal: spacing.md, 273 + alignItems: "center", 274 + justifyContent: "center", 275 + }, 276 + secondaryButton: { 277 + borderRadius: borderRadius.lg, 278 + borderWidth: 1, 279 + paddingVertical: 12, 280 + paddingHorizontal: spacing.lg, 281 + alignItems: "center", 282 + justifyContent: "center", 283 + flexDirection: "row", 284 + gap: spacing.sm, 285 + }, 286 + secondaryButtonText: { 287 + fontSize: 15, 288 + fontWeight: "500", 289 + }, 290 + });
+248
apps/mobile/components/detail/DetailHero.tsx
··· 1 + import type { ColorTheme } from "./types"; 2 + import { Ionicons } from "@expo/vector-icons"; 3 + import { Image } from "expo-image"; 4 + import { LinearGradient } from "expo-linear-gradient"; 5 + import type { ReactNode } from "react"; 6 + import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; 7 + import { useSafeAreaInsets } from "react-native-safe-area-context"; 8 + import { borderRadius, spacing } from "@/constants/spacing"; 9 + 10 + const BACKDROP_BASE_URL = "https://image.tmdb.org/t/p/w1280"; 11 + const POSTER_BASE_URL = "https://image.tmdb.org/t/p/w500"; 12 + 13 + interface DetailHeroProps { 14 + title: string; 15 + subtitle?: string; 16 + backdropUrl?: string | null; 17 + posterUrl?: string | null; 18 + colors: ColorTheme; 19 + onBack: () => void; 20 + isLoading?: boolean; 21 + posterLinkTo?: { 22 + onPress: () => void; 23 + }; 24 + } 25 + 26 + export function DetailHero({ 27 + title, 28 + subtitle, 29 + backdropUrl, 30 + posterUrl, 31 + colors, 32 + onBack, 33 + isLoading = false, 34 + posterLinkTo, 35 + }: DetailHeroProps) { 36 + const insets = useSafeAreaInsets(); 37 + 38 + if (isLoading) { 39 + return ( 40 + <View style={styles.heroWrapper}> 41 + <View 42 + style={[ 43 + styles.backdrop, 44 + { backgroundColor: colors.muted || "#1a1a2e" }, 45 + ]} 46 + /> 47 + <View style={styles.heroOverlay}> 48 + <View style={styles.posterWrapper}> 49 + <View 50 + style={[styles.poster, { backgroundColor: colors.muted }]} 51 + /> 52 + </View> 53 + <View style={styles.titleWrapper}> 54 + <View 55 + style={[ 56 + styles.titleSkeleton, 57 + { backgroundColor: colors.muted }, 58 + ]} 59 + /> 60 + </View> 61 + </View> 62 + </View> 63 + ); 64 + } 65 + 66 + const fullBackdropUrl = backdropUrl 67 + ? backdropUrl.startsWith("http") 68 + ? backdropUrl 69 + : `${BACKDROP_BASE_URL}${backdropUrl}` 70 + : null; 71 + 72 + const fullPosterUrl = posterUrl 73 + ? posterUrl.startsWith("http") 74 + ? posterUrl 75 + : `${POSTER_BASE_URL}${posterUrl}` 76 + : null; 77 + 78 + const posterContent = fullPosterUrl ? ( 79 + <Image 80 + source={{ uri: fullPosterUrl }} 81 + style={styles.poster} 82 + contentFit="cover" 83 + /> 84 + ) : ( 85 + <View style={[styles.poster, styles.noPoster]}> 86 + <Text style={styles.noPosterText}>No poster</Text> 87 + </View> 88 + ); 89 + 90 + return ( 91 + <View style={styles.heroWrapper}> 92 + {fullBackdropUrl ? ( 93 + <Image 94 + source={{ uri: fullBackdropUrl }} 95 + style={styles.backdrop} 96 + contentFit="cover" 97 + /> 98 + ) : ( 99 + <View 100 + style={[ 101 + styles.backdrop, 102 + { backgroundColor: colors.muted || "#1a1a2e" }, 103 + ]} 104 + /> 105 + )} 106 + 107 + <LinearGradient 108 + colors={[ 109 + "rgba(0,0,0,0.2)", 110 + "rgba(0,0,0,0.5)", 111 + "rgba(0,0,0,0)", 112 + ]} 113 + style={styles.backdropGradient} 114 + /> 115 + 116 + <TouchableOpacity 117 + onPress={onBack} 118 + style={[styles.backButton]} 119 + activeOpacity={0.8} 120 + > 121 + <Ionicons name="arrow-back" size={24} color="#f9fafb" /> 122 + </TouchableOpacity> 123 + 124 + <View style={styles.heroOverlay}> 125 + {posterLinkTo ? ( 126 + <TouchableOpacity 127 + onPress={posterLinkTo.onPress} 128 + activeOpacity={0.8} 129 + style={[ 130 + styles.posterWrapper, 131 + { shadowColor: colors.primary }, 132 + ]} 133 + > 134 + {posterContent} 135 + </TouchableOpacity> 136 + ) : ( 137 + <View 138 + style={[ 139 + styles.posterWrapper, 140 + { shadowColor: colors.primary }, 141 + ]} 142 + > 143 + {posterContent} 144 + </View> 145 + )} 146 + 147 + <View style={styles.titleWrapper}> 148 + <Text 149 + style={[styles.title, { textShadowColor: colors.primary }]} 150 + numberOfLines={2} 151 + adjustsFontSizeToFit 152 + minimumFontScale={0.7} 153 + > 154 + {title} 155 + </Text> 156 + {subtitle && ( 157 + <Text style={styles.subtitle} numberOfLines={1}> 158 + {subtitle} 159 + </Text> 160 + )} 161 + </View> 162 + </View> 163 + </View> 164 + ); 165 + } 166 + 167 + const styles = StyleSheet.create({ 168 + heroWrapper: { 169 + height: 280, 170 + position: "relative", 171 + }, 172 + backdrop: { 173 + ...StyleSheet.absoluteFillObject, 174 + width: "100%", 175 + height: "100%", 176 + }, 177 + backdropGradient: { 178 + ...StyleSheet.absoluteFillObject, 179 + }, 180 + backButton: { 181 + position: "absolute", 182 + top: spacing.sm, 183 + left: spacing.md, 184 + zIndex: 10, 185 + width: 40, 186 + height: 40, 187 + borderRadius: borderRadius.full, 188 + backgroundColor: "rgba(0, 0, 0, 0.5)", 189 + justifyContent: "center", 190 + alignItems: "center", 191 + }, 192 + heroOverlay: { 193 + position: "absolute", 194 + bottom: 0, 195 + left: 0, 196 + right: 0, 197 + flexDirection: "row", 198 + alignItems: "flex-end", 199 + paddingHorizontal: spacing.md, 200 + paddingBottom: spacing.md, 201 + }, 202 + posterWrapper: { 203 + width: 100, 204 + height: 150, 205 + borderRadius: borderRadius.lg, 206 + overflow: "hidden", 207 + backgroundColor: "#1f2937", 208 + shadowOffset: { width: 0, height: 4 }, 209 + shadowOpacity: 0.4, 210 + shadowRadius: 8, 211 + elevation: 8, 212 + }, 213 + poster: { 214 + width: "100%", 215 + height: "100%", 216 + }, 217 + noPoster: { 218 + justifyContent: "center", 219 + alignItems: "center", 220 + }, 221 + noPosterText: { 222 + color: "#6b7280", 223 + fontSize: 12, 224 + }, 225 + titleWrapper: { 226 + flex: 1, 227 + marginLeft: spacing.md, 228 + marginBottom: spacing.xs, 229 + }, 230 + title: { 231 + fontSize: 22, 232 + fontWeight: "700", 233 + color: "#f9fafb", 234 + textShadowOffset: { width: 0, height: 2 }, 235 + textShadowRadius: 8, 236 + }, 237 + subtitle: { 238 + fontSize: 15, 239 + fontWeight: "500", 240 + color: "#d1d5db", 241 + marginTop: 4, 242 + }, 243 + titleSkeleton: { 244 + height: 24, 245 + width: "80%", 246 + borderRadius: borderRadius.sm, 247 + }, 248 + });
+335
apps/mobile/components/detail/EpisodeCard.tsx
··· 1 + import type { ColorTheme, EpisodeSummary } from "./types"; 2 + import { Ionicons } from "@expo/vector-icons"; 3 + import { Image } from "expo-image"; 4 + import { 5 + showsControllerGetShowWatchHistoryQueryKey, 6 + showsControllerGetUserShowsQueryKey, 7 + showsControllerMarkWatchedMutation, 8 + showsControllerUnmarkWatchedMutation, 9 + } from "@opnshelf/api"; 10 + import { useMutation, useQueryClient } from "@tanstack/react-query"; 11 + import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from "react-native"; 12 + import { borderRadius, spacing } from "@/constants/spacing"; 13 + import { useTheme } from "@/contexts/theme"; 14 + import { useToast } from "@/contexts/toast"; 15 + 16 + const STILL_BASE_URL = "https://image.tmdb.org/t/p/w300"; 17 + 18 + interface EpisodeCardProps { 19 + showId: string; 20 + seasonNumber: string; 21 + episode: EpisodeSummary; 22 + watchedCount?: number; 23 + colors: ColorTheme; 24 + userDid?: string; 25 + onPress: () => void; 26 + } 27 + 28 + function formatDateOnly(dateString?: string): string { 29 + if (!dateString) return "TBA"; 30 + return new Date(dateString).toLocaleDateString("en-US", { 31 + month: "short", 32 + day: "numeric", 33 + year: "numeric", 34 + }); 35 + } 36 + 37 + export function EpisodeCard({ 38 + showId, 39 + seasonNumber, 40 + episode, 41 + watchedCount = 0, 42 + colors, 43 + userDid, 44 + onPress, 45 + }: EpisodeCardProps) { 46 + const { colors: themeColors } = useTheme(); 47 + const { showToast } = useToast(); 48 + const queryClient = useQueryClient(); 49 + 50 + const stillUrl = episode.still_path 51 + ? `${STILL_BASE_URL}${episode.still_path}` 52 + : null; 53 + 54 + const hasWatchedEpisodes = watchedCount > 0; 55 + 56 + const markMutation = useMutation({ 57 + ...showsControllerMarkWatchedMutation(), 58 + onSuccess: () => { 59 + if (userDid) { 60 + queryClient.invalidateQueries({ 61 + queryKey: showsControllerGetUserShowsQueryKey({ 62 + path: { userDid }, 63 + }), 64 + }); 65 + queryClient.invalidateQueries({ 66 + queryKey: showsControllerGetShowWatchHistoryQueryKey({ 67 + path: { userDid, showId }, 68 + }), 69 + }); 70 + } 71 + showToast("Episode marked watched"); 72 + }, 73 + onError: () => { 74 + showToast("Failed to mark episode watched", "error"); 75 + }, 76 + }); 77 + 78 + const unmarkMutation = useMutation({ 79 + ...showsControllerUnmarkWatchedMutation(), 80 + onSuccess: () => { 81 + if (userDid) { 82 + queryClient.invalidateQueries({ 83 + queryKey: showsControllerGetUserShowsQueryKey({ 84 + path: { userDid }, 85 + }), 86 + }); 87 + queryClient.invalidateQueries({ 88 + queryKey: showsControllerGetShowWatchHistoryQueryKey({ 89 + path: { userDid, showId }, 90 + }), 91 + }); 92 + } 93 + showToast("Removed from your shelf"); 94 + }, 95 + onError: () => { 96 + showToast("Failed to remove from shelf", "error"); 97 + }, 98 + }); 99 + 100 + const isPending = markMutation.isPending || unmarkMutation.isPending; 101 + 102 + const handleToggleWatched = (e: any) => { 103 + e.preventDefault(); 104 + e.stopPropagation(); 105 + 106 + if (hasWatchedEpisodes) { 107 + unmarkMutation.mutate({ 108 + path: { showId }, 109 + query: { 110 + mode: "all", 111 + seasonNumber: Number(seasonNumber), 112 + }, 113 + }); 114 + } else { 115 + markMutation.mutate({ 116 + body: { 117 + showId, 118 + seasonNumber: Number(seasonNumber), 119 + episodeNumber: episode.episode_number, 120 + }, 121 + }); 122 + } 123 + }; 124 + 125 + return ( 126 + <TouchableOpacity 127 + onPress={onPress} 128 + style={[ 129 + styles.container, 130 + { 131 + borderColor: hasWatchedEpisodes ? `${colors.primary}40` : themeColors.outline, 132 + backgroundColor: `${themeColors.surfaceContainer}50`, 133 + }, 134 + ]} 135 + activeOpacity={0.8} 136 + > 137 + <View style={styles.row}> 138 + <View style={styles.thumbnail}> 139 + {stillUrl ? ( 140 + <Image 141 + source={{ uri: stillUrl }} 142 + style={styles.still} 143 + contentFit="cover" 144 + /> 145 + ) : ( 146 + <View style={[styles.still, styles.noStill]}> 147 + <Ionicons name="film-outline" size={20} color="#6b7280" /> 148 + </View> 149 + )} 150 + </View> 151 + 152 + <View style={styles.content}> 153 + <View style={styles.header}> 154 + <Text style={[styles.title, { color: themeColors.onSurface }]} numberOfLines={1}> 155 + E{episode.episode_number} · {episode.name} 156 + </Text> 157 + {episode.vote_average ? ( 158 + <View style={styles.rating}> 159 + <Ionicons name="star" size={12} color="#fbbf24" /> 160 + <Text style={styles.ratingText}> 161 + {episode.vote_average.toFixed(1)} 162 + </Text> 163 + </View> 164 + ) : null} 165 + </View> 166 + 167 + <Text 168 + style={[styles.overview, { color: themeColors.onSurfaceVariant }]} 169 + numberOfLines={2} 170 + > 171 + {episode.overview || "No overview available."} 172 + </Text> 173 + 174 + <View style={styles.footer}> 175 + <View style={styles.dateRow}> 176 + <Ionicons 177 + name="calendar-outline" 178 + size={12} 179 + color={themeColors.onSurfaceVariant} 180 + /> 181 + <Text style={[styles.date, { color: themeColors.onSurfaceVariant }]}> 182 + {formatDateOnly(episode.air_date)} 183 + </Text> 184 + </View> 185 + {watchedCount > 0 && ( 186 + <View style={styles.watchedBadge}> 187 + <Ionicons name="checkmark-circle" size={12} color={colors.primary} /> 188 + <Text style={[styles.watchedText, { color: colors.primary }]}> 189 + {watchedCount} watched 190 + </Text> 191 + </View> 192 + )} 193 + </View> 194 + 195 + {userDid && ( 196 + <TouchableOpacity 197 + onPress={handleToggleWatched} 198 + disabled={isPending} 199 + style={[ 200 + styles.addButton, 201 + { 202 + backgroundColor: hasWatchedEpisodes 203 + ? `${themeColors.error}20` 204 + : `${colors.primary}20`, 205 + borderColor: hasWatchedEpisodes 206 + ? themeColors.error 207 + : colors.primary, 208 + }, 209 + ]} 210 + activeOpacity={0.7} 211 + > 212 + {isPending ? ( 213 + <><ActivityIndicator 214 + size="small" 215 + color={hasWatchedEpisodes ? themeColors.error : colors.primary} 216 + /> 217 + <Text style={[styles.addButtonText, { color: hasWatchedEpisodes ? themeColors.error : colors.primary }]}>Loading</Text> 218 + </> 219 + ) : ( 220 + <> 221 + <Ionicons 222 + name={hasWatchedEpisodes ? "trash-outline" : "add"} 223 + size={14} 224 + color={hasWatchedEpisodes ? themeColors.error : colors.primary} 225 + /> 226 + <Text 227 + style={[ 228 + styles.addButtonText, 229 + { color: hasWatchedEpisodes ? themeColors.error : colors.primary }, 230 + ]} 231 + > 232 + {hasWatchedEpisodes ? "Remove from Shelf" : "Add to Shelf"} 233 + </Text> 234 + </> 235 + )} 236 + </TouchableOpacity> 237 + )} 238 + </View> 239 + </View> 240 + </TouchableOpacity> 241 + ); 242 + } 243 + 244 + const styles = StyleSheet.create({ 245 + container: { 246 + borderRadius: borderRadius.lg, 247 + borderWidth: 1, 248 + }, 249 + row: { 250 + flexDirection: "row", 251 + gap: spacing.md, 252 + }, 253 + thumbnail: { 254 + width: 100, 255 + height: 100, 256 + }, 257 + still: { 258 + width: "100%", 259 + height: "100%", 260 + }, 261 + noStill: { 262 + backgroundColor: "#1f2937", 263 + justifyContent: "center", 264 + alignItems: "center", 265 + }, 266 + content: { 267 + flex: 1, 268 + paddingVertical: spacing.sm, 269 + paddingRight: spacing.sm, 270 + }, 271 + header: { 272 + flexDirection: "row", 273 + justifyContent: "space-between", 274 + alignItems: "flex-start", 275 + gap: spacing.xs, 276 + marginBottom: 4, 277 + }, 278 + title: { 279 + fontSize: 14, 280 + fontWeight: "600", 281 + flex: 1, 282 + }, 283 + rating: { 284 + flexDirection: "row", 285 + alignItems: "center", 286 + gap: 2, 287 + }, 288 + ratingText: { 289 + fontSize: 12, 290 + color: "#fbbf24", 291 + fontWeight: "600", 292 + }, 293 + overview: { 294 + fontSize: 12, 295 + lineHeight: 16, 296 + marginBottom: 4, 297 + }, 298 + footer: { 299 + flexDirection: "row", 300 + alignItems: "center", 301 + gap: spacing.sm, 302 + }, 303 + dateRow: { 304 + flexDirection: "row", 305 + alignItems: "center", 306 + gap: 4, 307 + }, 308 + date: { 309 + fontSize: 11, 310 + }, 311 + watchedBadge: { 312 + flexDirection: "row", 313 + alignItems: "center", 314 + gap: 2, 315 + }, 316 + watchedText: { 317 + fontSize: 11, 318 + fontWeight: "600", 319 + }, 320 + addButton: { 321 + flexDirection: "row", 322 + alignItems: "center", 323 + justifyContent: "center", 324 + gap: spacing.xs, 325 + marginTop: spacing.sm, 326 + paddingVertical: spacing.sm, 327 + paddingHorizontal: spacing.md, 328 + borderRadius: borderRadius.md, 329 + borderWidth: 1, 330 + }, 331 + addButtonText: { 332 + fontSize: 12, 333 + fontWeight: "600", 334 + }, 335 + });
+234
apps/mobile/components/detail/EpisodeNav.tsx
··· 1 + import type { ColorTheme, EpisodeSummary } from "./types"; 2 + import { Ionicons } from "@expo/vector-icons"; 3 + import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; 4 + import { borderRadius, spacing } from "@/constants/spacing"; 5 + import { useTheme } from "@/contexts/theme"; 6 + 7 + interface EpisodeNavProps { 8 + previousEpisode: EpisodeSummary | null; 9 + currentEpisode: EpisodeSummary; 10 + nextEpisode: EpisodeSummary | null; 11 + colors: ColorTheme; 12 + variant?: "sidebar" | "full"; 13 + onPreviousPress?: () => void; 14 + onNextPress?: () => void; 15 + } 16 + 17 + function formatDateOnly(dateString?: string): string { 18 + if (!dateString) return "TBA"; 19 + return new Date(dateString).toLocaleDateString("en-US", { 20 + month: "short", 21 + day: "numeric", 22 + }); 23 + } 24 + 25 + export function EpisodeNav({ 26 + previousEpisode, 27 + currentEpisode, 28 + nextEpisode, 29 + colors, 30 + variant = "full", 31 + onPreviousPress, 32 + onNextPress, 33 + }: EpisodeNavProps) { 34 + const { colors: themeColors } = useTheme(); 35 + const hasPrev = previousEpisode !== null; 36 + const hasNext = nextEpisode !== null; 37 + 38 + if (variant === "sidebar") { 39 + if (!hasPrev && !hasNext) { 40 + return null; 41 + } 42 + 43 + return ( 44 + <View style={styles.sidebarContainer}> 45 + {hasPrev ? ( 46 + <TouchableOpacity 47 + onPress={onPreviousPress} 48 + style={[styles.sidebarButton, { borderColor: themeColors.outline }]} 49 + activeOpacity={0.8} 50 + > 51 + <Ionicons name="arrow-back" size={16} color={themeColors.onSurfaceVariant} /> 52 + <Text style={[styles.sidebarText, { color: themeColors.onSurfaceVariant }]}> 53 + Ep {previousEpisode!.episode_number} 54 + </Text> 55 + </TouchableOpacity> 56 + ) : ( 57 + <View style={styles.sidebarPlaceholder} /> 58 + )} 59 + 60 + {hasNext ? ( 61 + <TouchableOpacity 62 + onPress={onNextPress} 63 + style={[styles.sidebarButton, { borderColor: themeColors.outline }]} 64 + activeOpacity={0.8} 65 + > 66 + <Text style={[styles.sidebarText, { color: themeColors.onSurfaceVariant }]}> 67 + Ep {nextEpisode!.episode_number} 68 + </Text> 69 + <Ionicons name="arrow-forward" size={16} color={themeColors.onSurfaceVariant} /> 70 + </TouchableOpacity> 71 + ) : ( 72 + <View style={styles.sidebarPlaceholder} /> 73 + )} 74 + </View> 75 + ); 76 + } 77 + 78 + return ( 79 + <View style={styles.container}> 80 + {[ 81 + { 82 + key: "previous", 83 + label: "Previous Episode", 84 + icon: "arrow-back", 85 + episode: previousEpisode, 86 + highlighted: false, 87 + onPress: onPreviousPress, 88 + }, 89 + { 90 + key: "current", 91 + label: "Current Episode", 92 + icon: "radio-button-on", 93 + episode: currentEpisode, 94 + highlighted: true, 95 + onPress: undefined, 96 + }, 97 + { 98 + key: "next", 99 + label: "Next Episode", 100 + icon: "arrow-forward", 101 + episode: nextEpisode, 102 + highlighted: false, 103 + onPress: onNextPress, 104 + }, 105 + ].map((slot) => { 106 + if (!slot.episode) { 107 + return ( 108 + <View 109 + key={slot.key} 110 + style={[ 111 + styles.card, 112 + { borderColor: themeColors.outline, opacity: 0.5 }, 113 + ]} 114 + > 115 + <View style={styles.cardHeader}> 116 + <Ionicons 117 + name={slot.icon as any} 118 + size={14} 119 + color={themeColors.onSurfaceVariant} 120 + /> 121 + <Text style={[styles.cardLabel, { color: themeColors.onSurfaceVariant }]}> 122 + {slot.label} 123 + </Text> 124 + </View> 125 + <Text style={[styles.cardEmpty, { color: themeColors.onSurfaceVariant }]}> 126 + No episode 127 + </Text> 128 + </View> 129 + ); 130 + } 131 + 132 + const Content = ( 133 + <View 134 + style={[ 135 + styles.card, 136 + { 137 + borderColor: slot.highlighted ? colors.primary : themeColors.outline, 138 + backgroundColor: slot.highlighted ? `${colors.primary}15` : "transparent", 139 + }, 140 + ]} 141 + > 142 + <View style={styles.cardHeader}> 143 + <Ionicons 144 + name={slot.icon as any} 145 + size={14} 146 + color={themeColors.onSurfaceVariant} 147 + /> 148 + <Text style={[styles.cardLabel, { color: themeColors.onSurfaceVariant }]}> 149 + {slot.label} 150 + </Text> 151 + </View> 152 + <Text style={[styles.cardTitle, { color: themeColors.onSurface }]} numberOfLines={1}> 153 + E{slot.episode.episode_number}: {slot.episode.name} 154 + </Text> 155 + <Text style={[styles.cardDate, { color: themeColors.onSurfaceVariant }]}> 156 + {formatDateOnly(slot.episode.air_date)} 157 + </Text> 158 + </View> 159 + ); 160 + 161 + if (slot.onPress) { 162 + return ( 163 + <TouchableOpacity 164 + key={slot.key} 165 + onPress={slot.onPress} 166 + activeOpacity={0.8} 167 + > 168 + {Content} 169 + </TouchableOpacity> 170 + ); 171 + } 172 + 173 + return <View key={slot.key}>{Content}</View>; 174 + })} 175 + </View> 176 + ); 177 + } 178 + 179 + const styles = StyleSheet.create({ 180 + container: { 181 + gap: spacing.sm, 182 + }, 183 + card: { 184 + borderRadius: borderRadius.lg, 185 + borderWidth: 1, 186 + padding: spacing.md, 187 + gap: 4, 188 + }, 189 + cardHeader: { 190 + flexDirection: "row", 191 + alignItems: "center", 192 + gap: 6, 193 + }, 194 + cardLabel: { 195 + fontSize: 11, 196 + textTransform: "uppercase", 197 + letterSpacing: 0.5, 198 + }, 199 + cardTitle: { 200 + fontSize: 14, 201 + fontWeight: "600", 202 + marginTop: 4, 203 + }, 204 + cardDate: { 205 + fontSize: 12, 206 + }, 207 + cardEmpty: { 208 + fontSize: 13, 209 + fontStyle: "italic", 210 + marginTop: 4, 211 + }, 212 + sidebarContainer: { 213 + flexDirection: "row", 214 + gap: spacing.sm, 215 + }, 216 + sidebarButton: { 217 + flex: 1, 218 + flexDirection: "row", 219 + alignItems: "center", 220 + justifyContent: "center", 221 + gap: spacing.xs, 222 + borderWidth: 1, 223 + borderRadius: borderRadius.lg, 224 + paddingVertical: spacing.sm, 225 + paddingHorizontal: spacing.md, 226 + }, 227 + sidebarText: { 228 + fontSize: 13, 229 + fontWeight: "500", 230 + }, 231 + sidebarPlaceholder: { 232 + flex: 1, 233 + }, 234 + });
+80
apps/mobile/components/detail/MetadataPills.tsx
··· 1 + import type { MetadataPill } from "./types"; 2 + import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; 3 + import { borderRadius, spacing } from "@/constants/spacing"; 4 + import { useTheme } from "@/contexts/theme"; 5 + 6 + interface MetadataPillsProps { 7 + items: MetadataPill[]; 8 + } 9 + 10 + export function MetadataPills({ items }: MetadataPillsProps) { 11 + const { colors } = useTheme(); 12 + 13 + if (items.length === 0) { 14 + return null; 15 + } 16 + 17 + return ( 18 + <View style={styles.container}> 19 + {items.map((item, index) => { 20 + const content = ( 21 + <> 22 + {typeof item.icon === "object" && item.icon} 23 + <Text style={[styles.label, { color: colors.onSurfaceVariant }]}> 24 + {item.label} 25 + </Text> 26 + </> 27 + ); 28 + 29 + if (item.onPress) { 30 + return ( 31 + <TouchableOpacity 32 + key={`${item.label}-${index}`} 33 + onPress={item.onPress} 34 + style={[ 35 + styles.pill, 36 + { borderColor: colors.outline }, 37 + ]} 38 + activeOpacity={0.7} 39 + > 40 + {content} 41 + </TouchableOpacity> 42 + ); 43 + } 44 + 45 + return ( 46 + <View 47 + key={`${item.label}-${index}`} 48 + style={[ 49 + styles.pill, 50 + { borderColor: colors.outline }, 51 + ]} 52 + > 53 + {content} 54 + </View> 55 + ); 56 + })} 57 + </View> 58 + ); 59 + } 60 + 61 + const styles = StyleSheet.create({ 62 + container: { 63 + flexDirection: "row", 64 + flexWrap: "wrap", 65 + gap: spacing.sm, 66 + }, 67 + pill: { 68 + flexDirection: "row", 69 + alignItems: "center", 70 + gap: 6, 71 + borderWidth: 1, 72 + borderRadius: borderRadius.full, 73 + paddingHorizontal: spacing.md, 74 + paddingVertical: 6, 75 + }, 76 + label: { 77 + fontSize: 13, 78 + fontWeight: "500", 79 + }, 80 + });
+386
apps/mobile/components/detail/SeasonCard.tsx
··· 1 + import type { ColorTheme } from "./types"; 2 + import { Ionicons } from "@expo/vector-icons"; 3 + import { Image } from "expo-image"; 4 + import { 5 + showsControllerGetShowWatchHistoryQueryKey, 6 + showsControllerGetUserShowsQueryKey, 7 + showsControllerMarkSeasonWatchedMutation, 8 + showsControllerUnmarkWatchedMutation, 9 + } from "@opnshelf/api"; 10 + import { useMutation, useQueryClient } from "@tanstack/react-query"; 11 + import { 12 + ActivityIndicator, 13 + StyleSheet, 14 + Text, 15 + TouchableOpacity, 16 + View, 17 + } from "react-native"; 18 + import { borderRadius, spacing } from "@/constants/spacing"; 19 + import { useTheme } from "@/contexts/theme"; 20 + import { useToast } from "@/contexts/toast"; 21 + 22 + const POSTER_BASE_URL = "https://image.tmdb.org/t/p/w500"; 23 + 24 + interface SeasonCardProps { 25 + showId: string; 26 + seasonNumber: number; 27 + posterUrl?: string | null; 28 + airDate?: string; 29 + episodeCount: number; 30 + watchedCount: number; 31 + overview?: string; 32 + colors: ColorTheme; 33 + userDid?: string; 34 + onPress: () => void; 35 + } 36 + 37 + export function SeasonCard({ 38 + showId, 39 + seasonNumber, 40 + posterUrl, 41 + airDate, 42 + episodeCount, 43 + watchedCount, 44 + overview, 45 + colors, 46 + userDid, 47 + onPress, 48 + }: SeasonCardProps) { 49 + const { colors: themeColors } = useTheme(); 50 + const { showToast } = useToast(); 51 + const queryClient = useQueryClient(); 52 + const progress = 53 + episodeCount > 0 ? Math.round((watchedCount / episodeCount) * 100) : 0; 54 + const hasWatchedEpisodes = watchedCount > 0; 55 + 56 + const markMutation = useMutation({ 57 + ...showsControllerMarkSeasonWatchedMutation(), 58 + onSuccess: (data) => { 59 + if (userDid) { 60 + queryClient.invalidateQueries({ 61 + queryKey: showsControllerGetUserShowsQueryKey({ 62 + path: { userDid }, 63 + }), 64 + }); 65 + queryClient.invalidateQueries({ 66 + queryKey: showsControllerGetShowWatchHistoryQueryKey({ 67 + path: { userDid, showId }, 68 + }), 69 + }); 70 + } 71 + showToast(`Marked ${data.count} episodes as watched`); 72 + }, 73 + onError: () => { 74 + showToast("Failed to mark season as watched. Please try again.", "error"); 75 + }, 76 + }); 77 + 78 + const unmarkMutation = useMutation({ 79 + ...showsControllerUnmarkWatchedMutation(), 80 + onSuccess: () => { 81 + if (userDid) { 82 + queryClient.invalidateQueries({ 83 + queryKey: showsControllerGetUserShowsQueryKey({ 84 + path: { userDid }, 85 + }), 86 + }); 87 + queryClient.invalidateQueries({ 88 + queryKey: showsControllerGetShowWatchHistoryQueryKey({ 89 + path: { userDid, showId }, 90 + }), 91 + }); 92 + } 93 + showToast("Removed season from your shelf"); 94 + }, 95 + onError: () => { 96 + showToast("Failed to remove from shelf. Please try again.", "error"); 97 + }, 98 + }); 99 + 100 + const isPending = markMutation.isPending || unmarkMutation.isPending; 101 + 102 + const handleToggleWatched = (e: any) => { 103 + e.preventDefault(); 104 + e.stopPropagation(); 105 + 106 + if (hasWatchedEpisodes) { 107 + unmarkMutation.mutate({ 108 + path: { showId }, 109 + query: { mode: "all", seasonNumber }, 110 + }); 111 + } else { 112 + markMutation.mutate({ 113 + body: { showId, seasonNumber }, 114 + }); 115 + } 116 + }; 117 + 118 + const fullPosterUrl = posterUrl 119 + ? posterUrl.startsWith("http") 120 + ? posterUrl 121 + : `${POSTER_BASE_URL}${posterUrl}` 122 + : null; 123 + 124 + const year = airDate ? new Date(airDate).getFullYear() : null; 125 + 126 + return ( 127 + <TouchableOpacity 128 + onPress={onPress} 129 + style={[ 130 + styles.container, 131 + { 132 + borderColor: hasWatchedEpisodes 133 + ? `${colors.primary}40` 134 + : themeColors.outline, 135 + backgroundColor: `${themeColors.surfaceContainer}50`, 136 + }, 137 + ]} 138 + activeOpacity={0.8} 139 + > 140 + <View style={styles.row}> 141 + <View style={styles.posterWrapper}> 142 + {fullPosterUrl ? ( 143 + <Image 144 + source={{ uri: fullPosterUrl }} 145 + style={styles.poster} 146 + contentFit="cover" 147 + /> 148 + ) : ( 149 + <View style={[styles.poster, styles.noPoster]}> 150 + <Ionicons name="film-outline" size={24} color="#6b7280" /> 151 + </View> 152 + )} 153 + </View> 154 + 155 + <View style={styles.content}> 156 + <View style={styles.header}> 157 + <Text style={[styles.title, { color: colors.primary }]}> 158 + Season {seasonNumber} 159 + </Text> 160 + {year && ( 161 + <Text 162 + style={[styles.year, { color: themeColors.onSurfaceVariant }]} 163 + > 164 + {year} 165 + </Text> 166 + )} 167 + </View> 168 + 169 + <View style={styles.meta}> 170 + <View style={styles.metaItem}> 171 + <Ionicons 172 + name="film-outline" 173 + size={12} 174 + color={themeColors.onSurfaceVariant} 175 + /> 176 + <Text 177 + style={[ 178 + styles.metaText, 179 + { color: themeColors.onSurfaceVariant }, 180 + ]} 181 + > 182 + {episodeCount} episodes 183 + </Text> 184 + </View> 185 + {watchedCount > 0 && ( 186 + <Text 187 + style={[styles.watchedText, { color: themeColors.onSurface }]} 188 + > 189 + {watchedCount} watched 190 + </Text> 191 + )} 192 + </View> 193 + 194 + {overview && ( 195 + <Text 196 + style={[styles.overview, { color: themeColors.onSurfaceVariant }]} 197 + numberOfLines={2} 198 + > 199 + {overview} 200 + </Text> 201 + )} 202 + 203 + {episodeCount > 0 && ( 204 + <View style={styles.progressContainer}> 205 + <View 206 + style={[ 207 + styles.progressTrack, 208 + { backgroundColor: themeColors.surfaceVariant }, 209 + ]} 210 + > 211 + <View 212 + style={[ 213 + styles.progressBar, 214 + { 215 + width: `${progress}%`, 216 + backgroundColor: colors.primary, 217 + }, 218 + ]} 219 + /> 220 + </View> 221 + </View> 222 + )} 223 + 224 + {userDid && ( 225 + <TouchableOpacity 226 + onPress={handleToggleWatched} 227 + disabled={isPending} 228 + style={[ 229 + styles.addButton, 230 + { 231 + backgroundColor: hasWatchedEpisodes 232 + ? `${themeColors.error}20` 233 + : `${colors.primary}20`, 234 + borderColor: hasWatchedEpisodes 235 + ? themeColors.error 236 + : colors.primary, 237 + }, 238 + ]} 239 + activeOpacity={0.7} 240 + > 241 + {isPending ? ( 242 + <> 243 + <ActivityIndicator 244 + size="small" 245 + color={ 246 + hasWatchedEpisodes ? themeColors.error : colors.primary 247 + } 248 + /> 249 + <Text 250 + style={[ 251 + styles.addButtonText, 252 + { 253 + color: hasWatchedEpisodes 254 + ? themeColors.error 255 + : colors.primary, 256 + }, 257 + ]} 258 + > 259 + Loading 260 + </Text> 261 + </> 262 + ) : ( 263 + <> 264 + <Ionicons 265 + name={hasWatchedEpisodes ? "trash-outline" : "add"} 266 + size={14} 267 + color={ 268 + hasWatchedEpisodes ? themeColors.error : colors.primary 269 + } 270 + /> 271 + <Text 272 + style={[ 273 + styles.addButtonText, 274 + { 275 + color: hasWatchedEpisodes 276 + ? themeColors.error 277 + : colors.primary, 278 + }, 279 + ]} 280 + > 281 + {hasWatchedEpisodes ? "Remove from Shelf" : "Add to Shelf"} 282 + </Text> 283 + </> 284 + )} 285 + </TouchableOpacity> 286 + )} 287 + </View> 288 + </View> 289 + </TouchableOpacity> 290 + ); 291 + } 292 + 293 + const styles = StyleSheet.create({ 294 + container: { 295 + borderRadius: borderRadius.lg, 296 + borderWidth: 1, 297 + overflow: "hidden", 298 + }, 299 + row: { 300 + flexDirection: "row", 301 + gap: spacing.md, 302 + alignItems: "center", 303 + }, 304 + posterWrapper: { 305 + width: 80, 306 + height: 120, 307 + }, 308 + poster: { 309 + width: "100%", 310 + height: "100%", 311 + }, 312 + noPoster: { 313 + backgroundColor: "#1f2937", 314 + justifyContent: "center", 315 + alignItems: "center", 316 + }, 317 + content: { 318 + flex: 1, 319 + paddingVertical: spacing.sm, 320 + paddingRight: spacing.sm, 321 + justifyContent: "center", 322 + }, 323 + header: { 324 + flexDirection: "row", 325 + justifyContent: "space-between", 326 + alignItems: "center", 327 + marginBottom: 4, 328 + }, 329 + title: { 330 + fontSize: 16, 331 + fontWeight: "600", 332 + }, 333 + year: { 334 + fontSize: 12, 335 + }, 336 + meta: { 337 + flexDirection: "row", 338 + alignItems: "center", 339 + gap: spacing.sm, 340 + marginBottom: 4, 341 + }, 342 + metaItem: { 343 + flexDirection: "row", 344 + alignItems: "center", 345 + gap: 4, 346 + }, 347 + metaText: { 348 + fontSize: 12, 349 + }, 350 + watchedText: { 351 + fontSize: 12, 352 + fontWeight: "500", 353 + }, 354 + overview: { 355 + fontSize: 12, 356 + lineHeight: 16, 357 + marginBottom: spacing.xs, 358 + }, 359 + progressContainer: { 360 + marginTop: spacing.xs, 361 + }, 362 + progressTrack: { 363 + height: 4, 364 + borderRadius: 2, 365 + overflow: "hidden", 366 + }, 367 + progressBar: { 368 + height: "100%", 369 + borderRadius: 2, 370 + }, 371 + addButton: { 372 + flexDirection: "row", 373 + alignItems: "center", 374 + justifyContent: "center", 375 + gap: spacing.xs, 376 + marginTop: spacing.sm, 377 + paddingVertical: spacing.sm, 378 + paddingHorizontal: spacing.md, 379 + borderRadius: borderRadius.md, 380 + borderWidth: 1, 381 + }, 382 + addButtonText: { 383 + fontSize: 12, 384 + fontWeight: "600", 385 + }, 386 + });
+85
apps/mobile/components/detail/SeasonNav.tsx
··· 1 + import { Ionicons } from "@expo/vector-icons"; 2 + import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; 3 + import { borderRadius, spacing } from "@/constants/spacing"; 4 + import { useTheme } from "@/contexts/theme"; 5 + 6 + interface SeasonNavProps { 7 + currentSeason: number; 8 + totalSeasons: number; 9 + onPreviousSeason?: () => void; 10 + onNextSeason?: () => void; 11 + } 12 + 13 + export function SeasonNav({ 14 + currentSeason, 15 + totalSeasons, 16 + onPreviousSeason, 17 + onNextSeason, 18 + }: SeasonNavProps) { 19 + const { colors } = useTheme(); 20 + const hasPrev = currentSeason > 1; 21 + const hasNext = currentSeason < totalSeasons; 22 + 23 + if (!hasPrev && !hasNext) { 24 + return null; 25 + } 26 + 27 + return ( 28 + <View style={styles.container}> 29 + {hasPrev ? ( 30 + <TouchableOpacity 31 + onPress={onPreviousSeason} 32 + style={[styles.button, { borderColor: colors.outline }]} 33 + activeOpacity={0.8} 34 + > 35 + <Ionicons name="arrow-back" size={18} color={colors.onSurfaceVariant} /> 36 + <Text style={[styles.buttonText, { color: colors.onSurfaceVariant }]}> 37 + Season {currentSeason - 1} 38 + </Text> 39 + </TouchableOpacity> 40 + ) : ( 41 + <View style={styles.placeholder} /> 42 + )} 43 + 44 + {hasNext ? ( 45 + <TouchableOpacity 46 + onPress={onNextSeason} 47 + style={[styles.button, { borderColor: colors.outline }]} 48 + activeOpacity={0.8} 49 + > 50 + <Text style={[styles.buttonText, { color: colors.onSurfaceVariant }]}> 51 + Season {currentSeason + 1} 52 + </Text> 53 + <Ionicons name="arrow-forward" size={18} color={colors.onSurfaceVariant} /> 54 + </TouchableOpacity> 55 + ) : ( 56 + <View style={styles.placeholder} /> 57 + )} 58 + </View> 59 + ); 60 + } 61 + 62 + const styles = StyleSheet.create({ 63 + container: { 64 + flexDirection: "row", 65 + gap: spacing.sm, 66 + }, 67 + button: { 68 + flex: 1, 69 + flexDirection: "row", 70 + alignItems: "center", 71 + justifyContent: "center", 72 + gap: spacing.xs, 73 + borderWidth: 1, 74 + borderRadius: borderRadius.lg, 75 + paddingVertical: spacing.sm, 76 + paddingHorizontal: spacing.md, 77 + }, 78 + buttonText: { 79 + fontSize: 14, 80 + fontWeight: "500", 81 + }, 82 + placeholder: { 83 + flex: 1, 84 + }, 85 + });
+158
apps/mobile/components/detail/TrackedStatusCard.tsx
··· 1 + import type { ColorTheme } from "./types"; 2 + import { Ionicons } from "@expo/vector-icons"; 3 + import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from "react-native"; 4 + import { borderRadius, spacing } from "@/constants/spacing"; 5 + import { useTheme } from "@/contexts/theme"; 6 + 7 + interface TrackedStatusCardProps { 8 + isWatched: boolean; 9 + watchedDate?: string | null; 10 + totalWatches?: number; 11 + onViewHistory?: () => void; 12 + onRemove?: () => void; 13 + isRemoving?: boolean; 14 + colors: ColorTheme; 15 + } 16 + 17 + export function TrackedStatusCard({ 18 + isWatched, 19 + watchedDate, 20 + totalWatches = 0, 21 + onViewHistory, 22 + onRemove, 23 + isRemoving = false, 24 + colors, 25 + }: TrackedStatusCardProps) { 26 + const { colors: themeColors } = useTheme(); 27 + 28 + if (!isWatched) { 29 + return null; 30 + } 31 + 32 + return ( 33 + <View 34 + style={[ 35 + styles.container, 36 + { backgroundColor: `${colors.primary}15` }, 37 + ]} 38 + > 39 + <View style={styles.header}> 40 + <Ionicons name="checkmark-circle" size={20} color={colors.primary} /> 41 + <Text style={[styles.title, { color: colors.primary }]}> 42 + On Your Shelf 43 + </Text> 44 + </View> 45 + 46 + {watchedDate && ( 47 + <Text style={[styles.dateText, { color: themeColors.onSurfaceVariant }]}> 48 + Watched on {watchedDate} 49 + </Text> 50 + )} 51 + 52 + {totalWatches > 1 && ( 53 + <> 54 + <View style={styles.historyRow}> 55 + <Ionicons 56 + name="time-outline" 57 + size={14} 58 + color={themeColors.onSurfaceVariant} 59 + /> 60 + <Text 61 + style={[styles.historyText, { color: themeColors.onSurfaceVariant }]} 62 + > 63 + {totalWatches} total watches 64 + </Text> 65 + </View> 66 + {onViewHistory && ( 67 + <TouchableOpacity 68 + onPress={onViewHistory} 69 + style={styles.actionButton} 70 + activeOpacity={0.7} 71 + > 72 + <Ionicons 73 + name="eye-outline" 74 + size={16} 75 + color={themeColors.onSurfaceVariant} 76 + /> 77 + <Text 78 + style={[ 79 + styles.actionText, 80 + { color: themeColors.onSurfaceVariant }, 81 + ]} 82 + > 83 + View all watches 84 + </Text> 85 + </TouchableOpacity> 86 + )} 87 + </> 88 + )} 89 + 90 + {totalWatches >= 1 && onRemove && ( 91 + <TouchableOpacity 92 + onPress={onRemove} 93 + disabled={isRemoving} 94 + style={styles.actionButton} 95 + activeOpacity={0.7} 96 + > 97 + {isRemoving ? ( 98 + <> 99 + <ActivityIndicator size="small" color={themeColors.error} /> 100 + <Text style={[styles.actionText, { color: themeColors.error }]}>Loading</Text> 101 + </> 102 + ) : ( 103 + <> 104 + <Ionicons name="trash-outline" size={16} color={themeColors.error} /> 105 + <Text style={[styles.actionText, { color: themeColors.error }]}> 106 + Remove from shelf 107 + </Text> 108 + </> 109 + )} 110 + </TouchableOpacity> 111 + )} 112 + </View> 113 + ); 114 + } 115 + 116 + const styles = StyleSheet.create({ 117 + container: { 118 + borderRadius: borderRadius.lg, 119 + padding: spacing.md, 120 + gap: spacing.xs, 121 + }, 122 + header: { 123 + flexDirection: "row", 124 + alignItems: "center", 125 + gap: spacing.xs, 126 + }, 127 + title: { 128 + fontSize: 16, 129 + fontWeight: "600", 130 + }, 131 + dateText: { 132 + fontSize: 14, 133 + marginTop: spacing.xs, 134 + }, 135 + historyRow: { 136 + flexDirection: "row", 137 + alignItems: "center", 138 + gap: spacing.xs, 139 + marginTop: spacing.xs, 140 + }, 141 + historyText: { 142 + fontSize: 13, 143 + }, 144 + actionButton: { 145 + flexDirection: "row", 146 + alignItems: "center", 147 + gap: spacing.xs, 148 + marginTop: spacing.sm, 149 + paddingVertical: spacing.sm, 150 + paddingHorizontal: spacing.sm, 151 + marginLeft: -spacing.sm, 152 + borderRadius: borderRadius.md, 153 + }, 154 + actionText: { 155 + fontSize: 14, 156 + fontWeight: "500", 157 + }, 158 + });
+15
apps/mobile/components/detail/index.ts
··· 1 + export { DetailHero } from "./DetailHero"; 2 + export { DetailActions } from "./DetailActions"; 3 + export { TrackedStatusCard } from "./TrackedStatusCard"; 4 + export { MetadataPills } from "./MetadataPills"; 5 + export { SeasonCard } from "./SeasonCard"; 6 + export { SeasonNav } from "./SeasonNav"; 7 + export { EpisodeCard } from "./EpisodeCard"; 8 + export { EpisodeNav } from "./EpisodeNav"; 9 + export type { 10 + ColorTheme, 11 + EpisodeSummary, 12 + SeasonSummary, 13 + MetadataPill, 14 + BreadcrumbItem, 15 + } from "./types";
+48
apps/mobile/components/detail/types.ts
··· 1 + import type { ReactNode } from "react"; 2 + 3 + export type EpisodeReference = { 4 + seasonNumber: number; 5 + episodeNumber: number; 6 + }; 7 + 8 + export type EpisodeContext = { 9 + previous: EpisodeReference | null; 10 + next: EpisodeReference | null; 11 + }; 12 + 13 + export type ColorTheme = { 14 + primary?: string; 15 + secondary?: string; 16 + accent?: string; 17 + muted?: string; 18 + }; 19 + 20 + export type EpisodeSummary = { 21 + episode_number: number; 22 + name: string; 23 + air_date?: string; 24 + overview?: string; 25 + still_path?: string; 26 + vote_average?: number; 27 + _context?: EpisodeContext; 28 + }; 29 + 30 + export type SeasonSummary = { 31 + season_number: number; 32 + name: string; 33 + air_date?: string; 34 + overview?: string; 35 + poster_path?: string; 36 + episode_count: number; 37 + }; 38 + 39 + export type MetadataPill = { 40 + icon?: ReactNode; 41 + label: string; 42 + onPress?: () => void; 43 + }; 44 + 45 + export type BreadcrumbItem = { 46 + label: string; 47 + onPress?: () => void; 48 + };